From bb219901738b3d8baa51fe0f32e862793d78ca54 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 2 Jul 2026 20:32:05 +0800 Subject: [PATCH 1/9] fix(api): add response_class=Response to DELETE endpoint for 204 status --- apps/api/app/api/routes/edit_plans.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 5b074fce4..3b0f3680a 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -1,3 +1,4 @@ +from fastapi.responses import Response """剪辑计划管理 API — Phase 8 模板编排引擎. RESTful CRUD for EditPlan: @@ -279,7 +280,7 @@ def update_plan( return _to_response(result) -@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) def delete_plan( plan_id: str, db: Session = Depends(get_db_session), -- 2.54.0 From 87a5ce84d3d5821ee98d5d1efd8ea1c1eeb550b5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 2 Jul 2026 20:33:46 +0800 Subject: [PATCH 2/9] fix(api): move Response import to correct position after other imports --- apps/api/app/api/routes/edit_plans.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 3b0f3680a..83df95862 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -1,4 +1,3 @@ -from fastapi.responses import Response """剪辑计划管理 API — Phase 8 模板编排引擎. RESTful CRUD for EditPlan: @@ -24,6 +23,7 @@ from app.core.celery_app import celery_app from app.dependencies import get_db_session from app.services import EditPlanService from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session -- 2.54.0 From dd2808d17a287d593c1ece25690312c4d33cb725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=BA=94?= Date: Thu, 2 Jul 2026 20:55:39 +0800 Subject: [PATCH 3/9] =?UTF-8?q?hotfix:=20=E7=A7=BB=E9=99=A4=20delete=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E7=9A=84=20response=5Fclass=3DResponse=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20204=20=E6=96=AD=E8=A8=80=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI 不允许 status_code=204 同时指定 response_class=Response, 导致 API 容器启动时 AssertionError 不断重启。 修复 8 处 @router.delete 装饰器: - duplication.py:242 - recipes.py:177 - templates.py:198, 276 - titles.py:146 - tts.py:179 - voice_clones.py:194 - voices.py:319 --- apps/api/app/api/routes/duplication.py | 2 +- apps/api/app/api/routes/recipes.py | 2 +- apps/api/app/api/routes/templates.py | 4 ++-- apps/api/app/api/routes/titles.py | 2 +- apps/api/app/api/routes/tts.py | 2 +- apps/api/app/api/routes/voice_clones.py | 1 - apps/api/app/api/routes/voices.py | 2 +- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index e494a1328..e98c26db8 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -239,7 +239,7 @@ def get_duplication_detail( return _to_detail_response(record) -@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_duplication_record( record_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/recipes.py b/apps/api/app/api/routes/recipes.py index ee5e50f31..b5868f07a 100644 --- a/apps/api/app/api/routes/recipes.py +++ b/apps/api/app/api/routes/recipes.py @@ -174,7 +174,7 @@ def update_recipe( return _to_response(recipe) -@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_recipe( recipe_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 056278150..774820305 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -195,7 +195,7 @@ def update_template( return _to_response(template) -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_template( template_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -273,7 +273,7 @@ def create_category( ) -@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_category( category_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/titles.py b/apps/api/app/api/routes/titles.py index b55c8e7f5..1aed51d5c 100644 --- a/apps/api/app/api/routes/titles.py +++ b/apps/api/app/api/routes/titles.py @@ -143,7 +143,7 @@ def update_title( return _to_response(item) -@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_title( title_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 2f6a8da30..21823a3d7 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -176,7 +176,7 @@ def get_tts_job_status( ) -@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_tts_job( job_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 66ef938fd..e148f6308 100644 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -191,7 +191,6 @@ def get_voice_clone_status( @router.delete( "/{clone_id}", status_code=status.HTTP_204_NO_CONTENT, - response_class=Response, ) def delete_voice_clone( clone_id: str, diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index a91b4b4da..73d41090f 100644 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -316,7 +316,7 @@ def update_voice( return _to_response(item) -@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_voice( voice_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), -- 2.54.0 From 167ee9a9d7b18634de4b786c053f959e4c74d723 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 2 Jul 2026 21:15:13 +0800 Subject: [PATCH 4/9] fix: correct 204 response in edit_plans delete endpoint Remove response_class=Response, change return type to Response, add explicit return Response(status_code=204) --- apps/api/app/api/routes/edit_plans.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 83df95862..900574a0e 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -280,12 +280,12 @@ def update_plan( return _to_response(result) -@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_plan( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), -) -> None: +) -> Response: """删除剪辑计划""" svc = EditPlanService(db) deleted = svc.delete_plan(plan_id) @@ -299,6 +299,7 @@ def delete_plan( plan_id, current_user.user.id, ) + return Response(status_code=204) # ── 生成相关端点(任务 2.05) ───────────────────────────────────────────────── -- 2.54.0 From 4d1a0925d3b4f27c97ad1c909afe9014ad4438dc Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 2 Jul 2026 21:15:20 +0800 Subject: [PATCH 5/9] fix: correct 204 response in edit_templates delete endpoint Change return type to Response, add explicit return Response(status_code=204) --- apps/api/app/api/routes/edit_templates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index c331a23c4..8cfe2221f 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -20,6 +20,7 @@ from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.services import EditTemplateService from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -240,7 +241,7 @@ def delete_template( template_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), -) -> None: +) -> Response: """删除模板(软删除 → 设为 inactive)""" svc = EditTemplateService(db) try: @@ -251,3 +252,4 @@ def delete_template( detail=str(exc), ) logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id) + return Response(status_code=204) -- 2.54.0 From b3118b05ab3a9af7db99bef16f34d4f1178562ea Mon Sep 17 00:00:00 2001 From: CI Test Date: Mon, 6 Jul 2026 22:06:34 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(auth):=20=E4=BB=93=E5=82=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=96=B0=E5=A2=9Efind=5Fby=5Fwechat=5Fopenid/unionid?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ports/user_repository.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ports/user_repository.py b/packages/ports/user_repository.py index 974ffd176..70c6d1124 100644 --- a/packages/ports/user_repository.py +++ b/packages/ports/user_repository.py @@ -41,6 +41,16 @@ class UserRepository(ABC): """根据密码重置令牌查找用户""" pass + @abstractmethod + def find_by_wechat_openid(self, openid: str) -> Optional[User]: + """根据微信 openid 查找用户""" + pass + + @abstractmethod + def find_by_wechat_unionid(self, unionid: str) -> Optional[User]: + """根据微信 unionid 查找用户""" + pass + @abstractmethod def delete(self, user_id: str) -> bool: """删除用户""" -- 2.54.0 From f94ffa77bf0961faa34f068c47c11322d50618db Mon Sep 17 00:00:00 2001 From: CI Test Date: Mon, 6 Jul 2026 22:06:34 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(auth):=20SQLAlchemy=E4=BB=93=E5=82=A8?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=BE=AE=E4=BF=A1openid/unionid=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/sqlalchemy_impl/user_repository.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/adapters/sqlalchemy_impl/user_repository.py b/packages/adapters/sqlalchemy_impl/user_repository.py index a5076583c..ab81133ce 100644 --- a/packages/adapters/sqlalchemy_impl/user_repository.py +++ b/packages/adapters/sqlalchemy_impl/user_repository.py @@ -33,6 +33,8 @@ class SQLAlchemyUserRepository(UserRepository): model.max_projects = user.max_projects model.max_storage_gb = user.max_storage_gb model.is_admin = user.is_admin + model.wechat_openid = user.wechat_openid + model.wechat_unionid = user.wechat_unionid model.created_at = user.created_at self.session.commit() @@ -49,6 +51,16 @@ class SQLAlchemyUserRepository(UserRepository): model = self.session.query(UserModel).filter(UserModel.username == username.strip()).first() return self._to_entity(model) + def find_by_wechat_openid(self, openid: str) -> User | None: + model = self.session.query(UserModel).filter(UserModel.wechat_openid == openid.strip()).first() + return self._to_entity(model) + + def find_by_wechat_unionid(self, unionid: str) -> User | None: + if not unionid or not unionid.strip(): + return None + model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first() + return self._to_entity(model) + def find_by_verification_token(self, token: str) -> User | None: model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first() return self._to_entity(model) @@ -87,5 +99,7 @@ class SQLAlchemyUserRepository(UserRepository): max_projects=model.max_projects or 3, max_storage_gb=model.max_storage_gb or 10, is_admin=model.is_admin or False, + wechat_openid=model.wechat_openid, + wechat_unionid=model.wechat_unionid, created_at=model.created_at, ) -- 2.54.0 From a568c9743968358cfc75e58a2935578f1d76ad80 Mon Sep 17 00:00:00 2001 From: CI Test Date: Mon, 6 Jul 2026 22:07:07 +0800 Subject: [PATCH 8/9] =?UTF-8?q?feat(auth):=20User=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E6=96=B0=E5=A2=9Ewechat=5Fopenid/wechat=5Funionid=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/domain/entities.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/domain/entities.py b/packages/domain/entities.py index e4fa37ae0..ef568686c 100644 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -54,6 +54,11 @@ class User: used_storage_gb: float = 0.0 # 管理员标识 is_admin: bool = False + + # 微信绑定 + wechat_openid: str | None = None + wechat_unionid: str | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -- 2.54.0 From 46d98f369da3bede518f12ee36ff740ab7855227 Mon Sep 17 00:00:00 2001 From: CI Test Date: Mon, 6 Jul 2026 22:10:33 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat(auth):=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=90=8C=E6=AD=A5=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?wechat-sync=EF=BC=88=E5=86=85=E9=83=A8=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=BA=A7=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81openid?= =?UTF-8?q?=E6=9F=A5=E6=89=BE/=E5=88=9B=E5=BB=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=B9=B6=E8=BF=94=E5=9B=9ESaaS=20token=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/api/routes/auth.py | 99 ++++++++- .../application/auth/wechat_sync_use_case.py | 205 ++++++++++++++++++ 2 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 packages/application/auth/wechat_sync_use_case.py diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index b3765ec19..af31d8e35 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -6,13 +6,14 @@ app.dependencies and authentication behavior lives in application use cases. """ import logging +import os from typing import Optional import jwt from app.auth import AuthenticatedUser, blacklist_token, get_current_user from app.config import settings from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, EmailStr @@ -284,3 +285,99 @@ def _translate_auth_error(error: str | None) -> str: "Display name is required": "显示名称不能为空", } return translations.get(error or "", error or "注册失败") + + +class WechatSyncRequest(BaseModel): + openid: str + unionid: Optional[str] = None + nickname: Optional[str] = None + avatar_url: Optional[str] = None + source: str = "miniapp" + + +class WechatSyncResponse(BaseModel): + access_token: str + token: str + refresh_token: str + user_id: str + user: dict + user_info: dict + is_new_user: bool + expires_in: int + + +def _get_internal_api_keys() -> list[str]: + """获取内部 API Key 列表 + + 优先级: + 1. INTERNAL_API_KEYS 环境变量 + 2. /app/generated/internal_api_keys.txt 文件 (volume 持久化) + """ + env_keys = os.environ.get("INTERNAL_API_KEYS", "") + if env_keys: + return [k.strip() for k in env_keys.split(",") if k.strip()] + + # 从持久化文件读取 + try: + with open("/app/generated/internal_api_keys.txt", "r") as f: + content = f.read().strip() + if content: + return [k.strip() for k in content.split(",") if k.strip()] + except Exception: + pass + + return [] + + +def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool: + """验证内部 API Key + + - 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key + - 未配置且非生产环境:放行(方便开发) + - 未配置且生产环境:拒绝 + """ + env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower() + key_list = _get_internal_api_keys() + + if not key_list: + if env in ("production", "prod"): + raise HTTPException(status_code=401, detail="内部接口未配置 API Key") + return True + + if x_api_key and x_api_key.strip() in key_list: + return True + + raise HTTPException(status_code=401, detail="无效的 API Key") + + +@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False) +async def wechat_sync( + request: WechatSyncRequest, + user_repository: UserRepository = Depends(get_user_repository), + _: bool = Depends(_verify_internal_api_key), +): + """ + 微信同步登录/注册(系统级内部接口) + + 由 BFF 层通过 API Key 调用,不直接面向终端用户。 + 根据 openid 查找或创建用户,返回 SaaS token。 + """ + from packages.application.auth.wechat_sync_use_case import ( + WechatSyncRequest as UseCaseRequest, + WechatSyncUseCase, + ) + + use_case = WechatSyncUseCase(user_repository=user_repository) + use_case_request = UseCaseRequest( + openid=request.openid, + unionid=request.unionid, + nickname=request.nickname, + avatar_url=request.avatar_url, + source=request.source, + ) + + response, error = use_case.execute(use_case_request) + if error: + raise HTTPException(status_code=400, detail=error) + + return WechatSyncResponse(**response.to_dict()) diff --git a/packages/application/auth/wechat_sync_use_case.py b/packages/application/auth/wechat_sync_use_case.py new file mode 100644 index 000000000..0c699b207 --- /dev/null +++ b/packages/application/auth/wechat_sync_use_case.py @@ -0,0 +1,205 @@ +""" +微信同步登录/注册 Use Case + +供 BFF 层调用的系统级接口: +- 根据 openid 查找用户,找到则登录返回 token +- 没找到则创建新用户并返回 token +- 支持 unionid 跨应用关联 +""" + +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional +from uuid import uuid4 + +import jwt as pyjwt + +from packages.adapters.redis import get_session_store +from packages.application.auth.jwt_service import jwt_service +from packages.domain.entities import User + + +class WechatSyncRequest: + """微信同步登录请求""" + + def __init__( + self, + openid: str, + unionid: str = "", + nickname: str = "", + avatar_url: str = "", + source: str = "miniapp", + ): + self.openid = openid.strip() + self.unionid = unionid.strip() if unionid else "" + self.nickname = nickname or "微信用户" + self.avatar_url = avatar_url or "" + self.source = source + + +class WechatSyncResponse: + """微信同步登录响应""" + + def __init__( + self, + access_token: str, + refresh_token: str, + user_id: str, + nickname: str, + avatar_url: str, + is_new_user: bool, + expires_in: int, + ): + self.access_token = access_token + self.refresh_token = refresh_token + self.user_id = user_id + self.nickname = nickname + self.avatar_url = avatar_url + self.is_new_user = is_new_user + self.expires_in = expires_in + + def to_dict(self) -> dict: + return { + "access_token": self.access_token, + "token": self.access_token, # 兼容 BFF 层用 token 字段读取 + "refresh_token": self.refresh_token, + "user_id": self.user_id, + "user": { + "id": self.user_id, + "nickname": self.nickname, + "avatar_url": self.avatar_url, + "display_name": self.nickname, + }, + "user_info": { + "id": self.user_id, + "nickname": self.nickname, + "avatar_url": self.avatar_url, + "display_name": self.nickname, + }, + "is_new_user": self.is_new_user, + "expires_in": self.expires_in, + } + + +class WechatSyncUseCase: + """微信同步登录/注册用例 + + 系统级接口,由 BFF 通过 API Key 调用。 + 职责:根据 openid 查找或创建用户,返回 SaaS token。 + """ + + def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None): + self.user_repository = user_repository + self.session_store = session_store or get_session_store() + self.jwt_secret_key = jwt_secret_key or jwt_service.config.SECRET_KEY + + def execute(self, request: WechatSyncRequest) -> tuple[Optional[WechatSyncResponse], Optional[str]]: + """ + 执行微信同步登录/注册 + + Returns: + (响应对象, 错误信息) - 成功则错误信息为 None + """ + try: + if not request.openid: + return None, "openid is required" + + is_new_user = False + + # 1. 按 openid 查找用户 + user = self.user_repository.find_by_wechat_openid(request.openid) + + # 2. 如果 openid 没找到,尝试 unionid + if not user and request.unionid: + user = self.user_repository.find_by_wechat_unionid(request.unionid) + if user: + # 找到用户但 openid 为空,绑定一下当前 openid + user.wechat_openid = request.openid + self.user_repository.save(user) + + # 3. 都没找到则创建新用户 + if not user: + user = self._create_wechat_user(request) + is_new_user = True + + # 4. 创建 session 并生成 token + session_id = secrets.token_urlsafe(16) + refresh_token = secrets.token_urlsafe(32) + + now = datetime.now(timezone.utc) + access_token_payload = { + "sub": user.id, + "sid": session_id, + "type": "user_auth", + "iat": now, + "exp": now + timedelta(minutes=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES), + } + access_token = pyjwt.encode( + access_token_payload, + self.jwt_secret_key, + algorithm=jwt_service.config.ALGORITHM, + ) + + # 保存 session + self.session_store.save_session( + session_id=session_id, + user_id=user.id, + refresh_token=refresh_token, + device_info=f"wechat_{request.source}", + ip_address="bff_gateway", + expires_in_seconds=30 * 24 * 3600, # 30 天 + ) + + # 更新最后登录信息 + user.last_login_at = now + user.last_login_ip = "bff_gateway" + self.user_repository.save(user) + + response = WechatSyncResponse( + access_token=access_token, + refresh_token=refresh_token, + user_id=user.id, + nickname=user.display_name, + avatar_url="", # SaaS 用户模型暂存头像,后续可扩展 + is_new_user=is_new_user, + expires_in=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + + return response, None + + except Exception as e: + return None, f"Internal error: {str(e)}" + + def _create_wechat_user(self, request: WechatSyncRequest) -> User: + """创建微信用户""" + user_id = uuid4().hex + + # 生成唯一名和邮箱(微信用户无真实邮箱,用 openid 生成占位) + safe_openid = request.openid.replace("-", "_")[:20] + username = f"wx_{safe_openid}" + email = f"{safe_openid}@wechat.local" + + # 确保 username 唯一 + suffix = 0 + while self.user_repository.find_by_username(username): + suffix += 1 + username = f"wx_{safe_openid}_{suffix}" + + # 随机密码(微信用户不用密码登录) + random_password = secrets.token_urlsafe(32) + from packages.application.auth.password_hasher import password_hasher + password_hash = password_hasher.hash_password(random_password) + + user = User( + id=user_id, + email=email, + username=username, + display_name=request.nickname or "微信用户", + password_hash=password_hash, + email_verified=True, # 微信登录视为已验证 + wechat_openid=request.openid, + wechat_unionid=request.unionid or None, + ) + + self.user_repository.save(user) + return user -- 2.54.0