包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.6 KiB
Python
126 lines
3.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
小程序头像上传路由。
|
||
|
||
端点清单:
|
||
- POST /api/xcx/avatar/upload — 上传头像(chooseAvatar 临时文件 → 服务器持久化)
|
||
- GET /api/xcx/avatar/{user_id} — 获取头像文件(静态文件服务)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||
from fastapi.responses import FileResponse
|
||
|
||
from app import config
|
||
from app.auth.dependencies import CurrentUser, get_current_user_or_limited
|
||
from app.database import get_connection
|
||
from app.schemas.base import CamelModel
|
||
from app.trace.decorators import trace_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/xcx/avatar", tags=["小程序头像"])
|
||
|
||
|
||
def _get_avatar_dir() -> Path:
|
||
"""获取头像存储目录,不存在则创建。"""
|
||
avatar_path = config.AVATAR_EXPORT_PATH
|
||
if not avatar_path:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="AVATAR_EXPORT_PATH 未配置",
|
||
)
|
||
p = Path(avatar_path)
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
return p
|
||
|
||
|
||
class AvatarUploadResponse(CamelModel):
|
||
"""头像上传响应。"""
|
||
avatar_url: str
|
||
|
||
|
||
# ── POST /api/xcx/avatar/upload ──────────────────────────
|
||
|
||
@router.post("/upload", response_model=AvatarUploadResponse)
|
||
@trace_service("上传头像", "Upload avatar")
|
||
async def upload_avatar(
|
||
file: UploadFile = File(...),
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
接收小程序 chooseAvatar 上传的头像文件。
|
||
|
||
流程:
|
||
1. 读取上传文件内容
|
||
2. 保存到 AVATAR_EXPORT_PATH/{user_id}.jpg(覆盖式,幂等)
|
||
3. 更新 auth.users.avatar_url
|
||
4. 返回相对路径
|
||
"""
|
||
avatar_dir = _get_avatar_dir()
|
||
# 固定 jpg 后缀,覆盖式保存
|
||
filename = f"{user.user_id}.jpg"
|
||
filepath = avatar_dir / filename
|
||
relative_url = f"avatars/{filename}"
|
||
|
||
# 读取并保存文件
|
||
content = await file.read()
|
||
if len(content) == 0:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="上传文件为空",
|
||
)
|
||
# 限制 2MB
|
||
if len(content) > 2 * 1024 * 1024:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||
detail="头像文件不能超过 2MB",
|
||
)
|
||
|
||
filepath.write_bytes(content)
|
||
logger.info("头像已保存: user_id=%s, path=%s", user.user_id, filepath)
|
||
|
||
# 更新数据库
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE auth.users SET avatar_url = %s WHERE id = %s",
|
||
(relative_url, user.user_id),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
return AvatarUploadResponse(avatar_url=relative_url)
|
||
|
||
|
||
# ── GET /api/xcx/avatar/{user_id} ────────────────────────
|
||
|
||
@router.get("/{user_id}")
|
||
async def get_avatar(user_id: int):
|
||
"""
|
||
获取用户头像文件。
|
||
|
||
无需鉴权(头像为公开资源,通过 user_id 访问)。
|
||
文件不存在时返回 404。
|
||
"""
|
||
avatar_dir = _get_avatar_dir()
|
||
filepath = avatar_dir / f"{user_id}.jpg"
|
||
|
||
if not filepath.exists():
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="头像不存在",
|
||
)
|
||
|
||
return FileResponse(
|
||
path=str(filepath),
|
||
media_type="image/jpeg",
|
||
headers={"Cache-Control": "public, max-age=3600"},
|
||
)
|