小程序后端接入微信登录与家长头像能力
This commit is contained in:
134
mini-program/app/service/avatar_storage.py
Normal file
134
mini-program/app/service/avatar_storage.py
Normal 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}"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
125
mini-program/app/service/wechat_login.py
Normal file
125
mini-program/app/service/wechat_login.py
Normal 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()
|
||||
Reference in New Issue
Block a user