- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
84 lines
3.4 KiB
Python
84 lines
3.4 KiB
Python
"""系统设置接口(公司信息)。
|
||
|
||
`GET /public` 与 `GET /logo/public` **无鉴权**:登录页渲染公司名/logo 时
|
||
尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),
|
||
仅暴露两个非敏感展示字段。
|
||
|
||
`/logo/public` 返回文件流而非统一信封——与参数导出同一例外口径
|
||
(二进制响应套 JSON 信封无意义)。
|
||
"""
|
||
|
||
from fastapi import APIRouter, Response, UploadFile
|
||
from fastapi.responses import FileResponse
|
||
|
||
from app.api.deps import AdminUser, DbSession, RequestMetaDep
|
||
from app.core.exceptions import BadRequestError
|
||
from app.core.response import ApiResponse, ok
|
||
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
|
||
from app.services import system_setting_service as service
|
||
|
||
router = APIRouter(prefix="/settings", tags=["系统管理 · 公司信息"])
|
||
|
||
_LOGO_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg"}
|
||
|
||
|
||
@router.get("", response_model=ApiResponse[SettingOut], summary="公司信息")
|
||
def get_settings(session: DbSession, _: AdminUser) -> ApiResponse[SettingOut]:
|
||
"""取公司信息(管理端)。"""
|
||
return ok(service.to_out(service.get_setting(session)))
|
||
|
||
|
||
@router.put("", response_model=ApiResponse[SettingOut], summary="更新公司名称")
|
||
def update_settings(
|
||
payload: SettingUpdateIn,
|
||
session: DbSession,
|
||
current_user: AdminUser,
|
||
meta: RequestMetaDep,
|
||
) -> ApiResponse[SettingOut]:
|
||
"""更新公司名称(审计留痕)。"""
|
||
return ok(
|
||
service.update_company(session, payload, operator=current_user, meta=meta),
|
||
message="已保存",
|
||
)
|
||
|
||
|
||
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
|
||
def upload_logo(
|
||
file: UploadFile,
|
||
session: DbSession,
|
||
current_user: AdminUser,
|
||
meta: RequestMetaDep,
|
||
) -> ApiResponse[SettingOut]:
|
||
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
|
||
|
||
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
|
||
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
|
||
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
|
||
"""
|
||
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
|
||
raise BadRequestError("logo 文件不能超过 2MB")
|
||
content = file.file.read()
|
||
ext = (file.filename or "").rsplit(".", 1)[-1]
|
||
return ok(
|
||
service.save_logo(session, content, ext, operator=current_user, meta=meta),
|
||
message="logo 已更新",
|
||
)
|
||
|
||
|
||
@router.get("/public", response_model=ApiResponse[SettingPublicOut], summary="公司信息(匿名)")
|
||
def get_settings_public(session: DbSession, response: Response) -> ApiResponse[SettingPublicOut]:
|
||
"""公司信息(登录页消费,无鉴权)。短缓存减轻匿名端点压力。"""
|
||
response.headers["Cache-Control"] = "public, max-age=300"
|
||
return ok(service.to_public_out(service.get_setting(session)))
|
||
|
||
|
||
@router.get("/logo/public", summary="logo 图片(匿名)")
|
||
def get_logo_public(session: DbSession) -> FileResponse:
|
||
"""logo 文件流(无鉴权)。no-cache:图片替换后由版本参数破缓存兜底。"""
|
||
path, ext = service.read_logo(session)
|
||
return FileResponse(
|
||
path,
|
||
media_type=_LOGO_MEDIA_TYPE.get(ext, "application/octet-stream"),
|
||
headers={"Cache-Control": "no-cache"},
|
||
)
|