528f56254d
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 17s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m17s
CI/CD Pipeline / Validate - Style (push) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
AI Code Review / AI Code Review (pull_request) Successful in 3m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m10s
CI/CD Pipeline / Unit Tests (push) Successful in 8m18s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 11m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
209 lines
6.8 KiB
Python
209 lines
6.8 KiB
Python
"""
|
|
微信同步登录/注册 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,
|
|
# 微信新建用户首次登录需引导设置昵称
|
|
profile_completed=False,
|
|
)
|
|
|
|
self.user_repository.save(user)
|
|
return user
|