From eabda2c6a959b8be7d63e9d1c5c70f87afbe14de Mon Sep 17 00:00:00 2001 From: Kevin Date: Mon, 18 May 2026 15:34:50 +0800 Subject: [PATCH] chore: resolve WIP after merging internal/development - .gitignore: keep api/uploads ignore and copyright_source_listing.pdf path - auth: keep COS avatar upload URL; delete prior COS object when applying preset - i18n: regenerate resources.ts (includes profile tapAwayToClose) - Avatar/COS tests and personal-info remain from prior local work Co-authored-by: Cursor --- .gitignore | 2 + api/app/core/cos_url_keys.py | 50 +++++ api/app/features/auth/router.py | 49 ++++- api/app/features/user/repo.py | 11 ++ api/app/features/user/router.py | 3 +- api/app/features/user/service.py | 4 +- api/tests/test_avatar_preset_http.py | 82 +++++++- api/tests/test_http_contract_errors.py | 22 +++ app-expo/src/app/(main)/personal-info.tsx | 221 ++++++++++++++-------- app-expo/src/i18n/generated/resources.ts | 1 + app-expo/src/i18n/locales/en/profile.json | 1 + app-expo/src/i18n/locales/zh/profile.json | 1 + 12 files changed, 350 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index 0890d63..512d49c 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ api/models/whisper/ # 脚本输出(预览 JSON/Markdown) api/scripts/output/ +# 本地上传(不提交) +api/uploads/ # 软著:源码摘录 PDF(默认生成在仓库根目录) /copyright_source_listing.pdf diff --git a/api/app/core/cos_url_keys.py b/api/app/core/cos_url_keys.py index efc9886..3d61681 100644 --- a/api/app/core/cos_url_keys.py +++ b/api/app/core/cos_url_keys.py @@ -14,6 +14,9 @@ logger = get_logger(__name__) # 客户端再读 TTS / 拉取音频:预签名有效期(秒),与移动端会话长度匹配 TTS_PRESIGNED_EXPIRES_SEC = 86_400 +# 用户头像 API 下发(与 TTS 一致) +AVATAR_PRESIGNED_EXPIRES_SEC = TTS_PRESIGNED_EXPIRES_SEC + def extract_cos_object_key_if_owned(url: str | None) -> str | None: """ @@ -119,3 +122,50 @@ def presign_tts_urls_for_playback( else: out.append(s) return out + + +def avatar_url_for_api_response(stored_url: str | None) -> str | None: + """DB 中的头像 URL:本环境 COS 直链改为预签名下载 URL。""" + if stored_url is None: + return None + s = str(stored_url).strip() + if not s: + return None + key = extract_cos_object_key_if_owned(s) + if not key: + return s + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + return s + from app.core.dependencies import get_object_storage + + storage = get_object_storage() + try: + return storage.get_url(key, expires=AVATAR_PRESIGNED_EXPIRES_SEC) + except Exception as exc: + logger.warning( + "presign avatar url failed, keeping original: key={} err={}", + key, + exc, + ) + return s + + +def best_effort_delete_cos_object_for_url(url: str | None) -> None: + """本环境 COS 对象则尽力 delete(换头像 / 预设时清理)。""" + key = extract_cos_object_key_if_owned(url) + if not key: + return + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + return + from app.core.dependencies import get_object_storage + + try: + get_object_storage().delete(key) + except Exception as exc: + logger.warning("delete cos avatar object failed: key={} err={}", key, exc) diff --git a/api/app/features/auth/router.py b/api/app/features/auth/router.py index 799cea9..d3108ae 100644 --- a/api/app/features/auth/router.py +++ b/api/app/features/auth/router.py @@ -7,6 +7,11 @@ from fastapi.responses import FileResponse from PIL import Image from app.core.config import settings +from app.core.cos_url_keys import ( + avatar_url_for_api_response, + best_effort_delete_cos_object_for_url, + extract_cos_object_key_if_owned, +) from app.core.dependencies import get_current_user from app.core.logging import get_logger from app.features.auth.deps import get_auth_service @@ -46,7 +51,6 @@ router = APIRouter( ) AVATAR_DIR = Path("uploads/avatars") -AVATAR_DIR.mkdir(parents=True, exist_ok=True) # ── helpers ────────────────────────────────────────────────── @@ -75,7 +79,7 @@ def _user_response(user: User) -> UserResponse: phone=user.phone, email=user.email, nickname=user.nickname, - avatar_url=user.avatar_url, + avatar_url=avatar_url_for_api_response(user.avatar_url), subscription_type=user.subscription_type, created_at=user.created_at.isoformat(), language_preference=lang, @@ -278,9 +282,17 @@ async def upload_avatar( len(file_content), ) - try: - AVATAR_DIR.mkdir(parents=True, exist_ok=True) + if not ( + (settings.tencent_cos_secret_id or "").strip() + and (settings.tencent_cos_secret_key or "").strip() + and (settings.tencent_cos_bucket or "").strip() + ): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="头像存储服务未配置,请稍后再试", + ) + try: image_bytes = io.BytesIO(file_content) image_bytes.seek(0) @@ -326,14 +338,28 @@ async def upload_avatar( if size > 512: image = image.resize((512, 512), Image.Resampling.LANCZOS) - file_extension = "jpg" - filename = f"{current_user.id}.{file_extension}" - file_path = AVATAR_DIR / filename + jpeg_buffer = io.BytesIO() + image.save(jpeg_buffer, format="JPEG", quality=85, optimize=True) + jpeg_bytes = jpeg_buffer.getvalue() - image.save(file_path, "JPEG", quality=85, optimize=True) + cos_key = f"avatars/{current_user.id}.jpg" + old_url = current_user.avatar_url + old_key = extract_cos_object_key_if_owned(old_url) if old_url else None + if old_key and old_key != cos_key: + best_effort_delete_cos_object_for_url(old_url) + + from app.core.dependencies import get_object_storage + + storage = get_object_storage() + try: + avatar_url = storage.upload(cos_key, jpeg_bytes, "image/jpeg") + except Exception as exc: + logger.exception("COS 头像上传失败: {}", exc) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="头像存储暂时不可用,请稍后再试", + ) from exc - # 路径固定为 {user_id}.jpg,客户端会缓存;每次写入新文件后 bump URL 以绕过缓存。 - avatar_url = f"/api/auth/avatars/{filename}?v={time.time_ns()}" user = await service.update_avatar_url(current_user.id, avatar_url) return _user_response(user) except HTTPException: @@ -381,6 +407,8 @@ async def set_avatar_preset( status_code=status.HTTP_400_BAD_REQUEST, detail="预设头像不可用", ) + best_effort_delete_cos_object_for_url(current_user.avatar_url) + avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}" try: user = await service.update_avatar_url(current_user.id, avatar_url) @@ -410,6 +438,7 @@ async def get_avatar_preset(filename: str): responses={404: {"description": "头像不存在"}}, ) async def get_avatar(filename: str): + AVATAR_DIR.mkdir(parents=True, exist_ok=True) file_path = safe_avatar_upload_path(filename, AVATAR_DIR) if file_path is None or not file_path.exists(): raise HTTPException( diff --git a/api/app/features/user/repo.py b/api/app/features/user/repo.py index 362b174..ffc1bf8 100644 --- a/api/app/features/user/repo.py +++ b/api/app/features/user/repo.py @@ -51,6 +51,10 @@ async def clear_user_demographics(db: AsyncSession, user_id: str) -> None: ) +async def clear_user_avatar_url(db: AsyncSession, user_id: str) -> None: + await db.execute(update(User).where(User.id == user_id).values(avatar_url=None)) + + async def collect_purge_context( db: AsyncSession, user_id: str ) -> tuple[list[str], list[str], list[str]]: @@ -130,6 +134,13 @@ async def collect_object_storage_keys_before_purge( tts_urls if isinstance(tts_urls, list) else None ) + r_av = await db.execute(select(User.avatar_url).where(User.id == user_id)) + avatar_url_val = r_av.scalar_one_or_none() + if avatar_url_val: + ak = extract_cos_object_key_if_owned(avatar_url_val) + if ak: + keys.add(ak) + return sorted(keys) diff --git a/api/app/features/user/router.py b/api/app/features/user/router.py index 0b3093b..b212d0e 100644 --- a/api/app/features/user/router.py +++ b/api/app/features/user/router.py @@ -3,6 +3,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from app.core.config import settings +from app.core.cos_url_keys import avatar_url_for_api_response from app.core.dependencies import get_current_user, get_object_storage from app.core.logging import get_logger from app.features.user.deps import get_user_service @@ -50,7 +51,7 @@ async def get_user_profile( phone=current_user.phone, email=current_user.email, nickname=current_user.nickname, - avatar_url=current_user.avatar_url, + avatar_url=avatar_url_for_api_response(current_user.avatar_url), subscription_type=current_user.subscription_type, created_at=current_user.created_at.isoformat(), birth_year=current_user.birth_year, diff --git a/api/app/features/user/service.py b/api/app/features/user/service.py index dcaf72c..cee51e7 100644 --- a/api/app/features/user/service.py +++ b/api/app/features/user/service.py @@ -2,6 +2,7 @@ from datetime import timedelta from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cos_url_keys import avatar_url_for_api_response from app.core.db import utc_now from app.core.logging import get_logger from app.core.redis import redis_service @@ -32,7 +33,7 @@ def _user_to_profile(user: User) -> UserProfileResponse: phone=user.phone, email=user.email, nickname=user.nickname, - avatar_url=user.avatar_url, + avatar_url=avatar_url_for_api_response(user.avatar_url), subscription_type=user.subscription_type, created_at=user.created_at.isoformat(), birth_year=user.birth_year, @@ -121,6 +122,7 @@ class UserService: await repo.purge_user_related_rows(self._db, user_id) await repo.clear_user_demographics(self._db, user_id) + await repo.clear_user_avatar_url(self._db, user_id) await self._db.commit() logger.info("用户数据 DB 行已删除、档案字段已清空并提交 user_id={}", user_id) diff --git a/api/tests/test_avatar_preset_http.py b/api/tests/test_avatar_preset_http.py index e9d2f67..61a1957 100644 --- a/api/tests/test_avatar_preset_http.py +++ b/api/tests/test_avatar_preset_http.py @@ -4,12 +4,14 @@ from __future__ import annotations import uuid from datetime import datetime, timezone +from io import BytesIO from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from PIL import Image from app.core.dependencies import get_current_user from app.features.auth.deps import get_auth_service @@ -27,6 +29,7 @@ def _mock_current_user() -> User: u.avatar_url = None u.subscription_type = "free" u.created_at = datetime.now(timezone.utc) + u.language_preference = "zh" return u @@ -91,7 +94,6 @@ async def test_get_avatar_preset_path_traversal(preset_auth_app: FastAPI) -> Non @pytest.mark.asyncio async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: - uid = preset_auth_app.state._fixed_user.id transport = ASGITransport(app=preset_auth_app) async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.put( @@ -101,11 +103,13 @@ async def test_set_avatar_preset_ok(preset_auth_app: FastAPI) -> None: ) assert r.status_code == 200 body = r.json() - assert body["avatar_url"] == "/api/auth/avatar-presets/02.png" + url = body["avatar_url"] + assert url.startswith("/api/auth/avatar-presets/02.png") + assert "?v=" in url svc: MagicMock = preset_auth_app.state._mock_auth_service - svc.update_avatar_url.assert_awaited_once_with( - uid, "/api/auth/avatar-presets/02.png" - ) + stored = svc.update_avatar_url.await_args[0][1] + assert stored.startswith("/api/auth/avatar-presets/02.png") + assert "?v=" in stored @pytest.mark.asyncio @@ -168,3 +172,71 @@ async def test_get_uploaded_avatar_ok_when_stem_has_underscore( async with AsyncClient(transport=transport, base_url="http://test") as ac: r = await ac.get("/api/auth/avatars/user_abc_01.jpg") assert r.status_code == 200 + + +def _minimal_jpeg_bytes() -> bytes: + img = Image.new("RGB", (2, 2), color=(120, 80, 200)) + buf = BytesIO() + img.save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +@pytest.mark.asyncio +async def test_upload_avatar_cos_calls_storage_and_presigns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fastapi import FastAPI + from httpx import ASGITransport, AsyncClient + + import app.core.dependencies as deps + from app.core.config import settings + from app.core.dependencies import get_current_user + from app.features.auth.router import router as auth_router + + bucket, region = "life-test-bucket", "ap-shanghai" + uid = str(uuid.uuid4()) + public = f"https://{bucket}.cos.{region}.myqcloud.com/avatars/{uid}.jpg" + for attr, val in ( + ("tencent_cos_secret_id", "sid"), + ("tencent_cos_secret_key", "sk"), + ("tencent_cos_bucket", bucket), + ("tencent_cos_region", region), + ("tencent_cos_base_url", f"https://{bucket}.cos.{region}.myqcloud.com"), + ): + monkeypatch.setattr(settings, attr, val, raising=False) + + mock_storage = MagicMock() + mock_storage.upload = MagicMock(return_value=public) + mock_storage.get_url = MagicMock(return_value="https://example.com/signed-avatar") + monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage) + + fixed_user = _mock_current_user() + fixed_user.id = uid + fixed_user.avatar_url = None + + async def _fake_update_avatar(u: str, url: str): + fixed_user.avatar_url = url + return fixed_user + + mock_service = MagicMock(spec=AuthService) + mock_service.update_avatar_url = AsyncMock(side_effect=_fake_update_avatar) + + app = FastAPI() + app.include_router(auth_router) + app.dependency_overrides[get_auth_service] = lambda: mock_service + app.dependency_overrides[get_current_user] = lambda: fixed_user + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + r = await ac.post( + "/api/auth/me/avatar", + files={"file": ("a.jpg", BytesIO(_minimal_jpeg_bytes()), "image/jpeg")}, + headers={"Authorization": "Bearer x"}, + ) + + assert r.status_code == 200 + assert r.json()["avatar_url"] == "https://example.com/signed-avatar" + mock_storage.upload.assert_called_once() + assert mock_storage.upload.call_args[0][0] == f"avatars/{uid}.jpg" + mock_storage.get_url.assert_called_once() + assert mock_storage.get_url.call_args[0][0] == f"avatars/{uid}.jpg" diff --git a/api/tests/test_http_contract_errors.py b/api/tests/test_http_contract_errors.py index 7f145a2..bddf8ae 100644 --- a/api/tests/test_http_contract_errors.py +++ b/api/tests/test_http_contract_errors.py @@ -50,8 +50,30 @@ async def test_avatar_upload_500_detail_sanitized( ) -> None: from fastapi import FastAPI + from app.core.config import settings + import app.core.dependencies as deps + fake_user = MagicMock() fake_user.id = "user-contract-test" + fake_user.avatar_url = None + + mock_storage = MagicMock() + mock_storage.upload = MagicMock( + return_value="https://test-bucket.cos.ap-shanghai.myqcloud.com/avatars/user-contract-test/abc.jpg" + ) + monkeypatch.setattr(deps, "get_object_storage", lambda: mock_storage) + monkeypatch.setattr(settings, "tencent_cos_secret_id", "sid", raising=False) + monkeypatch.setattr(settings, "tencent_cos_secret_key", "sk", raising=False) + monkeypatch.setattr(settings, "tencent_cos_bucket", "test-bucket", raising=False) + monkeypatch.setattr( + settings, "tencent_cos_region", "ap-shanghai", raising=False + ) + monkeypatch.setattr( + settings, + "tencent_cos_base_url", + "https://test-bucket.cos.ap-shanghai.myqcloud.com", + raising=False, + ) class BoomAuth: async def update_avatar_url(self, user_id: str, avatar_url: str): diff --git a/app-expo/src/app/(main)/personal-info.tsx b/app-expo/src/app/(main)/personal-info.tsx index 342ef7d..6ce2885 100644 --- a/app-expo/src/app/(main)/personal-info.tsx +++ b/app-expo/src/app/(main)/personal-info.tsx @@ -26,6 +26,7 @@ import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { resolveApiMediaUrl } from '@/core/api/media-url'; import { ApiError } from '@/core/api/types'; +import { cn } from '@/lib/utils'; import { buildAvatarUploadFormData } from '@/features/auth/avatar-upload-form-data'; import { useAvatarPresets, @@ -131,6 +132,7 @@ export default function PersonalInfoScreen() { const avatarBusy = uploadAvatar.isPending || setPreset.isPending; const avatarUri = resolveApiMediaUrl(profile?.avatar_url ?? null); const tileSize = computePresetTileSize(); + const avatarPresetSheetMaxH = Dimensions.get('window').height * 0.88; const handleSave = async () => { const trimmed = nickname.trim(); @@ -288,92 +290,151 @@ export default function PersonalInfoScreen() { - - - {avatarStep === 'presets' ? ( - setAvatarStep('menu')} - > - {t('personalInfo.back')} - - ) : ( - - )} - - {avatarStep === 'presets' - ? t('personalInfo.presetPickTitle') - : t('personalInfo.changeAvatar')} - - + + + - {t('personalInfo.cancel')} - - - - {avatarStep === 'menu' ? ( - - - - - ) : ( - - {presetsLoading ? ( - + + {avatarStep === 'presets' ? ( + setAvatarStep('menu')} + > + + {t('personalInfo.back')} + + + ) : null} + + + + {avatarStep === 'presets' + ? t('personalInfo.presetPickTitle') + : t('personalInfo.changeAvatar')} + + + + + + {t('personalInfo.cancel')} + + + + + + {avatarStep === 'menu' ? ( + + void pickFromLibrary()} + className={cn( + 'items-center rounded-md border border-border bg-background py-1 px-3 active:bg-accent', + avatarBusy && 'opacity-50', + )} + > + + {t('personalInfo.chooseFromLibrary')} + + + setAvatarStep('presets')} + className={cn( + 'items-center rounded-md border border-border bg-background py-1 px-3 active:bg-accent', + avatarBusy && 'opacity-50', + )} + > + + {t('personalInfo.choosePreset')} + + + ) : ( - - {(presets ?? []).map((item) => { - const uri = resolveApiMediaUrl(item.url); - return ( - void applyPreset(item.id)} - style={{ - width: tileSize, - height: tileSize, - }} - > - {uri ? ( - - ) : null} - - ); - })} - + + {presetsLoading ? ( + + ) : ( + + {(presets ?? []).map((item) => { + const uri = resolveApiMediaUrl(item.url); + return ( + void applyPreset(item.id)} + style={{ + width: tileSize, + height: tileSize, + }} + > + {uri ? ( + + ) : null} + + ); + })} + + )} + )} - - )} - + + + ); diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index de4082a..b95d62c 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -233,6 +233,7 @@ interface Resources { "savePartialBody": "Your nickname was saved, but profile fields below could not be saved. Check your connection and tap Save again.", "savePartialTitle": "Partially saved", "saving": "Saving…", + "tapAwayToClose": "Tap outside to close", "title": "Personal info" }, "signOut": "Sign Out", diff --git a/app-expo/src/i18n/locales/en/profile.json b/app-expo/src/i18n/locales/en/profile.json index b368656..a1abacf 100644 --- a/app-expo/src/i18n/locales/en/profile.json +++ b/app-expo/src/i18n/locales/en/profile.json @@ -37,6 +37,7 @@ "avatarPresetFailed": "Could not set preset avatar", "avatarUploadFailed": "Could not upload avatar", "cancel": "Cancel", + "tapAwayToClose": "Tap outside to close", "birthPlacePlaceholder": "Birthplace", "birthYearPlaceholder": "Birth year", "changeAvatar": "Change photo", diff --git a/app-expo/src/i18n/locales/zh/profile.json b/app-expo/src/i18n/locales/zh/profile.json index 268434a..d27bbcf 100644 --- a/app-expo/src/i18n/locales/zh/profile.json +++ b/app-expo/src/i18n/locales/zh/profile.json @@ -37,6 +37,7 @@ "avatarPresetFailed": "设置预设头像失败", "avatarUploadFailed": "上传头像失败", "cancel": "取消", + "tapAwayToClose": "点击空白处关闭", "birthPlacePlaceholder": "出生地", "birthYearPlaceholder": "出生年份", "changeAvatar": "更换头像",