Compare commits
3 Commits
bf4cb6b9a0
...
852fd4bf1e
| Author | SHA1 | Date | |
|---|---|---|---|
| 852fd4bf1e | |||
| aa2f798932 | |||
| 3f8e16e6d9 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -55,10 +55,12 @@ coverage/
|
||||
|
||||
# python环境
|
||||
/backend/venv
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# 其他文件
|
||||
*.exe
|
||||
*.wav
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.zip
|
||||
*.zip
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 与文件名匹配关系,避免错误附件直接入库。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 与文件名匹配校验,文件接口主链路已更新为“已完成”。 |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user