"""系统设置(公司信息 + 站点信息)业务编排。 单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对 路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由: 图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。 图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名 后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与 覆盖任意文件。 logo 与 favicon 相互独立(2026-10-20): - logo 用于登录页品牌位; - favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg (不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。 """ import logging from pathlib import Path from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.constants import ACTION_UPDATE, MODULE_SYSTEM_SETTINGS from app.core.exceptions import BadRequestError, NotFoundError from app.models import SystemSetting, User from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn from app.services import audit_service from app.services.audit_service import AuditRecord, RequestMeta logger = logging.getLogger(__name__) # magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始 _MAGIC = { "png": b"\x89PNG\r\n\x1a\n", "jpg": b"\xff\xd8\xff", "ico": b"\x00\x00\x01\x00", } # 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico LOGO_EXTS = ("png", "jpg") FAVICON_EXTS = ("png", "jpg", "ico") ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用 SETTINGS_ID = 1 # 全系统单行主键 # 公司档案中的可选文本字段:提交空串等价清空(落库 null,前端回退内置默认) _OPTIONAL_TEXT_FIELDS = ("site_title", "brand_slogan", "footer_text", "icp_no") # 资源种类 → 中文名(错误提示与审计摘要用) _ASSET_LABELS = {"logo": "logo", "favicon": "站点图标"} _ASSET_EXTS = {"logo": LOGO_EXTS, "favicon": FAVICON_EXTS} def get_setting(session: Session) -> SystemSetting: """取公司信息单行(种子保底 id=1;防御式兜底:极端环境下缺行则补建)。""" setting = session.get(SystemSetting, SETTINGS_ID) if setting is None: setting = SystemSetting(id=SETTINGS_ID, company_name="衍水集团") session.add(setting) session.flush() return setting def to_out(setting: SystemSetting) -> SettingOut: """管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。""" return SettingOut( company_name=setting.company_name, logo_url=_asset_url(setting, "logo"), site_title=setting.site_title, brand_slogan=setting.brand_slogan, footer_text=setting.footer_text, icp_no=setting.icp_no, favicon_url=_asset_url(setting, "favicon"), ) def to_public_out(setting: SystemSetting) -> SettingPublicOut: """匿名端点出参(白名单字段,独立 schema 见模块说明)。""" return SettingPublicOut( company_name=setting.company_name, logo_url=_asset_url(setting, "logo"), site_title=setting.site_title, brand_slogan=setting.brand_slogan, footer_text=setting.footer_text, icp_no=setting.icp_no, favicon_url=_asset_url(setting, "favicon"), ) def _asset_url(setting: SystemSetting, kind: str) -> str | None: """资源 URL(kind=logo/favicon);未上传返回 None。 版本号取**上传计数器**而非 update_time:同扩展名替换时固定文件名不变 (logo_path 值不变),MySQL 的 ON UPDATE 不会刷新 update_time,用它做 ?v= 会导致浏览器一直用旧图(2026-10-20 用户反馈的换图不刷新问题)。 """ if not getattr(setting, f"{kind}_path"): return None version = getattr(setting, f"{kind}_version") or 0 return f"/api/v1/system/settings/{kind}/public?v={version}" def update_company( session: Session, payload: SettingUpdateIn, *, operator: User, meta: RequestMeta | None = None, ) -> SettingOut: """更新公司信息与站点信息(部分更新 + 审计留痕)。 仅提交的字段生效(``exclude_unset``:未提交/显式 null 保持原值), 便于「只改名称」「只改站点标题」等场景不覆盖其他字段;可选文本传 空串等价清空(落库 null)。 """ changes = payload.model_dump(exclude_unset=True, exclude_none=True) summary = "修改公司信息" if "company_name" in changes: summary = f"修改公司信息(公司名称「{changes['company_name']}」)" with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SYSTEM_SETTINGS, action=ACTION_UPDATE, summary=summary, request_data=changes or None, ), meta=meta, ): setting = get_setting(session) if "company_name" in changes: # schema 已保证提交值非空(min_length=1) setting.company_name = changes["company_name"] for field in _OPTIONAL_TEXT_FIELDS: if field in changes: setattr(setting, field, changes[field].strip() or None) session.commit() return to_out(setting) def save_logo( session: Session, content: bytes, ext: str, *, operator: User, meta: RequestMeta | None = None, ) -> SettingOut: """上传公司 logo(登录页品牌位;png/jpg ≤2MB)。""" return _save_asset(session, content, ext, kind="logo", operator=operator, meta=meta) def save_favicon( session: Session, content: bytes, ext: str, *, operator: User, meta: RequestMeta | None = None, ) -> SettingOut: """上传站点图标(浏览器标签页;png/jpg/ico ≤2MB)。""" return _save_asset(session, content, ext, kind="favicon", operator=operator, meta=meta) def _save_asset( session: Session, content: bytes, ext: str, *, kind: str, operator: User, meta: RequestMeta | None, ) -> SettingOut: """校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。 旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。 """ label = _ASSET_LABELS[kind] allowed = _ASSET_EXTS[kind] ext = ext.lower() _validate_upload(content, ext, allowed=allowed, label=label) with audit_service.audit( session, operator=operator, record=AuditRecord( module=MODULE_SYSTEM_SETTINGS, action=ACTION_UPDATE, summary=f"更新{label}", request_data={"ext": ext, "size": len(content)}, ), meta=meta, ): setting = get_setting(session) old_path = getattr(setting, f"{kind}_path") # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前, # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名 # 由写入覆盖,无需也不会误删新文件 target = _asset_dir(kind) / f"{kind}.{ext}" target.write_bytes(content) setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}") # 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存 setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1) session.commit() old_ext = Path(old_path).suffix.lstrip(".") if old_path else None if old_ext and old_ext != ext and old_ext in allowed: try: _cleanup_old_asset(kind, old_ext) except OSError: # 清理失败不影响本次更新(库与新文件均已生效);若异常外抛, # 成功的更新会被记成失败审计并 500 —— 审计与事实相反 logger.warning( "旧%s清理失败(不影响本次更新)| ext=%s", label, old_ext, exc_info=True ) logger.info("更新%s | size=%s ext=%s", label, len(content), ext) return to_out(setting) def read_logo(session: Session) -> tuple[Path, str]: """按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。""" return _read_asset(session, "logo") def read_favicon(session: Session) -> tuple[Path, str]: """按库中记录定位站点图标文件,返回 (路径, 扩展名)。未上传抛 404。""" return _read_asset(session, "favicon") def _read_asset(session: Session, kind: str) -> tuple[Path, str]: label = _ASSET_LABELS[kind] setting = get_setting(session) path_str = getattr(setting, f"{kind}_path") if not path_str: raise NotFoundError(f"尚未上传{label}") path = get_settings().upload_dir / path_str if not path.is_file(): raise NotFoundError(f"{label}文件不存在,请重新上传") return path, path.suffix.lstrip(".") def _validate_upload(content: bytes, ext: str, *, allowed: tuple[str, ...], label: str) -> None: """后缀白名单 + magic bytes + 大小三重校验(同口径供 logo/favicon 复用)。""" if ext not in allowed: raise BadRequestError(f"{label}仅支持 {'/'.join(allowed)} 格式") if len(content) > ASSET_MAX_BYTES: raise BadRequestError(f"{label}文件不能超过 2MB") if not content.startswith(_MAGIC[ext]): raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)") def _asset_dir(kind: str) -> Path: directory = get_settings().upload_dir / kind directory.mkdir(parents=True, exist_ok=True) return directory def _cleanup_old_asset(kind: str, old_ext: str) -> None: if old_ext in _ASSET_EXTS[kind]: old_file = _asset_dir(kind) / f"{kind}.{old_ext}" if old_file.exists(): old_file.unlink(missing_ok=True)