feat: complete auth and reminder workflow hardening

This commit is contained in:
taiyi 2026-05-15 13:40:58 +08:00
parent bf4cb6b9a0
commit 3f8e16e6d9
12 changed files with 152 additions and 10 deletions

4
.gitignore vendored
View File

@ -55,10 +55,12 @@ coverage/
# python环境
/backend/venv
__pycache__/
*.py[cod]
# 其他文件
*.exe
*.wav
*.mp3
*.mp4
*.zip
*.zip

View File

@ -3,6 +3,7 @@ APP_NAME=order-flow
API_PREFIX=/api
SECRET_KEY=change-me
JWT_EXPIRE_MINUTES=1440
AUTH_TOKEN_STORE_PATH=D:/tmp/order-flow-auth/revoked_tokens.json
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=order_flow

View File

@ -50,3 +50,9 @@ python scripts/bootstrap_data.py
- `/api/ai/recognize` 当前支持通过 `AI_PROVIDER` 切换 OCR 提供方。
- `AI_PROVIDER=mock` 时,返回可联调用的 mock 识别结果,并保留识别记录、人工修正和审计日志链路。
- `AI_PROVIDER=aliyun` 时,会按 `ALIYUN_OCR_ENDPOINT`、`ALIYUN_OCR_PATH`、`ALIYUN_OCR_APPCODE` 发起真实 HTTP OCR 请求;如果配置不完整或第三方调用失败,会明确返回第三方调用失败,而不是继续伪装成功。
## 认证与提醒补充说明
- `/api/auth/logout` 当前会把 token 失效信息持久化到 `AUTH_TOKEN_STORE_PATH`,避免服务重启后已退出 token 重新生效。
- 订单与系统管理关键接口已开始同时走角色校验和权限码校验,和真实菜单授权结果对齐。
- `/api/reminders/check-all` 已补齐统一提醒触发入口,会串行执行欠款提醒、沉默客户提醒和物流超时提醒检查。

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header
from sqlalchemy.orm import Session
from backend.app.api.deps import get_auth_service, get_current_user

View File

@ -43,6 +43,19 @@ def require_roles(*role_codes: str):
return _require_roles
def require_permissions(*permission_codes: str):
def _require_permissions(current_user: dict = Depends(get_current_user)) -> dict:
if not permission_codes:
return current_user
granted = set(current_user.get("permissions") or [])
missing = [code for code in permission_codes if code not in granted]
if missing:
raise AppException(code=ErrorCode.FORBIDDEN, message="缺少必要权限", status_code=403)
return current_user
return _require_permissions
def get_order_service():
return order_service

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.app.api.deps import get_order_service, require_roles
from backend.app.api.deps import get_order_service, require_permissions, require_roles
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.db import get_db_session
@ -35,6 +35,7 @@ def list_orders(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:list")),
) -> dict:
orders = order_service.list_orders(
session,
@ -71,6 +72,7 @@ def create_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "admin")),
_permission_user: dict = Depends(require_permissions("order:create")),
) -> dict:
if not payload.items:
raise AppException(code=ErrorCode.PARAM_ERROR, message="订单明细不能为空", status_code=400)
@ -84,6 +86,7 @@ def get_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:list")),
) -> dict:
order = order_service.get_order(order_id, session, current_user)
if not order:
@ -97,6 +100,7 @@ def submit_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "admin")),
_permission_user: dict = Depends(require_permissions("order:submit")),
) -> dict:
order = order_service.submit_order(order_id, session, current_user)
if not order:
@ -111,6 +115,7 @@ def cancel_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "admin")),
_permission_user: dict = Depends(require_permissions("order:list")),
) -> dict:
order = order_service.cancel_order(order_id, payload.model_dump(), session, current_user)
if not order:
@ -125,6 +130,7 @@ def change_order_status(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
_permission_user: dict = Depends(require_permissions("logistics:task:create")),
) -> dict:
order = order_service.change_order_status(order_id, payload.model_dump(), session, current_user)
if not order:
@ -139,6 +145,7 @@ def approve_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:approve")),
) -> dict:
order = order_service.approve_order(order_id, payload.model_dump(), session, current_user)
if not order:
@ -153,6 +160,7 @@ def cancel_approve_order(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:cancel-approve")),
) -> dict:
order = order_service.cancel_approve_order(order_id, payload.model_dump(), session, current_user)
if not order:
@ -167,6 +175,7 @@ def supplier_text(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:supplier-text")),
) -> dict:
order = order_service.get_supplier_text(order_id, payload.model_dump(), session, current_user)
if not order:
@ -181,6 +190,7 @@ def confirm_supplier_text(
order_service: OrderService = Depends(get_order_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
_permission_user: dict = Depends(require_permissions("order:supplier-text")),
) -> dict:
order = order_service.confirm_supplier_text(order_id, payload.model_dump(), session, current_user)
if not order:

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.app.api.deps import require_roles
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.services.reminder_service import reminder_service
@ -16,6 +17,7 @@ def list_reminders(
page_no: int = Query(default=1),
page_size: int = Query(default=20),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
return success_payload(
reminder_service.list_reminders(
@ -35,6 +37,7 @@ def list_reminders(
def read_reminder(
reminder_id: int,
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("salesman", "manager", "admin")),
) -> dict:
return success_payload(reminder_service.read_reminder(reminder_id, session))
@ -42,6 +45,7 @@ def read_reminder(
@router.post("/arrears/check")
def check_arrears_reminders(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
) -> dict:
return success_payload(reminder_service.check_arrears(session))
@ -49,5 +53,22 @@ def check_arrears_reminders(
@router.post("/inactive-customers/check")
def check_inactive_customer_reminders(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
) -> dict:
return success_payload(reminder_service.check_inactive_customers(session))
@router.post("/logistics-timeout/check")
def check_logistics_timeout_reminders(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
) -> dict:
return success_payload(reminder_service.check_logistics_timeout(session))
@router.post("/check-all")
def check_all_reminders(
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("manager", "admin")),
) -> dict:
return success_payload(reminder_service.check_all(session))

View File

@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from backend.app.api.deps import get_system_service, require_roles
from backend.app.api.deps import get_system_service, require_permissions, require_roles
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.schemas.system import (
@ -31,6 +31,7 @@ def list_users(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:user:list")),
) -> dict:
result = system_service.list_users(
session,
@ -55,6 +56,7 @@ def create_user(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:user:list")),
) -> dict:
return success_payload(system_service.create_user(payload.model_dump(), session))
@ -66,6 +68,7 @@ def update_user(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:user:list")),
) -> dict:
return success_payload(system_service.update_user(user_id, payload.model_dump(), session))
@ -77,6 +80,7 @@ def reset_password(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:user:list")),
) -> dict:
return success_payload(system_service.reset_password(user_id, payload.new_password, session))
@ -91,6 +95,7 @@ def list_roles(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:role:list")),
) -> dict:
result = system_service.list_roles(
session,
@ -109,6 +114,7 @@ def create_role(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:role:list")),
) -> dict:
return success_payload(system_service.create_role(payload.model_dump(), session))
@ -120,6 +126,7 @@ def update_role(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:role:list")),
) -> dict:
return success_payload(system_service.update_role(role_id, payload.model_dump(), session))
@ -130,6 +137,7 @@ def get_role_menus(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:role:list")),
) -> dict:
return success_payload(system_service.get_role_menu_assignment(role_id, session))
@ -141,6 +149,7 @@ def assign_role_menus(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:role:list")),
) -> dict:
return success_payload(system_service.assign_role_menus(role_id, payload.model_dump(), session))
@ -152,6 +161,7 @@ def list_menus(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:menu:list")),
) -> dict:
return success_payload(
system_service.list_menus(
@ -167,6 +177,7 @@ def create_menu(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:menu:list")),
) -> dict:
return success_payload(system_service.create_menu(payload.model_dump(), session))
@ -178,5 +189,6 @@ def update_menu(
system_service: SystemService = Depends(get_system_service),
session: Session = Depends(get_db_session),
current_user: dict = Depends(require_roles("admin")),
_permission_user: dict = Depends(require_permissions("system:menu:list")),
) -> dict:
return success_payload(system_service.update_menu(menu_id, payload.model_dump(), session))

View File

@ -16,6 +16,7 @@ class Settings(BaseSettings):
api_prefix: str = Field(default="/api", alias="API_PREFIX")
secret_key: str = Field(default="change-me", alias="SECRET_KEY")
jwt_expire_minutes: int = Field(default=1440, alias="JWT_EXPIRE_MINUTES")
auth_token_store_path: str = Field(default="D:/tmp/order-flow-auth/revoked_tokens.json", alias="AUTH_TOKEN_STORE_PATH")
cors_allow_origins: str = Field(default="http://localhost:5173", alias="CORS_ALLOW_ORIGINS")
mysql_host: str = Field(default="127.0.0.1", alias="MYSQL_HOST")
mysql_port: int = Field(default=3306, alias="MYSQL_PORT")

View File

@ -1,6 +1,11 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from backend.app.core.config import get_settings
from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.core.security import create_access_token, decode_access_token, verify_password
@ -25,7 +30,10 @@ class AuthService:
def __init__(self) -> None:
self.repository = SystemRepository()
self.revoked_tokens: set[str] = set()
self.settings = get_settings()
self.token_store_path = Path(self.settings.auth_token_store_path)
self.token_store_path.parent.mkdir(parents=True, exist_ok=True)
self.revoked_tokens = self._load_revoked_tokens()
def login(self, username: str, password: str, role_type: str, session: Session | None = None) -> dict | None:
if session is not None:
@ -57,7 +65,7 @@ class AuthService:
def get_me(self, token: str, session: Session | None = None) -> dict | None:
if session is not None:
try:
if token in self.revoked_tokens:
if self._is_token_revoked(token):
return None
payload = decode_access_token(token)
if payload is None:
@ -78,7 +86,8 @@ class AuthService:
def logout(self, token: str) -> dict:
if token:
self.revoked_tokens.add(token)
# token 失效需要跨进程持久化,避免服务重启后已退出的 token 又重新可用。
self._revoke_token(token)
return {"success": True}
def _build_profile(self, session: Session, user, role, include_token: bool) -> dict:
@ -152,5 +161,42 @@ class AuthService:
permissions = [menu.permission_code for menu in menus if menu.permission_code in allowed]
return permissions or self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
def _load_revoked_tokens(self) -> dict[str, int]:
if not self.token_store_path.exists():
return {}
try:
payload = json.loads(self.token_store_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
now_ts = int(datetime.now(timezone.utc).timestamp())
tokens = {
token: int(exp)
for token, exp in payload.items()
if isinstance(token, str) and isinstance(exp, int) and exp > now_ts
}
if len(tokens) != len(payload):
self._save_revoked_tokens(tokens)
return tokens
def _save_revoked_tokens(self, tokens: dict[str, int]) -> None:
self.token_store_path.write_text(json.dumps(tokens, ensure_ascii=False, indent=2), encoding="utf-8")
def _is_token_revoked(self, token: str) -> bool:
if not token:
return False
now_ts = int(datetime.now(timezone.utc).timestamp())
expired_tokens = [item for item, exp in self.revoked_tokens.items() if exp <= now_ts]
if expired_tokens:
for item in expired_tokens:
self.revoked_tokens.pop(item, None)
self._save_revoked_tokens(self.revoked_tokens)
return token in self.revoked_tokens
def _revoke_token(self, token: str) -> None:
payload = decode_access_token(token)
expire_at = int(payload.get("exp", 0)) if payload else int(datetime.now(timezone.utc).timestamp()) + 60
self.revoked_tokens[token] = expire_at
self._save_revoked_tokens(self.revoked_tokens)
auth_service = AuthService()

View File

@ -241,6 +241,33 @@ class ReminderService:
return {"checked": True, "created_count": 0}
def check_all(self, session: Session | None = None) -> dict:
if session is None:
return {
"checked": True,
"arrears_created_count": 0,
"inactive_customer_created_count": 0,
"logistics_timeout_created_count": 0,
"total_created_count": 0,
}
# 统一触发入口直接串起三类提醒,便于管理端定时任务或手动补跑。
arrears_result = self.check_arrears(session)
inactive_result = self.check_inactive_customers(session)
logistics_result = self.check_logistics_timeout(session)
total_created_count = (
int(arrears_result.get("created_count", 0))
+ int(inactive_result.get("created_count", 0))
+ int(logistics_result.get("created_count", 0))
)
return {
"checked": True,
"arrears_created_count": arrears_result.get("created_count", 0),
"inactive_customer_created_count": inactive_result.get("created_count", 0),
"logistics_timeout_created_count": logistics_result.get("created_count", 0),
"total_created_count": total_created_count,
}
def _get_config_value(self, session: Session, config_key: str, default: str) -> str:
config = self.config_repository.get_by_key(session, config_key)
return config.config_value if config is not None and config.config_value else default

View File

@ -29,7 +29,7 @@
| 任务 | 来源 | 验证方式 | 状态 |
| --- | --- | --- | --- |
| 认证接口 | 02-API细化设计 | 已接入数据库优先登录校验、密码哈希校验、签名 token 生成、`/me` 过期校验、`/logout` 失效处理,并补齐关键业务接口统一 token 鉴权入口与基础角色拦截;仍待继续补 token 持久化黑名单与更细粒度权限点 | 待完成 |
| 认证接口 | 02-API细化设计 | 已接入数据库优先登录校验、密码哈希校验、签名 token 生成、`/me` 过期校验、`/logout` 持久化失效处理,并补齐关键业务接口统一 token 鉴权入口、基础角色拦截以及订单/系统管理关键接口的权限码校验 | 已完成 |
| 订单接口 | 02-API细化设计 | 已支持真实建单、列表筛选、详情、提交、取消、审批、发厂确认与状态变更,并在 `shipped/delivered` 等节点接入欠款生成;已补业务员本人订单数据范围控制与详情敏感字段基础收口,更多异常场景仍待补 | 待完成 |
| 审批接口 | 02-API细化设计 | 已支持真实订单审批、取消审批和审批日志写入,并补齐管理层/管理员角色限制;更多审批流分支与权限颗粒度仍待补 | 待完成 |
| 客户接口 | 02-API细化设计 | 已有接口与 service客户列表、创建、详情已接入真实数据库优先链路并补齐业务员本人客户数据范围控制客户导入已支持本地 `csv/xlsx/xls` 真实解析、逐行校验、跳过重复/覆盖重复导入,并兼容系统统一对象存储 OSS URL 口径文件来源 | 已完成 |
@ -38,7 +38,7 @@
| 司机任务接口 | 02-API细化设计 | 已支持真实任务创建、列表、详情、接单/揽货/送达状态联动,并补齐司机本人任务/轨迹访问限制与管理端创建权限限制;附件留痕细节与更多异常场景仍待补 | 待完成 |
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并统一切到 OSS URL / object_key / bucket 元数据口径;当前已形成“上传凭证 + 附件落库 + 本地对象存储发布目录模拟 OSS 公网地址”的完整链路,云上 SDK 直传签名与上传校验仍待补 | 待完成 |
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹AI 识别记录保存、置信度回包、人工修正落库已接入数据库优先链路,并新增可切换的阿里云 OCR provider 接入结构,配置齐全可走真实 HTTP OCR请求失败会明确按第三方异常返回第三方物流轨迹仍待接入 | 待完成 |
| 提醒接口 | 02-API细化设计 | 提醒列表、标记已读、欠款提醒检查、沉默客户提醒检查已接入数据库优先链路,并按配置做基础去重与阈值判断;物流超时提醒规则已补到 service提醒触发入口与更多业务类型仍待收口 | 待完成 |
| 提醒接口 | 02-API细化设计 | 提醒列表、标记已读、欠款提醒检查、沉默客户提醒检查、物流超时提醒检查均已接入数据库优先链路,并按配置做基础去重与阈值判断;已补统一 `/api/reminders/check-all` 触发入口,当前主链路已闭环 | 已完成 |
| 报表接口 | 02-API细化设计 | 业绩统计已接入真实数据库优先聚合,支持按月/季度/年度、日期区间、产品分类统计订单金额与固定提成;导出已生成真实 CSV、发布到本地对象存储模拟目录并返回 OSS 公网 URL 口径同时写入附件记录与审计日志Excel 格式与真实云上 OSS SDK 上传仍待补 | 待完成 |
| 配置与审计接口 | 02-API细化设计 | 配置查询与更新已接入 `system_config` 数据库优先链路,并补齐欠款模式/提醒阈值等基础校验;`audit_log` 已接入真实查询,并覆盖订单状态变更、配置更新、附件保存、提醒已读等关键写操作 | 已完成 |
| 系统管理接口 | 02-API细化设计 | 已接入真实用户/角色/菜单查询、新增、编辑、重置密码与角色授权链路,并补齐对应审计写入;现已补齐 admin 角色访问限制,仍待进一步补齐迁移落地与更细粒度权限控制 | 待完成 |
@ -74,14 +74,16 @@
- 订单、审批、发厂确认、司机任务创建等主链路的数据库优先实现
- `web-admin` 管理后台真实登录入口与 token 鉴权联调
- `web-sales` 业务员端真实登录入口与 token 鉴权联调
- 认证接口持久化失效与关键权限码校验闭环
- 客户接口真实数据库联调闭环
- 产品与分类接口真实数据库联调闭环
- 供应商接口真实数据库联调闭环
- 提醒接口真实数据库联调闭环
- 配置与审计接口真实数据库联调闭环
### 仍未真正完成
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截订单/客户/司机任务的基础数据范围控制)
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截、token 持久化失效以及订单/系统管理关键权限码校验,并已覆盖订单/客户/司机任务的基础数据范围控制)
- 更多客户导入模板兼容
- 真实阿里云 OSS SDK 直传签名 / 上传校验、第三方物流轨迹接入与更完整导出格式
- 系统测试与部署收口
@ -141,3 +143,4 @@
| 2026-05-15 | 已修正第三项“后端接口”状态口径:产品与分类接口、供应商接口、配置与审计接口已按真实联调闭环更新为“已完成”;同时确认认证、订单、客户、文件、物流与 AI 等条目仍存在待收口事项,继续保留为“待完成”。 |
| 2026-05-15 | 已继续推进 AI/OCR 主线:`backend/app/services/ai_service.py` 已从固定 mock 适配器升级为可切换 provider 结构,支持 `AI_PROVIDER=aliyun` 时按配置发起真实 OCR HTTP 请求,配置缺失或第三方失败会明确返回错误,并保留识别落库、人工修正和审计链路。 |
| 2026-05-15 | 已继续收口客户接口:客户导入除本地 `csv/xlsx/xls` 外,已兼容系统统一对象存储 OSS URL 口径文件来源;`frontend/web-admin` 客户导入入口文案已同步改为支持本地路径或 OSS 公网 URL。 |
| 2026-05-15 | 已继续收口认证与提醒主线:`/api/auth/logout` 已补 token 持久化失效存储,避免服务重启后 token 恢复可用;订单与系统管理关键接口已补权限码校验;提醒模块已新增 `/api/reminders/logistics-timeout/check``/api/reminders/check-all` 统一触发入口。 |