Compare commits

...

3 Commits

17 changed files with 302 additions and 43 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
@ -14,6 +15,7 @@ ALIYUN_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=xxx
ALIYUN_OSS_ACCESS_KEY_SECRET=xxx
ALIYUN_OSS_PUBLIC_BASE_URL=https://cdn.example.com
OSS_SIGNED_URL_EXPIRE_SECONDS=3600
OSS_UPLOAD_MAX_IMAGE_MB=10
OSS_UPLOAD_MAX_VIDEO_MB=200
AI_PROVIDER=mock

View File

@ -50,3 +50,19 @@ 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` 已补齐统一提醒触发入口,会串行执行欠款提醒、沉默客户提醒和物流超时提醒检查。
## 报表导出补充说明
- `/api/reports/performance/export` 当前支持 `export_format=csv``export_format=xlsx` 两种导出格式。
- `xlsx` 导出会生成基础表头样式与列宽,便于直接给业务查看。
## OSS 上传补充说明
- `/api/files/upload-token` 当前会按 OSS V1 预签名 URL 规则生成真实 PUT 上传地址,而不是仅返回 mock 签名占位值。
- `/api/files/attachments` 保存附件时会再次校验文件大小、对象 key 与文件名匹配关系,避免错误附件直接入库。

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

@ -35,6 +35,7 @@ def export_performance_report(
start_date: str | None = Query(default=None),
end_date: str | None = Query(default=None),
category_id: int | None = Query(default=None, gt=0),
export_format: str = Query(default="csv"),
session: Session = Depends(get_db_session),
) -> dict:
return success_payload(
@ -44,6 +45,7 @@ def export_performance_report(
"start_date": start_date,
"end_date": end_date,
"category_id": category_id,
"export_format": export_format,
},
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")
@ -28,6 +29,7 @@ class Settings(BaseSettings):
aliyun_oss_public_base_url: str = Field(default="https://cdn.example.com", alias="ALIYUN_OSS_PUBLIC_BASE_URL")
aliyun_oss_access_key_id: str = Field(default="xxx", alias="ALIYUN_OSS_ACCESS_KEY_ID")
aliyun_oss_access_key_secret: str = Field(default="xxx", alias="ALIYUN_OSS_ACCESS_KEY_SECRET")
oss_signed_url_expire_seconds: int = Field(default=3600, alias="OSS_SIGNED_URL_EXPIRE_SECONDS")
oss_upload_max_image_mb: int = Field(default=10, alias="OSS_UPLOAD_MAX_IMAGE_MB")
oss_upload_max_video_mb: int = Field(default=200, alias="OSS_UPLOAD_MAX_VIDEO_MB")
ai_provider: str = Field(default="mock", alias="AI_PROVIDER")

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

@ -2,43 +2,79 @@ import csv
from datetime import datetime
from pathlib import Path
from openpyxl import Workbook
class ExportService:
def __init__(self) -> None:
self.export_root = Path("D:/tmp/order-flow-exports")
self.export_root.mkdir(parents=True, exist_ok=True)
def build_performance_export(self, report: dict) -> dict:
def build_performance_export(self, report: dict, export_format: str = "csv") -> dict:
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
suffix = report["stat_type"]
file_name = f"performance-{suffix}-{timestamp}.csv"
normalized_format = self._normalize_export_format(export_format)
file_name = f"performance-{suffix}-{timestamp}.{normalized_format}"
file_path = self.export_root / file_name
with file_path.open("w", encoding="utf-8-sig", newline="") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"])
for item in report["list"]:
category_text = "".join(
f"{category['category_name']}:{category['amount']:.2f}"
for category in item.get("category_amounts", [])
)
writer.writerow(
[
item["stat_period"],
item["order_count"],
f"{item['order_amount']:.2f}",
f"{item['commission_amount']:.2f}",
category_text,
]
)
if normalized_format == "xlsx":
self._write_xlsx(file_path, report)
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
self._write_csv(file_path, report)
content_type = "text/csv"
return {
"file_name": file_name,
"file_path": str(file_path),
"file_size": file_path.stat().st_size,
"content_type": "text/csv",
"content_type": content_type,
"object_key": f"exports/performance/{file_name}",
"export_format": normalized_format,
}
def _normalize_export_format(self, export_format: str) -> str:
normalized = (export_format or "csv").strip().lower()
if normalized not in {"csv", "xlsx"}:
return "csv"
return normalized
def _write_csv(self, file_path: Path, report: dict) -> None:
with file_path.open("w", encoding="utf-8-sig", newline="") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"])
for item in report["list"]:
writer.writerow(self._build_row(item))
def _write_xlsx(self, file_path: Path, report: dict) -> None:
workbook = Workbook()
sheet = workbook.active
sheet.title = "业绩统计"
sheet.append(["统计周期", "订单数", "订单金额", "固定提成", "分类明细"])
for item in report["list"]:
sheet.append(self._build_row(item))
# 给导出表加最小可读性优化,避免客户打开后仍像原始数据转储。
for cell in sheet[1]:
cell.font = cell.font.copy(bold=True)
sheet.column_dimensions["A"].width = 18
sheet.column_dimensions["B"].width = 12
sheet.column_dimensions["C"].width = 16
sheet.column_dimensions["D"].width = 16
sheet.column_dimensions["E"].width = 48
workbook.save(file_path)
def _build_row(self, item: dict) -> list:
category_text = "".join(
f"{category['category_name']}:{category['amount']:.2f}"
for category in item.get("category_amounts", [])
)
return [
item["stat_period"],
item["order_count"],
f"{item['order_amount']:.2f}",
f"{item['commission_amount']:.2f}",
category_text,
]
export_service = ExportService()

View File

@ -44,8 +44,10 @@ class FileService:
raise AppException(code=ErrorCode.PARAM_ERROR, message="文件地址不能为空", status_code=400)
file_category = self._detect_file_category(payload["file_type"], payload["file_name"])
self._validate_file_size(file_category, payload["file_size"])
object_key = storage_service.extract_object_key(payload["file_url"])
file_ext = self._extract_file_ext(payload["file_name"])
self._validate_attachment_file(payload["file_name"], payload["file_url"], object_key)
if session is not None:
try:
@ -106,6 +108,15 @@ class FileService:
self.attachments.append(attachment)
return attachment
def _validate_attachment_file(self, file_name: str, file_url: str, object_key: str) -> None:
expected_name = file_name.strip()
object_name = object_key.rsplit("/", 1)[-1]
# 保存附件时再次核对对象 key 与文件名,避免前端绕过上传凭证直接塞入不匹配文件。
if expected_name and expected_name not in object_name:
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件文件名与对象地址不匹配", status_code=400)
if not file_url.startswith(("http://", "https://")):
raise AppException(code=ErrorCode.FILE_UPLOAD_FAILED, message="附件地址格式不正确", status_code=400)
def _detect_file_category(self, file_type: str, file_name: str) -> str:
content_type = (file_type or "").lower()
suffix = self._extract_file_ext(file_name).lower()

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

@ -36,7 +36,8 @@ class ReportService:
def export_performance_report(self, filters: dict, session: Session | None = None) -> dict:
report = self.performance_report(filters, session)
export_meta = export_service.build_performance_export(report)
export_format = self._normalize_export_format(filters.get("export_format"))
export_meta = export_service.build_performance_export(report, export_format)
publish_meta = storage_service.publish_local_file(export_meta["file_path"], export_meta["object_key"])
attachment_payload = {
"biz_type": "performance_report",
@ -65,6 +66,7 @@ class ReportService:
"after_value": {
"file_name": export_meta["file_name"],
"stat_type": report["stat_type"],
"export_format": export_format,
"total_periods": len(report["list"]),
},
"remark": "导出业绩统计报表",
@ -79,6 +81,7 @@ class ReportService:
"storage_provider": publish_meta["storage_provider"],
"bucket_name": publish_meta["bucket_name"],
"stat_type": report["stat_type"],
"export_format": export_format,
"total_periods": len(report["list"]),
"filters": {
"start_date": filters.get("start_date"),
@ -93,6 +96,12 @@ class ReportService:
raise AppException(code=ErrorCode.PARAM_ERROR, message="统计类型不支持", status_code=400)
return value
def _normalize_export_format(self, export_format: str | None) -> str:
value = (export_format or "csv").strip().lower()
if value not in {"csv", "xlsx"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="导出格式仅支持 csv 或 xlsx", status_code=400)
return value
def _parse_filters(self, filters: dict, stat_type: str) -> dict:
parsed = {
"stat_type": stat_type,

View File

@ -1,5 +1,8 @@
from __future__ import annotations
import base64
import hashlib
import hmac
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import quote
@ -17,18 +20,19 @@ class StorageService:
def create_upload_token(self, biz_type: str, biz_id: int, file_name: str, file_size: int) -> dict:
object_key = self.build_object_key(biz_type, biz_id, file_name)
expire_at = datetime.now() + timedelta(hours=1)
expire_at = datetime.now() + timedelta(seconds=self.settings.oss_signed_url_expire_seconds)
content_type = self._guess_content_type(file_name)
signed_meta = self._build_signed_upload_url(object_key, expire_at, content_type)
return {
"upload_url": self._build_signed_upload_url(object_key),
"upload_url": signed_meta["upload_url"],
"file_url": self.build_public_file_url(object_key),
"headers": {
"x-oss-object-acl": "private",
},
"headers": signed_meta["headers"],
"method": "PUT",
"object_key": object_key,
"bucket_name": self.settings.aliyun_oss_bucket,
"expire_at": expire_at.strftime("%Y-%m-%d %H:%M:%S"),
"max_file_size": file_size,
"content_type": content_type,
}
def publish_local_file(self, local_file_path: str, object_key: str) -> dict:
@ -61,14 +65,54 @@ class StorageService:
prefix = f"{self.settings.aliyun_oss_public_base_url.rstrip('/')}/"
if file_url.startswith(prefix):
return file_url[len(prefix) :]
return file_url.split('://', 1)[-1].split('/', 1)[-1] if "/" in file_url.split('://', 1)[-1] else file_url
return file_url.split("://", 1)[-1].split("/", 1)[-1] if "/" in file_url.split("://", 1)[-1] else file_url
def _build_signed_upload_url(self, object_key: str) -> str:
def _build_signed_upload_url(self, object_key: str, expire_at: datetime, content_type: str) -> dict:
# 当前按 OSS V1 预签名 URL 规则生成上传地址,便于前端直接联调真实 PUT 上传口径。
expires = str(int(expire_at.timestamp()))
encoded_key = quote(object_key.replace("\\", "/"), safe="/-_.*()")
return (
canonical_headers = "x-oss-object-acl:private\n"
canonical_resource = f"/{self.settings.aliyun_oss_bucket}/{object_key}"
string_to_sign = f"PUT\n\n{content_type}\n{expires}\n{canonical_headers}{canonical_resource}"
signature = base64.b64encode(
hmac.new(
self.settings.aliyun_oss_access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
).decode("utf-8")
query_signature = quote(signature, safe="")
upload_url = (
f"https://{self.settings.aliyun_oss_bucket}.{self.settings.aliyun_oss_endpoint}/{encoded_key}"
f"?x-oss-signature-version=mock-v1"
f"?OSSAccessKeyId={quote(self.settings.aliyun_oss_access_key_id, safe='')}"
f"&Expires={expires}"
f"&Signature={query_signature}"
)
return {
"upload_url": upload_url,
"headers": {
"Content-Type": content_type,
"x-oss-object-acl": "private",
},
}
def _guess_content_type(self, file_name: str) -> str:
suffix = Path(file_name).suffix.lower()
mapping = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".csv": "text/csv",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
".pdf": "application/pdf",
}
return mapping.get(suffix, "application/octet-stream")
storage_service = StorageService()

View File

@ -29,17 +29,17 @@
| 任务 | 来源 | 验证方式 | 状态 |
| --- | --- | --- | --- |
| 认证接口 | 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 口径文件来源 | 已完成 |
| 产品与分类接口 | 02-API细化设计 | 已有接口与 service产品分类列表/新增/更新、产品列表/详情已接入真实数据库优先链路,基础资料页已形成真实联调闭环 | 已完成 |
| 供应商接口 | 02-API细化设计 | 已有接口与 service供应商列表、创建、详情已接入真实数据库优先链路基础资料页供应商详情已形成真实联调当前主链路已闭环 | 已完成 |
| 司机任务接口 | 02-API细化设计 | 已支持真实任务创建、列表、详情、接单/揽货/送达状态联动,并补齐司机本人任务/轨迹访问限制与管理端创建权限限制;附件留痕细节与更多异常场景仍待补 | 待完成 |
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并统一切到 OSS URL / object_key / bucket 元数据口径;当前已形成“上传凭证 + 附件落库 + 本地对象存储发布目录模拟 OSS 公网地址”的完整链路,云上 SDK 直传签名与上传校验仍待补 | 待完成 |
| 文件接口 | 02-API细化设计 | 上传凭证生成和附件记录保存已接入数据库优先链路,并统一切到 OSS URL / object_key / bucket 元数据口径;当前已形成“真实 OSS V1 预签名上传地址 + 附件落库 + 文件名/对象 key/大小校验 + 本地对象存储发布目录模拟 OSS 公网地址”的完整链路 | 已完成 |
| 物流与 AI 接口 | 02-API细化设计 | 物流轨迹查询与新增已接入数据库优先链路,司机接单/揽货/送达会写入真实轨迹AI 识别记录保存、置信度回包、人工修正落库已接入数据库优先链路,并新增可切换的阿里云 OCR provider 接入结构,配置齐全可走真实 HTTP OCR请求失败会明确按第三方异常返回第三方物流轨迹仍待接入 | 待完成 |
| 提醒接口 | 02-API细化设计 | 提醒列表、标记已读、欠款提醒检查、沉默客户提醒检查已接入数据库优先链路,并按配置做基础去重与阈值判断;物流超时提醒规则已补到 service提醒触发入口与更多业务类型仍待收口 | 待完成 |
| 报表接口 | 02-API细化设计 | 业绩统计已接入真实数据库优先聚合,支持按月/季度/年度、日期区间、产品分类统计订单金额与固定提成;导出已生成真实 CSV、发布到本地对象存储模拟目录并返回 OSS 公网 URL 口径,同时写入附件记录与审计日志;Excel 格式与真实云上 OSS SDK 上传仍待补 | 待完成 |
| 提醒接口 | 02-API细化设计 | 提醒列表、标记已读、欠款提醒检查、沉默客户提醒检查、物流超时提醒检查均已接入数据库优先链路,并按配置做基础去重与阈值判断;已补统一 `/api/reminders/check-all` 触发入口,当前主链路已闭环 | 已完成 |
| 报表接口 | 02-API细化设计 | 业绩统计已接入真实数据库优先聚合,支持按月/季度/年度、日期区间、产品分类统计订单金额与固定提成;导出已支持真实 CSV/XLSX、发布到本地对象存储模拟目录并返回 OSS 公网 URL 口径,同时写入附件记录与审计日志;真实云上 OSS SDK 上传仍待补 | 待完成 |
| 配置与审计接口 | 02-API细化设计 | 配置查询与更新已接入 `system_config` 数据库优先链路,并补齐欠款模式/提醒阈值等基础校验;`audit_log` 已接入真实查询,并覆盖订单状态变更、配置更新、附件保存、提醒已读等关键写操作 | 已完成 |
| 系统管理接口 | 02-API细化设计 | 已接入真实用户/角色/菜单查询、新增、编辑、重置密码与角色授权链路,并补齐对应审计写入;现已补齐 admin 角色访问限制,仍待进一步补齐迁移落地与更细粒度权限控制 | 待完成 |
@ -74,16 +74,19 @@
- 订单、审批、发厂确认、司机任务创建等主链路的数据库优先实现
- `web-admin` 管理后台真实登录入口与 token 鉴权联调
- `web-sales` 业务员端真实登录入口与 token 鉴权联调
- 认证接口持久化失效与关键权限码校验闭环
- 客户接口真实数据库联调闭环
- 产品与分类接口真实数据库联调闭环
- 供应商接口真实数据库联调闭环
- 文件接口真实上传签名与校验闭环
- 提醒接口真实数据库联调闭环
- 配置与审计接口真实数据库联调闭环
### 仍未真正完成
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截订单/客户/司机任务的基础数据范围控制)
- 更完整的权限拦截与数据范围控制(当前已完成统一鉴权入口、关键角色拦截、token 持久化失效以及订单/系统管理关键权限码校验,并已覆盖订单/客户/司机任务的基础数据范围控制)
- 更多客户导入模板兼容
- 真实阿里云 OSS SDK 直传签名 / 上传校验、第三方物流轨迹接入与更完整导出格式
- 第三方物流轨迹接入
- 系统测试与部署收口
## 修正记录
@ -141,3 +144,6 @@
| 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` 统一触发入口。 |
| 2026-05-15 | 已继续推进报表导出主线:`/api/reports/performance/export` 已补 `export_format=csv/xlsx` 双格式导出,`xlsx` 文件会生成基础表头样式与列宽;任务状态跟踪中“更完整导出格式”待办已同步收口。 |
| 2026-05-15 | 已继续收口文件接口:`/api/files/upload-token` 已改为按 OSS V1 预签名 URL 规则生成真实 PUT 上传地址;`/api/files/attachments` 已补文件大小、对象 key 与文件名匹配校验,文件接口主链路已更新为“已完成”。 |