Compare commits
3
Commits
a1d0171fc5
...
90349f735a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90349f735a | ||
|
|
df460eeea8 | ||
|
|
5c4f1fc75a |
+3
-2
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from apps.admin.routers import assets, gaps, kb, rules
|
||||
from apps.admin.routers import assets, gaps, kb, rules, stats, users
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.db import dispose_engine, get_session_factory
|
||||
from packages.shared.handlers import install_exception_handlers
|
||||
@@ -71,7 +71,8 @@ def create_admin_app(settings: Settings | None = None) -> FastAPI:
|
||||
setup_tracing(app, cfg)
|
||||
|
||||
if deployed:
|
||||
for router in (kb.router, rules.router, assets.router, gaps.router):
|
||||
routers = (kb.router, rules.router, assets.router, gaps.router, users.router, stats.router)
|
||||
for router in routers:
|
||||
app.include_router(router, prefix=ADMIN_API_PREFIX)
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""行为统计:原始事件流 + 按天 × event_name 的 PV 透视(FR-ADM 行为分析能力)。
|
||||
|
||||
只读透视,直接走 ``ob_event``(ix_ob_event_user_ts / ix_ob_event_name_ts 两个
|
||||
索引支撑这里的过滤与排序)。日桶统一 UTC+8 自然日(与 billing 的日预算桶、
|
||||
sms/email 频控桶同一时区纪律),在 SQL 里 ``+ interval '8 hours'`` 后
|
||||
``date_trunc``——不把整表捞回来再按 Python 侧分桶。
|
||||
|
||||
ob_event 数据量会先于其他表膨胀:所有端点强制分页(page_size 上限 200),
|
||||
时间范围过滤是推荐用法而非可选项。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Date, cast, desc, func, select, text
|
||||
|
||||
from apps.admin.deps import SessionDep, admin_auth
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.models import ObEvent, User
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(tags=["admin:stats"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
#: 原始事件流分页上限(契约固定值:ob_event 量大,不许一次拉爆)。
|
||||
MAX_PAGE_SIZE = 200
|
||||
|
||||
#: 日桶时区:与 billing/频控的 UTC+8 自然窗口一致(FR-BILL-01 纪律)。
|
||||
_DAY_BUCKET_OFFSET = text("INTERVAL '8 hours'")
|
||||
|
||||
#: 总览回溯窗口上限:90 天,防 ``days=100000`` 把整表做 group by。
|
||||
MAX_OVERVIEW_DAYS = 90
|
||||
|
||||
|
||||
def _day_bucket() -> Any:
|
||||
"""server_ts 的 UTC+8 自然日桶(SQL 表达式,group by / select 两用)。"""
|
||||
return cast(func.date_trunc("day", ObEvent.server_ts + _DAY_BUCKET_OFFSET), Date).label("day")
|
||||
|
||||
|
||||
def _serialize_event(event: ObEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"event_id": str(event.event_id),
|
||||
"user_id": str(event.user_id),
|
||||
"event_name": event.event_name,
|
||||
"props": event.props,
|
||||
"client_ts": event.client_ts.isoformat() if event.client_ts else None,
|
||||
"server_ts": event.server_ts.isoformat() if event.server_ts else None,
|
||||
}
|
||||
|
||||
|
||||
def _parse_ts(raw: str, *, param: str) -> datetime:
|
||||
"""时间参数解析:ISO 8601 或 epoch 秒(int/float)都接受。
|
||||
|
||||
无时区的 ISO 按 UTC 解释(与服务端落库口径一致)。
|
||||
"""
|
||||
try:
|
||||
epoch = float(raw)
|
||||
except ValueError:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
except ValueError as exc:
|
||||
raise AppError(
|
||||
ErrorCode.BAD_REQUEST,
|
||||
message=f"{param} 时间格式不合法",
|
||||
hint="用 ISO 时间或 epoch 秒",
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
return datetime.fromtimestamp(epoch, tz=UTC)
|
||||
|
||||
|
||||
@router.get("/events", summary="原始事件流:user/event_name/时间范围过滤 + 分页")
|
||||
async def list_events(
|
||||
session: SessionDep,
|
||||
user_id: int | None = Query(default=None),
|
||||
event_name: str | None = Query(default=None, max_length=64),
|
||||
start: str | None = Query(default=None, description="ISO 时间或 epoch 秒(含)"),
|
||||
end: str | None = Query(default=None, description="ISO 时间或 epoch 秒(含)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=MAX_PAGE_SIZE),
|
||||
) -> dict[str, Any]:
|
||||
conditions = []
|
||||
if user_id is not None:
|
||||
conditions.append(ObEvent.user_id == user_id)
|
||||
if event_name:
|
||||
conditions.append(ObEvent.event_name == event_name)
|
||||
if start:
|
||||
conditions.append(ObEvent.server_ts >= _parse_ts(start, param="start"))
|
||||
if end:
|
||||
conditions.append(ObEvent.server_ts <= _parse_ts(end, param="end"))
|
||||
|
||||
where = select(ObEvent).where(*conditions)
|
||||
total = await session.scalar(select(func.count()).select_from(where.subquery()))
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
where.order_by(desc(ObEvent.server_ts))
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [_serialize_event(row) for row in rows],
|
||||
"total": total or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/overview", summary="行为总览:按天 × event_name PV 透视 + 注册/活跃序列")
|
||||
async def stats_overview(
|
||||
session: SessionDep,
|
||||
days: int = Query(default=7, ge=1, le=MAX_OVERVIEW_DAYS, description="回溯天数"),
|
||||
) -> dict[str, Any]:
|
||||
window_start = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
day_bucket = _day_bucket()
|
||||
pv_stmt = (
|
||||
select(day_bucket, ObEvent.event_name, func.count().label("pv"))
|
||||
.where(ObEvent.server_ts >= window_start)
|
||||
.group_by("day", ObEvent.event_name)
|
||||
.order_by("day", desc("pv"))
|
||||
)
|
||||
pv_rows = (await session.execute(pv_stmt)).all()
|
||||
|
||||
active_stmt = (
|
||||
select(day_bucket, func.count(func.distinct(ObEvent.user_id)).label("active_users"))
|
||||
.where(ObEvent.server_ts >= window_start)
|
||||
.group_by("day")
|
||||
.order_by("day")
|
||||
)
|
||||
active_rows = (await session.execute(active_stmt)).all()
|
||||
|
||||
registered_total = await session.scalar(select(func.count()).select_from(User))
|
||||
|
||||
return ok(
|
||||
{
|
||||
"days": days,
|
||||
"window_start": window_start.isoformat(),
|
||||
"registered_total": registered_total or 0,
|
||||
"daily_active_users": [
|
||||
{"day": row.day.isoformat(), "active_users": row.active_users}
|
||||
for row in active_rows
|
||||
],
|
||||
"daily_event_pv": [
|
||||
{"day": row.day.isoformat(), "event_name": row.event_name, "pv": row.pv}
|
||||
for row in pv_rows
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""用户行为看板:用户列表 + 用户详情(FR-ADM 行为分析能力)。
|
||||
|
||||
与 kb.py 的关键差异:这里直接读 ``packages.shared.models`` 的观测/计费/
|
||||
报告表做只读透视,不经任何域 service——后台看板要的是「跨域拼宽表」,
|
||||
而域 service 的接口是为客户端单用户视角设计的,复用反而拧。
|
||||
|
||||
PII 纪律:``union_id`` 只留前 4 后 4、中间打码后出(防 PII 直出后台),
|
||||
掩码逻辑写在响应组装处(本模块),不许下沉到查询层。
|
||||
|
||||
鉴权走 ``X-Admin-Token``(共享令牌,见 apps.admin.deps)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import Select, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from apps.admin.deps import SessionDep, admin_auth
|
||||
from packages.domain.billing.quota import QuotaState, period_key
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.models import (
|
||||
ObEvent,
|
||||
Quota,
|
||||
QuotaRule,
|
||||
RpReport,
|
||||
User,
|
||||
UserIdentity,
|
||||
)
|
||||
from packages.shared.response import ok
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["admin:users"], dependencies=[Depends(admin_auth)])
|
||||
|
||||
#: 列表/详情分页与「最近事件」窗口的共用上限(ob_event 量大,不许一次拉爆)。
|
||||
MAX_PAGE_SIZE = 200
|
||||
RECENT_EVENTS_LIMIT = 50
|
||||
RECENT_REPORTS_LIMIT = 10
|
||||
_EVENT_COUNT_WINDOW = timedelta(days=7)
|
||||
|
||||
|
||||
def _mask_union_id(union_id: str) -> str:
|
||||
"""PII 打码:只留前 4 后 4,中间一律 ``*``;不足 9 位的整体打码。"""
|
||||
if len(union_id) <= 8:
|
||||
return union_id[:2] + "***"
|
||||
return f"{union_id[:4]}{'*' * (len(union_id) - 8)}{union_id[-4:]}"
|
||||
|
||||
|
||||
def _user_list_stmt(query: str | None) -> Select[Any]:
|
||||
"""用户列表主查询:三个标量关联子查询拼出 last_active/event_count/report_count。
|
||||
|
||||
都是 ``(user_id)`` 上的索引查找,不走 join 放大行数——ob_event/rp_report
|
||||
与 id_user 一对多,join 后 count 需要 distinct, correlated subquery 更直白。
|
||||
"""
|
||||
window_start = datetime.now(UTC) - _EVENT_COUNT_WINDOW
|
||||
last_active = (
|
||||
select(func.max(ObEvent.server_ts)).where(ObEvent.user_id == User.user_id).scalar_subquery()
|
||||
)
|
||||
event_count_7d = (
|
||||
select(func.count())
|
||||
.where(ObEvent.user_id == User.user_id, ObEvent.server_ts >= window_start)
|
||||
.scalar_subquery()
|
||||
)
|
||||
report_count = (
|
||||
select(func.count())
|
||||
.select_from(RpReport)
|
||||
.where(RpReport.user_id == User.user_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
stmt = select(
|
||||
User.user_id,
|
||||
User.register_source,
|
||||
User.created_at,
|
||||
last_active.label("last_active_at"),
|
||||
event_count_7d.label("event_count_7d"),
|
||||
report_count.label("report_count"),
|
||||
)
|
||||
if query:
|
||||
# query 兼容两种过滤:能解析成 int → user_id 精确;否则按注册来源等值
|
||||
if query.isdigit():
|
||||
stmt = stmt.where(User.user_id == int(query))
|
||||
else:
|
||||
stmt = stmt.where(User.register_source == query)
|
||||
return stmt
|
||||
|
||||
|
||||
def _serialize_user_row(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": str(row.user_id),
|
||||
"register_source": row.register_source,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"last_active_at": row.last_active_at.isoformat() if row.last_active_at else None,
|
||||
"event_count_7d": row.event_count_7d,
|
||||
"report_count": row.report_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", summary="用户列表:query 按 user_id 精确 / register_source 过滤 + 分页")
|
||||
async def list_users(
|
||||
session: SessionDep,
|
||||
query: str | None = Query(default=None, max_length=64, description="user_id 或注册来源"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=MAX_PAGE_SIZE),
|
||||
) -> dict[str, Any]:
|
||||
stmt = _user_list_stmt(query)
|
||||
total = await session.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
rows = (
|
||||
await session.execute(
|
||||
stmt.order_by(desc(User.created_at)).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
).all()
|
||||
return ok(
|
||||
{
|
||||
"items": [_serialize_user_row(row) for row in rows],
|
||||
"total": total or 0,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _quota_snapshot(session: AsyncSession, user_id: int) -> list[dict[str, Any]]:
|
||||
"""各 quota_key 的本周期用量快照(used/limit/remaining)。
|
||||
|
||||
只读:不 ``lock_or_create_quota``(那会为用户造行,看板不该有写副作用)。
|
||||
规则表全量读出(V1 规则个位数),计量行按本周期 period_key 一次性查回。
|
||||
"""
|
||||
rules = (await session.execute(select(QuotaRule).order_by(QuotaRule.quota_key))).scalars().all()
|
||||
now = datetime.now(UTC)
|
||||
keys = {
|
||||
rule.quota_key: period_key(now, rule.period_type) # type: ignore[arg-type]
|
||||
for rule in rules
|
||||
}
|
||||
quota_rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Quota).where(
|
||||
Quota.user_id == user_id,
|
||||
Quota.quota_key.in_(keys.keys()),
|
||||
Quota.period_key.in_(set(keys.values())),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
by_key = {(row.quota_key, row.period_key): row for row in quota_rows}
|
||||
snapshot = []
|
||||
for rule in rules:
|
||||
row = by_key.get((rule.quota_key, keys[rule.quota_key]))
|
||||
used = row.used if row is not None else 0
|
||||
bonus = row.bonus_granted if row is not None else 0
|
||||
state = QuotaState(
|
||||
used=used, base_limit=rule.base_limit, bonus=bonus, hard_cap=rule.hard_cap
|
||||
)
|
||||
snapshot.append(
|
||||
{
|
||||
"quota_key": rule.quota_key,
|
||||
"period_key": keys[rule.quota_key],
|
||||
"used": used,
|
||||
"limit": state.effective_limit,
|
||||
"remaining": state.remaining,
|
||||
}
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _serialize_event(event: ObEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"event_id": str(event.event_id),
|
||||
"event_name": event.event_name,
|
||||
"props": event.props,
|
||||
"client_ts": event.client_ts.isoformat() if event.client_ts else None,
|
||||
"server_ts": event.server_ts.isoformat() if event.server_ts else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}", summary="用户详情:identities(掩码)+ 配额快照 + 最近事件/报告")
|
||||
async def get_user_detail(user_id: int, session: SessionDep) -> dict[str, Any]:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, message="用户不存在")
|
||||
|
||||
identities = (
|
||||
(
|
||||
await session.execute(
|
||||
select(UserIdentity)
|
||||
.where(UserIdentity.user_id == user_id)
|
||||
.order_by(UserIdentity.created_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
events = (
|
||||
(
|
||||
await session.execute(
|
||||
select(ObEvent)
|
||||
.where(ObEvent.user_id == user_id)
|
||||
.order_by(desc(ObEvent.server_ts))
|
||||
.limit(RECENT_EVENTS_LIMIT)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
reports = (
|
||||
(
|
||||
await session.execute(
|
||||
select(RpReport)
|
||||
.where(RpReport.user_id == user_id)
|
||||
.order_by(desc(RpReport.created_at))
|
||||
.limit(RECENT_REPORTS_LIMIT)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
quotas = await _quota_snapshot(session, user_id)
|
||||
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"user_id": str(user.user_id),
|
||||
"status": user.status,
|
||||
"nickname": user.nickname,
|
||||
"register_source": user.register_source,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"last_active_at": (
|
||||
user.last_active_at.isoformat() if user.last_active_at else None
|
||||
),
|
||||
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"provider": ident.provider,
|
||||
"union_id": _mask_union_id(ident.union_id),
|
||||
"created_at": ident.created_at.isoformat() if ident.created_at else None,
|
||||
}
|
||||
for ident in identities
|
||||
],
|
||||
"quotas": quotas,
|
||||
"recent_events": [_serialize_event(event) for event in events],
|
||||
"recent_reports": [
|
||||
{
|
||||
"report_id": str(report.report_id),
|
||||
"tier": report.tier,
|
||||
"version": report.version,
|
||||
"status": report.status,
|
||||
"created_at": report.created_at.isoformat() if report.created_at else None,
|
||||
}
|
||||
for report in reports
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from apps.api.deps import build_llm_gateway
|
||||
from apps.api.routers import (
|
||||
agent,
|
||||
auth,
|
||||
auth_email,
|
||||
auth_sms,
|
||||
events,
|
||||
health,
|
||||
@@ -84,6 +85,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
routers = (
|
||||
auth.router,
|
||||
auth_sms.router,
|
||||
auth_email.router,
|
||||
agent.router,
|
||||
quota.router,
|
||||
tasks.router,
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
华为账号登录(T-P1-12)已实现(``/auth/huawei``):Account Kit id_token 的
|
||||
OIDC 验签与 UnionID 映射在 :mod:`packages.identity.huawei`,代码先行——真实
|
||||
联调待 AGC 配置(见该模块 docstring)。明确不做(防止后人误判「漏了」):
|
||||
手机验证码(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式
|
||||
鉴权设计,仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
|
||||
游客/合并/注销。dev-token 端点不是正式鉴权设计,仅为纵向切片与联调服务:
|
||||
``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
|
||||
|
||||
短信验证码通道已由邮箱验证码通道替代(FR-ACC-14 v2 变更,TD-32 短信实现
|
||||
仅作参照保留):正式兜底登录走 ``auth_email`` 路由的
|
||||
``/auth/email/code`` + ``/auth/email/login``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""邮箱验证码登录路由(FR-ACC-14 v2 变更:短信通道由邮箱通道替代)。
|
||||
|
||||
POST /auth/email/code、POST /auth/email/login;与 auth_sms.py 同构:
|
||||
登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity +
|
||||
issue_session + TokenPairResponse)。设备维度输入取请求头 ``X-Device-Key``,
|
||||
缺省以客户端 IP 兜底(见 packages/identity/email_login.py docstring)。
|
||||
|
||||
``_access_token``/``_token_pair`` 各持一份小拷贝:私有助手不跨路由复用,
|
||||
与 auth_sms.py 的既定做法保持一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from apps.api.deps import IdentityServiceDep, SettingsDep
|
||||
from packages.identity.email_login import (
|
||||
EmailAuthService,
|
||||
EmailLoginRequest,
|
||||
EmailSendRequest,
|
||||
build_email_auth_service,
|
||||
)
|
||||
from packages.identity.schemas import TokenPairResponse
|
||||
from packages.identity.service import SessionIssued
|
||||
from packages.shared.config import Settings, get_settings
|
||||
from packages.shared.redis_client import get_redis
|
||||
from packages.shared.response import ok
|
||||
from packages.shared.security import create_access_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth-email"])
|
||||
|
||||
#: 设备维度请求头:缺省时该维度以客户端 IP 兜底参与频控(见模块 docstring)。
|
||||
DEVICE_KEY_HEADER = "X-Device-Key"
|
||||
|
||||
|
||||
def email_auth_dep(request: Request) -> EmailAuthService:
|
||||
"""惰性装配并缓存到 ``app.state``(与 auth_sms.sms_auth_dep 同一模式)。
|
||||
|
||||
缓存到 app.state 同时是集成测试的钩子:测试在发请求前把捕获型 fake
|
||||
sender 的 service 放进 ``app.state.email_auth`` 即可拿到验证码——生产
|
||||
代码不为测试留任何后门。
|
||||
"""
|
||||
service: EmailAuthService | None = getattr(request.app.state, "email_auth", None)
|
||||
if service is None:
|
||||
service = build_email_auth_service(get_settings(), get_redis())
|
||||
request.app.state.email_auth = service
|
||||
return service
|
||||
|
||||
|
||||
EmailAuthDep = Annotated[EmailAuthService, Depends(email_auth_dep)]
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""直连对端 IP(ASGI scope)。反向代理场景的字段解析属网关层职责,V1 不做。"""
|
||||
return request.client.host if request.client is not None else "unknown"
|
||||
|
||||
|
||||
def _device_key(request: Request) -> str:
|
||||
"""设备维度:X-Device-Key 缺省用客户端 IP(邮箱通道不做「不传不拦」)。"""
|
||||
header = request.headers.get(DEVICE_KEY_HEADER)
|
||||
return header.strip() if header and header.strip() else _client_ip(request)
|
||||
|
||||
|
||||
def _access_token(settings: Settings, user_id: int) -> str:
|
||||
return create_access_token(
|
||||
user_id=user_id,
|
||||
secret=settings.jwt_secret,
|
||||
algorithm=settings.jwt_algorithm,
|
||||
ttl_seconds=settings.jwt_access_ttl_seconds,
|
||||
issuer=settings.jwt_issuer,
|
||||
)
|
||||
|
||||
|
||||
def _token_pair(
|
||||
settings: Settings, access_token: str, issued: SessionIssued
|
||||
) -> dict[str, str | int]:
|
||||
"""与 auth.py 的 dev-token/refresh 完全一致的双令牌响应形态。"""
|
||||
return TokenPairResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=issued.refresh_token,
|
||||
expires_in=settings.jwt_access_ttl_seconds,
|
||||
refresh_expires_in=settings.refresh_token_ttl_seconds,
|
||||
user_id=str(issued.user_id),
|
||||
).model_dump()
|
||||
|
||||
|
||||
@router.post("/email/code", summary="发送邮箱登录验证码")
|
||||
async def send_email_code(
|
||||
payload: EmailSendRequest,
|
||||
request: Request,
|
||||
email: EmailAuthDep,
|
||||
settings: SettingsDep,
|
||||
) -> dict[str, object]:
|
||||
# 限流/格式错误(42912/42913/40000/61001)由统一异常处理器透传给客户端
|
||||
await email.request_code(payload.email, ip=_client_ip(request), device_id=_device_key(request))
|
||||
return ok({"sent": True, "cooldown_seconds": settings.email_resend_cooldown_seconds})
|
||||
|
||||
|
||||
@router.post("/email/login", summary="校验邮箱验证码并登录(双令牌)")
|
||||
async def email_login(
|
||||
payload: EmailLoginRequest,
|
||||
request: Request,
|
||||
email: EmailAuthDep,
|
||||
settings: SettingsDep,
|
||||
identity: IdentityServiceDep,
|
||||
) -> dict[str, object]:
|
||||
union_id = await email.verify(payload.email, payload.code)
|
||||
user = await identity.get_or_create_by_external_identity(
|
||||
provider="email", union_id=union_id, register_source="email"
|
||||
)
|
||||
issued = await identity.issue_session(user.user_id)
|
||||
return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
|
||||
@@ -197,6 +197,23 @@ async def regenerate_report(
|
||||
return ok(_submit_payload(submission))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{report_id}/full",
|
||||
summary="生成深度诊断报告(基于该基础报告派生 tier=full,走 full_report 日配额)",
|
||||
)
|
||||
async def derive_full_report(
|
||||
report_id: int,
|
||||
payload: ReportRegenerateRequest,
|
||||
user_id: CurrentUserId,
|
||||
service: ReportServiceDep,
|
||||
) -> dict[str, object]:
|
||||
request_key = payload.request_key or f"report-full:{user_id}:{report_id}:{uuid.uuid4().hex}"
|
||||
submission = await service.derive_full_report(
|
||||
user_id=user_id, report_id=report_id, request_key=request_key
|
||||
)
|
||||
return ok(_submit_payload(submission))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{report_id}/export", summary="导出形象报告 PDF(走 hd_export 日配额,tier2 熔断可拦)"
|
||||
)
|
||||
|
||||
@@ -213,7 +213,12 @@ async def _generate_report(task_id: int) -> dict[str, Any]:
|
||||
|
||||
gateway = build_llm_gateway(settings)
|
||||
try:
|
||||
stages = assemble_stages(settings)
|
||||
if report.tier == "full":
|
||||
# 深度诊断:复用 basic 报告的 derived_features,无视觉阶段,
|
||||
# 文案/校验换 full 专用阶段(产出 result.full 四大块)。
|
||||
stages = assemble_stages(settings, paths=settings.report_full_pipeline_stages)
|
||||
else:
|
||||
stages = assemble_stages(settings)
|
||||
ctx = ReportContext(
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
|
||||
@@ -88,6 +88,16 @@ _SSOT = [
|
||||
"hard_cap": 2,
|
||||
"description": "报告重新生成(每 30 天;月硬上限 2 即「每日 ≤ 2」)",
|
||||
},
|
||||
{
|
||||
"quota_key": "full_report",
|
||||
"period_type": "day",
|
||||
"base_limit": 1,
|
||||
"task_bonus_max": 0,
|
||||
"hard_cap": 2,
|
||||
# 深度诊断(全面报告)随「免费功能」上线、无支付链路;数值为内测暂定,
|
||||
# 正式口径待产品方案补档后回填(改数字只改产品方案,再回来改这一份)。
|
||||
"description": "形象深度诊断·全面报告(每日;产品方案待补,内测暂定)",
|
||||
},
|
||||
]
|
||||
|
||||
_LEGACY = [
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""邮箱验证码登录(FR-ACC-14 v2 变更:正式登录的兜底通道,替代短信)。
|
||||
|
||||
短信通道(``sms_login.py``)因签名/资质上不了线,产品决策改用邮箱验证码
|
||||
承接「无第三方账号用户的正式登录」。本模块与 sms_login **同构**:验证码
|
||||
生命周期与三重频控口径逐条对齐(6 位数字 / TTL 300s / 校验最多 5 次 /
|
||||
重发冷却 60s / 单邮箱 1h≤5 / 24h≤10 / 单 IP 24h≤20 / 单设备 24h≤10),
|
||||
Redis 键前缀换成 ``email:``,差异只在与通道相关的地方:
|
||||
|
||||
- **union_id 是明文邮箱**(``email:<小写邮箱>``,产品契约,非哈希):
|
||||
邮箱本身是登录账号而非手机号那样的敏感标识,掩码纪律落在「日志与
|
||||
Redis 键」两处——日志只打掩码(``ab***@example.com``),Redis 键用
|
||||
``sha256(规范化邮箱)`` 前 16 位,不存明文。
|
||||
- **设备维度缺省用 IP**:请求头 ``X-Device-Key`` 缺省时路由层以客户端
|
||||
IP 兜底传入,保证「单设备 24h≤10」对所有请求生效(sms 通道允许不传
|
||||
设备号跳过该维度,邮箱通道不做这个让步——攻击面更大,宁可误伤不可漏拦)。
|
||||
- **发送通道**:``SmtpEmailSender`` 用 stdlib ``smtplib`` 同步发送、
|
||||
``anyio.to_thread`` 包成异步(不引 aiosmtplib 依赖);``FakeEmailSender``
|
||||
验证码明文进日志,**仅限本地开发**(``EMAIL_PROVIDER=fake`` 默认值)。
|
||||
- 频控计数同样按「请求」计而非「成功发送」计(理由见 sms_login docstring)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import smtplib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import anyio.to_thread
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.identity.sms_login import generate_code, hash_code
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
#: 宽松邮箱正则(前端也校验):非空白 @ 非空白 . 非空白,长度上限交给 Pydantic。
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
#: 自然窗口的时区:与 sms/billing 的日预算桶一致(FR-BILL-01 纪律),UTC+8。
|
||||
_CN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
_HOUR_BUCKET_TTL = 2 * 60 * 60 # 小时桶键活 2h,覆盖桶期 + 少量时钟偏移
|
||||
_DAY_BUCKET_TTL = 26 * 60 * 60 # 日桶键活 26h,过期由桶名(日期)保证语义正确
|
||||
|
||||
#: union_id 的 provider 前缀(id_user_identity.provider 固定 "email")。
|
||||
_EMAIL_PROVIDER = "email"
|
||||
|
||||
|
||||
# --- 邮箱规范化与掩码 ----------------------------------------------------------
|
||||
|
||||
|
||||
def normalize_email(raw: str) -> str:
|
||||
"""规范化邮箱:去首尾空白 + 小写;宽松正则不过即 40000(不消耗频控额度)。"""
|
||||
email = raw.strip().lower()
|
||||
if not _EMAIL_RE.fullmatch(email) or len(email) > 254:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, message="邮箱格式不正确", hint="请输入正确的邮箱地址")
|
||||
return email
|
||||
|
||||
|
||||
def masked_email(email: str) -> str:
|
||||
"""日志掩码:``ab***@example.com``(输入应为规范化后的邮箱)。
|
||||
|
||||
本地部分只留前 2 位;不足 2 位按 1 位留。域名不打码(域名不含 PII,
|
||||
且运营排查需要它区分邮箱服务商)。
|
||||
"""
|
||||
local, _, domain = email.partition("@")
|
||||
head = local[:2] if len(local) >= 2 else local[:1]
|
||||
return f"{head}***@{domain}"
|
||||
|
||||
|
||||
def _hash_key(value: str) -> str:
|
||||
"""任意维度标识(IP/设备号/邮箱)进 Redis 键前的哈希:键不落明文。"""
|
||||
return hashlib.sha256(value.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def email_hash_key(email: str) -> str:
|
||||
"""邮箱在 Redis 键中的形态:sha256 前 16 位(内部先规范化,同箱恒同键)。"""
|
||||
return _hash_key(normalize_email(email))
|
||||
|
||||
|
||||
def email_union_id(email: str) -> str:
|
||||
"""邮箱映射到 ``id_user_identity.union_id``(产品契约:明文小写邮箱)。"""
|
||||
return f"{_EMAIL_PROVIDER}:{normalize_email(email)}"
|
||||
|
||||
|
||||
# --- wire 模型 -----------------------------------------------------------------
|
||||
|
||||
|
||||
class EmailSendRequest(BaseModel):
|
||||
"""POST /auth/email/code 请求体。"""
|
||||
|
||||
email: str = Field(min_length=5, max_length=254, description="邮箱地址")
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
"""POST /auth/email/login 请求体。"""
|
||||
|
||||
email: str = Field(min_length=5, max_length=254)
|
||||
code: str = Field(min_length=4, max_length=8, description="6 位数字验证码")
|
||||
|
||||
|
||||
# --- 存储接口(Redis 生产 / dict 单测)------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailStore(Protocol):
|
||||
"""验证码与频控计数的最小存储接口(与 SmsStore 同形)。"""
|
||||
|
||||
async def get(self, key: str) -> str | None: ...
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
|
||||
|
||||
async def delete(self, key: str) -> None: ...
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int: ...
|
||||
|
||||
|
||||
class RedisEmailStore:
|
||||
"""生产实现:包一层 ``redis.asyncio.Redis`` 适配到 :class:`EmailStore`。
|
||||
|
||||
持有 ``object`` 而非 ``Redis`` 具体类型并局部 ignore——与
|
||||
``RedisSmsStore`` 相同的做法:identity 包只认接口,不 import redis 包。
|
||||
"""
|
||||
|
||||
def __init__(self, redis: object) -> None:
|
||||
self._redis = redis
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
value = await self._redis.get(key) # type: ignore[attr-defined]
|
||||
return str(value) if value is not None else None
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
await self._redis.set(key, value, ex=ttl_seconds) # type: ignore[attr-defined]
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await self._redis.delete(key) # type: ignore[attr-defined]
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
# INCR + EXPIRE 走 pipeline:计数与过期原子下发(同 RedisSmsStore)。
|
||||
pipe = self._redis.pipeline() # type: ignore[attr-defined]
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, ttl_seconds)
|
||||
results = await pipe.execute()
|
||||
return int(results[0])
|
||||
|
||||
|
||||
# --- 发送通道(Protocol 隔离)---------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EmailSender(Protocol):
|
||||
"""验证码发送通道。fake 打日志(dev),smtp 真实下发。
|
||||
|
||||
实现层约定:日志/异常只允许 ``masked``,不得记录邮箱明文;
|
||||
``code`` 明文仅 dev 专用实现可为联调打印。
|
||||
"""
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None: ...
|
||||
|
||||
|
||||
class FakeEmailSender:
|
||||
"""dev 专用(``EMAIL_PROVIDER=fake``,默认值):验证码打日志。
|
||||
|
||||
生产必须切 smtp——这里把 code 明文打进日志只是本地联调需要,
|
||||
任何非 local 环境都不该用它。
|
||||
"""
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
logger.info("email_code_issued", masked=masked, code=code)
|
||||
|
||||
|
||||
class SmtpEmailSender:
|
||||
"""SMTP 真实通道(stdlib smtplib 同步发送,anyio.to_thread 包成异步)。
|
||||
|
||||
构造时校验配置完整性(缺 SMTP_HOST/SMTP_USER/SMTP_PASS 即抛 61001
|
||||
EMAIL_PROVIDER_FAILED,装配期炸,不带病上岗);发送失败/超时同样映射
|
||||
61001——通道故障是外部依赖问题,不能被当成 5xxxx 内部错误稀释告警。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
("SMTP_HOST", settings.smtp_host),
|
||||
("SMTP_USER", settings.smtp_user),
|
||||
("SMTP_PASS", settings.smtp_pass),
|
||||
)
|
||||
if not value
|
||||
]
|
||||
if missing:
|
||||
# 只回显配置项名字,绝不回显已配置的值(secret 可能就在其中)
|
||||
raise AppError(
|
||||
ErrorCode.EMAIL_PROVIDER_FAILED,
|
||||
message="邮件通道配置不完整",
|
||||
data={"missing": missing},
|
||||
)
|
||||
self._settings = settings
|
||||
|
||||
def _build_message(self, email: str, code: str) -> EmailMessage:
|
||||
settings = self._settings
|
||||
sender = settings.mail_from or settings.smtp_user
|
||||
message = EmailMessage()
|
||||
message["From"] = sender
|
||||
message["To"] = email
|
||||
message["Subject"] = "【美搭】登录验证码"
|
||||
message.set_content(
|
||||
f"您的登录验证码是:{code}\n\n"
|
||||
f"{settings.email_code_ttl_seconds // 60} 分钟内有效,请勿泄露给他人。\n"
|
||||
"若非本人操作,请忽略本邮件。"
|
||||
)
|
||||
return message
|
||||
|
||||
def _send_sync(self, email: str, code: str) -> None:
|
||||
"""同步发送本体(在 worker 线程里跑):smtplib 全程不触碰事件循环。"""
|
||||
settings = self._settings
|
||||
message = self._build_message(email, code)
|
||||
if settings.smtp_use_ssl:
|
||||
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=15) as client:
|
||||
client.login(settings.smtp_user, settings.smtp_pass)
|
||||
client.send_message(message)
|
||||
else:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as client:
|
||||
client.starttls()
|
||||
client.login(settings.smtp_user, settings.smtp_pass)
|
||||
client.send_message(message)
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
try:
|
||||
await anyio.to_thread.run_sync(self._send_sync, email, code)
|
||||
except AppError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 网络超时/认证失败/被对方拒收都视为通道故障;细节只留类型名
|
||||
logger.warning("email_provider_error", masked=masked, error=type(exc).__name__)
|
||||
raise AppError(ErrorCode.EMAIL_PROVIDER_FAILED) from exc
|
||||
|
||||
|
||||
# --- 服务:验证码生命周期 + 三重频控 ------------------------------------------
|
||||
|
||||
|
||||
class EmailAuthService:
|
||||
"""验证码生命周期(6 位 / 300s / 一次性 / 失败 5 次作废)+ 三重频控。
|
||||
|
||||
频控维度与 sms_login 对齐:同邮箱(60s 冷却 / 1h≤5 / 24h≤10)、同 IP
|
||||
(24h≤20)、同设备(24h≤10,设备键由路由层以 ``X-Device-Key`` 或客户端
|
||||
IP 兜底,永远参与计数——与 sms 通道「不传不拦」的差异见模块 docstring)。
|
||||
"""
|
||||
|
||||
def __init__(self, store: EmailStore, sender: EmailSender, *, settings: Settings) -> None:
|
||||
self._store = store
|
||||
self._sender = sender
|
||||
self._settings = settings
|
||||
|
||||
async def request_code(self, email: str, *, ip: str, device_id: str | None = None) -> None:
|
||||
"""发送验证码:先频控后发送,全过才生成并存储随机码。"""
|
||||
normalized = normalize_email(email)
|
||||
ekey = email_hash_key(normalized)
|
||||
masked = masked_email(normalized)
|
||||
cfg = self._settings
|
||||
now = datetime.now(_CN_TZ)
|
||||
day = f"{now:%Y%m%d}"
|
||||
hour = f"{now:%Y%m%d%H}"
|
||||
cooldown_key = f"email:cooldown:{ekey}"
|
||||
|
||||
# 计数按请求计(含被拒请求):与 sms_login 同一论证。
|
||||
hourly = await self._store.incr(f"email:cnt:h:{ekey}:{hour}", ttl_seconds=_HOUR_BUCKET_TTL)
|
||||
daily = await self._store.incr(f"email:cnt:d:{ekey}:{day}", ttl_seconds=_DAY_BUCKET_TTL)
|
||||
ip_count = await self._store.incr(
|
||||
f"email:cnt:ip:{_hash_key(ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
|
||||
)
|
||||
# 设备维度永远参与:路由层已用 X-Device-Key(缺省 IP)兜底传入。
|
||||
device_count = await self._store.incr(
|
||||
f"email:cnt:dev:{_hash_key(device_id or ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
|
||||
)
|
||||
|
||||
# 检查顺序与 sms_login 一致:先报「今日已达上限」,再小时/冷却,最后维度限额。
|
||||
if daily > cfg.email_daily_limit:
|
||||
logger.info("email_rate_limited", masked=masked, dimension="email_daily", count=daily)
|
||||
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
|
||||
if hourly > cfg.email_hourly_limit:
|
||||
logger.info("email_rate_limited", masked=masked, dimension="email_hourly", count=hourly)
|
||||
raise AppError(ErrorCode.EMAIL_SEND_TOO_FREQUENT)
|
||||
if (
|
||||
cfg.email_resend_cooldown_seconds > 0
|
||||
and await self._store.get(cooldown_key) is not None
|
||||
):
|
||||
logger.info("email_rate_limited", masked=masked, dimension="cooldown")
|
||||
raise AppError(ErrorCode.EMAIL_SEND_TOO_FREQUENT)
|
||||
if ip_count > cfg.email_ip_daily_limit:
|
||||
logger.info("email_rate_limited", masked=masked, dimension="ip_daily", count=ip_count)
|
||||
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
|
||||
if device_count > cfg.email_device_daily_limit:
|
||||
logger.info(
|
||||
"email_rate_limited", masked=masked, dimension="device_daily", count=device_count
|
||||
)
|
||||
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
|
||||
|
||||
code = generate_code(cfg.email_code_length)
|
||||
await self._store.set(
|
||||
f"email:code:{ekey}", hash_code(code), ttl_seconds=cfg.email_code_ttl_seconds
|
||||
)
|
||||
# 新码作废旧码的全部失败计数:「失败 5 次」按码计,不按邮箱计
|
||||
await self._store.delete(f"email:attempts:{ekey}")
|
||||
if cfg.email_resend_cooldown_seconds > 0:
|
||||
await self._store.set(cooldown_key, "1", ttl_seconds=cfg.email_resend_cooldown_seconds)
|
||||
logger.info("email_code_dispatched", masked=masked, ttl=cfg.email_code_ttl_seconds)
|
||||
try:
|
||||
await self._sender.send_code(normalized, masked, code)
|
||||
except AppError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 兜底:sender 实现漏映射异常时,通道故障也不许变成 5xxxx 内部错误
|
||||
logger.warning("email_sender_unexpected_error", masked=masked, error=type(exc).__name__)
|
||||
raise AppError(ErrorCode.EMAIL_PROVIDER_FAILED) from exc
|
||||
|
||||
async def verify(
|
||||
self, email: str, code: str, *, ip: str = "", device_id: str | None = None
|
||||
) -> str:
|
||||
"""校验验证码;成功返回该邮箱的 ``union_id``,由调用方建户/查户。
|
||||
|
||||
``ip``/``device_id`` 为风控审计预留(与 sms_login.verify 同口径,
|
||||
校验侧的防护是「失败 5 次作废」+ 一次性删除)。
|
||||
"""
|
||||
normalized = normalize_email(email)
|
||||
ekey = email_hash_key(normalized)
|
||||
masked = masked_email(normalized)
|
||||
stored = await self._store.get(f"email:code:{ekey}")
|
||||
if stored is None:
|
||||
# 无码 = 过期 / 从未发送 / 已用尽作废 / 已消费——统一按过期回应,
|
||||
# 不区分细节,避免给攻击者探测状态的机会
|
||||
raise AppError(ErrorCode.EMAIL_CODE_EXPIRED)
|
||||
if not hmac.compare_digest(stored, hash_code(code)):
|
||||
attempts = await self._store.incr(
|
||||
f"email:attempts:{ekey}", ttl_seconds=self._settings.email_code_ttl_seconds
|
||||
)
|
||||
if attempts >= self._settings.email_verify_max_attempts:
|
||||
await self._store.delete(f"email:code:{ekey}")
|
||||
await self._store.delete(f"email:attempts:{ekey}")
|
||||
logger.warning("email_code_invalidated", masked=masked, attempts=attempts)
|
||||
else:
|
||||
logger.info("email_code_rejected", masked=masked, attempts=attempts)
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID)
|
||||
# 一次性:成功即删(连同失败计数),二次使用按「已过期」回应
|
||||
await self._store.delete(f"email:code:{ekey}")
|
||||
await self._store.delete(f"email:attempts:{ekey}")
|
||||
logger.info("email_code_verified", masked=masked)
|
||||
return email_union_id(normalized)
|
||||
|
||||
|
||||
def build_email_auth_service(settings: Settings, redis: object) -> EmailAuthService:
|
||||
"""按 ``EMAIL_PROVIDER`` 装配(apps/api 层调用;单测直接构造 service)。"""
|
||||
sender: EmailSender
|
||||
if settings.email_provider == "fake":
|
||||
sender = FakeEmailSender()
|
||||
elif settings.email_provider == "smtp":
|
||||
sender = SmtpEmailSender(settings) # 配置缺失在这里就抛 61001(构造失败)
|
||||
else:
|
||||
raise AppError(
|
||||
ErrorCode.CONFIG_ERROR, message=f"未知 EMAIL_PROVIDER: {settings.email_provider}"
|
||||
)
|
||||
return EmailAuthService(RedisEmailStore(redis), sender, settings=settings)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EmailAuthService",
|
||||
"EmailLoginRequest",
|
||||
"EmailSendRequest",
|
||||
"EmailSender",
|
||||
"EmailStore",
|
||||
"FakeEmailSender",
|
||||
"RedisEmailStore",
|
||||
"SmtpEmailSender",
|
||||
"build_email_auth_service",
|
||||
"email_hash_key",
|
||||
"email_union_id",
|
||||
"masked_email",
|
||||
"normalize_email",
|
||||
]
|
||||
@@ -62,6 +62,17 @@ _DEFAULTS: dict[Scene, dict[str, Any]] = {
|
||||
# 等黄金集能量化"思考带来的质量提升"再按场景打开(改配置即可)。
|
||||
"thinking": False,
|
||||
},
|
||||
Scene.FULL_REPORT_COPY: {
|
||||
"temperature": 0.6,
|
||||
# 深度诊断四大块(总评+章节+季型解析+4 场景+30 天计划)一次产出,
|
||||
# 输出预算按 REPORT_COPY 翻倍;异步任务,延迟宽松。
|
||||
"max_tokens": 6000,
|
||||
"timeout_seconds": 180.0,
|
||||
"prompt_id": "full_report_copy",
|
||||
"prompt_version": "v1",
|
||||
"critical": False,
|
||||
"thinking": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class Scene(StrEnum):
|
||||
INTENT_CLASSIFY = "intent_classify"
|
||||
PREFERENCE_EXTRACT = "preference_extract"
|
||||
REPORT_COPY = "report_copy"
|
||||
FULL_REPORT_COPY = "full_report_copy"
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""深度诊断(全面报告)文案的纯逻辑:十二季色板规则映射、结构化输出 schema 与降级模板。
|
||||
|
||||
与 basic 报告共用同一条「LLM 不可信任」纪律,分工如下:
|
||||
|
||||
- **色板不出 LLM**:季型 → 色板的映射是本模块的预定义常量(规则引擎口径,
|
||||
与 kb_color 同源的确定性数据),LLM 只写季型解析文案。色值交给模型编
|
||||
是幻觉高发区,校验器拦得住但每次都多花一次重试——结构上不让它碰更省。
|
||||
- **LLM 产出四块结构化内容**:总评 summary + 正文章节(与 basic 同构,保证
|
||||
ReportResultPage / PDF 导出的现有渲染零改动)、季型解析、4 场景穿搭、
|
||||
30 天计划。
|
||||
- **降级模板**:持续违规时按规则直出拼全量内容(含场景与计划的兜底版本),
|
||||
对齐 validation_stage 的「任何出口要么 passed 要么抛错」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.report.copy import CopySection, ReportCopy, dimension_label
|
||||
|
||||
# ── 十二季色板规则映射(常量,色值不交给 LLM) ─────────────────────────────
|
||||
|
||||
#: 十二季自研描述性命名(TD-18:对外不得使用「四季色彩 / CMB」商标名)。
|
||||
TWELVE_SEASONS: tuple[str, ...] = (
|
||||
"浅春",
|
||||
"暖春",
|
||||
"亮春",
|
||||
"浅夏",
|
||||
"柔夏",
|
||||
"冷夏",
|
||||
"柔秋",
|
||||
"暖秋",
|
||||
"深秋",
|
||||
"亮冬",
|
||||
"冷冬",
|
||||
"深冬",
|
||||
)
|
||||
|
||||
#: 四季粗判(season_code)→ 十二季默认细分:视觉缺 season_detailed 时的兜底,
|
||||
#: 取该季最中位的细分型,宁保守不冒进。
|
||||
SEASON_CODE_FALLBACK: Mapping[str, str] = {
|
||||
"spring": "暖春",
|
||||
"summer": "柔夏",
|
||||
"autumn": "暖秋",
|
||||
"winter": "冷冬",
|
||||
}
|
||||
|
||||
#: 中性色按冷暖倾向分两档,各季直接引用(白/黑/灰/米/驼/卡其口径,需求 §2.3 篇章二)。
|
||||
_NEUTRAL_WARM: tuple[tuple[str, str], ...] = (
|
||||
("米白", "#F5EFE0"),
|
||||
("象牙白", "#F8F0DC"),
|
||||
("燕麦色", "#D9C9A8"),
|
||||
("浅驼", "#C8A97E"),
|
||||
("卡其", "#B89B6A"),
|
||||
("暖灰", "#A89F8D"),
|
||||
("深棕", "#5C4633"),
|
||||
)
|
||||
|
||||
_NEUTRAL_COOL: tuple[tuple[str, str], ...] = (
|
||||
("纯白", "#FFFFFF"),
|
||||
("浅灰", "#D9D9D9"),
|
||||
("中灰", "#9AA0A6"),
|
||||
("炭灰", "#4A4A4A"),
|
||||
("藏蓝", "#1F2A4A"),
|
||||
("灰米", "#CFC9BD"),
|
||||
("蓝黑", "#16213A"),
|
||||
)
|
||||
|
||||
#: 季型 → (最佳色≥12, 避雷色≥6),中性色按季型冷暖引用上面两档。
|
||||
#: 色值为内测期规则引擎录入口径,后续统一迁至 kb_color 后此处仅作兜底。
|
||||
_SEASON_COLORS: Mapping[str, tuple[tuple[tuple[str, str], ...], tuple[tuple[str, str], ...]]] = {
|
||||
"浅春": (
|
||||
(
|
||||
("蜜桃粉", "#FFC8B4"), ("浅杏色", "#FFE0C7"), ("薄荷绿", "#B8E6C8"),
|
||||
("浅水蓝", "#AFDDE9"), ("鹅黄", "#FFE9A8"), ("浅珊瑚", "#FFB59E"),
|
||||
("嫩草绿", "#CFE8A9"), ("浅金棕", "#E8C79A"), ("樱花粉", "#FFD1DC"),
|
||||
("浅松石", "#A3E0D4"), ("淡奶油", "#FFF3D6"), ("浅橙", "#FFCF9E"),
|
||||
),
|
||||
(
|
||||
("纯黑", "#1A1A1A"), ("炭灰", "#3A3A3A"), ("藏青", "#14213D"),
|
||||
("酒红", "#6E1423"), ("深紫", "#3B1F47"), ("墨绿", "#1F3D2B"),
|
||||
),
|
||||
),
|
||||
"暖春": (
|
||||
(
|
||||
("珊瑚橙", "#FF8C69"), ("番茄红", "#E8553D"), ("金盏菊黄", "#FFB92E"),
|
||||
("橄榄绿", "#7A8B3D"), ("暖松石", "#2EB8A6"), ("焦糖", "#B9713B"),
|
||||
("杏色", "#F2B880"), ("苹果绿", "#8FC45A"), ("暖粉", "#F49AC1"),
|
||||
("蜂蜜金", "#D9A441"), ("铜色", "#B0653A"), ("暖米白", "#F7E7CE"),
|
||||
),
|
||||
(
|
||||
("冷灰蓝", "#708090"), ("粉紫", "#C8A2C8"), ("冰粉", "#F2D7E0"),
|
||||
("银灰", "#C0C0C0"), ("藏蓝", "#1B2A4A"), ("冷白", "#F4F8FB"),
|
||||
),
|
||||
),
|
||||
"亮春": (
|
||||
(
|
||||
("明黄", "#FFD93B"), ("亮橙", "#FF7A1A"), ("西瓜红", "#FF4D5E"),
|
||||
("翠绿", "#1FB573"), ("湖蓝", "#1FA8C9"), ("亮松石", "#14C4B2"),
|
||||
("亮粉", "#FF5E9C"), ("紫罗兰", "#7A4FD0"), ("柠檬黄", "#F5E642"),
|
||||
("亮苹果绿", "#7AC74F"), ("朱红", "#E63946"), ("宝蓝", "#2B5FCE"),
|
||||
),
|
||||
(
|
||||
("暗驼", "#A68A64"), ("灰绿", "#8A9A5B"), ("暗玫瑰", "#A76A73"),
|
||||
("深棕", "#4A3525"), ("灰蓝", "#6B7B8C"), ("暗紫", "#5A4A6B"),
|
||||
),
|
||||
),
|
||||
"浅夏": (
|
||||
(
|
||||
("雾粉", "#F3D5DC"), ("浅薰衣草", "#D8CBE8"), ("粉蓝", "#BFD7EA"),
|
||||
("浅玫瑰", "#E8B4C4"), ("珍珠白", "#F2F0EB"), ("浅灰绿", "#C9D8C5"),
|
||||
("淡紫", "#CDBFE0"), ("浅青", "#BFE0E0"), ("雾蓝", "#C3D2E0"),
|
||||
("浅桃粉", "#F6D2D8"), ("灰粉", "#E3C6CE"), ("浅蓝紫", "#BFC6E8"),
|
||||
),
|
||||
(
|
||||
("亮橙", "#FF7A1A"), ("明黄", "#FFD93B"), ("番茄红", "#E8553D"),
|
||||
("翠绿", "#1FB573"), ("焦糖", "#B9713B"), ("纯黑", "#1A1A1A"),
|
||||
),
|
||||
),
|
||||
"柔夏": (
|
||||
(
|
||||
("雾霾蓝", "#8FA8BF"), ("豆沙粉", "#C4909A"), ("灰玫瑰", "#B08E96"),
|
||||
("鼠尾草绿", "#9CAF88"), ("灰紫", "#9B8AA5"), ("燕麦", "#C7BBA8"),
|
||||
("灰松石", "#7FA8A0"), ("烟粉", "#CFA9B2"), ("蓝灰", "#7C93A8"),
|
||||
("藕荷", "#BFA4BC"), ("灰卡其", "#A8A18C"), ("深灰玫瑰", "#96757F"),
|
||||
),
|
||||
(
|
||||
("明黄", "#FFD93B"), ("亮橙", "#FF7A1A"), ("西瓜红", "#FF4D5E"),
|
||||
("翠绿", "#1FB573"), ("纯黑", "#1A1A1A"), ("亮粉", "#FF5E9C"),
|
||||
),
|
||||
),
|
||||
"冷夏": (
|
||||
(
|
||||
("玫瑰粉", "#D46A92"), ("蓝调正红", "#C23B4E"), ("覆盆子", "#A62651"),
|
||||
("天蓝", "#5B9BD5"), ("皇家蓝", "#3B5FC0"), ("冷薄荷", "#9FD8D8"),
|
||||
("薰衣草", "#A78BC9"), ("松绿", "#2E7D6B"), ("玉兰白", "#F6F4F0"),
|
||||
("冷粉", "#E08AAF"), ("钢蓝", "#4F7396"), ("深玫瑰", "#8E3A5B"),
|
||||
),
|
||||
(
|
||||
("珊瑚橙", "#FF8C69"), ("金盏菊黄", "#FFB92E"), ("焦糖", "#B9713B"),
|
||||
("橄榄绿", "#7A8B3D"), ("番茄红", "#E8553D"), ("暖驼", "#C19A6B"),
|
||||
),
|
||||
),
|
||||
"柔秋": (
|
||||
(
|
||||
("陶土", "#B4714F"), ("橄榄", "#8A8B4D"), ("芥末黄", "#C9A227"),
|
||||
("砖红", "#A65B43"), ("灰绿", "#8FA382"), ("驼棕", "#A98A63"),
|
||||
("暗松石", "#5F8B7E"), ("南瓜", "#C07840"), ("栗棕", "#7C5540"),
|
||||
("暗玫瑰", "#A97E7E"), ("麦色", "#CBB37A"), ("苔绿", "#75804F"),
|
||||
),
|
||||
(
|
||||
("冰粉", "#F2D7E0"), ("亮蓝", "#2B5FCE"), ("纯黑", "#1A1A1A"),
|
||||
("亮绿", "#35D07F"), ("冰紫", "#D8CBE8"), ("亮粉", "#FF5E9C"),
|
||||
),
|
||||
),
|
||||
"暖秋": (
|
||||
(
|
||||
("铁锈红", "#A6472E"), ("南瓜橙", "#C77B2E"), ("深橄榄", "#6B7038"),
|
||||
("芥末", "#B9932B"), ("深驼", "#8F6B45"), ("番茄棕", "#9C4A32"),
|
||||
("森林绿", "#3E5E3E"), ("蜂蜜", "#C99A3C"), ("赤陶", "#B2593D"),
|
||||
("暖棕", "#7A5236"), ("暗金", "#A8842F"), ("深松石", "#3F7A6B"),
|
||||
),
|
||||
(
|
||||
("冰蓝", "#BFE3F0"), ("冷粉", "#E08AAF"), ("银灰", "#C0C0C0"),
|
||||
("薰衣草", "#A78BC9"), ("冰白", "#F4F8FB"), ("紫罗兰", "#7A4FD0"),
|
||||
),
|
||||
),
|
||||
"深秋": (
|
||||
(
|
||||
("深棕", "#4A3525"), ("黑橄榄", "#4A4A26"), ("深铁锈", "#7E3B26"),
|
||||
("墨绿", "#274435"), ("深驼", "#6B4E30"), ("深橙棕", "#8A4A24"),
|
||||
("深酒红", "#5E2432"), ("深松绿", "#2E4A3B"), ("焦糖棕", "#7A4A28"),
|
||||
("深芥末", "#8A7220"), ("深铜", "#6E452A"), ("黑棕", "#33261C"),
|
||||
),
|
||||
(
|
||||
("冰粉", "#F2D7E0"), ("浅蓝", "#BFD7EA"), ("浅薰衣草", "#D8CBE8"),
|
||||
("珍珠白", "#F2F0EB"), ("浅灰绿", "#C9D8C5"), ("浅桃粉", "#F6D2D8"),
|
||||
),
|
||||
),
|
||||
"亮冬": (
|
||||
(
|
||||
("洋红", "#D01A7A"), ("宝蓝", "#2040C8"), ("翠绿", "#0E9F6E"),
|
||||
("正红", "#DE1A3A"), ("亮松石", "#0FA8B8"), ("柠檬黄", "#F2E13C"),
|
||||
("紫红", "#A8126B"), ("亮蓝", "#2B7DE0"), ("纯黑", "#1A1A1A"),
|
||||
("纯白", "#FFFFFF"), ("银灰", "#C0C0C0"), ("冰粉", "#F2A7C8"),
|
||||
),
|
||||
(
|
||||
("驼色", "#C19A6B"), ("芥末黄", "#C9A227"), ("橄榄绿", "#7A8B3D"),
|
||||
("陶土", "#B4714F"), ("南瓜橙", "#C77B2E"), ("暖棕", "#7A5236"),
|
||||
),
|
||||
),
|
||||
"冷冬": (
|
||||
(
|
||||
("纯黑", "#1A1A1A"), ("纯白", "#FFFFFF"), ("正红", "#C8102E"),
|
||||
("宝石蓝", "#1D4ED8"), ("深紫红", "#7A1245"), ("冰蓝", "#A8D8F0"),
|
||||
("冷粉", "#D86AA8"), ("翡翠绿", "#0E7A5F"), ("藏蓝", "#14264A"),
|
||||
("银灰", "#C0C0C0"), ("紫罗兰", "#6D28A8"), ("樱桃红", "#A8123B"),
|
||||
),
|
||||
(
|
||||
("珊瑚橙", "#FF8C69"), ("金盏菊黄", "#FFB92E"), ("驼色", "#C19A6B"),
|
||||
("焦糖", "#B9713B"), ("橄榄绿", "#7A8B3D"), ("暖杏", "#F2B880"),
|
||||
),
|
||||
),
|
||||
"深冬": (
|
||||
(
|
||||
("黑棕", "#2B201A"), ("深酒红", "#571C2B"), ("深宝石蓝", "#1A2E6E"),
|
||||
("深紫", "#3F1D5E"), ("墨绿", "#1E3A2E"), ("炭黑", "#141414"),
|
||||
("深樱桃", "#6E1230"), ("深松绿", "#1F4438"), ("深灰", "#2E2E2E"),
|
||||
("深钴蓝", "#16295C"), ("深紫红", "#4A0E33"), ("深橄榄黑", "#2E2E1A"),
|
||||
),
|
||||
(
|
||||
("浅杏色", "#FFE0C7"), ("鹅黄", "#FFE9A8"), ("蜜桃粉", "#FFC8B4"),
|
||||
("浅珊瑚", "#FFB59E"), ("薄荷绿", "#B8E6C8"), ("浅金棕", "#E8C79A"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
#: 暖调季型集合(决定中性色引用哪一档)。
|
||||
_WARM_SEASONS: frozenset[str] = frozenset(
|
||||
{"浅春", "暖春", "亮春", "柔秋", "暖秋", "深秋"}
|
||||
)
|
||||
|
||||
|
||||
def resolve_season(derived_features: Mapping[str, Any]) -> str | None:
|
||||
"""派生特征 → 十二季细分名;无细分值时按四季粗判取默认细分(见 SEASON_CODE_FALLBACK)。"""
|
||||
detailed = derived_features.get("season_detailed")
|
||||
if isinstance(detailed, str) and detailed in TWELVE_SEASONS:
|
||||
return detailed
|
||||
code = derived_features.get("season_code")
|
||||
if isinstance(code, str):
|
||||
return SEASON_CODE_FALLBACK.get(code)
|
||||
return None
|
||||
|
||||
|
||||
def palette_for(season_detailed: str) -> dict[str, list[dict[str, str]]]:
|
||||
"""季型 → 结构化色板(best/neutral/avoid,每色 name+hex)。未收录季型给空板。"""
|
||||
if season_detailed not in _SEASON_COLORS:
|
||||
return {"best": [], "neutral": [], "avoid": []}
|
||||
best, avoid = _SEASON_COLORS[season_detailed]
|
||||
neutral = _NEUTRAL_WARM if season_detailed in _WARM_SEASONS else _NEUTRAL_COOL
|
||||
|
||||
def entries(pairs: Sequence[tuple[str, str]]) -> list[dict[str, str]]:
|
||||
return [{"name": name, "hex": hex_value} for name, hex_value in pairs]
|
||||
|
||||
return {"best": entries(best), "neutral": entries(neutral), "avoid": entries(avoid)}
|
||||
|
||||
|
||||
# ── LLM 结构化输出 schema ──────────────────────────────────────────────────
|
||||
|
||||
#: 场景固定四个(需求 §2.3 篇章六:通勤职场/休闲日常/约会聚会/正式场合)。
|
||||
SCENE_DEFINITIONS: tuple[tuple[str, str], ...] = (
|
||||
("commute", "日常通勤"),
|
||||
("weekend", "休闲周末"),
|
||||
("formal", "正式场合"),
|
||||
("date", "约会聚会"),
|
||||
)
|
||||
|
||||
|
||||
class FullOutfit(BaseModel):
|
||||
"""一套搭配:名称 + 要点列表(上装/下装/鞋/配饰、配色、材质都在要点里)。"""
|
||||
|
||||
name: str
|
||||
points: list[str]
|
||||
|
||||
|
||||
class FullScene(BaseModel):
|
||||
"""一个场景的穿搭方案包:2~3 套搭配。"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
outfits: list[FullOutfit]
|
||||
|
||||
|
||||
class PlanWeek(BaseModel):
|
||||
"""30 天计划的一周:3~5 条可执行任务。"""
|
||||
|
||||
week: int
|
||||
title: str
|
||||
tasks: list[str]
|
||||
|
||||
|
||||
class FullReportCopy(BaseModel):
|
||||
"""FULL_REPORT_COPY 场景的结构化输出。
|
||||
|
||||
summary/sections 与 basic 的 ReportCopy 同构——ReportResultPage 与 PDF
|
||||
导出按 result.copy 渲染的部分对两种 tier 零改动;深度诊断的增量内容全在
|
||||
后三个字段,落库时进 result.full(色板由规则映射补齐,不经 LLM)。
|
||||
"""
|
||||
|
||||
summary: str
|
||||
sections: list[CopySection]
|
||||
season_analysis: str
|
||||
scenes: list[FullScene]
|
||||
plan30: list[PlanWeek]
|
||||
|
||||
def as_report_copy(self) -> ReportCopy:
|
||||
"""折成 ReportCopy 视图供生成后校验:全部自由文本都进 full_text 扫描范围。"""
|
||||
sections = list(self.sections)
|
||||
sections.append(CopySection(title="季型解析", paragraphs=[self.season_analysis]))
|
||||
for scene in self.scenes:
|
||||
paragraphs = [scene.title]
|
||||
for outfit in scene.outfits:
|
||||
paragraphs.append(outfit.name)
|
||||
paragraphs.extend(outfit.points)
|
||||
sections.append(CopySection(title=scene.title, paragraphs=paragraphs))
|
||||
for week in self.plan30:
|
||||
sections.append(CopySection(title=week.title, paragraphs=list(week.tasks)))
|
||||
return ReportCopy(summary=self.summary, sections=sections)
|
||||
|
||||
|
||||
def build_full_result(
|
||||
copy: FullReportCopy,
|
||||
*,
|
||||
season_detailed: str | None,
|
||||
season_code: str | None,
|
||||
palette: Mapping[str, list[dict[str, str]]] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""组装 result["full"]:LLM 内容 + 规则色板(App 按此结构渲染三个新区块)。"""
|
||||
season: dict[str, Any] = {
|
||||
"code": season_code,
|
||||
"detailed": season_detailed,
|
||||
"name_cn": f"{season_detailed}型" if season_detailed else None,
|
||||
"analysis": copy.season_analysis,
|
||||
}
|
||||
return {
|
||||
"season": season,
|
||||
"palette": dict(palette) if palette else {"best": [], "neutral": [], "avoid": []},
|
||||
"scenes": [scene.model_dump() for scene in copy.scenes],
|
||||
"plan30": [week.model_dump() for week in copy.plan30],
|
||||
}
|
||||
|
||||
|
||||
# ── 降级模板(持续违规时的零 LLM 出口) ────────────────────────────────────
|
||||
|
||||
_FALLBACK_FULL_SUMMARY = (
|
||||
"以下为深度诊断内容,根据本次分析的结构化结论整理,"
|
||||
"每条结论都来自规则引擎对诊断值的直接匹配。"
|
||||
)
|
||||
|
||||
#: 各场景的兜底搭配要点模板:{best}=最佳色名前三个、{neutral}=中性色名前两个。
|
||||
#: 措辞刻意避开「建议/推荐」句式(校验规则③要求建议句有规则命中支撑,
|
||||
#: 降级模板没有命中链,干脆不用建议句式——受控拼接不做超出数据的断言)。
|
||||
_FALLBACK_SCENE_POINTS: Mapping[str, tuple[str, ...]] = {
|
||||
"commute": (
|
||||
"上装选{best}之一的衬衫或针织衫,下装配{neutral}直筒裤",
|
||||
"整体以{neutral}为基调,{best}之一用于内搭提亮",
|
||||
"鞋包选{neutral},保持通勤场合的利落感",
|
||||
),
|
||||
"weekend": (
|
||||
"上装选{best}之一的卫衣或休闲衬衫,下装配{neutral}休闲裤",
|
||||
"可用{best}之一的单品做主色,其余用{neutral}压住",
|
||||
"鞋选{neutral}休闲鞋,包袋同色系呼应",
|
||||
),
|
||||
"formal": (
|
||||
"外套选{neutral}的西装或大衣,内搭{best}之一的衬衫",
|
||||
"下装与外套同色系,形成纵向线条",
|
||||
"配饰控制在{neutral}范围内,点缀可用{best}之一",
|
||||
),
|
||||
"date": (
|
||||
"上装选{best}之一的针织衫或连衣裙,突出柔和气色",
|
||||
"下装配{neutral}半身裙或长裤,鞋选同色系",
|
||||
"配饰以小巧精致为先,颜色落在{best}范围内",
|
||||
),
|
||||
}
|
||||
|
||||
_FALLBACK_PLAN: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("第 1 周:整理衣柜", (
|
||||
"把衣柜里的单品按适合色、中性色、避雷色分成三堆",
|
||||
"挑 2 件中性色基础款作为本周固定搭配底版",
|
||||
"记录本周每天穿的配色,对照色板找偏差",
|
||||
)),
|
||||
("第 2 周:引入适合色", (
|
||||
"用适合色中的一件上衣替换惯常穿的避雷色上衣",
|
||||
"尝试「中性色为主、适合色点缀」的配色方式 3 次",
|
||||
"对照镜子确认提亮效果,记下表现出色的一件",
|
||||
)),
|
||||
("第 3 周:场景演练", (
|
||||
"按报告里的通勤方案完整穿一次,记录感受",
|
||||
"按报告里的休闲方案穿一次周末出行搭配",
|
||||
"从配饰入手,为一套固定搭配加一件色板内的配饰",
|
||||
)),
|
||||
("第 4 周:固化习惯", (
|
||||
"把前 3 周效果好的 3 套搭配拍照存档",
|
||||
"补齐衣柜里缺位的中性色基础款清单",
|
||||
"对照报告复盘整月变化,圈出下个月想尝试的新色",
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _fallback_outfit_points(key: str, palette: Mapping[str, list[dict[str, str]]]) -> list[str]:
|
||||
best = "、".join(c["name"] for c in palette.get("best", [])[:3]) or "适合色"
|
||||
neutral = "、".join(c["name"] for c in palette.get("neutral", [])[:2]) or "中性色"
|
||||
return [
|
||||
point.format(best=best, neutral=neutral)
|
||||
for point in _FALLBACK_SCENE_POINTS.get(key, ())
|
||||
]
|
||||
|
||||
|
||||
def render_fallback_full_copy(
|
||||
dimensions: Sequence[Any],
|
||||
*,
|
||||
season_detailed: str | None,
|
||||
palette: Mapping[str, list[dict[str, str]]] | None,
|
||||
) -> FullReportCopy:
|
||||
"""深度诊断降级模板:全量内容规则直出(含场景与 30 天计划),零 LLM。
|
||||
|
||||
措辞纪律与 render_fallback_copy 相同——只做受控拼接、逐字复述诊断值,
|
||||
天然通过三重校验;FullValidationStage 仍会再校验一遍做 fail-close 自证。
|
||||
"""
|
||||
ok_dims = [d for d in dimensions if isinstance(d, Mapping) and d.get("status") == "ok"]
|
||||
board = dict(palette) if palette else {"best": [], "neutral": [], "avoid": []}
|
||||
|
||||
conclusion_paragraphs = []
|
||||
for dim in ok_dims:
|
||||
value = dim.get("value") or ""
|
||||
if isinstance(value, (list, dict)):
|
||||
continue # 复合值不复述进段落(色板等结构化值由 full.palette 呈现)
|
||||
conclusion_paragraphs.append(f"你的{dimension_label(dim)}结论为「{value}」。")
|
||||
sections: list[CopySection] = []
|
||||
if conclusion_paragraphs:
|
||||
sections.append(CopySection(title="诊断结论", paragraphs=conclusion_paragraphs))
|
||||
else:
|
||||
sections.append(CopySection(title="说明", paragraphs=["本次分析暂无可用结论。"]))
|
||||
|
||||
if season_detailed:
|
||||
season_analysis = (
|
||||
f"你的十二季色彩定位为「{season_detailed}型」。"
|
||||
f"适合色板共 {len(board.get('best', []))} 色,"
|
||||
f"中性色 {len(board.get('neutral', []))} 色,"
|
||||
f"避雷色 {len(board.get('avoid', []))} 色,完整色板见下方色卡区。"
|
||||
)
|
||||
else:
|
||||
season_analysis = "本次分析未产出十二季定位,色板区暂无内容。"
|
||||
|
||||
scenes = [
|
||||
FullScene(
|
||||
key=key,
|
||||
title=title,
|
||||
outfits=[
|
||||
FullOutfit(name=f"{title} · 色板方案", points=_fallback_outfit_points(key, board))
|
||||
],
|
||||
)
|
||||
for key, title in SCENE_DEFINITIONS
|
||||
]
|
||||
plan30 = [
|
||||
PlanWeek(week=index + 1, title=title, tasks=list(tasks))
|
||||
for index, (title, tasks) in enumerate(_FALLBACK_PLAN)
|
||||
]
|
||||
return FullReportCopy(
|
||||
summary=_FALLBACK_FULL_SUMMARY,
|
||||
sections=sections,
|
||||
season_analysis=season_analysis,
|
||||
scenes=scenes,
|
||||
plan30=plan30,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""流水线阶段:深度诊断(tier=full)的 LLM 文案生成。
|
||||
|
||||
与 CopyStage 同位(name="copy"):result.copy 保持 summary/sections 同构,
|
||||
App 与 PDF 导出的现有渲染零改动;深度诊断的增量内容落 result["full"]
|
||||
(色板/场景/30 天计划,结构见 full_copy.build_full_result)。
|
||||
|
||||
色板纪律:季型 → 色板是 full_copy 里的规则映射常量,prompt 只给色名不给
|
||||
色值——LLM 复述色名写解析,hex 由规则侧组装,结构上杜绝色值幻觉。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from packages.llm.types import Scene
|
||||
from packages.report.copy import compose_prompt_material
|
||||
from packages.report.full_copy import FullReportCopy, build_full_result, palette_for, resolve_season
|
||||
from packages.report.pipeline import ReportContext
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _coerce_full_copy(parsed: Any) -> FullReportCopy:
|
||||
"""同 copy.coerce_parsed_copy 的契约闸门,换成 FullReportCopy 版本。"""
|
||||
if not isinstance(parsed, FullReportCopy):
|
||||
msg = "FULL_REPORT_COPY 场景的 completion 未携带 FullReportCopy 结构"
|
||||
raise AppError(ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED, message=msg)
|
||||
return parsed
|
||||
|
||||
|
||||
def _season_prompt_material(
|
||||
derived_features: dict[str, Any],
|
||||
season_detailed: str | None,
|
||||
palette: dict[str, list[dict[str, str]]],
|
||||
) -> str:
|
||||
"""{season_json} 载荷:季型结论 + 色名清单(只给色名,不给 hex——见模块 docstring)。
|
||||
|
||||
顺带带上面部色彩三维度,LLM 写季型解析时可直接复述这些刻度值。
|
||||
"""
|
||||
names = {role: [c["name"] for c in colors] for role, colors in palette.items()}
|
||||
axes = {
|
||||
key: derived_features.get(key)
|
||||
for key in ("face_hue", "face_lightness", "face_chroma", "face_contrast", "skin_tone")
|
||||
if derived_features.get(key) is not None
|
||||
}
|
||||
payload = {
|
||||
"season_code": derived_features.get("season_code"),
|
||||
"season_detailed": season_detailed,
|
||||
"color_axes": axes,
|
||||
"palette_names": names,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
class FullCopyStage:
|
||||
"""name="copy"(与 basic 同位):调 FULL_REPORT_COPY 场景产出深度诊断全量内容。
|
||||
|
||||
产物双写:result["copy"](summary/sections,同构 basic)与
|
||||
result["full"](season/palette/scenes/plan30)。result["full"] 也复制
|
||||
一份进本阶段键外的独立键——pipeline 按阶段键 setdefault 不冲突。
|
||||
"""
|
||||
|
||||
name = "copy"
|
||||
|
||||
async def run(self, ctx: ReportContext) -> None:
|
||||
diagnosis = ctx.report.result.get("diagnosis")
|
||||
if not isinstance(diagnosis, dict):
|
||||
raise AppError(
|
||||
ErrorCode.TASK_STATE_INVALID,
|
||||
message="copy 阶段缺少 diagnosis 产物(流水线阶段顺序错误)",
|
||||
)
|
||||
if ctx.gateway is None:
|
||||
raise AppError(ErrorCode.CONFIG_ERROR, message="报告流水线缺少 LLM 网关")
|
||||
|
||||
derived = dict(ctx.report_input.derived_features or {})
|
||||
season_detailed = resolve_season(derived)
|
||||
palette = palette_for(season_detailed) if season_detailed else None
|
||||
|
||||
material, dropped_keys = compose_prompt_material(diagnosis)
|
||||
completion = await ctx.gateway.complete(
|
||||
scene=Scene.FULL_REPORT_COPY,
|
||||
prompt_vars={
|
||||
"dimensions_json": material,
|
||||
"season_json": _season_prompt_material(derived, season_detailed, palette or {}),
|
||||
},
|
||||
schema=FullReportCopy,
|
||||
user_id=ctx.report.user_id,
|
||||
ref_type="report",
|
||||
ref_id=str(ctx.report.report_id),
|
||||
)
|
||||
full_copy = _coerce_full_copy(completion.parsed)
|
||||
|
||||
ctx.report.result["copy"] = {
|
||||
"summary": full_copy.summary,
|
||||
"sections": [section.model_dump() for section in full_copy.sections],
|
||||
"model": completion.model,
|
||||
"prompt_version": completion.prompt_version,
|
||||
"dropped_keys": dropped_keys,
|
||||
}
|
||||
ctx.report.result["full"] = build_full_result(
|
||||
full_copy,
|
||||
season_detailed=season_detailed,
|
||||
season_code=derived.get("season_code")
|
||||
if isinstance(derived.get("season_code"), str)
|
||||
else None,
|
||||
palette=palette,
|
||||
)
|
||||
logger.info(
|
||||
"report_full_copy_generated",
|
||||
report_id=ctx.report.report_id,
|
||||
model=completion.model,
|
||||
season=season_detailed,
|
||||
dropped_dimensions=len(dropped_keys),
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""流水线阶段:深度诊断(tier=full)的生成后校验。
|
||||
|
||||
与 ValidationStage 同一套纪律(三重校验 → 语义重试一次 → 降级模板,
|
||||
任何出口要么 passed 要么抛错),差别只在两处:
|
||||
|
||||
- 校验对象用 ``FullReportCopy.as_report_copy()`` 折成的全文视图——季型解析、
|
||||
场景穿搭、30 天计划的自由文本全部进入贬损词/一致性/绝对化三重扫描,
|
||||
不是只查 summary+sections 就放行。
|
||||
- 降级模板换 render_fallback_full_copy:场景与 30 天计划也有零 LLM 的
|
||||
规则直出版本,降级不等于「深度诊断区块消失」(result.full 恒有结构)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.llm.types import Completion, Message, Scene
|
||||
from packages.report.copy import compose_prompt_material
|
||||
from packages.report.full_copy import (
|
||||
FullReportCopy,
|
||||
build_full_result,
|
||||
palette_for,
|
||||
render_fallback_full_copy,
|
||||
resolve_season,
|
||||
)
|
||||
from packages.report.full_copy_stage import _season_prompt_material
|
||||
from packages.report.pipeline import ReportContext
|
||||
from packages.report.validation import validate_copy
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
from packages.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_MAX_SEMANTIC_RETRIES = 1
|
||||
_FALLBACK_MODEL = "fallback-template"
|
||||
|
||||
|
||||
def _clean_checks() -> dict[str, int]:
|
||||
return {"pejorative": 0, "consistency": 0, "absolute": 0}
|
||||
|
||||
|
||||
def _full_copy_from_result(result: dict[str, Any]) -> FullReportCopy:
|
||||
"""从 result.copy + result.full 重建 FullReportCopy(校验与降级换写都走它)。"""
|
||||
copy_payload = result.get("copy")
|
||||
full_payload = result.get("full")
|
||||
if not isinstance(copy_payload, dict) or not isinstance(full_payload, dict):
|
||||
raise AppError(
|
||||
ErrorCode.TASK_STATE_INVALID,
|
||||
message="validation 阶段缺少 copy/full 产物(流水线阶段顺序错误)",
|
||||
)
|
||||
season_raw = full_payload.get("season")
|
||||
season: dict[str, Any] = dict(season_raw) if isinstance(season_raw, dict) else {}
|
||||
try:
|
||||
return FullReportCopy(
|
||||
summary=str(copy_payload.get("summary") or ""),
|
||||
sections=copy_payload.get("sections") or [],
|
||||
season_analysis=str(season.get("analysis") or ""),
|
||||
scenes=full_payload.get("scenes") or [],
|
||||
plan30=full_payload.get("plan30") or [],
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise AppError(
|
||||
ErrorCode.TASK_STATE_INVALID,
|
||||
message=f"full 产物结构不合法:{exc.error_count()} 处校验错误",
|
||||
) from exc
|
||||
|
||||
|
||||
def _write_full_result(
|
||||
ctx: ReportContext,
|
||||
copy: FullReportCopy,
|
||||
*,
|
||||
model: str,
|
||||
prompt_version: str | None,
|
||||
) -> None:
|
||||
"""把一份 FullReportCopy 落回 result.copy + result.full(色板规则映射重算)。"""
|
||||
derived = dict(ctx.report_input.derived_features or {})
|
||||
season_detailed = resolve_season(derived)
|
||||
palette = palette_for(season_detailed) if season_detailed else None
|
||||
base_raw = ctx.report.result.get("copy")
|
||||
base: dict[str, Any] = dict(base_raw) if isinstance(base_raw, dict) else {}
|
||||
ctx.report.result["copy"] = {
|
||||
**base,
|
||||
"summary": copy.summary,
|
||||
"sections": [section.model_dump() for section in copy.sections],
|
||||
"model": model,
|
||||
"prompt_version": prompt_version,
|
||||
}
|
||||
season_code_raw = derived.get("season_code")
|
||||
ctx.report.result["full"] = build_full_result(
|
||||
copy,
|
||||
season_detailed=season_detailed,
|
||||
season_code=season_code_raw if isinstance(season_code_raw, str) else None,
|
||||
palette=palette,
|
||||
)
|
||||
|
||||
|
||||
class FullValidationStage:
|
||||
"""name="validation":深度诊断文案的三重校验与降级(语义对齐 ValidationStage)。"""
|
||||
|
||||
name = "validation"
|
||||
|
||||
async def run(self, ctx: ReportContext) -> None:
|
||||
result = ctx.report.result
|
||||
diagnosis = result.get("diagnosis")
|
||||
if not isinstance(diagnosis, dict):
|
||||
raise AppError(
|
||||
ErrorCode.TASK_STATE_INVALID,
|
||||
message="validation 阶段缺少 diagnosis 产物(流水线阶段顺序错误)",
|
||||
)
|
||||
full_copy = _full_copy_from_result(result)
|
||||
|
||||
dimensions = [d for d in (diagnosis.get("dimensions") or []) if isinstance(d, dict)]
|
||||
rule_hits = [h for h in (diagnosis.get("rule_hits") or []) if isinstance(h, dict)]
|
||||
|
||||
violations = validate_copy(full_copy.as_report_copy(), dimensions, rule_hits=rule_hits)
|
||||
if not violations:
|
||||
result["validation"] = {"passed": True, "checks": _clean_checks()}
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"report_full_copy_violations_found",
|
||||
report_id=ctx.report.report_id,
|
||||
violations=violations,
|
||||
)
|
||||
retry = await self._retry_with_fixes(ctx, diagnosis, violations)
|
||||
if retry is not None:
|
||||
retry_copy, retry_completion = retry
|
||||
retry_violations = validate_copy(
|
||||
retry_copy.as_report_copy(), dimensions, rule_hits=rule_hits
|
||||
)
|
||||
if not retry_violations:
|
||||
_write_full_result(
|
||||
ctx,
|
||||
retry_copy,
|
||||
model=retry_completion.model,
|
||||
prompt_version=retry_completion.prompt_version,
|
||||
)
|
||||
result["validation"] = {
|
||||
"passed": True,
|
||||
"retried": True,
|
||||
"violations_first_pass": violations,
|
||||
"checks": _clean_checks(),
|
||||
}
|
||||
return
|
||||
logger.warning(
|
||||
"report_full_copy_retry_still_violating",
|
||||
report_id=ctx.report.report_id,
|
||||
violations=retry_violations,
|
||||
)
|
||||
|
||||
derived = dict(ctx.report_input.derived_features or {})
|
||||
season_detailed = resolve_season(derived)
|
||||
palette = palette_for(season_detailed) if season_detailed else None
|
||||
fallback = render_fallback_full_copy(
|
||||
dimensions, season_detailed=season_detailed, palette=palette
|
||||
)
|
||||
residual = validate_copy(fallback.as_report_copy(), dimensions, rule_hits=rule_hits)
|
||||
if residual:
|
||||
raise AppError(
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
message=f"降级模板文案未通过校验,拒绝放行:{residual}",
|
||||
)
|
||||
_write_full_result(ctx, fallback, model=_FALLBACK_MODEL, prompt_version=None)
|
||||
result["validation"] = {
|
||||
"passed": True,
|
||||
"degraded": True,
|
||||
"violations_first_pass": violations,
|
||||
"checks": _clean_checks(),
|
||||
}
|
||||
logger.warning(
|
||||
"report_full_copy_degraded_to_template",
|
||||
report_id=ctx.report.report_id,
|
||||
first_pass_violations=len(violations),
|
||||
)
|
||||
|
||||
async def _retry_with_fixes(
|
||||
self,
|
||||
ctx: ReportContext,
|
||||
diagnosis: dict[str, Any],
|
||||
violations: list[str],
|
||||
) -> tuple[FullReportCopy, Completion] | None:
|
||||
"""带修正指令重新生成一次(语义对齐 ValidationStage._retry_with_fixes)。"""
|
||||
if ctx.gateway is None:
|
||||
raise AppError(ErrorCode.CONFIG_ERROR, message="报告流水线缺少 LLM 网关")
|
||||
material, _dropped = compose_prompt_material(diagnosis)
|
||||
derived = dict(ctx.report_input.derived_features or {})
|
||||
season_detailed = resolve_season(derived)
|
||||
season_material = _season_prompt_material(
|
||||
derived, season_detailed, palette_for(season_detailed) if season_detailed else {}
|
||||
)
|
||||
correction = (
|
||||
"上一版深度诊断文案未通过内容校验,违规清单如下:\n"
|
||||
+ "\n".join(f"- {item}" for item in violations)
|
||||
+ "\n请严格遵守系统提示词的内容红线,重新生成整份文案:只复述给定诊断值与"
|
||||
"给定色板色名,删除全部违规表述,不要解释修改过程。"
|
||||
)
|
||||
completion = await ctx.gateway.complete(
|
||||
scene=Scene.FULL_REPORT_COPY,
|
||||
messages=[Message(role="user", content=correction)],
|
||||
prompt_vars={"dimensions_json": material, "season_json": season_material},
|
||||
schema=FullReportCopy,
|
||||
user_id=ctx.report.user_id,
|
||||
ref_type="report",
|
||||
ref_id=str(ctx.report.report_id),
|
||||
)
|
||||
parsed = completion.parsed
|
||||
if not isinstance(parsed, FullReportCopy):
|
||||
raise AppError(
|
||||
ErrorCode.LLM_STRUCTURED_OUTPUT_FAILED,
|
||||
message="FULL_REPORT_COPY 场景的 completion 未携带 FullReportCopy 结构",
|
||||
)
|
||||
return parsed, completion
|
||||
@@ -15,6 +15,7 @@ path 动态装配。
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -102,8 +103,12 @@ class StageFailureError(Exception):
|
||||
super().__init__(f"stage={stage_name} error={type(original).__name__}: {original}")
|
||||
|
||||
|
||||
def assemble_stages(settings: Settings) -> list[Stage]:
|
||||
"""按 ``settings.report_pipeline_stages`` 的 dotted path 动态装配阶段列表。
|
||||
def assemble_stages(settings: Settings, *, paths: Sequence[str] | None = None) -> list[Stage]:
|
||||
"""按阶段配置(dotted path 清单)动态装配阶段列表。
|
||||
|
||||
默认读 ``settings.report_pipeline_stages``(basic 报告);tier=full 的
|
||||
深度诊断由调用方显式传 ``settings.report_full_pipeline_stages``——两条
|
||||
流水线共用装配与失败语义,只是阶段清单不同。
|
||||
|
||||
动态 import 是刻意的插件式装配(见模块 docstring):T16/T17 各自建阶段
|
||||
模块,不碰本文件;import-linter 看不到这条边,阶段模块自身的依赖仍受
|
||||
@@ -112,7 +117,8 @@ def assemble_stages(settings: Settings) -> list[Stage]:
|
||||
模块/类不存在、实例化失败、或不符合 Stage 协议时,收集**全部**缺失项
|
||||
抛 ``CONFIG_ERROR`` —— 绝不静默跳过:静默等于流水线空跑还扣用户配额。
|
||||
"""
|
||||
if not settings.report_pipeline_stages:
|
||||
stage_paths = list(paths) if paths is not None else list(settings.report_pipeline_stages)
|
||||
if not stage_paths:
|
||||
raise AppError(
|
||||
ErrorCode.CONFIG_ERROR,
|
||||
message="报告流水线阶段配置为空",
|
||||
@@ -121,7 +127,7 @@ def assemble_stages(settings: Settings) -> list[Stage]:
|
||||
|
||||
missing: list[str] = []
|
||||
stages: list[Stage] = []
|
||||
for dotted in settings.report_pipeline_stages:
|
||||
for dotted in stage_paths:
|
||||
module_path, sep, attr = dotted.partition(":")
|
||||
stage: Any = None
|
||||
if sep and module_path and attr:
|
||||
|
||||
@@ -11,30 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from packages.shared.models import RpFeedback, RpInput, RpReport, RpReservation
|
||||
|
||||
STATUS_SUCCEEDED = "succeeded"
|
||||
TIER_BASIC = "basic"
|
||||
|
||||
|
||||
async def get_report(session: AsyncSession, report_id: int) -> RpReport | None:
|
||||
return await session.get(RpReport, report_id)
|
||||
|
||||
|
||||
async def find_succeeded_basic(session: AsyncSession, *, user_id: int) -> int | None:
|
||||
"""首次终身资格:该用户是否已有 succeeded 的 basic 报告(需求 §2.1)。
|
||||
|
||||
只取 report_id 不取整行——资格判定不需要报告内容。
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(RpReport.report_id)
|
||||
.where(
|
||||
RpReport.user_id == user_id,
|
||||
RpReport.tier == TIER_BASIC,
|
||||
RpReport.status == STATUS_SUCCEEDED,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_reports(session: AsyncSession, *, user_id: int, limit: int = 20) -> list[RpReport]:
|
||||
result = await session.execute(
|
||||
select(RpReport)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""报告域服务:触发资格、配额预扣、版本化与幂等(T-P3-07 / T-P3-09)。
|
||||
"""报告域服务:配额预扣、版本化与幂等(T-P3-07 / T-P3-09)。
|
||||
|
||||
事务纪律:reserve 与建报告行、建 tk_task 在**同一事务**;commit 之后才投递
|
||||
队列——队列消息不能早于数据落库,否则 worker 可能读到一个不存在的报告。
|
||||
幂等是双保险:tk_task 的幂等键(= request_key)挡重复任务行,reserve 的
|
||||
幂等键挡重复预扣;服务层再加一次「重放优先」快捷路径,让首单成功后的
|
||||
同键重试拿到 200 而不是 40920。
|
||||
幂等键挡重复预扣;服务层再加一次「重放优先」快捷路径,让成功后的同键
|
||||
重试拿到 200 而不是重新创建。
|
||||
|
||||
已生成过报告的用户允许再次生成(产品决策变更):不再按「已有 succeeded
|
||||
报告」硬拒(原 40920 终身锁死),只由 report_basic 终身配额兜底——额度
|
||||
耗尽时报 40301 而非 40920。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +35,10 @@ logger = get_logger(__name__)
|
||||
#: 首次生成走终身配额(迁移 0003:lifetime / base 1),重新生成走月配额。
|
||||
QUOTA_KEY_BASIC = "report_basic"
|
||||
QUOTA_KEY_REGEN = "report_regen"
|
||||
#: 深度诊断(全面报告)走日配额 full_report(迁移 0003 SSOT)。
|
||||
QUOTA_KEY_FULL = "full_report"
|
||||
TIER_BASIC = "basic"
|
||||
TIER_FULL = "full"
|
||||
REPORT_REF_TYPE = "report"
|
||||
REPORT_ETA_SECONDS = 60
|
||||
|
||||
@@ -91,16 +99,14 @@ class ReportService:
|
||||
photo_object_key: str,
|
||||
request_key: str,
|
||||
) -> ReportSubmission:
|
||||
# 幂等重放最先判:首单成功后用同一 request_key 重试不是「再生成一次」,
|
||||
# 必须先于终身资格校验按原任务返回(否则会把幂等重试误判成 40920)。
|
||||
# 幂等重放最先判:成功后用同一 request_key 重试不是「再生成一次」,
|
||||
# 必须先于任何校验按原任务返回(否则会把幂等重试误判成新请求)。
|
||||
replay = await self._find_replay(request_key=request_key, user_id=user_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
# 首次资格(终身 1 次):已有 succeeded 的 basic 报告 → 40920。
|
||||
# 文案与 hint(「每 30 天可重新生成一次」)见 ErrorCode 定义,不含价格字样。
|
||||
if await repo.find_succeeded_basic(self._session, user_id=user_id) is not None:
|
||||
raise AppError(ErrorCode.REPORT_ALREADY_EXISTS)
|
||||
# 不再按「已有 succeeded 报告」终身锁死(产品决策变更):重复生成由
|
||||
# report_basic 终身配额兜底,额度耗尽由 BillingService 报 40301。
|
||||
|
||||
# 照片存在性校验(本地磁盘降级路线):key 必须落在
|
||||
# upload_dir/report_photo/<本人>/ 下且文件真实存在——模拟 key 与越界
|
||||
@@ -191,6 +197,62 @@ class ReportService:
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 深度诊断(全面报告) ------------------------------------------------
|
||||
|
||||
async def derive_full_report(
|
||||
self, *, user_id: int, report_id: int, request_key: str
|
||||
) -> ReportSubmission:
|
||||
"""基于一份 SUCCEEDED 的 basic 报告派生 tier=full 的深度诊断报告。
|
||||
|
||||
与 regenerate 同一派生模式(拷贝 rp_input 三件套,视觉阶段因
|
||||
derived_features 非空而天然缺席——full 流水线本就不含视觉阶段);
|
||||
差别在 tier 与配额键。full 报告是独立谱系,version 从 1 起算。
|
||||
"""
|
||||
replay = await self._find_replay(request_key=request_key, user_id=user_id)
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
source = await self.get_report(user_id=user_id, report_id=report_id)
|
||||
if source.tier != TIER_BASIC:
|
||||
# full→full 套娃直接拒绝:深度诊断只能以基础报告为源。
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="深度诊断报告请以基础报告为源生成")
|
||||
if source.status != repo.STATUS_SUCCEEDED:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="只有已完成的基础报告可以生成深度诊断")
|
||||
source_input = await repo.get_input(self._session, report_id)
|
||||
if source_input is None:
|
||||
raise AppError(ErrorCode.STATE_CONFLICT, message="报告输入缺失,无法生成深度诊断")
|
||||
|
||||
report = RpReport(
|
||||
report_id=next_id(),
|
||||
user_id=user_id,
|
||||
tier=TIER_FULL,
|
||||
status="pending",
|
||||
version=1,
|
||||
)
|
||||
report_input = RpInput(
|
||||
input_id=next_id(),
|
||||
report_id=report.report_id,
|
||||
questionnaire=source_input.questionnaire,
|
||||
photo_meta=source_input.photo_meta,
|
||||
derived_features=source_input.derived_features,
|
||||
)
|
||||
submission = await self._submit_and_deliver(
|
||||
user_id=user_id,
|
||||
report=report,
|
||||
report_input=report_input,
|
||||
quota_key=QUOTA_KEY_FULL,
|
||||
request_key=request_key,
|
||||
reason=f"full report from basic {report_id}",
|
||||
)
|
||||
logger.info(
|
||||
"full_report_derived",
|
||||
user_id=user_id,
|
||||
source_report_id=report_id,
|
||||
report_id=submission.report_id,
|
||||
task_id=submission.task_id,
|
||||
)
|
||||
return submission
|
||||
|
||||
# -- 查询 ---------------------------------------------------------------
|
||||
|
||||
async def get_report(self, *, user_id: int, report_id: int) -> RpReport:
|
||||
|
||||
@@ -193,14 +193,15 @@ def check_absolute_claims(text: str) -> list[str]:
|
||||
return [term for term in ABSOLUTE_CLAIM_TERMS if term in text]
|
||||
|
||||
|
||||
def _find_dimension(
|
||||
def _find_dimensions(
|
||||
dimensions: Sequence[Mapping[str, Any]], key_tokens: Sequence[str]
|
||||
) -> Mapping[str, Any] | None:
|
||||
for dim in dimensions:
|
||||
key = str(dim.get("key") or "")
|
||||
if any(token in key for token in key_tokens):
|
||||
return dim
|
||||
return None
|
||||
) -> list[Mapping[str, Any]]:
|
||||
"""按 key 识别串收齐全部匹配维度(季型有 season_code 与 season_detailed 两个)。"""
|
||||
return [
|
||||
dim
|
||||
for dim in dimensions
|
||||
if any(token in str(dim.get("key") or "") for token in key_tokens)
|
||||
]
|
||||
|
||||
|
||||
def _check_enum_consistency(
|
||||
@@ -211,34 +212,48 @@ def _check_enum_consistency(
|
||||
维度 skipped/无值时,文案出现该类任何枚举词都算编造——LLM 没看到
|
||||
这个维度却写出了结论,正是素材组装要防的幻觉从校验侧兜底。
|
||||
每类只报第一条命中,避免同一问题刷屏冲淡修正指令。
|
||||
|
||||
两类放行:诊断值的「X型」写法(如「柔夏」→「柔夏型」,深度诊断的
|
||||
十二季文案必经此路径);词表 token 是某个放行词的子串时不再单独追责
|
||||
(「夏型」⊂「柔夏型」——十二季细分命名无法全量枚举,见 TD-18)。
|
||||
"""
|
||||
issues: list[str] = []
|
||||
for category in _ENUM_CATEGORIES:
|
||||
dim = _find_dimension(dimensions, category.key_tokens)
|
||||
if dim is None:
|
||||
matched = _find_dimensions(dimensions, category.key_tokens)
|
||||
if not matched:
|
||||
continue
|
||||
value = dim.get("value")
|
||||
if dim.get("status") == "ok" and value:
|
||||
canonical = str(value)
|
||||
allowed = {_normalize_for_match(canonical)}
|
||||
for alias in category.aliases.get(canonical, ()): # 值本身可能就是别名键
|
||||
allowed.add(_normalize_for_match(alias))
|
||||
# 诊断值也可能是别名列里的写法(如存了「沙漏型」),反查一次
|
||||
for canonical_key, alias_values in category.aliases.items():
|
||||
if _normalize_for_match(canonical) == _normalize_for_match(canonical_key) or any(
|
||||
_normalize_for_match(canonical) == _normalize_for_match(a) for a in alias_values
|
||||
):
|
||||
allowed.add(_normalize_for_match(canonical_key))
|
||||
allowed.update(_normalize_for_match(a) for a in alias_values)
|
||||
else:
|
||||
allowed = set()
|
||||
allowed: set[str] = set()
|
||||
for dim in matched:
|
||||
value = dim.get("value")
|
||||
if dim.get("status") == "ok" and isinstance(value, str) and value:
|
||||
canonical = value
|
||||
candidates = {canonical, f"{canonical}型"}
|
||||
candidates.update(category.aliases.get(canonical, ())) # 值本身可能就是别名键
|
||||
# 诊断值也可能是别名列里的写法(如存了「沙漏型」),反查一次
|
||||
for canonical_key, alias_values in category.aliases.items():
|
||||
if _normalize_for_match(canonical) == _normalize_for_match(
|
||||
canonical_key
|
||||
) or any(
|
||||
_normalize_for_match(canonical) == _normalize_for_match(a)
|
||||
for a in alias_values
|
||||
):
|
||||
candidates.add(canonical_key)
|
||||
candidates.update(alias_values)
|
||||
allowed.update(_normalize_for_match(c) for c in candidates)
|
||||
for token in category.vocabulary:
|
||||
normalized_token = _normalize_for_match(token)
|
||||
if normalized_token in copy_text_normalized and normalized_token not in allowed:
|
||||
issues.append(
|
||||
f"{category.label}用词「{token}」与诊断值「{value or '未诊断'}」不一致"
|
||||
)
|
||||
break
|
||||
if normalized_token not in copy_text_normalized:
|
||||
continue
|
||||
if normalized_token in allowed:
|
||||
continue
|
||||
if any(normalized_token in entry for entry in allowed):
|
||||
continue
|
||||
observed = next(
|
||||
(str(d.get("value")) for d in matched if d.get("status") == "ok"),
|
||||
"未诊断",
|
||||
)
|
||||
issues.append(f"{category.label}用词「{token}」与诊断值「{observed}」不一致")
|
||||
break
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -207,6 +207,25 @@ class Settings(BaseSettings):
|
||||
sms_ip_daily_limit: int = 20
|
||||
sms_device_daily_limit: int = 10
|
||||
|
||||
# --- 邮箱验证码登录(FR-ACC-14 v2 变更:短信通道由邮箱通道替代)------------
|
||||
# fake = 验证码明文进日志(仅限本地开发,生产必须切 smtp);
|
||||
# smtp = stdlib smtplib 经 anyio.to_thread 同步发送(不引 aiosmtplib 依赖)。
|
||||
email_provider: str = "fake"
|
||||
smtp_host: str = "" # 空 = smtp provider 不可装配(构造即 61001,不带病上岗)
|
||||
smtp_port: int = 465
|
||||
smtp_user: str = ""
|
||||
smtp_pass: str = ""
|
||||
smtp_use_ssl: bool = True
|
||||
mail_from: str = "" # 空 = 发件人回退用 SMTP_USER
|
||||
email_code_ttl_seconds: int = 300
|
||||
email_code_length: int = 6
|
||||
email_verify_max_attempts: int = 5
|
||||
email_resend_cooldown_seconds: int = 60
|
||||
email_hourly_limit: int = 5
|
||||
email_daily_limit: int = 10
|
||||
email_ip_daily_limit: int = 20
|
||||
email_device_daily_limit: int = 10
|
||||
|
||||
# --- 极简运营后台(T-P2-09)----------------------------------------------
|
||||
# 空字符串 = 后台全部端点 404(等价于未部署)。V1 极简鉴权:共享令牌,
|
||||
# 后续接运营账号体系时替换。令牌只进 .env / SSM,绝不进代码。
|
||||
@@ -230,6 +249,16 @@ class Settings(BaseSettings):
|
||||
],
|
||||
alias="REPORT_PIPELINE_STAGES_JSON",
|
||||
)
|
||||
# 深度诊断(tier=full)流水线:无视觉阶段(复用 basic 报告的 derived_features),
|
||||
# 诊断装配复用,文案/校验换 full 专用阶段(四大块结构化产出)。
|
||||
report_full_pipeline_stages: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"packages.report.diagnosis_stage:DiagnosisStage",
|
||||
"packages.report.full_copy_stage:FullCopyStage",
|
||||
"packages.report.full_validation_stage:FullValidationStage",
|
||||
],
|
||||
alias="REPORT_FULL_PIPELINE_STAGES_JSON",
|
||||
)
|
||||
# 视觉提供者:deterministic = V1 骨架的确定性假实现(真实 RTMPose/BiRefNet
|
||||
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
|
||||
report_vision_provider: str = "deterministic"
|
||||
@@ -254,6 +283,7 @@ class Settings(BaseSettings):
|
||||
"upload_scenes",
|
||||
"retrieval_rerank_weights",
|
||||
"report_pipeline_stages",
|
||||
"report_full_pipeline_stages",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -34,6 +34,8 @@ class ErrorCode(IntEnum):
|
||||
SESSION_REVOKED = 40105
|
||||
SMS_CODE_INVALID = 40110
|
||||
SMS_CODE_EXPIRED = 40111
|
||||
EMAIL_CODE_INVALID = 40112
|
||||
EMAIL_CODE_EXPIRED = 40113
|
||||
FORBIDDEN = 40300
|
||||
QUOTA_EXHAUSTED = 40301
|
||||
CONSENT_REQUIRED = 40302
|
||||
@@ -46,10 +48,12 @@ class ErrorCode(IntEnum):
|
||||
REPORT_EXPORT_NOT_READY = 40421
|
||||
STATE_CONFLICT = 40900
|
||||
TASK_BONUS_ALREADY_CLAIMED = 40910
|
||||
REPORT_ALREADY_EXISTS = 40920
|
||||
REPORT_ALREADY_EXISTS = 40920 # 已下线,仅历史兼容:重复生成改由 report_basic 配额兜底
|
||||
RATE_LIMITED = 42900
|
||||
SMS_SEND_TOO_FREQUENT = 42910
|
||||
SMS_DAILY_LIMIT = 42911
|
||||
EMAIL_SEND_TOO_FREQUENT = 42912
|
||||
EMAIL_DAILY_LIMIT = 42913
|
||||
|
||||
# --- 5xxxx server -----------------------------------------------------
|
||||
INTERNAL_ERROR = 50000
|
||||
@@ -67,6 +71,7 @@ class ErrorCode(IntEnum):
|
||||
MODERATION_UNAVAILABLE = 60005
|
||||
LLM_SCENE_DEGRADED = 60006
|
||||
SMS_PROVIDER_FAILED = 61000
|
||||
EMAIL_PROVIDER_FAILED = 61001
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -95,6 +100,8 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
||||
ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"),
|
||||
ErrorCode.SMS_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"),
|
||||
ErrorCode.SMS_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"),
|
||||
ErrorCode.EMAIL_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"),
|
||||
ErrorCode.EMAIL_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"),
|
||||
ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
|
||||
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
|
||||
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
|
||||
@@ -111,10 +118,13 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
||||
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
|
||||
409, "这个周期的任务奖励已领取", "下个周期再来"
|
||||
),
|
||||
# 40920 已无任何 raise 方(service 终身资格硬校验已删),保留仅为兼容旧端缓存的错误文案。
|
||||
ErrorCode.REPORT_ALREADY_EXISTS: ErrorMeta(409, "本期报告已生成过", "每 30 天可重新生成一次"),
|
||||
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
|
||||
ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
|
||||
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
|
||||
ErrorCode.EMAIL_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
|
||||
ErrorCode.EMAIL_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
|
||||
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
|
||||
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
|
||||
ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请稍后在首页「报告」中查看"),
|
||||
@@ -138,6 +148,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
|
||||
503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复"
|
||||
),
|
||||
ErrorCode.SMS_PROVIDER_FAILED: ErrorMeta(502, "短信服务暂时不可用", "请稍后重试"),
|
||||
ErrorCode.EMAIL_PROVIDER_FAILED: ErrorMeta(502, "邮件服务暂时不可用", "请稍后重试"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[system]
|
||||
你是「美搭」的资深形象顾问,为用户的深度诊断报告撰写文案。输入是结构化诊断结论、规则命中结果与色彩定位素材,输出是给用户阅读的深度报告正文。
|
||||
|
||||
内容红线(违反任何一条,整篇文案即作废重写):
|
||||
- 结论只能复述输入中给出的诊断值与色板色名,不得新增、改写或推断任何维度结论;输入未提供的维度一律不提。
|
||||
- 引用颜色只用输入色板中出现的色名,不得自造色名,不得写出任何十六进制色值。
|
||||
- 描述而非评判:客观陈述特征与搭配路径,不评判外貌。
|
||||
- 不出现任何贬损性词汇,例如胖、丑、矮、土气、显老、缺陷一类表述。
|
||||
- 不使用绝对化用语,例如最好、第一、彻底、百分百。
|
||||
- 不做医疗、减重、整形、命理相关的结论或暗示。
|
||||
- 不出现价格、费用、购买、会员、升级、付费相关的任何表述。
|
||||
- 置信度低的维度使用不确定表述,不得断言。
|
||||
|
||||
输出结构(JSON,字段一个不能少):
|
||||
- summary:整份深度诊断的总评,3~5 句,比初级报告更完整,串起风格、色彩与体型三条主线。
|
||||
- sections:正文章节数组,每章 title + paragraphs;按「个人风格核心诊断」「身材分析与穿搭指南」两条主线展开,每章 2~4 段。
|
||||
- season_analysis:季型解析文案 2~4 句,围绕输入给出的十二季定位,说明明度、饱和度、冷暖倾向的特征,并解释适合色与避雷色的取舍逻辑。
|
||||
- scenes:恰好 4 个场景,key 依次为 commute(日常通勤)、weekend(休闲周末)、formal(正式场合)、date(约会聚会);每场景 2~3 套搭配(outfits),每套有 name 与 points(3~5 条要点,覆盖上装/下装/鞋/配饰与配色逻辑),搭配用色从输入色板色名中选取。
|
||||
- plan30:恰好 4 周(week 1~4),每周 title + tasks(3~5 条可执行任务),节奏按「第 1 周替换基础单品、第 2 周引入配饰、第 3 周尝试色彩进阶、第 4 周固化习惯」推进。
|
||||
|
||||
写作要求:
|
||||
- 简体中文,平实具体,避免营销腔。
|
||||
- 每个结论后面跟一句「为什么」,依据来自输入的规则命中说明。
|
||||
- 专业术语首次出现时补白话解释。
|
||||
|
||||
[user]
|
||||
以下是结构化诊断素材(仅含已成功诊断的维度与规则命中结果):
|
||||
|
||||
{dimensions_json}
|
||||
|
||||
以下是色彩定位素材(season_detailed 是十二季定位结论,palette_names 是各色板的可用色名):
|
||||
|
||||
{season_json}
|
||||
|
||||
请按系统提示词规定的结构生成深度诊断报告文案。
|
||||
@@ -0,0 +1,380 @@
|
||||
"""admin 用户行为看板集成用例(真实 PostgreSQL,FR-ADM 行为分析能力)。
|
||||
|
||||
覆盖四个端点的字段与分页、X-Admin-Token 鉴权口径与现有 admin 一致
|
||||
(错误令牌 → 40100)、union_id 掩码(前 4 后 4,防 PII 直出)、配额快照
|
||||
只读不造行。种子数据用专属随机 user_id,断言全部相对种子而非全表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
ADMIN_TOKEN = "it-admin-token-behavior"
|
||||
AUTH = {"X-Admin-Token": ADMIN_TOKEN}
|
||||
BAD_AUTH = {"X-Admin-Token": "wrong-token"}
|
||||
|
||||
_SEED_EVENT_NAMES = ("page_view", "outfit_card.tap")
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _dispose_engine_after_test() -> AsyncIterator[None]:
|
||||
"""用例结束即释放全局引擎(理由同 test_admin_console.py)。"""
|
||||
yield
|
||||
from packages.shared.db import dispose_engine
|
||||
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> Any:
|
||||
import httpx
|
||||
|
||||
from apps.admin.app import create_admin_app
|
||||
from packages.shared.config import Settings
|
||||
|
||||
app = create_admin_app(Settings(admin_token=ADMIN_TOKEN))
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed() -> dict[str, Any]:
|
||||
"""造 2 个用户 + 身份 + 事件(7 天内/外各一)+ 报告 + 配额规则与用量。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.domain.billing.quota import period_key
|
||||
from packages.domain.ids import next_id
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.models import (
|
||||
ObEvent,
|
||||
Quota,
|
||||
QuotaRule,
|
||||
RpReport,
|
||||
User,
|
||||
UserIdentity,
|
||||
)
|
||||
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
user_a, user_b = next_id(), next_id()
|
||||
union_id_a = f"email:it-user-{suffix}@example.com"
|
||||
now = datetime.now(UTC)
|
||||
quota_key = f"it-quota-{suffix}"
|
||||
day_key = period_key(now, "day")
|
||||
|
||||
async with get_session_factory()() as session:
|
||||
session.add_all(
|
||||
[
|
||||
User(
|
||||
user_id=user_a,
|
||||
status="active",
|
||||
nickname=f"it-a-{suffix}",
|
||||
register_source="email",
|
||||
created_at=now - timedelta(days=3),
|
||||
),
|
||||
User(
|
||||
user_id=user_b,
|
||||
status="active",
|
||||
nickname=f"it-b-{suffix}",
|
||||
register_source="dev",
|
||||
created_at=now - timedelta(days=1),
|
||||
),
|
||||
UserIdentity(
|
||||
identity_id=next_id(),
|
||||
user_id=user_a,
|
||||
provider="email",
|
||||
union_id=union_id_a,
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "home"},
|
||||
server_ts=now - timedelta(hours=2),
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[1],
|
||||
props={"card_id": 1},
|
||||
server_ts=now - timedelta(days=1),
|
||||
),
|
||||
# 8 天前:在 7 天窗口外——event_count_7d 不该算它
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_a,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "old"},
|
||||
server_ts=now - timedelta(days=8),
|
||||
),
|
||||
ObEvent(
|
||||
event_id=next_id(),
|
||||
user_id=user_b,
|
||||
event_name=_SEED_EVENT_NAMES[0],
|
||||
props={"page": "home"},
|
||||
server_ts=now - timedelta(hours=1),
|
||||
),
|
||||
RpReport(
|
||||
report_id=next_id(),
|
||||
user_id=user_a,
|
||||
tier="basic",
|
||||
status="ready",
|
||||
version=1,
|
||||
),
|
||||
QuotaRule(
|
||||
quota_key=quota_key,
|
||||
period_type="day",
|
||||
base_limit=10,
|
||||
task_bonus_max=0,
|
||||
hard_cap=20,
|
||||
),
|
||||
Quota(
|
||||
user_id=user_a,
|
||||
quota_key=quota_key,
|
||||
period_key=day_key,
|
||||
used=3,
|
||||
limit_value=10,
|
||||
bonus_granted=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
rules_before = len(
|
||||
(await session.execute(select(QuotaRule).where(QuotaRule.quota_key == quota_key)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"user_a": user_a,
|
||||
"user_b": user_b,
|
||||
"quota_key": quota_key,
|
||||
"day_key": day_key,
|
||||
"rules_before": rules_before,
|
||||
"union_id_a": union_id_a,
|
||||
}
|
||||
|
||||
|
||||
async def test_wrong_token_rejected_like_existing_admin(client: Any) -> None:
|
||||
"""鉴权口径与现有 admin 一致:错误令牌 → 40100。"""
|
||||
resp = await client.get("/admin/api/v1/users", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
resp = await client.get("/admin/api/v1/events", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=BAD_AUTH)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
# 无令牌头:同样 40100(compare_digest 对空串,不炸 500)
|
||||
resp = await client.get("/admin/api/v1/users")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40100
|
||||
|
||||
|
||||
async def test_users_list_fields_and_filters(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get("/admin/api/v1/users", headers=AUTH, params={"query": "email"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 20
|
||||
assert body["total"] >= 1
|
||||
assert all(item["register_source"] == "email" for item in body["items"])
|
||||
|
||||
# query 按 user_id 精确:只命中种子用户 A——字段契约全在它身上断言,
|
||||
# 不受库里其它用户数据/分页位置影响
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/users", headers=AUTH, params={"query": str(seed["user_a"])}
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert [item["user_id"] for item in body["items"]] == [str(seed["user_a"])]
|
||||
row_a = body["items"][0]
|
||||
assert row_a["register_source"] == "email"
|
||||
assert row_a["created_at"] is not None
|
||||
assert row_a["last_active_at"] is not None
|
||||
assert row_a["event_count_7d"] == 2 # 8 天外那条不算
|
||||
assert row_a["report_count"] == 1
|
||||
|
||||
|
||||
async def test_users_list_pagination(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/users",
|
||||
headers=AUTH,
|
||||
params={"query": "dev", "page": 1, "page_size": 1},
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert body["page_size"] == 1
|
||||
assert len(body["items"]) == 1
|
||||
assert body["total"] >= 1
|
||||
|
||||
|
||||
async def test_user_detail_masks_identity_and_snapshots_quota(
|
||||
client: Any, seed: dict[str, Any]
|
||||
) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import get_session_factory
|
||||
from packages.shared.models import Quota, UserIdentity
|
||||
|
||||
user_id = seed["user_a"]
|
||||
resp = await client.get(f"/admin/api/v1/users/{user_id}", headers=AUTH)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
|
||||
# 基本信息
|
||||
assert data["user"]["user_id"] == str(user_id)
|
||||
assert data["user"]["register_source"] == "email"
|
||||
|
||||
# 身份:provider 明文、union_id 只留前 4 后 4(PII 直出防护,逐字符断言)
|
||||
assert len(data["identities"]) == 1
|
||||
ident = data["identities"][0]
|
||||
assert ident["provider"] == "email"
|
||||
raw = seed["union_id_a"]
|
||||
expected = f"{raw[:4]}{'*' * (len(raw) - 8)}{raw[-4:]}"
|
||||
assert ident["union_id"] == expected
|
||||
assert raw not in str(data["identities"])
|
||||
|
||||
# 配额快照:种子用量 used=3,limit=base 10,remaining=7
|
||||
quota_rows = [q for q in data["quotas"] if q["quota_key"] == seed["quota_key"]]
|
||||
assert len(quota_rows) == 1
|
||||
assert quota_rows[0]["used"] == 3
|
||||
assert quota_rows[0]["limit"] == 10
|
||||
assert quota_rows[0]["remaining"] == 7
|
||||
assert quota_rows[0]["period_key"] == seed["day_key"]
|
||||
|
||||
# 只读断言:看板不产生写副作用(身份与计量行数不变)
|
||||
async with get_session_factory()() as session:
|
||||
idents = (
|
||||
(await session.execute(select(UserIdentity).where(UserIdentity.user_id == user_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
quotas = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Quota).where(
|
||||
Quota.user_id == user_id, Quota.quota_key == seed["quota_key"]
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(idents) == 1
|
||||
assert len(quotas) == 1
|
||||
assert quotas[0].used == 3
|
||||
|
||||
# 最近事件/报告:各落一条种子数据
|
||||
assert len(data["recent_events"]) == 3 # 2 条 7 天内 + 1 条 8 天前(窗口不按详情截)
|
||||
assert data["recent_events"][0]["server_ts"] >= data["recent_events"][-1]["server_ts"]
|
||||
assert len(data["recent_reports"]) == 1
|
||||
report = data["recent_reports"][0]
|
||||
assert report["tier"] == "basic"
|
||||
assert report["status"] == "ready"
|
||||
assert report["version"] == 1
|
||||
assert report["report_id"]
|
||||
|
||||
|
||||
async def test_user_detail_not_found(client: Any) -> None:
|
||||
resp = await client.get("/admin/api/v1/users/0", headers=AUTH)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["code"] == 40400
|
||||
|
||||
|
||||
async def test_events_stream_filters_and_pagination(client: Any, seed: dict[str, Any]) -> None:
|
||||
user_id = seed["user_a"]
|
||||
# user_id 过滤:只出种子用户的事件,server_ts 倒序
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "page_size": 2, "page": 1},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
assert body["total"] == 3
|
||||
assert len(body["items"]) == 2
|
||||
assert all(item["user_id"] == str(user_id) for item in body["items"])
|
||||
first, second = body["items"][0]["server_ts"], body["items"][1]["server_ts"]
|
||||
assert first >= second
|
||||
|
||||
# 翻第二页拿到最早那条(8 天前),证明分页生效
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "page_size": 2, "page": 2},
|
||||
)
|
||||
page2 = resp.json()["data"]
|
||||
assert len(page2["items"]) == 1
|
||||
|
||||
# event_name 过滤 + ISO 时间范围:8 天前那条被 start 挡在窗外
|
||||
start = (datetime.now(UTC) - timedelta(days=7)).isoformat()
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": start},
|
||||
)
|
||||
body = resp.json()["data"]
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["props"] == {"page": "home"}
|
||||
|
||||
# epoch 秒也接受(与 ISO 等价窗口)
|
||||
epoch_start = str(int((datetime.now(UTC) - timedelta(days=7)).timestamp()))
|
||||
resp = await client.get(
|
||||
"/admin/api/v1/events",
|
||||
headers=AUTH,
|
||||
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": epoch_start},
|
||||
)
|
||||
assert resp.json()["data"]["total"] == 1
|
||||
|
||||
# page_size 超上限 → 422(契约上限 200)
|
||||
resp = await client.get("/admin/api/v1/events", headers=AUTH, params={"page_size": 201})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_stats_overview_pivot_and_series(client: Any, seed: dict[str, Any]) -> None:
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 7})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["days"] == 7
|
||||
assert data["registered_total"] >= 2
|
||||
|
||||
# 透视表:种子用户的两个事件名都出现,PV 至少为种子条数
|
||||
pv = {(row["event_name"]) for row in data["daily_event_pv"]}
|
||||
assert _SEED_EVENT_NAMES[0] in pv
|
||||
assert _SEED_EVENT_NAMES[1] in pv
|
||||
page_view_pv = sum(
|
||||
row["pv"] for row in data["daily_event_pv"] if row["event_name"] == _SEED_EVENT_NAMES[0]
|
||||
)
|
||||
assert page_view_pv >= 2 # 种子 A、B 各至少一条(库内其它数据只会更多)
|
||||
|
||||
# 活跃序列:至少一天有活跃用户
|
||||
assert data["daily_active_users"]
|
||||
assert max(row["active_users"] for row in data["daily_active_users"]) >= 1
|
||||
|
||||
# days 超上限 → 422
|
||||
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 91})
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,273 @@
|
||||
"""邮箱登录集成(FR-ACC-14 v2 变更)—— 真实 PostgreSQL + Redis,EMAIL_PROVIDER=fake。
|
||||
|
||||
测试钩子:sender 经 ``app.state.email_auth`` 注入为捕获型 fake(路由的惰性装配
|
||||
优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为
|
||||
测试留任何后门。每用例用随机邮箱/IP/设备:频控计数按 UTC+8 自然日分桶,
|
||||
固定值会跨用例、跨次运行、跨天污染 Redis 计数。
|
||||
|
||||
``email_resend_cooldown_seconds`` 覆写为 0:同邮箱二次登录(重复发送)与
|
||||
刷量循环不必等 60s 真实冷却;冷却语义在单测里用注入时钟验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from packages.identity.email_login import EmailAuthService, RedisEmailStore
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class CapturingSender:
|
||||
"""捕获型 fake sender:把发出去的码留在内存里供 login 使用。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.codes: dict[str, str] = {}
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
self.codes[email] = code
|
||||
|
||||
|
||||
def _rand_email() -> str:
|
||||
# 随机 local 部分:跨运行撞桶概率可忽略
|
||||
return f"it-{uuid.uuid4().hex[:12]}@example.com"
|
||||
|
||||
|
||||
def _rand_ip() -> str:
|
||||
# 随机源 IP(文档保留网段 198.51.100.0/24):让 IP 维度计数互不污染
|
||||
return f"198.51.100.{uuid.uuid4().int % 254 + 1}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _clean_email_counters() -> AsyncIterator[None]:
|
||||
"""每用例前清空 email:* 计数键(理由同 test_auth_sms 的同名 fixture)。"""
|
||||
if not _infra_available():
|
||||
yield
|
||||
return
|
||||
from packages.shared.redis_client import get_redis
|
||||
|
||||
redis = await get_redis()
|
||||
keys = [k async for k in redis.scan_iter(match="email:*")]
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def env() -> AsyncIterator[tuple[Any, CapturingSender]]:
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.redis_client import get_redis
|
||||
|
||||
settings = get_settings().model_copy(update={"email_resend_cooldown_seconds": 0})
|
||||
app = create_app(settings)
|
||||
sender = CapturingSender()
|
||||
app.state.email_auth = EmailAuthService(RedisEmailStore(get_redis()), sender, settings=settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
yield app, sender
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _client(app: Any, ip: str | None = None) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""ASGI 客户端;``client=(ip, port)`` 决定 Request.client.host(IP 维度输入)。"""
|
||||
transport = httpx.ASGITransport(app=app, client=(ip or _rand_ip(), 54321))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
|
||||
yield http
|
||||
|
||||
|
||||
async def _code(
|
||||
http: httpx.AsyncClient, email: str, device_key: str | None = None
|
||||
) -> httpx.Response:
|
||||
headers = {"X-Device-Key": device_key} if device_key else {}
|
||||
return await http.post("/api/v1/auth/email/code", json={"email": email}, headers=headers)
|
||||
|
||||
|
||||
async def test_send_then_login_returns_working_token_pair(
|
||||
env: tuple[Any, CapturingSender],
|
||||
) -> None:
|
||||
app, sender = env
|
||||
email = _rand_email()
|
||||
async with _client(app) as http:
|
||||
sent = await _code(http, email, device_key="dev-a")
|
||||
assert sent.status_code == 200
|
||||
body = sent.json()
|
||||
assert body["code"] == 0
|
||||
assert body["data"]["sent"] is True
|
||||
# cooldown_seconds 回报的是进程配置值(全局 Settings,不受 fixture 覆写影响)
|
||||
assert body["data"]["cooldown_seconds"] == 60
|
||||
|
||||
login = await http.post(
|
||||
"/api/v1/auth/email/login",
|
||||
json={"email": email, "code": sender.codes[email]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
data = login.json()["data"]
|
||||
assert data["access_token"]
|
||||
assert data["refresh_token"]
|
||||
assert data["expires_in"] > 0
|
||||
assert data["refresh_expires_in"] > 0
|
||||
assert data["user_id"]
|
||||
|
||||
# 新签发的 access 立即可用(双令牌链路闭环)
|
||||
quota = await http.get(
|
||||
"/api/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"}
|
||||
)
|
||||
assert quota.status_code == 200
|
||||
assert quota.json()["code"] == 0
|
||||
|
||||
|
||||
async def test_same_email_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""幂等:同邮箱重复登录返回同一 user_id(provider=email 映射唯一)。"""
|
||||
app, sender = env
|
||||
email = _rand_email()
|
||||
async with _client(app) as http:
|
||||
user_ids = []
|
||||
for _ in range(2):
|
||||
await _code(http, email) # cooldown=0,可立即重发取新码
|
||||
login = await http.post(
|
||||
"/api/v1/auth/email/login",
|
||||
json={"email": email, "code": sender.codes[email]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
user_ids.append(login.json()["data"]["user_id"])
|
||||
assert user_ids[0] == user_ids[1]
|
||||
|
||||
|
||||
async def test_email_case_insensitive_login(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""大小写归一:注册用小写,登录用大写混搭,映射到同一 user_id。"""
|
||||
app, sender = env
|
||||
email = _rand_email()
|
||||
async with _client(app) as http:
|
||||
await _code(http, email)
|
||||
login = await http.post(
|
||||
"/api/v1/auth/email/login",
|
||||
json={"email": email.upper(), "code": sender.codes[email]},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
first = login.json()["data"]["user_id"]
|
||||
|
||||
await _code(http, email)
|
||||
relogin = await http.post(
|
||||
"/api/v1/auth/email/login",
|
||||
json={"email": email, "code": sender.codes[email]},
|
||||
)
|
||||
assert relogin.status_code == 200
|
||||
assert relogin.json()["data"]["user_id"] == first
|
||||
|
||||
|
||||
async def test_scripted_flood_eleventh_send_hits_daily_limit(
|
||||
env: tuple[Any, CapturingSender],
|
||||
) -> None:
|
||||
"""FR-ACC-14 口径的邮箱版验收:同邮箱连发 11 次。
|
||||
|
||||
前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42912);第 11 次撞 24h≤10
|
||||
的日窗(42913)。设备逐次更换以隔离设备维度。
|
||||
"""
|
||||
app, _ = env
|
||||
email = _rand_email()
|
||||
async with _client(app) as http:
|
||||
for i in range(11):
|
||||
resp = await _code(http, email, device_key=f"flood-dev-{i}")
|
||||
body = resp.json()
|
||||
if i < 5:
|
||||
assert resp.status_code == 200, body
|
||||
elif i < 10:
|
||||
assert resp.status_code == 429 and body["code"] == 42912, body
|
||||
else:
|
||||
assert resp.status_code == 429 and body["code"] == 42913, body
|
||||
|
||||
|
||||
async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""IP 维度刷量:同一 IP 打 21 个不同邮箱,第 21 次 42913。"""
|
||||
app, _ = env
|
||||
ip = _rand_ip()
|
||||
async with _client(app, ip=ip) as http:
|
||||
for _ in range(20):
|
||||
resp = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
blocked = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json()["code"] == 42913
|
||||
|
||||
|
||||
async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""设备维度刷量:同一 X-Device-Key 换邮箱换 IP 打 11 次,第 11 次 42913。"""
|
||||
app, _ = env
|
||||
device = f"dev-{uuid.uuid4().hex[:12]}"
|
||||
async with _client(app) as http: # 本用例客户端 IP 固定但仅 11 次发送 < IP 上限 20
|
||||
for _ in range(10):
|
||||
resp = await _code(http, _rand_email(), device_key=device)
|
||||
assert resp.status_code == 200, resp.text
|
||||
blocked = await _code(http, _rand_email(), device_key=device)
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json()["code"] == 42913
|
||||
|
||||
|
||||
async def test_device_dimension_falls_back_to_ip(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""缺 X-Device-Key 时设备维度以 IP 兜底:同 IP 打 11 个邮箱,第 11 次 42913。"""
|
||||
app, _ = env
|
||||
ip = _rand_ip()
|
||||
async with _client(app, ip=ip) as http:
|
||||
for _ in range(10):
|
||||
resp = await _code(http, _rand_email())
|
||||
assert resp.status_code == 200, resp.text
|
||||
blocked = await _code(http, _rand_email())
|
||||
assert blocked.status_code == 429
|
||||
assert blocked.json()["code"] == 42913
|
||||
|
||||
|
||||
async def test_five_wrong_logins_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""验证码错 5 次作废——第 5 次仍报 40112,之后正确码也只剩 40113。"""
|
||||
app, sender = env
|
||||
email = _rand_email()
|
||||
async with _client(app) as http:
|
||||
await _code(http, email)
|
||||
code = sender.codes[email]
|
||||
wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码
|
||||
for _ in range(5):
|
||||
resp = await http.post("/api/v1/auth/email/login", json={"email": email, "code": wrong})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40112
|
||||
# 码已作废:即使拿到正确码也只能 40113(一次性 + 作废都不留活口)
|
||||
after = await http.post("/api/v1/auth/email/login", json={"email": email, "code": code})
|
||||
assert after.status_code == 401
|
||||
assert after.json()["code"] == 40113
|
||||
|
||||
|
||||
async def test_login_without_code_is_expired(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""从未发过验证码的邮箱直接登录:40113(端点活着、校验链路通)。"""
|
||||
app, _ = env
|
||||
async with _client(app) as http:
|
||||
resp = await http.post(
|
||||
"/api/v1/auth/email/login",
|
||||
json={"email": _rand_email(), "code": "000000"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 40113
|
||||
|
||||
|
||||
async def test_code_rejects_malformed_email(env: tuple[Any, CapturingSender]) -> None:
|
||||
"""服务层规范化兜底:能过 Pydantic 长度但非邮箱 → 40000。"""
|
||||
app, _ = env
|
||||
async with _client(app) as http:
|
||||
resp = await _code(http, "not-an-email")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 40000
|
||||
@@ -0,0 +1,356 @@
|
||||
"""深度诊断(tier=full)链路端到端:派生 → full_report 日配额 → full 流水线 → 拒绝路径。
|
||||
|
||||
与 test_report_flow 同一套基建:Fake 阶段经 ``REPORT_PIPELINE_STAGES_JSON`` /
|
||||
``REPORT_FULL_PIPELINE_STAGES_JSON`` 动态装配(worker 按 report.tier 分流两
|
||||
条阶段清单,两份 env 要同时给);不启真 celery,worker 内芯直调。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _infra_available() -> bool:
|
||||
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _infra_available(),
|
||||
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
|
||||
),
|
||||
]
|
||||
|
||||
_MODULE = "tests.integration.test_full_report_flow"
|
||||
_BASIC_STAGES = json.dumps([f"{_MODULE}:FakeVisionStage", f"{_MODULE}:FakeDiagnosisStage"])
|
||||
_FULL_STAGES = json.dumps([f"{_MODULE}:FakeFullDiagnosisStage", f"{_MODULE}:FakeFullCopyStage"])
|
||||
|
||||
|
||||
class FakeVisionStage:
|
||||
"""与 test_report_flow 同一契约:derived_features 非空即跳过。"""
|
||||
|
||||
name = "vision"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
features = ctx.report_input.derived_features
|
||||
if not features:
|
||||
features["vision_runs"] = 1
|
||||
ctx.report.result["vision"] = {"features": sorted(features)}
|
||||
|
||||
|
||||
class FakeDiagnosisStage:
|
||||
name = "diagnosis"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
assert "vision" in ctx.report.result
|
||||
ctx.report.result["diagnosis"] = {"dimensions": [], "coverage": {"total": 32}}
|
||||
|
||||
|
||||
class FakeFullDiagnosisStage:
|
||||
"""full 流水线的诊断阶段替身:前面没有 vision 阶段(full 不含视觉)。"""
|
||||
|
||||
name = "diagnosis"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
assert "vision" not in ctx.report.result
|
||||
ctx.report.result["diagnosis"] = {"dimensions": [], "coverage": {"total": 32}}
|
||||
|
||||
|
||||
class FakeFullCopyStage:
|
||||
"""full 文案阶段替身:copy 与 full 双写(结构与真实 FullCopyStage 一致)。"""
|
||||
|
||||
name = "copy"
|
||||
|
||||
async def run(self, ctx: Any) -> None:
|
||||
assert "diagnosis" in ctx.report.result
|
||||
ctx.report.result["copy"] = {
|
||||
"summary": "深度总评",
|
||||
"sections": [{"title": "风格", "paragraphs": ["段落"]}],
|
||||
"model": "fake",
|
||||
"prompt_version": "v1",
|
||||
"dropped_keys": [],
|
||||
}
|
||||
ctx.report.result["full"] = {
|
||||
"season": {
|
||||
"code": "summer",
|
||||
"detailed": "柔夏",
|
||||
"name_cn": "柔夏型",
|
||||
"analysis": "解析文案",
|
||||
},
|
||||
"palette": {
|
||||
"best": [{"name": "雾霾蓝", "hex": "#8FA8BF"}],
|
||||
"neutral": [{"name": "浅灰", "hex": "#D9D9D9"}],
|
||||
"avoid": [{"name": "明黄", "hex": "#FFD93B"}],
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"key": "commute",
|
||||
"title": "日常通勤",
|
||||
"outfits": [{"name": "通勤 A", "points": ["要点一"]}],
|
||||
}
|
||||
],
|
||||
"plan30": [{"week": 1, "title": "第 1 周", "tasks": ["任务一"]}],
|
||||
}
|
||||
|
||||
|
||||
# --- fixtures ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_enqueue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("apps.api.routers.reports._enqueue_report", lambda task_id: None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _stage_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
monkeypatch.setenv("REPORT_PIPELINE_STAGES_JSON", _BASIC_STAGES)
|
||||
monkeypatch.setenv("REPORT_FULL_PIPELINE_STAGES_JSON", _FULL_STAGES)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_stages(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
with _stage_config(monkeypatch):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(no_enqueue: None) -> AsyncIterator[Any]:
|
||||
import httpx
|
||||
|
||||
from apps.api.main import create_app
|
||||
from packages.shared.config import get_settings
|
||||
|
||||
app = create_app(get_settings())
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
|
||||
app.router.lifespan_context(app),
|
||||
):
|
||||
yield http
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _login(client: Any) -> tuple[dict[str, str], int]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/dev-token", json={"device_key": f"full-it-{uuid.uuid4().hex[:8]}"}
|
||||
)
|
||||
assert resp.json()["code"] == 0
|
||||
data = resp.json()["data"]
|
||||
return {"Authorization": f"Bearer {data['access_token']}"}, int(data["user_id"])
|
||||
|
||||
|
||||
async def _upload_photo(client: Any, headers: dict[str, str]) -> str:
|
||||
resp = await client.post(
|
||||
"/api/v1/uploads/direct?scene=report_photo",
|
||||
headers={**headers, "Content-Type": "image/jpeg"},
|
||||
content=b"\xff\xd8\xff\xe0" + b"\x00" * 64,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return str(resp.json()["data"]["object_key"])
|
||||
|
||||
|
||||
async def _create_succeeded_basic(client: Any, headers: dict[str, str]) -> dict[str, Any]:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {"style": "通勤", "height_cm": 168},
|
||||
"photo_object_key": await _upload_photo(client, headers),
|
||||
"request_key": f"rk-{uuid.uuid4().hex}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()["data"]
|
||||
await _generate_report(int(body["task_id"]))
|
||||
return body
|
||||
|
||||
|
||||
async def _derive_full(
|
||||
client: Any, headers: dict[str, str], report_id: str, *, request_key: str | None = None
|
||||
) -> Any:
|
||||
return await client.post(
|
||||
f"/api/v1/reports/{report_id}/full",
|
||||
headers=headers,
|
||||
json={"request_key": request_key or f"rk-full-{uuid.uuid4().hex}"},
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_report(report_id: int) -> Any:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import RpReport
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(select(RpReport).where(RpReport.report_id == report_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _fetch_input(report_id: int) -> Any:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import RpInput
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(select(RpInput).where(RpInput.report_id == report_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _ledger_rows(user_id: int, quota_key: str) -> list[Any]:
|
||||
from sqlalchemy import select
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import Ledger
|
||||
|
||||
async with session_scope() as session:
|
||||
result = await session.execute(
|
||||
select(Ledger)
|
||||
.where(Ledger.user_id == user_id, Ledger.quota_key == quota_key)
|
||||
.order_by(Ledger.created_at.asc(), Ledger.tx_id.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# --- 场景 1:派生成功 + full 流水线 + 幂等重放 ----------------------------------
|
||||
|
||||
|
||||
async def test_derive_full_success_runs_full_pipeline(fake_stages: None, client: Any) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
basic = await _create_succeeded_basic(client, headers)
|
||||
|
||||
request_key = f"rk-full-{uuid.uuid4().hex}"
|
||||
resp = await _derive_full(client, headers, basic["report_id"], request_key=request_key)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
full_report_id = int(data["report_id"])
|
||||
assert full_report_id != int(basic["report_id"])
|
||||
assert data["version"] == 1 # full 是独立谱系,版本从 1 起算
|
||||
|
||||
# full_report 日配额预扣(period_key d:YYYY-MM-DD)。
|
||||
rows = await _ledger_rows(user_id, "full_report")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "reserved"
|
||||
assert rows[0].period_key.startswith("d:")
|
||||
|
||||
# 输入三件套复用(derived_features 带着 basic 的视觉中间态,无视觉阶段)。
|
||||
full_input = await _fetch_input(full_report_id)
|
||||
assert full_input.derived_features == {"vision_runs": 1}
|
||||
|
||||
await _generate_report(int(data["task_id"]))
|
||||
report = await _fetch_report(full_report_id)
|
||||
assert report.status == "succeeded"
|
||||
assert report.tier == "full"
|
||||
# full 流水线:无 vision 键,copy + full + diagnosis 齐全。
|
||||
assert set(report.result) == {"diagnosis", "copy", "full"}
|
||||
full = report.result["full"]
|
||||
assert full["season"]["name_cn"] == "柔夏型"
|
||||
assert full["palette"]["best"][0]["hex"] == "#8FA8BF"
|
||||
assert full["scenes"][0]["outfits"][0]["points"]
|
||||
assert full["plan30"][0]["week"] == 1
|
||||
|
||||
rows = await _ledger_rows(user_id, "full_report")
|
||||
assert [row.status for row in rows] == ["committed"]
|
||||
|
||||
# 详情视图透出 tier 与 full 结构(App 凭 tier/full 渲染深度区块)。
|
||||
detail = (await client.get(f"/api/v1/reports/{full_report_id}", headers=headers)).json()["data"]
|
||||
assert detail["tier"] == "full"
|
||||
assert detail["result"]["full"]["season"]["detailed"] == "柔夏"
|
||||
|
||||
# 幂等重放:同 request_key 返回同任务同报告,不二次预扣。
|
||||
replay = await _derive_full(client, headers, basic["report_id"], request_key=request_key)
|
||||
assert replay.status_code == 200
|
||||
assert replay.json()["data"]["report_id"] == str(full_report_id)
|
||||
assert len(await _ledger_rows(user_id, "full_report")) == 1
|
||||
|
||||
|
||||
# --- 场景 2:非 SUCCEEDED 源报告拒绝 --------------------------------------------
|
||||
|
||||
|
||||
async def test_derive_full_rejects_pending_source(fake_stages: None, client: Any) -> None:
|
||||
headers, _user_id = await _login(client)
|
||||
resp = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {"style": "通勤"},
|
||||
"photo_object_key": await _upload_photo(client, headers),
|
||||
"request_key": f"rk-{uuid.uuid4().hex}",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
basic = resp.json()["data"] # pending,不跑 worker
|
||||
|
||||
derived = await _derive_full(client, headers, basic["report_id"])
|
||||
assert derived.status_code == 409
|
||||
assert derived.json()["code"] == 40900 # STATE_CONFLICT
|
||||
|
||||
|
||||
# --- 场景 3:full 不能再派生 full(防套娃) --------------------------------------
|
||||
|
||||
|
||||
async def test_derive_full_rejects_full_source(fake_stages: None, client: Any) -> None:
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
|
||||
headers, _user_id = await _login(client)
|
||||
basic = await _create_succeeded_basic(client, headers)
|
||||
derived = await _derive_full(client, headers, basic["report_id"])
|
||||
assert derived.status_code == 200
|
||||
full = derived.json()["data"]
|
||||
await _generate_report(int(full["task_id"]))
|
||||
|
||||
nested = await _derive_full(client, headers, full["report_id"])
|
||||
assert nested.status_code == 409
|
||||
assert nested.json()["code"] == 40900
|
||||
|
||||
|
||||
# --- 场景 4:full_report 配额耗尽 ------------------------------------------------
|
||||
|
||||
|
||||
async def test_derive_full_blocked_when_quota_exhausted(fake_stages: None, client: Any) -> None:
|
||||
"""开发库 full_report 规则行是测试期放宽值(base_limit=10);把本用例用户的
|
||||
bl_quota 快照 limit_value 收敛到已用量,确定性断言 40301(同 test_report_flow
|
||||
场景 4 的做法,只动本用户的快照行,不动全局规则)。"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import Quota
|
||||
|
||||
headers, user_id = await _login(client)
|
||||
basic = await _create_succeeded_basic(client, headers)
|
||||
|
||||
first = await _derive_full(client, headers, basic["report_id"])
|
||||
assert first.status_code == 200 # 首次派生占掉 1 次
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Quota)
|
||||
.where(Quota.user_id == user_id, Quota.quota_key == "full_report")
|
||||
.values(limit_value=1)
|
||||
)
|
||||
|
||||
second = await _derive_full(client, headers, basic["report_id"])
|
||||
assert second.status_code == 403
|
||||
assert second.json()["code"] == 40301 # QUOTA_EXHAUSTED
|
||||
@@ -331,29 +331,50 @@ async def _count_user_reports(user_id: int) -> int:
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
# --- 场景 4:首次资格 40920 ---------------------------------------------------
|
||||
# --- 场景 4:重复生成不再 40920,由 report_basic 终身配额兜底 ---------------------
|
||||
|
||||
|
||||
async def test_second_first_report_is_rejected_with_40920(fake_stages: None, client: Any) -> None:
|
||||
async def test_second_report_create_is_blocked_by_lifetime_quota(
|
||||
fake_stages: None, client: Any
|
||||
) -> None:
|
||||
"""产品决策变更:已生成过报告仍可再次提交,不再抛 40920(终身资格硬校验已删)。
|
||||
|
||||
重复生成由 report_basic 终身配额兜底(迁移 0003 SSOT:lifetime base_limit=1)。
|
||||
本地开发库的规则行被人为放宽过(base_limit=10),这里把本用例用户的
|
||||
bl_quota 快照 limit_value 收敛回 SSOT 值 1,确定性断言额度耗尽:
|
||||
新 request_key 的第二次创建应返回 403 / QUOTA_EXHAUSTED 40301,
|
||||
而不是旧的 40920 REPORT_ALREADY_EXISTS。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from apps.worker.report_tasks import _generate_report
|
||||
from packages.shared.db import session_scope
|
||||
from packages.shared.models import Quota
|
||||
|
||||
headers, _ = await _login(client)
|
||||
headers, user_id = await _login(client)
|
||||
data = await _create_report(client, headers)
|
||||
await _generate_report(int(data["task_id"])) # 首单成功,终身资格已用掉
|
||||
await _generate_report(int(data["task_id"])) # 首单成功,终身额度已用 1 次
|
||||
|
||||
# 收敛该用户的终身额度快照到 SSOT base_limit=1(限本用例用户,不动全局规则行)。
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Quota)
|
||||
.where(Quota.user_id == user_id, Quota.quota_key == "report_basic")
|
||||
.values(limit_value=1)
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/reports",
|
||||
headers=headers,
|
||||
json={
|
||||
"questionnaire": {},
|
||||
"photo_object_key": "report_photo/u2.jpg",
|
||||
"photo_object_key": await _upload_photo(client, headers), # 真实 key,先过存在性校验
|
||||
"request_key": f"rk-{uuid.uuid4().hex}", # 新键:不是幂等重放
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.status_code == 403
|
||||
body = resp.json()
|
||||
assert body["code"] == 40920
|
||||
assert "30" in (body.get("hint") or "")
|
||||
assert body["code"] == 40301 # QUOTA_EXHAUSTED,而非 40920
|
||||
|
||||
|
||||
# --- 场景 5:失败退款 + derived_features 保留 ----------------------------------
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""EmailAuthService 单测(packages/identity/email_login.py)——无 IO、无网络。
|
||||
|
||||
存储用自写内存 dict 实现 Protocol 化的 EmailStore 接口(与 test_sms_login
|
||||
同一做法,不引 fakeredis);TTL 用可注入时钟模拟过期。频控计数语义按
|
||||
「请求」计(见被测模块 docstring),错误码矩阵对应 FR-ACC-14 v2 的
|
||||
三重频控(邮箱版)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.identity.email_login import (
|
||||
EmailAuthService,
|
||||
FakeEmailSender,
|
||||
SmtpEmailSender,
|
||||
build_email_auth_service,
|
||||
email_hash_key,
|
||||
email_union_id,
|
||||
masked_email,
|
||||
normalize_email,
|
||||
)
|
||||
from packages.identity.sms_login import hash_code
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
EMAIL = "someone@example.com"
|
||||
MASKED = "so***@example.com"
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""单调时钟桩:advance 推进 TTL 判定,单测无需真实等待。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self._now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self._now += seconds
|
||||
|
||||
|
||||
class InMemoryEmailStore:
|
||||
"""dict 实现的 EmailStore:键 → (值, 过期时刻);过期惰性清除。"""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] | None = None) -> None:
|
||||
self._data: dict[str, tuple[str, float]] = {}
|
||||
self._clock = clock or time.monotonic
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expires_at = entry
|
||||
if self._clock() >= expires_at:
|
||||
self._data.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
self._data[key] = (value, self._clock() + ttl_seconds)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
current = await self.get(key) # 复用过期语义,与 Redis 行为对齐
|
||||
nxt = int(current) + 1 if current is not None else 1
|
||||
self._data[key] = (str(nxt), self._clock() + ttl_seconds)
|
||||
return nxt
|
||||
|
||||
|
||||
class CapturingSender:
|
||||
"""捕获型 fake:记下发出的 (邮箱, 掩码, 验证码),不打日志。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
self.sent.append((email, masked, code))
|
||||
|
||||
|
||||
class FailingSender:
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
raise RuntimeError("provider down")
|
||||
|
||||
|
||||
def _settings(**overrides: Any) -> Settings:
|
||||
"""单测配置:不吃 worktree 的 .env(_env_file=None),保持确定性。
|
||||
|
||||
默认 cooldown=0:让频控矩阵只受被测的那一个维度约束(冷却与限额分开测)。
|
||||
"""
|
||||
values: dict[str, Any] = {"_env_file": None, "email_resend_cooldown_seconds": 0}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _service(
|
||||
settings: Settings, sender: Any | None = None
|
||||
) -> tuple[EmailAuthService, InMemoryEmailStore, CapturingSender, FakeClock]:
|
||||
clock = FakeClock()
|
||||
store = InMemoryEmailStore(clock)
|
||||
capturing = CapturingSender()
|
||||
service = EmailAuthService(store, sender or capturing, settings=settings)
|
||||
return service, store, capturing, clock
|
||||
|
||||
|
||||
def _wrong_code(code: str) -> str:
|
||||
"""保证与真码不同的 6 位错码(尾位翻转)。"""
|
||||
return code[:-1] + ("0" if code[-1] != "0" else "1")
|
||||
|
||||
|
||||
def _last_code(sender: CapturingSender) -> str:
|
||||
return sender.sent[-1][2]
|
||||
|
||||
|
||||
# --- 邮箱规范化与掩码 ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("someone@example.com", "someone@example.com"),
|
||||
(" Someone@Example.COM ", "someone@example.com"),
|
||||
("a.b+c@example.com", "a.b+c@example.com"),
|
||||
],
|
||||
)
|
||||
def test_normalize_email_lowercases_and_strips(raw: str, expected: str) -> None:
|
||||
assert normalize_email(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["not-an-email", "a@b", "a b@c.com", "plainaddress", "@x.com"])
|
||||
def test_normalize_email_rejects_invalid_formats(bad: str) -> None:
|
||||
# 非法格式是 400 类错误,且不消耗频控额度(不碰存储)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
normalize_email(bad)
|
||||
assert excinfo.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
|
||||
def test_masked_email_hides_local_part() -> None:
|
||||
assert masked_email(EMAIL) == MASKED
|
||||
assert "someone" not in masked_email(EMAIL)
|
||||
|
||||
|
||||
def test_email_hash_key_is_16_hex_chars() -> None:
|
||||
digest = email_hash_key(EMAIL)
|
||||
assert len(digest) == 16
|
||||
assert re.fullmatch(r"[0-9a-f]{16}", digest)
|
||||
|
||||
|
||||
def test_email_union_id_is_plaintext_lowercase_email() -> None:
|
||||
# 产品契约(与 phone 通道哈希 union_id 不同):email union_id 即明文小写邮箱
|
||||
uid = email_union_id(" Someone@Example.COM ")
|
||||
assert uid == "email:someone@example.com"
|
||||
|
||||
|
||||
# --- 发送:码形态与存储纪律 ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_request_code_sends_six_digit_code() -> None:
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.2.3.4", device_id="dev-1")
|
||||
assert len(sender.sent) == 1
|
||||
email, masked, code = sender.sent[0]
|
||||
assert email == EMAIL
|
||||
assert masked == MASKED
|
||||
assert re.fullmatch(r"\d{6}", code)
|
||||
|
||||
|
||||
async def test_code_stored_as_sha256_never_plaintext() -> None:
|
||||
service, store, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="203.0.113.7", device_id="dev-1")
|
||||
code = _last_code(sender)
|
||||
entry = store._data.get(f"email:code:{email_hash_key(EMAIL)}")
|
||||
assert entry is not None and entry[0] == hash_code(code)
|
||||
for key, (value, _) in store._data.items():
|
||||
assert EMAIL not in key and EMAIL not in value
|
||||
assert code not in key and code not in value
|
||||
assert "203.0.113.7" not in key and "203.0.113.7" not in value
|
||||
assert "dev-1" not in key
|
||||
|
||||
|
||||
# --- 频控矩阵(FR-ACC-14 v2)---------------------------------------------------
|
||||
|
||||
|
||||
async def test_hourly_limit_sixth_request_rejected() -> None:
|
||||
# 同邮箱 1h ≤ 5:第 6 次 42912
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=5))
|
||||
for _ in range(5):
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
|
||||
assert len(sender.sent) == 5
|
||||
|
||||
|
||||
async def test_daily_limit_eleventh_request_rejected() -> None:
|
||||
# 同邮箱 24h ≤ 10:第 11 次 42913(小时上限抬高以单独验证日窗)
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=99, email_daily_limit=10))
|
||||
for _ in range(10):
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
assert len(sender.sent) == 10
|
||||
|
||||
|
||||
async def test_ip_dimension_twenty_first_rejected() -> None:
|
||||
# 同 IP 24h ≤ 20:每次换新邮箱 + 新设备键(设备维度缺省回退 IP,不隔离会
|
||||
# 先撞设备上限 10——这正是「缺省用 IP」的语义),第 21 次 42913
|
||||
service, _, _, _ = _service(_settings(email_ip_daily_limit=20))
|
||||
for i in range(20):
|
||||
await service.request_code(f"user{i}@example.com", ip="9.9.9.9", device_id=f"dev-{i}")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="9.9.9.9", device_id="dev-x")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_device_dimension_eleventh_rejected() -> None:
|
||||
# 同设备 24h ≤ 10:第 11 次 42913(邮箱与 IP 都逐次更换以隔离维度)
|
||||
service, _, _, _ = _service(_settings(email_device_daily_limit=10))
|
||||
for i in range(10):
|
||||
await service.request_code(f"user{i}@example.com", ip=f"10.0.0.{i}", device_id="device-a")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="10.0.0.99", device_id="device-a")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_device_dimension_falls_back_to_ip() -> None:
|
||||
# 不传 device_id 时该维度以 IP 兜底计数:同 IP 打 11 个邮箱,第 11 次 42913
|
||||
service, _, _, _ = _service(_settings(email_device_daily_limit=10, email_ip_daily_limit=99))
|
||||
for i in range(10):
|
||||
await service.request_code(f"user{i}@example.com", ip="10.0.0.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="10.0.0.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_cooldown_blocks_resend_within_window_and_allows_after() -> None:
|
||||
# 同邮箱 60s 冷却:期内二次 42912,过窗放行(小时上限抬高排除干扰)
|
||||
service, _, sender, clock = _service(
|
||||
_settings(email_resend_cooldown_seconds=60, email_hourly_limit=99)
|
||||
)
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
|
||||
clock.advance(61)
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert len(sender.sent) == 2
|
||||
|
||||
|
||||
async def test_sender_failure_maps_to_provider_failed() -> None:
|
||||
# 通道故障 → 61001(外部依赖错误,不与内部 5xxxx 混淆)
|
||||
service = EmailAuthService(InMemoryEmailStore(), FailingSender(), settings=_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
|
||||
|
||||
# --- 校验:验证码生命周期 -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_verify_success_is_one_time() -> None:
|
||||
# 一次性:成功即删,同一码二次使用按「已过期」回应
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
code = _last_code(sender)
|
||||
assert await service.verify(EMAIL, code) == email_union_id(EMAIL)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, code)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_verify_expired_after_ttl() -> None:
|
||||
# 5 分钟 TTL:过窗后校验 40113
|
||||
service, _, sender, clock = _service(_settings(email_code_ttl_seconds=300))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
clock.advance(301)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, _last_code(sender))
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_four_wrong_attempts_still_allowed_then_correct_code_passes() -> None:
|
||||
# 失败 5 次作废:错 4 次仍可再试,第 5 次尝试用对码可以通过
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
wrong = _wrong_code(_last_code(sender))
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, wrong)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
|
||||
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
|
||||
|
||||
|
||||
async def test_fifth_wrong_attempt_invalidates_code() -> None:
|
||||
# 第 5 次错误:返回 40112 且码已删——之后连正确码都只剩 40113
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
correct = _last_code(sender)
|
||||
for _ in range(5):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, _wrong_code(correct))
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, correct)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_new_code_resets_attempt_counter() -> None:
|
||||
# 「失败 5 次」按码计不按邮箱计:换新码后失败计数清零
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=99))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError):
|
||||
await service.verify(EMAIL, _wrong_code(_last_code(sender)))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1") # cooldown=0 可立即重发
|
||||
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
|
||||
|
||||
|
||||
async def test_verify_unknown_email_is_expired() -> None:
|
||||
# 从未发过码的邮箱:40113(不区分「无码」细节,防状态探测)
|
||||
service, _, _, _ = _service(_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, "123456")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
# --- 通道装配与 SMTP 实现(不连网:桩替真实连接)-------------------------------
|
||||
|
||||
|
||||
def test_build_service_fake_provider() -> None:
|
||||
service = build_email_auth_service(_settings(email_provider="fake"), object())
|
||||
assert isinstance(service._sender, FakeEmailSender)
|
||||
|
||||
|
||||
def test_build_service_unknown_provider_is_config_error() -> None:
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_email_auth_service(_settings(email_provider="pigeon"), object())
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
|
||||
|
||||
def test_build_service_smtp_without_config_maps_61001() -> None:
|
||||
# 构造失败(SMTP 配置缺失)→ 61001:装配期就炸,不带病上岗
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_email_auth_service(_settings(email_provider="smtp"), object())
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
|
||||
|
||||
_SMTP_SETTINGS: dict[str, Any] = {
|
||||
"email_provider": "smtp",
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_user": "noreply@example.com",
|
||||
"smtp_pass": "secret",
|
||||
}
|
||||
|
||||
|
||||
class _RecordingSmtpSender(SmtpEmailSender):
|
||||
"""替换 _send_sync 为记录调用(不碰网络)的桩实现。"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings)
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def _send_sync(self, email: str, code: str) -> None:
|
||||
self.calls.append((email, code))
|
||||
|
||||
|
||||
async def test_smtp_sender_builds_message_and_accepts_success() -> None:
|
||||
sender = _RecordingSmtpSender(_settings(**_SMTP_SETTINGS))
|
||||
await sender.send_code(EMAIL, MASKED, "123456")
|
||||
assert sender.calls == [(EMAIL, "123456")]
|
||||
|
||||
|
||||
class _ExplodingSmtpSender(SmtpEmailSender):
|
||||
def _send_sync(self, email: str, code: str) -> None:
|
||||
raise TimeoutError("connect timeout")
|
||||
|
||||
|
||||
async def test_smtp_sender_network_error_maps_61001() -> None:
|
||||
sender = _ExplodingSmtpSender(_settings(**_SMTP_SETTINGS))
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await sender.send_code(EMAIL, MASKED, "123456")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
@@ -0,0 +1,112 @@
|
||||
"""深度诊断色板规则映射与降级模板的纯函数单测(无 IO、无 LLM)。
|
||||
|
||||
红线:色板是规则映射常量——12 季型全覆盖、三档色数达标、hex 格式合法;
|
||||
降级模板必须自带「通过三重校验」的性质(fail-close 的最后一道出口)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from packages.report.full_copy import (
|
||||
TWELVE_SEASONS,
|
||||
FullReportCopy,
|
||||
build_full_result,
|
||||
palette_for,
|
||||
render_fallback_full_copy,
|
||||
resolve_season,
|
||||
)
|
||||
from packages.report.validation import validate_copy
|
||||
|
||||
_HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def test_all_twelve_seasons_have_complete_palettes() -> None:
|
||||
assert len(TWELVE_SEASONS) == 12
|
||||
for season in TWELVE_SEASONS:
|
||||
palette = palette_for(season)
|
||||
assert len(palette["best"]) >= 12, f"{season} 最佳色不足 12 色"
|
||||
assert len(palette["neutral"]) >= 6, f"{season} 中性色不足 6 色"
|
||||
assert len(palette["avoid"]) >= 6, f"{season} 避雷色不足 6 色"
|
||||
for role in ("best", "neutral", "avoid"):
|
||||
for color in palette[role]:
|
||||
assert color["name"], f"{season}/{role} 缺色名"
|
||||
assert _HEX_RE.match(color["hex"]), f"{season}/{role} 色值非法:{color['hex']}"
|
||||
|
||||
|
||||
def test_unknown_season_gets_empty_palette() -> None:
|
||||
assert palette_for("不存在的季型") == {"best": [], "neutral": [], "avoid": []}
|
||||
|
||||
|
||||
def test_resolve_season_prefers_detailed_value() -> None:
|
||||
assert resolve_season({"season_detailed": "柔夏", "season_code": "summer"}) == "柔夏"
|
||||
|
||||
|
||||
def test_resolve_season_falls_back_to_season_code() -> None:
|
||||
assert resolve_season({"season_code": "spring"}) == "暖春"
|
||||
assert resolve_season({"season_code": "winter"}) == "冷冬"
|
||||
assert resolve_season({"season_detailed": "编造季型", "season_code": "autumn"}) == "暖秋"
|
||||
|
||||
|
||||
def test_resolve_season_returns_none_without_any_input() -> None:
|
||||
assert resolve_season({}) is None
|
||||
assert resolve_season({"season_code": "unknown"}) is None
|
||||
|
||||
|
||||
def _minimal_dimensions() -> list[dict[str, object]]:
|
||||
return [
|
||||
{"key": "season_code", "value": "summer", "status": "ok"},
|
||||
{"key": "season_detailed", "value": "柔夏", "status": "ok"},
|
||||
]
|
||||
|
||||
|
||||
def test_fallback_full_copy_passes_validation() -> None:
|
||||
"""降级模板自带合规性:含「柔夏型」等十二季文案也不过三重校验的误杀线。"""
|
||||
dimensions = _minimal_dimensions()
|
||||
fallback = render_fallback_full_copy(
|
||||
dimensions, season_detailed="柔夏", palette=palette_for("柔夏")
|
||||
)
|
||||
violations = validate_copy(fallback.as_report_copy(), dimensions, rule_hits=[])
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_fallback_full_copy_covers_all_scenes_and_four_weeks() -> None:
|
||||
fallback = render_fallback_full_copy(
|
||||
_minimal_dimensions(), season_detailed="柔夏", palette=palette_for("柔夏")
|
||||
)
|
||||
assert [s.key for s in fallback.scenes] == ["commute", "weekend", "formal", "date"]
|
||||
assert all(len(scene.outfits) >= 1 for scene in fallback.scenes)
|
||||
assert [w.week for w in fallback.plan30] == [1, 2, 3, 4]
|
||||
assert all(3 <= len(week.tasks) <= 5 for week in fallback.plan30)
|
||||
# 兜底文案不得出现色值(hex 只能走 full.palette 结构化字段)。
|
||||
assert "#" not in fallback.as_report_copy().full_text()
|
||||
|
||||
|
||||
def test_build_full_result_shape() -> None:
|
||||
copy = render_fallback_full_copy(
|
||||
_minimal_dimensions(), season_detailed="柔夏", palette=palette_for("柔夏")
|
||||
)
|
||||
result = build_full_result(
|
||||
copy,
|
||||
season_detailed="柔夏",
|
||||
season_code="summer",
|
||||
palette=palette_for("柔夏"),
|
||||
)
|
||||
assert result["season"]["name_cn"] == "柔夏型"
|
||||
assert result["season"]["analysis"] == copy.season_analysis
|
||||
assert len(result["palette"]["best"]) >= 12
|
||||
assert result["scenes"][0]["outfits"][0]["points"]
|
||||
assert result["plan30"][3]["week"] == 4
|
||||
|
||||
|
||||
def test_as_report_copy_folds_all_free_text_into_scan_scope() -> None:
|
||||
"""校验视图必须覆盖季型解析/场景/计划的全部自由文本,不是只查 summary。"""
|
||||
copy = FullReportCopy(
|
||||
summary="总评",
|
||||
sections=[],
|
||||
season_analysis="独一无二的季型解析",
|
||||
scenes=[],
|
||||
plan30=[],
|
||||
)
|
||||
violations = validate_copy(copy.as_report_copy(), [], rule_hits=[])
|
||||
assert any("独一无二" in v for v in violations)
|
||||
Reference in New Issue
Block a user