2026-10-19 23:06:01 -04:00
|
|
|
|
"""系统设置(公司信息 + 站点信息)业务编排。
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo/favicon 只存**相对
|
|
|
|
|
|
路径**(uploads/logo、uploads/favicon 下固定名覆盖),文件不入库的理由:
|
|
|
|
|
|
图片进数据库会让每次读取与备份放大传输,且无法独立替换;DB 只持有指针。
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
图像安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
2026-10-15 06:19:58 -04:00
|
|
|
|
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
|
|
|
|
|
覆盖任意文件。
|
2026-10-19 23:06:01 -04:00
|
|
|
|
|
|
|
|
|
|
logo 与 favicon 相互独立(2026-10-20):
|
|
|
|
|
|
- logo 用于登录页品牌位;
|
|
|
|
|
|
- favicon 用于浏览器标签页图标,未上传时前端使用内置 favicon.svg
|
|
|
|
|
|
(不再跟随 logo——公司 logo 是横版,作标签页图标常被裁切)。
|
2026-10-15 06:19:58 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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__)
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
# magic bytes:PNG 8 字节签名;JPEG 以 SOI 起始;ICO 以 00 00 01 00 起始
|
2026-10-15 06:19:58 -04:00
|
|
|
|
_MAGIC = {
|
|
|
|
|
|
"png": b"\x89PNG\r\n\x1a\n",
|
|
|
|
|
|
"jpg": b"\xff\xd8\xff",
|
2026-10-19 23:06:01 -04:00
|
|
|
|
"ico": b"\x00\x00\x01\x00",
|
2026-10-15 06:19:58 -04:00
|
|
|
|
}
|
2026-10-19 23:06:01 -04:00
|
|
|
|
# 允许的后缀集合:logo 沿用 png/jpg(页眉/登录页展示);favicon 额外接受 ico
|
|
|
|
|
|
LOGO_EXTS = ("png", "jpg")
|
|
|
|
|
|
FAVICON_EXTS = ("png", "jpg", "ico")
|
|
|
|
|
|
ASSET_MAX_BYTES = 2 * 1024 * 1024 # 2MB:展示尺寸上限,防上传滥用
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
SETTINGS_ID = 1 # 全系统单行主键
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
# 公司档案中的可选文本字段:提交空串等价清空(落库 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}
|
|
|
|
|
|
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-19 23:06:01 -04:00
|
|
|
|
"""管理端出参(图片地址带更新时间版本参数,替换后浏览器不再用旧缓存)。"""
|
2026-10-15 06:19:58 -04:00
|
|
|
|
return SettingOut(
|
|
|
|
|
|
company_name=setting.company_name,
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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"),
|
2026-10-15 06:19:58 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
|
|
|
|
|
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
|
|
|
|
|
return SettingPublicOut(
|
|
|
|
|
|
company_name=setting.company_name,
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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"),
|
2026-10-15 06:19:58 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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"):
|
2026-10-15 06:19:58 -04:00
|
|
|
|
return None
|
2026-10-19 23:06:01 -04:00
|
|
|
|
version = getattr(setting, f"{kind}_version") or 0
|
|
|
|
|
|
return f"/api/v1/system/settings/{kind}/public?v={version}"
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_company(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
payload: SettingUpdateIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> SettingOut:
|
2026-10-19 23:06:01 -04:00
|
|
|
|
"""更新公司信息与站点信息(部分更新 + 审计留痕)。
|
|
|
|
|
|
|
|
|
|
|
|
仅提交的字段生效(``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']}」)"
|
2026-10-15 06:19:58 -04:00
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_SYSTEM_SETTINGS,
|
|
|
|
|
|
action=ACTION_UPDATE,
|
2026-10-19 23:06:01 -04:00
|
|
|
|
summary=summary,
|
|
|
|
|
|
request_data=changes or None,
|
2026-10-15 06:19:58 -04:00
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
setting = get_setting(session)
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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)
|
2026-10-15 06:19:58 -04:00
|
|
|
|
session.commit()
|
|
|
|
|
|
return to_out(setting)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_logo(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
content: bytes,
|
|
|
|
|
|
ext: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> SettingOut:
|
2026-10-19 23:06:01 -04:00
|
|
|
|
"""上传公司 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:
|
|
|
|
|
|
"""校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
|
|
|
|
|
"""
|
2026-10-19 23:06:01 -04:00
|
|
|
|
label = _ASSET_LABELS[kind]
|
|
|
|
|
|
allowed = _ASSET_EXTS[kind]
|
2026-10-15 06:19:58 -04:00
|
|
|
|
ext = ext.lower()
|
2026-10-19 23:06:01 -04:00
|
|
|
|
_validate_upload(content, ext, allowed=allowed, label=label)
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
with audit_service.audit(
|
|
|
|
|
|
session,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
record=AuditRecord(
|
|
|
|
|
|
module=MODULE_SYSTEM_SETTINGS,
|
|
|
|
|
|
action=ACTION_UPDATE,
|
2026-10-19 23:06:01 -04:00
|
|
|
|
summary=f"更新{label}",
|
2026-10-15 06:19:58 -04:00
|
|
|
|
request_data={"ext": ext, "size": len(content)},
|
|
|
|
|
|
),
|
|
|
|
|
|
meta=meta,
|
|
|
|
|
|
):
|
|
|
|
|
|
setting = get_setting(session)
|
2026-10-19 23:06:01 -04:00
|
|
|
|
old_path = getattr(setting, f"{kind}_path")
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
|
|
|
|
|
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
|
|
|
|
|
|
# 刚落盘的新文件一并删掉(回读 404)
|
|
|
|
|
|
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
2026-10-19 23:06:01 -04:00
|
|
|
|
if old_ext and old_ext != ext and old_ext in allowed:
|
|
|
|
|
|
_cleanup_old_asset(kind, old_ext)
|
2026-10-15 06:19:58 -04:00
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
target = _asset_dir(kind) / f"{kind}.{ext}"
|
2026-10-15 06:19:58 -04:00
|
|
|
|
target.write_bytes(content)
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
setattr(setting, f"{kind}_path", f"{kind}/{kind}.{ext}")
|
|
|
|
|
|
# 版本号每次上传必增:即使同扩展名覆盖(路径不变)也能破浏览器缓存
|
|
|
|
|
|
setattr(setting, f"{kind}_version", (getattr(setting, f"{kind}_version") or 0) + 1)
|
2026-10-15 06:19:58 -04:00
|
|
|
|
session.commit()
|
2026-10-19 23:06:01 -04:00
|
|
|
|
logger.info("更新%s | size=%s ext=%s", label, len(content), ext)
|
2026-10-15 06:19:58 -04:00
|
|
|
|
return to_out(setting)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_logo(session: Session) -> tuple[Path, str]:
|
|
|
|
|
|
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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]
|
2026-10-15 06:19:58 -04:00
|
|
|
|
setting = get_setting(session)
|
2026-10-19 23:06:01 -04:00
|
|
|
|
path_str = getattr(setting, f"{kind}_path")
|
|
|
|
|
|
if not path_str:
|
|
|
|
|
|
raise NotFoundError(f"尚未上传{label}")
|
|
|
|
|
|
path = get_settings().upload_dir / path_str
|
2026-10-15 06:19:58 -04:00
|
|
|
|
if not path.is_file():
|
2026-10-19 23:06:01 -04:00
|
|
|
|
raise NotFoundError(f"{label}文件不存在,请重新上传")
|
2026-10-15 06:19:58 -04:00
|
|
|
|
return path, path.suffix.lstrip(".")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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
|
2026-10-15 06:19:58 -04:00
|
|
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
return directory
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 23:06:01 -04:00
|
|
|
|
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}"
|
2026-10-15 06:19:58 -04:00
|
|
|
|
if old_file.exists():
|
|
|
|
|
|
old_file.unlink(missing_ok=True)
|