"""系统设置接口(公司信息 + 站点信息)。 `GET /public`、`GET /logo/public`、`GET /favicon/public` **无鉴权**:登录页 渲染公司名/logo、浏览器加载标签页图标时尚无令牌。响应经 `SettingPublicOut` 白名单收敛(见 schemas/setting.py),仅暴露展示必需字段。 `/logo/public` 与 `/favicon/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"} _FAVICON_MEDIA_TYPE = {"png": "image/png", "jpg": "image/jpeg", "ico": "image/x-icon"} @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.ASSET_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.post("/favicon", response_model=ApiResponse[SettingOut], summary="上传站点图标") def upload_favicon( file: UploadFile, session: DbSession, current_user: AdminUser, meta: RequestMetaDep, ) -> ApiResponse[SettingOut]: """上传站点图标(png/jpg/ico ≤2MB;固定名覆盖,旧扩展名文件清理)。 同步 `def` 与 logo 上传同口径:磁盘写与审计提交均为阻塞操作。 """ if file.size is not None and file.size > service.ASSET_MAX_BYTES: raise BadRequestError("站点图标文件不能超过 2MB") content = file.file.read() ext = (file.filename or "").rsplit(".", 1)[-1] return ok( service.save_favicon(session, content, ext, operator=current_user, meta=meta), message="站点图标已更新", ) @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"}, ) @router.get("/favicon/public", summary="站点图标(匿名)") def get_favicon_public(session: DbSession) -> FileResponse: """站点图标文件流(无鉴权)。no-cache:图标替换后由版本参数破缓存兜底。""" path, ext = service.read_favicon(session) return FileResponse( path, media_type=_FAVICON_MEDIA_TYPE.get(ext, "application/octet-stream"), headers={"Cache-Control": "no-cache"}, )