PayrollManagementSystem/backend/app/services/system_setting_service.py

257 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""系统设置(公司信息 + 站点信息)业务编排。
单例行语义:整个系统恒一行(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")
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
# 刚落盘的新文件一并删掉(回读 404)
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext and old_ext in allowed:
_cleanup_old_asset(kind, old_ext)
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()
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)