Compare commits

...
3 Commits
28 changed files with 3501 additions and 74 deletions
+3 -2
View File
@@ -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(
+158
View File
@@ -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
],
}
)
+257
View File
@@ -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
],
}
)
+2
View File
@@ -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,
+6 -2
View File
@@ -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
+114
View File
@@ -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))
+17
View File
@@ -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 熔断可拦)"
)
+6 -1
View File
@@ -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 = [
+384
View File
@@ -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",
]
+11
View File
@@ -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,
},
}
+1
View File
@@ -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):
+458
View File
@@ -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,
)
+117
View File
@@ -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),
)
+214
View File
@@ -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
+10 -4
View File
@@ -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:
-18
View File
@@ -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)
+71 -9
View File
@@ -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:
+44 -29
View File
@@ -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
+30
View File
@@ -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
+12 -1
View File
@@ -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, "邮件服务暂时不可用", "请稍后重试"),
}
+35
View File
@@ -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}
请按系统提示词规定的结构生成深度诊断报告文案。
+380
View File
@@ -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
+273
View File
@@ -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
+356
View File
@@ -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
+29 -8
View File
@@ -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 保留 ----------------------------------
+391
View File
@@ -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
+112
View File
@@ -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)