Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58d57033ea | |||
| 711d03a409 | |||
| 0554b4b1dd | |||
| 8c24e252e1 | |||
| d95ca5d723 | |||
| 0c15e5be03 | |||
| 75478e3fed | |||
| b0d6260617 | |||
| 83542cab86 | |||
| 1cd592c957 | |||
| 278208d31a | |||
| 0c636fd913 | |||
| 692d0b3c7e | |||
| d00a37fc31 | |||
| ad5bc81604 | |||
| 662de73a31 | |||
| 0e924c36e7 | |||
| 2b9fcb08b6 | |||
| 419e763ad6 | |||
| 86d9743909 | |||
| a65e6e293e | |||
| 7f21252634 | |||
| 1e483b7bf9 | |||
| 72b30d7959 | |||
| d1b934970a | |||
| d8dd510cba | |||
| f988a028fe | |||
| 57cd3d92dd | |||
| 5d18b76cea | |||
| 6d5b860952 | |||
| 12fb0a8e14 | |||
| 545ff0fab8 | |||
| 89a8c8b6fb | |||
| e4997b9b4a | |||
| 68c89861e9 | |||
| 076601b431 | |||
| e23a60e98b | |||
| e2e91e1d58 | |||
| 682972469c | |||
| f8b3552a4e | |||
| 5e0dcaead8 | |||
| 1eca707db3 | |||
| 5331307cbc | |||
| 3bea00aa56 | |||
| 1addcffeba | |||
| f3819653b8 | |||
| b73d75a3e4 | |||
| 62967e078b | |||
| 2818f44282 | |||
| c9e5129ec8 | |||
| cd3d366f4a | |||
| 1dd640da87 | |||
| 4587d0b014 | |||
| 4749071c16 | |||
| 3353865f5b | |||
| 7061d7e672 | |||
| 6efdfbe194 | |||
| 02e3246f5a | |||
| 5cdafd2559 | |||
| a6afb344ba | |||
| 504e2e71c9 | |||
| fb2884b03c | |||
| 7fab42c3d0 | |||
| 561548c84c | |||
| 77704e7ec6 | |||
| 5ae6c33bf6 | |||
| db07738178 | |||
| 53c09e7d3c | |||
| e602439769 | |||
| a26fda1597 | |||
| 99a8ffa97b | |||
| f03c9d5453 | |||
| 8322e2b6e2 | |||
| d2409e16c1 | |||
| 6eac0b2cf2 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 9b034764ad | |||
| 8748b43070 | |||
| d213a055a1 | |||
| 2371860f82 | |||
| dbd956fc6e | |||
| 1d59ee5336 |
@@ -1460,7 +1460,6 @@ jobs:
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
if: github.event_name == 'push' && github.ref_name == 'main' && !failure() && !cancelled()
|
||||
strategy:
|
||||
@@ -2117,3 +2116,4 @@ jobs:
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
# CI retry trigger
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
retrigger3
|
||||
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -2,8 +2,10 @@
|
||||
微信同步登录/注册 Use Case
|
||||
|
||||
供 BFF 层调用的系统级接口:
|
||||
- 根据 openid 查找用户,找到则登录返回 token
|
||||
- 没找到则创建新用户并返回 token
|
||||
- 优先按 unionid 识别用户(跨应用/跨端识别同一微信用户)
|
||||
- 再按 openid 识别(同一应用内)
|
||||
- openid 命中老账号但 unionid 缺失时补写 unionid(开放平台绑定前的存量账号自动关联)
|
||||
- 都未命中则创建新用户
|
||||
- 支持 unionid 跨应用关联
|
||||
"""
|
||||
|
||||
@@ -82,10 +84,10 @@ class WechatSyncResponse:
|
||||
|
||||
|
||||
class WechatSyncUseCase:
|
||||
"""微信同步登录/注册用例
|
||||
"""微信登录/注册同步用例
|
||||
|
||||
系统级接口,由 BFF 通过 API Key 调用。
|
||||
职责:根据 openid 查找或创建用户,返回 SaaS token。
|
||||
职责:根据 unionid/openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
|
||||
@@ -105,24 +107,54 @@ class WechatSyncUseCase:
|
||||
return None, "openid is required"
|
||||
|
||||
is_new_user = False
|
||||
user = None
|
||||
openid_user = None
|
||||
unionid_user = None
|
||||
|
||||
# 1. 按 openid 查找用户
|
||||
user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
# 1. 先按 unionid 查找(跨应用识别同一微信用户,优先级最高)
|
||||
if request.unionid:
|
||||
unionid_user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
|
||||
# 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)
|
||||
# 2. 再按 openid 查找(同一应用内)
|
||||
openid_user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
|
||||
# 3. 都没找到则创建新用户
|
||||
if not user:
|
||||
if unionid_user and openid_user:
|
||||
# 3a. 两边都命中
|
||||
if unionid_user.id == openid_user.id:
|
||||
# 同一个用户,直接登录
|
||||
user = unionid_user
|
||||
else:
|
||||
# unionid 与 openid 分属两个不同账号:数据异常,拒绝写入,
|
||||
# 交由人工/数据修复合并,避免账号被错误串联
|
||||
return None, ("wechat account conflict: unionid and openid bound to " "different users")
|
||||
elif unionid_user:
|
||||
# 3b. unionid 命中(跨端老用户),当前 openid 未绑定过:
|
||||
# 确认 openid 没有落在其他账号上后,把新 openid 绑到该用户
|
||||
if openid_user is not None and openid_user.id != unionid_user.id:
|
||||
return None, ("wechat account conflict: openid bound to another user")
|
||||
if unionid_user.wechat_openid != request.openid:
|
||||
unionid_user.wechat_openid = request.openid
|
||||
self.user_repository.save(unionid_user)
|
||||
user = unionid_user
|
||||
elif openid_user:
|
||||
# 3c. 仅 openid 命中(开放平台绑定前创建的存量账号):
|
||||
# 本次请求带了 unionid 且该账号还没有 unionid 时补写
|
||||
if request.unionid and not openid_user.wechat_unionid:
|
||||
# 去重:确认该 unionid 没有关联到其他用户
|
||||
conflict = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if conflict is not None and conflict.id != openid_user.id:
|
||||
return None, ("wechat account conflict: unionid already bound to " "another user")
|
||||
openid_user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(openid_user)
|
||||
user = openid_user
|
||||
else:
|
||||
# 4. 都没找到,创建新用户
|
||||
# 额外兜底:若 unionid 已被其他账号占用(理论上上面已查过),
|
||||
# 不创建带冲突 unionid 的新账号
|
||||
user = self._create_wechat_user(request)
|
||||
is_new_user = True
|
||||
|
||||
# 4. 创建 session 并生成 token
|
||||
# 5. 创建 session 并生成 token
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# 基础镜像同步脚本 - 从公共镜像源同步到私有ACR
|
||||
# 用法:
|
||||
# ACR_USERNAME=xxx ACR_PASSWORD=yyy bash scripts/ci/sync_base_images.sh
|
||||
# ============================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ACR_REGISTRY="${ACR_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
ACR_USERNAME="${ACR_USERNAME:-}"
|
||||
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
||||
SOURCE_PREFIX="${SOURCE_PREFIX:-docker.m.daocloud.io/library}"
|
||||
|
||||
# 需要同步的镜像列表 (源镜像名:tag => ACR目标名:tag)
|
||||
IMAGES=(
|
||||
"python:3.12-slim-bookworm"
|
||||
"python:3.12-slim"
|
||||
"node:20"
|
||||
"nginx:alpine"
|
||||
)
|
||||
|
||||
echo "============================================"
|
||||
echo " 基础镜像同步到 ACR"
|
||||
echo " ACR: $ACR_REGISTRY"
|
||||
echo " 源: $SOURCE_PREFIX"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 登录 ACR
|
||||
if [ -n "$ACR_PASSWORD" ] && [ -n "$ACR_USERNAME" ]; then
|
||||
echo "登录 ACR..."
|
||||
ACR_HOST=$(echo "$ACR_REGISTRY" | cut -d/ -f1)
|
||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_HOST" -u "$ACR_USERNAME" --password-stdin
|
||||
echo "ACR 登录成功"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
success=0
|
||||
failed=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
source_image="${SOURCE_PREFIX}/${image}"
|
||||
target_image="${ACR_REGISTRY}/base/${image}"
|
||||
|
||||
echo "--- 同步: $image ---"
|
||||
echo " 源: $source_image"
|
||||
echo " 目标: $target_image"
|
||||
|
||||
# Pull 源镜像(带重试)
|
||||
pulled=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Pull 尝试 $attempt/3..."
|
||||
if docker pull "$source_image"; then
|
||||
pulled=1
|
||||
break
|
||||
fi
|
||||
echo " Pull 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pulled" -eq 0 ]; then
|
||||
echo " ❌ Pull 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Tag
|
||||
docker tag "$source_image" "$target_image"
|
||||
echo " Tag 完成"
|
||||
|
||||
# Push 到 ACR
|
||||
pushed=0
|
||||
for attempt in 1 2 3; do
|
||||
echo " Push 尝试 $attempt/3..."
|
||||
if docker push "$target_image"; then
|
||||
pushed=1
|
||||
break
|
||||
fi
|
||||
echo " Push 失败,5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$pushed" -eq 1 ]; then
|
||||
echo " ✅ 同步成功: $image"
|
||||
success=$((success + 1))
|
||||
else
|
||||
echo " ❌ Push 失败: $image"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "============================================"
|
||||
echo " 同步完成"
|
||||
echo " 成功: $success"
|
||||
echo " 失败: $failed"
|
||||
echo "============================================"
|
||||
|
||||
if [ "$failed" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# CI Validate: 代码质量与安全扫描(并行Job 1/3)
|
||||
# 包含:密钥扫描、格式检查、安全扫描、依赖漏洞、死代码检测、脚本语法校验
|
||||
set -eu
|
||||
|
||||
echo "=== CI Validate: 代码质量与安全扫描 ==="
|
||||
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/6] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
--all-files \
|
||||
--exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \
|
||||
--exclude-files '\.(md|rst|txt|lock|example|sample|min\.js|min\.css|spec\.ts|test\.ts|test\.py)$' \
|
||||
--exclude-files '(package-lock|yarn\.lock|poetry\.lock|Pipfile\.lock)$' \
|
||||
--disable-plugin Base64HighEntropyString \
|
||||
--disable-plugin HexHighEntropyString \
|
||||
--disable-plugin BasicAuthDetector \
|
||||
--disable-plugin KeywordDetector \
|
||||
--disable-plugin IPPublicDetector \
|
||||
> /tmp/secrets-scan.json 2>&1
|
||||
|
||||
FOUND=$(python3 -c "
|
||||
import json
|
||||
try:
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
results = data.get('results', {})
|
||||
total = sum(len(v) for v in results.values())
|
||||
print(total)
|
||||
except Exception:
|
||||
print('error')
|
||||
")
|
||||
|
||||
echo "Secrets detected: $FOUND"
|
||||
if [ "$FOUND" != "0" ] && [ "$FOUND" != "error" ]; then
|
||||
echo ""
|
||||
echo "=== Secret details ==="
|
||||
python3 -c "
|
||||
import json
|
||||
with open('/tmp/secrets-scan.json') as f:
|
||||
data = json.load(f)
|
||||
for fpath, items in data.get('results', {}).items():
|
||||
for item in items:
|
||||
line = item.get('line_number', '?')
|
||||
stype = item.get('type', '?')
|
||||
hashed = item.get('hashed_secret', '')[:16]
|
||||
print(f' {fpath}:{line} [{stype}] {hashed}...')
|
||||
"
|
||||
echo ""
|
||||
echo "ERROR: Potential secrets detected in code!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Secret scan passed"
|
||||
|
||||
# --- 代码质量检查(全量,PR 和 push 统一标准)---
|
||||
# 历史:PR 侧用增量检查以加速,但会导致 push 侧全量检查失败时 PR 侧感知不到
|
||||
# 现在统一全量检查,确保 CI 真正保护主分支(black/isort/ruff 全量仅多几十秒)
|
||||
echo ""
|
||||
echo "=== [2/6] Code quality checks (full scan) ==="
|
||||
SCAN_MODE="full"
|
||||
echo "Full scan mode"
|
||||
python3 -m compileall -q alembic apps packages tests scripts
|
||||
python3 -m black --check --fast alembic apps packages tests scripts
|
||||
python3 -m isort --check-only alembic apps packages tests scripts
|
||||
python3 -m ruff check apps packages tests --statistics
|
||||
|
||||
echo "✅ Code quality checks passed"
|
||||
|
||||
# --- Bandit 安全扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [3/6] Security scan (bandit, advisory only) ==="
|
||||
set +e
|
||||
bandit -r apps packages -q -ll
|
||||
BANDIT_EXIT=$?
|
||||
set -e
|
||||
if [ "$BANDIT_EXIT" -ne 0 ]; then
|
||||
echo "⚠️ Bandit found security issues (advisory mode - not blocking CI)"
|
||||
else
|
||||
echo "✅ Bandit security scan passed"
|
||||
fi
|
||||
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [4/6] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
|
||||
# --- Vulture 死代码检测(仅告警) ---
|
||||
echo ""
|
||||
echo "=== [5/6] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
vulture apps packages scripts \
|
||||
--exclude "tests,test,migrations,.gitea,docs,node_modules,site-packages,*/test_*.py,*/conftest.py" \
|
||||
--min-confidence 70 \
|
||||
2>&1 | sort -t'(' -k2 -rn | head -80
|
||||
echo ""
|
||||
echo "=== vulture scan summary ==="
|
||||
echo "发现潜在死代码(可能包含框架装饰器注册的函数,为误报)"
|
||||
echo "建议:定期人工审查高置信度(>=90%)条目"
|
||||
set -e
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
echo "=== [6/6] CI & shell scripts syntax validation ==="
|
||||
SYNTAX_ERROR=0
|
||||
# 检查所有 CI shell 脚本
|
||||
for script in scripts/ci/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查所有 CI Python 脚本语法
|
||||
for script in scripts/ci/*.py; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! python3 -m py_compile "$script" 2>&1; then
|
||||
echo "❌ Python语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# 检查 .gitea/workflows 下的脚本(如果有)
|
||||
for script in .gitea/workflows/*.sh; do
|
||||
if [ -f "$script" ]; then
|
||||
if ! bash -n "$script" 2>&1; then
|
||||
echo "❌ 语法错误: $script"
|
||||
SYNTAX_ERROR=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$SYNTAX_ERROR" -ne 0 ]; then
|
||||
echo "❌ CI脚本语法校验失败,见上方错误"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All CI scripts syntax OK"
|
||||
|
||||
echo ""
|
||||
echo "=== CI Validate: 代码质量与安全扫描 全部通过 ✅ ==="
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -13,10 +13,17 @@ from packages.application.auth.wechat_sync_use_case import (
|
||||
)
|
||||
from packages.domain.entities import User
|
||||
|
||||
JWT_KEY = "test-secret-key-for-jwt-12345"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_repo():
|
||||
return MagicMock()
|
||||
repo = MagicMock()
|
||||
# 默认全部查不到,具体用例再覆盖
|
||||
repo.find_by_wechat_openid.return_value = None
|
||||
repo.find_by_wechat_unionid.return_value = None
|
||||
repo.find_by_username.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -41,40 +48,29 @@ def sample_user():
|
||||
return user
|
||||
|
||||
|
||||
class TestWechatSyncRequest:
|
||||
"""WechatSyncRequest 测试"""
|
||||
def make_use_case(repo, store):
|
||||
return WechatSyncUseCase(repo, session_store=store, jwt_secret_key=JWT_KEY)
|
||||
|
||||
|
||||
class TestWechatSyncRequest:
|
||||
def test_openid_stripped(self):
|
||||
"""openid 被 strip"""
|
||||
req = WechatSyncRequest(openid=" openid_123 ")
|
||||
assert req.openid == "openid_123"
|
||||
assert WechatSyncRequest(openid=" openid_123 ").openid == "openid_123"
|
||||
|
||||
def test_unionid_stripped(self):
|
||||
"""unionid 被 strip"""
|
||||
req = WechatSyncRequest(openid="o1", unionid=" unionid_456 ")
|
||||
assert req.unionid == "unionid_456"
|
||||
assert WechatSyncRequest(openid="o1", unionid=" unionid_456 ").unionid == "unionid_456"
|
||||
|
||||
def test_default_nickname(self):
|
||||
"""默认昵称"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.nickname == "微信用户"
|
||||
assert WechatSyncRequest(openid="o1").nickname == "微信用户"
|
||||
|
||||
def test_default_source(self):
|
||||
"""默认来源"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.source == "miniapp"
|
||||
assert WechatSyncRequest(openid="o1").source == "miniapp"
|
||||
|
||||
def test_empty_unionid(self):
|
||||
"""不传 unionid 默认为空字符串"""
|
||||
req = WechatSyncRequest(openid="o1")
|
||||
assert req.unionid == ""
|
||||
assert WechatSyncRequest(openid="o1").unionid == ""
|
||||
|
||||
|
||||
class TestWechatSyncResponse:
|
||||
"""WechatSyncResponse 测试"""
|
||||
|
||||
def test_to_dict_contains_fields(self):
|
||||
"""to_dict 包含所有必要字段"""
|
||||
resp = WechatSyncResponse(
|
||||
access_token="access_123",
|
||||
refresh_token="refresh_456",
|
||||
@@ -85,276 +81,241 @@ class TestWechatSyncResponse:
|
||||
expires_in=1800,
|
||||
)
|
||||
data = resp.to_dict()
|
||||
|
||||
assert data["access_token"] == "access_123"
|
||||
assert data["token"] == "access_123" # 兼容字段
|
||||
assert data["token"] == "access_123"
|
||||
assert data["refresh_token"] == "refresh_456"
|
||||
assert data["user_id"] == "user_001"
|
||||
assert data["is_new_user"] is False
|
||||
assert data["expires_in"] == 1800
|
||||
assert "user" in data
|
||||
assert "user_info" in data
|
||||
assert data["user"]["id"] == "user_001"
|
||||
assert data["user"]["nickname"] == "测试用户"
|
||||
assert data["user"]["display_name"] == "测试用户"
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseLoginExisting:
|
||||
"""已有用户登录测试"""
|
||||
|
||||
class TestWechatSyncLoginExisting:
|
||||
def test_login_by_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""通过 openid 登录已有用户"""
|
||||
"""openid 命中、unionid 一致,正常登录"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123", nickname="测试")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.user_id == "user_001"
|
||||
assert response.is_new_user is False
|
||||
mock_user_repo.find_by_wechat_openid.assert_called_once_with("openid_123")
|
||||
mock_session_store.save_session.assert_called_once()
|
||||
|
||||
def test_login_by_unionid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""openid 没找到,通过 unionid 找到并绑定 openid"""
|
||||
sample_user.wechat_openid = None # 没有当前 openid
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||||
)
|
||||
request = WechatSyncRequest(
|
||||
openid="new_openid",
|
||||
unionid="unionid_456",
|
||||
nickname="测试",
|
||||
)
|
||||
response, error = use_case.execute(request)
|
||||
assert err is None
|
||||
assert resp.user_id == "user_001"
|
||||
assert resp.is_new_user is False
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is False
|
||||
# 应该保存了新的 openid
|
||||
assert sample_user.wechat_openid == "new_openid"
|
||||
def test_backfill_unionid_for_legacy_openid_user(self, mock_user_repo, mock_session_store):
|
||||
"""核心修复:openid 命中的老账号没有 unionid,请求带 unionid 时补写"""
|
||||
legacy = User(
|
||||
id="legacy_001",
|
||||
email="legacy@wechat.local",
|
||||
username="wx_legacy",
|
||||
display_name="微信用户",
|
||||
password_hash="h",
|
||||
email_verified=True,
|
||||
wechat_openid="oGjxK3_old",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = legacy
|
||||
# unionid 查找:补写前确认无其他账号占用
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="oGjxK3_old", unionid="o5nVk_union")
|
||||
)
|
||||
assert err is None
|
||||
assert resp.user_id == "legacy_001"
|
||||
assert resp.is_new_user is False
|
||||
assert legacy.wechat_unionid == "o5nVk_union"
|
||||
# 至少保存过一次(补写 + 最后登录更新)
|
||||
mock_user_repo.save.assert_called()
|
||||
|
||||
def test_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""登录时更新最后登录信息"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
def test_login_by_unionid_binds_new_openid(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""unionid 命中(跨端老用户),openid 未绑定过 → 绑定新 openid"""
|
||||
sample_user.wechat_openid = None
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="new_openid", unionid="unionid_456", nickname="测试")
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
use_case.execute(request)
|
||||
assert err is None
|
||||
assert resp.is_new_user is False
|
||||
assert sample_user.wechat_openid == "new_openid"
|
||||
|
||||
def test_unionid_user_already_has_same_openid_no_extra_write(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""unionid 命中且 openid 已经是当前 openid,不额外改写"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
saved = []
|
||||
mock_user_repo.save.side_effect = lambda u: saved.append(u)
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", unionid="unionid_456")
|
||||
)
|
||||
# 只有最后登录信息那一次 save,没有绑定/补写导致的额外 save
|
||||
assert len(saved) == 1
|
||||
|
||||
def test_updates_last_login(self, mock_user_repo, mock_session_store, sample_user):
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert sample_user.last_login_at is not None
|
||||
assert sample_user.last_login_ip == "bff_gateway"
|
||||
|
||||
def test_returns_tokens(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""返回 access_token 和 refresh_token"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
resp, _ = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert resp.access_token and resp.refresh_token and resp.expires_in > 0
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
|
||||
class TestWechatSyncConflicts:
|
||||
def test_unionid_and_openid_bound_to_different_users(self, mock_user_repo, mock_session_store):
|
||||
"""unionid 与 openid 分属两个账号 → 冲突报错,不写库"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="o1",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
response, _ = use_case.execute(request)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="oX",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||||
|
||||
assert response.access_token is not None
|
||||
assert len(response.access_token) > 0
|
||||
assert response.refresh_token is not None
|
||||
assert len(response.refresh_token) > 0
|
||||
assert response.expires_in > 0
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="o1", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
# 补写不得发生
|
||||
assert ua.wechat_unionid is None
|
||||
|
||||
def test_backfill_unionid_already_used_by_other(self, mock_user_repo, mock_session_store):
|
||||
"""给 openid 老账号补 unionid 时发现 unionid 已被他人占用 → 冲突"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="o1",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="o2",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
# openid 命中 ua;unionid 首次查找(优先级查询)命中 ub
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ua
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ub
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="o1", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
assert ua.wechat_unionid is None
|
||||
|
||||
def test_unionid_user_openid_belongs_to_other(self, mock_user_repo, mock_session_store):
|
||||
"""unionid 命中 ua,但请求的 openid 属于另一个账号 ub → 冲突,不抢占 openid"""
|
||||
ua = User(
|
||||
id="ua",
|
||||
email="a@wechat.local",
|
||||
username="wxa",
|
||||
display_name="A",
|
||||
password_hash="h",
|
||||
wechat_openid="oA",
|
||||
wechat_unionid="un1",
|
||||
)
|
||||
ub = User(
|
||||
id="ub",
|
||||
email="b@wechat.local",
|
||||
username="wxb",
|
||||
display_name="B",
|
||||
password_hash="h",
|
||||
wechat_openid="oB",
|
||||
wechat_unionid=None,
|
||||
)
|
||||
mock_user_repo.find_by_wechat_openid.return_value = ub
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = ua
|
||||
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="oB", unionid="un1")
|
||||
)
|
||||
assert resp is None
|
||||
assert "conflict" in err
|
||||
assert ua.wechat_openid == "oA" # 未被改写
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseNewUser:
|
||||
"""新用户注册测试"""
|
||||
|
||||
class TestWechatSyncNewUser:
|
||||
def test_create_new_user(self, mock_user_repo, mock_session_store):
|
||||
"""openid 和 unionid 都没找到,创建新用户"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None # username 不重复
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
saved = {}
|
||||
mock_user_repo.save.side_effect = lambda u: saved.update({u.id: u})
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="new_openid_789", unionid="new_union_789", nickname="新用户")
|
||||
)
|
||||
request = WechatSyncRequest(
|
||||
openid="new_openid_789",
|
||||
unionid="new_union_789",
|
||||
nickname="新用户",
|
||||
avatar_url="https://example.com/avatar.jpg",
|
||||
)
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
assert saved_user is not None
|
||||
assert saved_user.wechat_openid == "new_openid_789"
|
||||
assert saved_user.wechat_unionid == "new_union_789"
|
||||
assert saved_user.email.endswith("@wechat.local")
|
||||
assert saved_user.username.startswith("wx_")
|
||||
assert saved_user.email_verified is True
|
||||
|
||||
def test_new_user_email_based_on_openid(self, mock_user_repo, mock_session_store):
|
||||
"""新用户邮箱基于 openid 生成"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="abcdef1234567890")
|
||||
use_case.execute(request)
|
||||
|
||||
assert "abcdef1234567890" in saved_user.email or "abcdef1234567890"[:20] in saved_user.email
|
||||
assert saved_user.email.endswith("@wechat.local")
|
||||
assert err is None
|
||||
assert resp.is_new_user is True
|
||||
u = saved[resp.user_id]
|
||||
assert u.wechat_openid == "new_openid_789"
|
||||
assert u.wechat_unionid == "new_union_789"
|
||||
assert u.email.endswith("@wechat.local")
|
||||
assert u.username.startswith("wx_")
|
||||
assert u.email_verified is True
|
||||
assert u.password_hash
|
||||
|
||||
def test_username_conflict_adds_suffix(self, mock_user_repo, mock_session_store):
|
||||
"""用户名冲突时加后缀"""
|
||||
call_count = [0]
|
||||
|
||||
def mock_find_by_username(username):
|
||||
# 前两次返回存在(模拟冲突),第三次返回 None(可用)
|
||||
def find_by_username(username):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 2:
|
||||
return MagicMock()
|
||||
return None
|
||||
return MagicMock() if call_count[0] <= 2 else None
|
||||
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.side_effect = mock_find_by_username
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="test_openid")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.is_new_user is True
|
||||
# find_by_username 被调用了多次(找不冲突的用户名)
|
||||
assert mock_user_repo.find_by_username.call_count >= 2
|
||||
|
||||
def test_new_user_has_password_hash(self, mock_user_repo, mock_session_store):
|
||||
"""新用户有随机密码哈希(不能是空的)"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = None
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = None
|
||||
mock_user_repo.find_by_username.return_value = None
|
||||
|
||||
saved_user = None
|
||||
|
||||
def capture_save(user):
|
||||
nonlocal saved_user
|
||||
saved_user = user
|
||||
|
||||
mock_user_repo.save.side_effect = capture_save
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="new_openid")
|
||||
use_case.execute(request)
|
||||
|
||||
assert saved_user.password_hash is not None
|
||||
assert len(saved_user.password_hash) > 0
|
||||
mock_user_repo.find_by_username.side_effect = find_by_username
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="test_openid"))
|
||||
assert err is None
|
||||
assert resp.is_new_user is True
|
||||
assert call_count[0] >= 2
|
||||
|
||||
|
||||
class TestWechatSyncUseCaseErrors:
|
||||
"""错误场景测试"""
|
||||
|
||||
class TestWechatSyncErrors:
|
||||
def test_empty_openid(self, mock_user_repo, mock_session_store):
|
||||
"""空 openid 返回错误"""
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "openid is required" in error
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid=""))
|
||||
assert resp is None
|
||||
assert "openid is required" in err
|
||||
|
||||
def test_exception_returns_error(self, mock_user_repo, mock_session_store):
|
||||
"""异常时返回友好错误"""
|
||||
mock_user_repo.find_by_wechat_openid.side_effect = Exception("DB error")
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123")
|
||||
response, error = use_case.execute(request)
|
||||
|
||||
assert response is None
|
||||
assert "Internal error" in error
|
||||
mock_user_repo.find_by_wechat_unionid.side_effect = Exception("DB error")
|
||||
resp, err = make_use_case(mock_user_repo, mock_session_store).execute(WechatSyncRequest(openid="openid_123"))
|
||||
assert resp is None
|
||||
assert "Internal error" in err
|
||||
|
||||
|
||||
class TestWechatSyncSession:
|
||||
"""Session 相关测试"""
|
||||
|
||||
def test_session_saved(self, mock_user_repo, mock_session_store, sample_user):
|
||||
"""登录时保存 session"""
|
||||
mock_user_repo.find_by_wechat_openid.return_value = sample_user
|
||||
mock_user_repo.save.return_value = sample_user
|
||||
|
||||
use_case = WechatSyncUseCase(
|
||||
mock_user_repo,
|
||||
session_store=mock_session_store,
|
||||
jwt_secret_key="test-secret-key-for-jwt-12345",
|
||||
mock_user_repo.find_by_wechat_unionid.return_value = sample_user
|
||||
make_use_case(mock_user_repo, mock_session_store).execute(
|
||||
WechatSyncRequest(openid="openid_123", source="miniapp")
|
||||
)
|
||||
request = WechatSyncRequest(openid="openid_123", source="miniapp")
|
||||
use_case.execute(request)
|
||||
|
||||
mock_session_store.save_session.assert_called_once()
|
||||
call_kwargs = mock_session_store.save_session.call_args[1]
|
||||
assert call_kwargs["user_id"] == "user_001"
|
||||
assert "wechat_miniapp" in call_kwargs["device_info"]
|
||||
assert call_kwargs["expires_in_seconds"] == 30 * 24 * 3600
|
||||
kw = mock_session_store.save_session.call_args[1]
|
||||
assert kw["user_id"] == "user_001"
|
||||
assert "wechat_miniapp" in kw["device_info"]
|
||||
assert kw["expires_in_seconds"] == 30 * 24 * 3600
|
||||
|
||||
Reference in New Issue
Block a user