feat(settings): 公司信息配置与 logo 管理(public 端点供登录页)
- system_settings 单行 CRUD:GET/PUT(AdminUser)+ GET /public(无鉴权, SettingPublicOut 白名单仅 company_name/logo_url 两字段,短缓存 300s) - logo 上传:后缀+magic bytes+2MB 三重校验,固定名覆盖落盘 uploads/logo/ (不使用上传方文件名,杜绝路径拼接);旧扩展名文件清理;文件流端点 no-cache,URL 带 update_time 版本参数破缓存 - logo 存目录不存库:图片入 DB 让每次读取与 mysqldump 放大传输且无法 独立替换;DB 只持一行相对路径 - 新增依赖 python-multipart(FastAPI UploadFile 必备传输层);.gitignore 增 backend/uploads/ 与 db/backups/ - 集成 11 例:单行语义/白名单字段/三重校验/越权 403/匿名 401/审计留痕 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
e95dc4b2de
commit
efed29647c
9 files changed
+433
No files matched your search
@@ -19,6 +19,7 @@ from app.api.v1.system import business_roles as system_business_roles
|
||||
from app.api.v1.system import logs as system_logs
|
||||
from app.api.v1.system import params as system_params
|
||||
from app.api.v1.system import roles as system_roles
|
||||
from app.api.v1.system import settings as system_settings
|
||||
from app.api.v1.system import users as system_users
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -36,6 +37,7 @@ system_router.include_router(system_users.router)
|
||||
system_router.include_router(system_roles.router)
|
||||
system_router.include_router(system_business_roles.router)
|
||||
system_router.include_router(system_params.router)
|
||||
system_router.include_router(system_settings.router)
|
||||
system_router.include_router(system_logs.operation_router)
|
||||
system_router.include_router(system_logs.login_router)
|
||||
v1_router.include_router(system_router)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""系统设置接口(公司信息)。
|
||||
|
||||
`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.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")
|
||||
async def upload_logo(
|
||||
file: UploadFile,
|
||||
session: DbSession,
|
||||
current_user: AdminUser,
|
||||
meta: RequestMetaDep,
|
||||
) -> ApiResponse[SettingOut]:
|
||||
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。"""
|
||||
content = await 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"},
|
||||
)
|
||||
@@ -92,6 +92,15 @@ class Settings(BaseSettings):
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> Path:
|
||||
"""上传文件根目录(公司 logo 等):backend/uploads/。
|
||||
|
||||
不叫 static/ 是刻意的:静态目录将来可能整体交给前端构建产物,
|
||||
运行期上传物必须与之隔离;该目录不入版本库(.gitignore)。
|
||||
"""
|
||||
return BACKEND_DIR / "uploads"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""系统设置(公司信息)请求/响应模型。
|
||||
|
||||
`SettingPublicOut` 与 `SettingOut` **刻意不共用**:public 端点无鉴权,
|
||||
响应字段必须白名单显式列出——将来往 SettingOut 加敏感字段时不会
|
||||
顺着复用链泄漏到匿名端点。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
"""公司信息(管理端)。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingPublicOut(BaseModel):
|
||||
"""公司信息(匿名端点白名单):登录页展示所需的两个字段。"""
|
||||
|
||||
company_name: str
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class SettingUpdateIn(BaseModel):
|
||||
"""公司信息更新。"""
|
||||
|
||||
company_name: str = Field(min_length=1, max_length=64, description="公司名称")
|
||||
@@ -0,0 +1,165 @@
|
||||
"""系统设置(公司信息)业务编排。
|
||||
|
||||
单例行语义:整个系统恒一行(id=1,迁移种子保底)。logo 只存**相对路径**
|
||||
(uploads/logo/ 下固定名覆盖),文件不入库的理由:图片进数据库会让每次
|
||||
读取与备份放大传输,且无法独立替换;DB 只持有一行指针。
|
||||
|
||||
logo 安全:后缀白名单 + magic bytes 双重校验(上传内容不可信,仅看文件名
|
||||
后缀可被伪造);固定文件名落盘,**不使用上传方文件名**——杜绝路径拼接与
|
||||
覆盖任意文件。
|
||||
"""
|
||||
|
||||
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 + APP0/JFIF 起始
|
||||
_MAGIC = {
|
||||
"png": b"\x89PNG\r\n\x1a\n",
|
||||
"jpg": b"\xff\xd8\xff",
|
||||
}
|
||||
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2MB:页眉/登录页展示尺寸上限,防上传滥用
|
||||
|
||||
SETTINGS_ID = 1 # 全系统单行主键
|
||||
|
||||
|
||||
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:
|
||||
"""管理端出参(logo_url 带更新时间版本参数,替换图片后浏览器不再用旧缓存)。"""
|
||||
return SettingOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def to_public_out(setting: SystemSetting) -> SettingPublicOut:
|
||||
"""匿名端点出参(白名单字段,独立 schema 见模块说明)。"""
|
||||
return SettingPublicOut(
|
||||
company_name=setting.company_name,
|
||||
logo_url=_logo_url(setting),
|
||||
)
|
||||
|
||||
|
||||
def _logo_url(setting: SystemSetting) -> str | None:
|
||||
if not setting.logo_path:
|
||||
return None
|
||||
version = int(setting.update_time.timestamp()) if setting.update_time else 0
|
||||
return f"/api/v1/system/settings/logo/public?v={version}"
|
||||
|
||||
|
||||
def update_company(
|
||||
session: Session,
|
||||
payload: SettingUpdateIn,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""更新公司名称(审计留痕)。"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary=f"修改公司名称为「{payload.company_name}」",
|
||||
request_data=payload.model_dump(),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
setting.company_name = payload.company_name.strip()
|
||||
session.commit()
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def save_logo(
|
||||
session: Session,
|
||||
content: bytes,
|
||||
ext: str,
|
||||
*,
|
||||
operator: User,
|
||||
meta: RequestMeta | None = None,
|
||||
) -> SettingOut:
|
||||
"""上传 logo:校验 → 固定名覆盖落盘 → 更新相对路径(审计留痕)。
|
||||
|
||||
旧扩展名文件一并清理(logo.png 换 jpg 后残留文件会继续占盘)。
|
||||
"""
|
||||
ext = ext.lower()
|
||||
if ext not in _MAGIC:
|
||||
raise BadRequestError("logo 仅支持 png/jpg 格式")
|
||||
if len(content) > LOGO_MAX_BYTES:
|
||||
raise BadRequestError("logo 文件不能超过 2MB")
|
||||
if not content.startswith(_MAGIC[ext]):
|
||||
raise BadRequestError("文件内容与扩展名不符(仅接受真实图片)")
|
||||
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_SYSTEM_SETTINGS,
|
||||
action=ACTION_UPDATE,
|
||||
summary="更新公司 logo",
|
||||
request_data={"ext": ext, "size": len(content)},
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
setting = get_setting(session)
|
||||
old_path = setting.logo_path
|
||||
|
||||
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把
|
||||
# 刚落盘的新文件一并删掉(回读 404)
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
_cleanup_old_logo(old_ext)
|
||||
|
||||
target = _logo_dir() / f"logo.{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
setting.logo_path = f"logo/logo.{ext}"
|
||||
session.commit()
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
def read_logo(session: Session) -> tuple[Path, str]:
|
||||
"""按库中记录定位 logo 文件,返回 (路径, 扩展名)。未上传抛 404。"""
|
||||
setting = get_setting(session)
|
||||
if not setting.logo_path:
|
||||
raise NotFoundError("尚未上传 logo")
|
||||
path = get_settings().upload_dir / setting.logo_path
|
||||
if not path.is_file():
|
||||
raise NotFoundError("logo 文件不存在,请重新上传")
|
||||
return path, path.suffix.lstrip(".")
|
||||
|
||||
|
||||
def _logo_dir() -> Path:
|
||||
directory = get_settings().upload_dir / "logo"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _cleanup_old_logo(old_ext: str) -> None:
|
||||
if old_ext in _MAGIC:
|
||||
old_file = _logo_dir() / f"logo.{old_ext}"
|
||||
if old_file.exists():
|
||||
old_file.unlink(missing_ok=True)
|
||||
Reference in new issue
Block a user