小程序后端接入微信登录与家长头像能力

This commit is contained in:
stu2not
2026-04-16 18:19:19 +08:00
parent db888df50f
commit 2526365af1
10 changed files with 515 additions and 77 deletions

View File

@@ -0,0 +1,134 @@
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
try:
from qcloud_cos import CosConfig, CosS3Client
except ModuleNotFoundError: # pragma: no cover - exercised in runtime env
CosConfig = None
CosS3Client = None
from app.settings import settings
_CONTENT_TYPE_TO_EXT = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
_EXTENSION_ALIASES = {
".jpg": "jpg",
".jpeg": "jpg",
".png": "png",
".webp": "webp",
}
_EXT_TO_CONTENT_TYPE = {
"jpg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
}
class AvatarStorageError(Exception):
def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class StoredAvatar:
file_key: str
class AvatarStorageService:
def __init__(self) -> None:
self._client = None
def upload_avatar(
self,
*,
user_id: int,
filename: str | None,
content_type: str | None,
content: bytes,
) -> StoredAvatar:
self._assert_ready()
normalized_ext = self._normalize_extension(filename=filename, content_type=content_type)
if not content:
raise AvatarStorageError("avatar file is empty")
if len(content) > settings.cos_avatar_max_bytes:
raise AvatarStorageError("avatar file too large", status_code=413)
key = self._build_key(user_id=user_id, extension=normalized_ext)
self._get_client().put_object(
Bucket=settings.cos_bucket_ava,
Body=content,
Key=key,
ContentType=_EXT_TO_CONTENT_TYPE[normalized_ext],
EnableMD5=False,
)
return StoredAvatar(file_key=key)
def get_avatar_url(self, file_key: str) -> str:
self._assert_ready()
if not file_key:
raise AvatarStorageError("avatar file key is required", status_code=500)
return self._get_client().get_presigned_url(
Bucket=settings.cos_bucket_ava,
Key=file_key,
Method="GET",
Expired=settings.cos_avatar_url_expire_seconds,
)
def delete_avatar(self, file_key: str) -> None:
self._assert_ready()
if not file_key:
return
self._get_client().delete_object(Bucket=settings.cos_bucket_ava, Key=file_key)
def _assert_ready(self) -> None:
if CosConfig is None or CosS3Client is None:
raise AvatarStorageError("COS SDK is not installed", status_code=500)
required_pairs = {
"COS_SECRET_ID": settings.cos_secret_id,
"COS_SECRET_KEY": settings.cos_secret_key,
"COS_REGION": settings.cos_region,
"COS_BUCKET_AVA": settings.cos_bucket_ava,
}
missing = [key for key, value in required_pairs.items() if not value]
if missing:
raise AvatarStorageError(
f"missing COS avatar config: {', '.join(missing)}",
status_code=500,
)
def _get_client(self):
if self._client is None:
config = CosConfig(
Region=settings.cos_region,
SecretId=settings.cos_secret_id,
SecretKey=settings.cos_secret_key,
Scheme="https",
)
self._client = CosS3Client(config)
return self._client
def _normalize_extension(self, *, filename: str | None, content_type: str | None) -> str:
if content_type in _CONTENT_TYPE_TO_EXT:
return _CONTENT_TYPE_TO_EXT[content_type]
suffix = Path(filename or "").suffix.lower()
if suffix in _EXTENSION_ALIASES:
return _EXTENSION_ALIASES[suffix]
raise AvatarStorageError("unsupported avatar file type", status_code=415)
def _build_key(self, *, user_id: int, extension: str) -> str:
prefix = settings.cos_avatar_prefix.strip("/") or "avatars"
now = datetime.now(UTC)
return (
f"{prefix}/{user_id}/{now.strftime('%Y/%m/%d')}/"
f"{uuid4().hex}.{extension}"
)

View File

@@ -2,12 +2,14 @@ from collections.abc import Mapping
from typing import Optional
from app.dao.parent import ParentDAO
from app.service import get_db_session
from app.service.avatar_storage import AvatarStorageService
from app.settings import settings
class ParentService:
def __init__(self, db):
def __init__(self, db, avatar_storage: AvatarStorageService | None = None):
self.dao = ParentDAO(db)
self.avatar_storage = avatar_storage or AvatarStorageService()
def create(
self,
@@ -17,10 +19,10 @@ class ParentService:
avatar_url: Optional[str] = None,
) -> Mapping:
user_id = self.dao.upsert(openid, unionid, nickname, avatar_url)
return self.dao.get_by_id(user_id)
return self.get(user_id)
def get(self, user_id: int) -> Optional[Mapping]:
return self.dao.get_by_id(user_id)
return self._present_parent(self.dao.get_by_id(user_id))
def update(
self,
@@ -30,4 +32,71 @@ class ParentService:
phone: Optional[str] = None,
) -> Mapping:
self.dao.update(user_id, nickname, avatar_url, phone)
return self.dao.get_by_id(user_id)
return self.get(user_id)
def upload_avatar(
self,
*,
user_id: int,
filename: str | None,
content_type: str | None,
content: bytes,
) -> Optional[Mapping]:
existing = self.dao.get_by_id(user_id)
if not existing:
return None
stored = self.avatar_storage.upload_avatar(
user_id=user_id,
filename=filename,
content_type=content_type,
content=content,
)
old_avatar_file_key = existing.get("avatar_file_key")
try:
self.dao.set_avatar_file_key(user_id, stored.file_key)
except Exception:
try:
self.avatar_storage.delete_avatar(stored.file_key)
except Exception:
pass
raise
if old_avatar_file_key and old_avatar_file_key != stored.file_key:
try:
self.avatar_storage.delete_avatar(old_avatar_file_key)
except Exception:
pass
return self.get(user_id)
def get_avatar_download(self, user_id: int) -> Optional[dict]:
parent = self.dao.get_by_id(user_id)
if not parent:
return None
avatar_file_key = parent.get("avatar_file_key")
if avatar_file_key:
return {
"avatar_url": self.avatar_storage.get_avatar_url(avatar_file_key),
"expires_in": settings.cos_avatar_url_expire_seconds,
}
avatar_url = parent.get("avatar_url")
if avatar_url:
return {
"avatar_url": avatar_url,
"expires_in": None,
}
return None
def _present_parent(self, parent: Optional[Mapping]) -> Optional[dict]:
if not parent:
return None
data = dict(parent)
avatar_file_key = data.get("avatar_file_key")
if avatar_file_key:
data["avatar_url"] = self.avatar_storage.get_avatar_url(avatar_file_key)
return data

View File

@@ -0,0 +1,125 @@
import logging
from dataclasses import dataclass
import httpx
try:
from app.settings import settings
except ModuleNotFoundError:
from settings import settings
logger = logging.getLogger("app.wechat_login")
INVALID_CODE_ERRCODES = {40029, 40163}
MISCONFIGURED_APP_ERRCODES = {40013, 40125}
@dataclass(frozen=True)
class WechatCodeSession:
openid: str
session_key: str
unionid: str | None = None
class WechatAuthError(Exception):
def __init__(self, detail: str, *, status_code: int, errcode: int | None = None):
super().__init__(detail)
self.status_code = status_code
self.errcode = errcode
class WechatAuthService:
def __init__(self, client: httpx.Client | None = None):
self._client = client
def exchange_code(self, code: str) -> WechatCodeSession:
if not settings.wechat_app_id or not settings.wechat_app_secret:
raise WechatAuthError("wechat login is not configured", status_code=503)
response = self._request_code2session(code)
data = self._parse_response_json(response)
errcode = self._parse_errcode(data.get("errcode"))
if errcode not in (None, 0):
errmsg = data.get("errmsg")
logger.warning(
"wechat code2session rejected login code",
extra={
"event": "wechat_code2session_rejected",
"errcode": errcode,
"errmsg": errmsg,
},
)
raise self._map_exchange_error(errcode)
openid = data.get("openid")
session_key = data.get("session_key")
unionid = data.get("unionid")
if not isinstance(openid, str) or not openid:
raise WechatAuthError("wechat login response missing openid", status_code=502)
if not isinstance(session_key, str) or not session_key:
raise WechatAuthError("wechat login response missing session_key", status_code=502)
if not isinstance(unionid, str) or not unionid:
unionid = None
return WechatCodeSession(openid=openid, session_key=session_key, unionid=unionid)
def _request_code2session(self, code: str) -> httpx.Response:
params = {
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
"js_code": code,
"grant_type": "authorization_code",
}
client = self._client
owns_client = client is None
if client is None:
client = httpx.Client(
base_url=settings.wechat_api_base_url.rstrip("/"),
timeout=settings.wechat_http_timeout_seconds,
)
try:
response = client.get("/sns/jscode2session", params=params)
response.raise_for_status()
return response
except httpx.HTTPError as exc:
logger.warning(
"wechat code2session request failed",
extra={"event": "wechat_code2session_request_failed"},
)
raise WechatAuthError("wechat login service unavailable", status_code=502) from exc
finally:
if owns_client:
client.close()
def _parse_response_json(self, response: httpx.Response) -> dict:
try:
data = response.json()
except ValueError as exc:
raise WechatAuthError("invalid response from wechat login service", status_code=502) from exc
if not isinstance(data, dict):
raise WechatAuthError("invalid response from wechat login service", status_code=502)
return data
def _parse_errcode(self, value: object) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _map_exchange_error(self, errcode: int) -> WechatAuthError:
if errcode in INVALID_CODE_ERRCODES:
return WechatAuthError("invalid or expired wechat login code", status_code=401, errcode=errcode)
if errcode in MISCONFIGURED_APP_ERRCODES:
return WechatAuthError("wechat login is not configured correctly", status_code=503, errcode=errcode)
return WechatAuthError("wechat login service unavailable", status_code=502, errcode=errcode)
def get_wechat_auth_service() -> WechatAuthService:
return WechatAuthService()