fix: 终审修复——站点停用接线/prefix查重补创建reset/默认凭据警示+取舍补录
- I-1 站点停用全链接线: Site 数据类加 status 字段; DB 源 _load_one 与 文件源 read_tenant 均只输出 active 站点(停用站点域名不进 registry 索引 → 该域名 404;全停用=合法运营状态输出空 sites;文件源无 status 键默认 active 兼容)。E2E: PATCH 站点 disabled → 其域名 404、复启用 恢复;源级直测两源过滤。 - I-3 prefix 跨租户查重补两口: db.other_push_prefixes 共用助手 (MAX(version) 逐租户当前前缀,排除自身);创建向导(出厂档案前缀被占 → 400 提示改 blank/改占用方)与 reset push(重置回出厂撞车 → 400 提示 走 PUT)接入;PUT 原有查重改用同一助手。rollback 维持豁免(历史不可变 语义优先,取舍清单记档)。既有测试第二租户改 blank profile 避撞。 - I-4 默认凭据警示: lifespan 启动检测 ADMIN_PASSWORD 仍为 Settings 字段 默认值(非硬编码第二份,防双源漂移)→ logger.warning 醒目提示; CLAUDE.md Phase 4 部署记档补 ADMIN_PASSWORD 生产要求。 - I-2/M-2 取舍补录: 密钥掩码未实现(GET config/settings 明文返回 api_key/smtp 密码,Phase 3 前须定 __SET__ 哨兵方案);站点管理权限现为 仅超管(Phase 3 对接时决策放开或改 spec);取舍清单第 2 条同步刷新 (rollback 现为唯一豁免口)。 全量回归: 571 passed(基线 566 + 新增 5)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,15 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
try:
|
||||
await ensure_platform_schema(platform_engine)
|
||||
# 默认凭据警示(终审 I-4):ADMIN_PASSWORD 仍是出厂默认时启动日志
|
||||
# 醒目提示。比较对象取 Settings 字段定义的默认值(非硬编码第二份
|
||||
# "admin123"——将来改默认值,警示判定自动跟随,不双源漂移)。
|
||||
if settings.ADMIN_PASSWORD == type(
|
||||
settings).model_fields["ADMIN_PASSWORD"].default:
|
||||
logger.warning(
|
||||
"!!! 安全警示: 超管账号 %s 正在使用默认密码 —— 生产环境必须"
|
||||
"设置 ADMIN_PASSWORD 环境变量,且首登后立即改密 !!!",
|
||||
settings.ADMIN_USERNAME)
|
||||
await bootstrap_superuser(
|
||||
platform_engine,
|
||||
username=settings.ADMIN_USERNAME,
|
||||
|
||||
@@ -205,6 +205,40 @@ async def write_config_version(
|
||||
return version
|
||||
|
||||
|
||||
async def other_push_prefixes(
|
||||
session: AsyncSession, exclude_tenant_id: int | None) -> frozenset:
|
||||
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源;空/坏数据
|
||||
不参战)。MAX(version) 子查询定位每租户当前版。
|
||||
|
||||
终审 I-3 收口:自 config_routes 的私有 helper 提取共用——PUT 分组编辑
|
||||
(经 config_validation 的 ValidationContext)、创建向导(首版快照带出厂
|
||||
prefix,同 profile 第二个租户必撞)、reset 重置出厂(重置回出厂 prefix
|
||||
可能撞上现在的占用方)三口共用。exclude_tenant_id=None = 不排除任何
|
||||
租户(创建向导:租户尚未落库)。rollback 仍不查(历史不可变语义优先,
|
||||
挂账见 CLAUDE.md 取舍清单)。"""
|
||||
max_versions = (
|
||||
select(TenantConfigVersion.tenant_id.label("tid"),
|
||||
func.max(TenantConfigVersion.version).label("mv"))
|
||||
.group_by(TenantConfigVersion.tenant_id).subquery())
|
||||
rows = (await session.execute(
|
||||
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
|
||||
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
|
||||
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
|
||||
prefixes = set()
|
||||
for tenant_id, config_json in rows:
|
||||
if tenant_id == exclude_tenant_id:
|
||||
continue
|
||||
try:
|
||||
snapshot = json.loads(config_json)
|
||||
except ValueError:
|
||||
continue
|
||||
push = snapshot.get("push") if isinstance(snapshot, dict) else None
|
||||
prefix = push.get("prefix") if isinstance(push, dict) else None
|
||||
if isinstance(prefix, str) and prefix:
|
||||
prefixes.add(prefix)
|
||||
return frozenset(prefixes)
|
||||
|
||||
|
||||
# ---- 超管引导 ---------------------------------------------------------------
|
||||
|
||||
async def bootstrap_superuser(
|
||||
|
||||
@@ -47,6 +47,7 @@ from app.platform.db import (
|
||||
PlatformSetting,
|
||||
Tenant,
|
||||
TenantConfigVersion,
|
||||
other_push_prefixes,
|
||||
write_config_version,
|
||||
)
|
||||
from app.platform.defaults import DEFAULT_CONFIG
|
||||
@@ -105,33 +106,6 @@ async def _load_current(
|
||||
return row, snapshot
|
||||
|
||||
|
||||
async def _other_prefixes(
|
||||
session: AsyncSession, exclude_tenant_id: int) -> frozenset:
|
||||
"""各租户**当前版本**的 push.prefix 集合(spec E1 查重数据源,排除自身;
|
||||
空/坏数据不参战)。MAX(version) 子查询定位每租户当前版。"""
|
||||
max_versions = (
|
||||
select(TenantConfigVersion.tenant_id.label("tid"),
|
||||
func.max(TenantConfigVersion.version).label("mv"))
|
||||
.group_by(TenantConfigVersion.tenant_id).subquery())
|
||||
rows = (await session.execute(
|
||||
select(TenantConfigVersion.tenant_id, TenantConfigVersion.config_json)
|
||||
.join(max_versions, (TenantConfigVersion.tenant_id == max_versions.c.tid)
|
||||
& (TenantConfigVersion.version == max_versions.c.mv)))).all()
|
||||
prefixes = set()
|
||||
for tenant_id, config_json in rows:
|
||||
if tenant_id == exclude_tenant_id:
|
||||
continue
|
||||
try:
|
||||
snapshot = json.loads(config_json)
|
||||
except ValueError:
|
||||
continue
|
||||
push = snapshot.get("push") if isinstance(snapshot, dict) else None
|
||||
prefix = push.get("prefix") if isinstance(push, dict) else None
|
||||
if isinstance(prefix, str) and prefix:
|
||||
prefixes.add(prefix)
|
||||
return frozenset(prefixes)
|
||||
|
||||
|
||||
def _updated_by(user: UserContext) -> str:
|
||||
return f"{user.role}#{user.user_id}"
|
||||
|
||||
@@ -163,7 +137,7 @@ async def put_config_group(
|
||||
async with AsyncSession(engine, expire_on_commit=False) as session:
|
||||
tenant = await _get_tenant_or_404(session, slug)
|
||||
context = ValidationContext(
|
||||
other_prefixes=await _other_prefixes(session, tenant.id))
|
||||
other_prefixes=await other_push_prefixes(session, tenant.id))
|
||||
errors = validate_group(group, body, context)
|
||||
if errors:
|
||||
raise HTTPException(status_code=400, detail="; ".join(errors))
|
||||
@@ -203,6 +177,16 @@ async def reset_config_group(
|
||||
value = copy.deepcopy(factory[group])
|
||||
else:
|
||||
value = copy.deepcopy(DEFAULT_CONFIG[group])
|
||||
# push 组重置补跨租户查重(终审 I-3):重置回出厂 prefix 可能撞上
|
||||
# 现在的占用方(本租户改过前缀后,出厂前缀被别人取走)。
|
||||
if group == "push" and isinstance(value, dict):
|
||||
prefix = value.get("prefix")
|
||||
if (isinstance(prefix, str) and prefix and prefix
|
||||
in await other_push_prefixes(session, tenant.id)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"重置后的 push.prefix {prefix!r} 已被其他租户"
|
||||
f"占用——请改用 PUT /config/push 指定其他前缀")
|
||||
_, current = await _load_current(session, tenant.id, slug)
|
||||
new_snapshot = dict(current)
|
||||
new_snapshot[group] = value
|
||||
|
||||
@@ -50,6 +50,7 @@ from app.platform.db import (
|
||||
SiteDomain,
|
||||
Tenant,
|
||||
TenantConfigVersion,
|
||||
other_push_prefixes,
|
||||
write_config_version,
|
||||
)
|
||||
from app.platform.registry import _normalize_host
|
||||
@@ -227,6 +228,18 @@ async def create_tenant(
|
||||
detail=f"管理员用户名已被占用: {body.admin_username!r}")
|
||||
for domain in domains:
|
||||
await _assert_domain_free(session, domain)
|
||||
# push.prefix 跨租户查重(终审 I-3):首版快照带出厂 prefix——同
|
||||
# profile 第二个租户必撞(jiancai=ai-kefu-jc / auto=ai-kefu),
|
||||
# spec E1 前缀全局唯一防 CRM 线索串号,创建期就拦。blank 出厂
|
||||
# prefix 为空串,不参战(与 config_validation 同款空串豁免)。
|
||||
prefix = (snapshot.get("push") or {}).get("prefix")
|
||||
if (isinstance(prefix, str) and prefix
|
||||
and prefix in await other_push_prefixes(session, None)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"出厂档案的 push.prefix {prefix!r} 已被其他租户占用"
|
||||
f"——请改用 blank profile(创建后经配置 API 改前缀)"
|
||||
f"或先修改占用方前缀")
|
||||
|
||||
tenant = Tenant(slug=body.slug, name=body.name, status="active",
|
||||
profile=body.profile)
|
||||
|
||||
@@ -16,6 +16,9 @@ class Site:
|
||||
domains: tuple[str, ...]
|
||||
brand: dict # 站点门面覆盖层 ⊕ 租户默认 brand(已深合并)。
|
||||
# 只读约定:构造后不得修改
|
||||
status: str = "active" # 终审 I-1:站点停用接线——数据源只输出
|
||||
# active 站点(停用站点的域名不进索引 → 404),
|
||||
# 本字段记录该契约(当前恒为 "active")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -32,6 +35,7 @@ class TenantConfig:
|
||||
merged = deep_merge(DEFAULT_CONFIG, snapshot)
|
||||
resolved = tuple(
|
||||
Site(s["slug"], s.get("name", s["slug"]), tuple(s.get("domains", ())),
|
||||
deep_merge(merged["brand"], s.get("brand", {})))
|
||||
deep_merge(merged["brand"], s.get("brand", {})),
|
||||
s.get("status", "active"))
|
||||
for s in sites)
|
||||
return cls(slug=slug, name=name, status=status, data=merged, sites=resolved)
|
||||
|
||||
@@ -114,6 +114,12 @@ class FileTenantSource:
|
||||
sites = tenant_json.get("sites") or []
|
||||
if not sites:
|
||||
raise ValueError(f"{slug}: 每租户至少一个站点(sites 为空)")
|
||||
# 站点停用过滤(终审 I-1,与 DB 源同一语义):status 键缺省 = active
|
||||
# (既有 tenant.json 均无该键,兼容);停用站点不进 LoadedTenant——
|
||||
# 域名随之不进 registry 索引(该域名 404)、site_by_slug 取不到。
|
||||
sites = [s for s in sites
|
||||
if not isinstance(s, dict)
|
||||
or s.get("status", "active") == "active"]
|
||||
# wall 并入 snapshot["wall"]:data["wall"] 保留原始词表(后台编辑/
|
||||
# 回滚用),编译产物挂 runtime.compiled_wall(引擎消费,单一编译)
|
||||
snapshot = dict(tenant_json.get("config") or {})
|
||||
@@ -148,8 +154,10 @@ class DbTenantSource:
|
||||
的 config_json 解析。版本快照由 Task 5 创建向导写入(config 组 ⊕ wall
|
||||
⊕ prompts 同构嵌套)。
|
||||
|
||||
已知边界:site.status 列暂不参与映射(全量输出)——Task 5 站点
|
||||
CRUD/停用接线时定语义。
|
||||
已知边界(终审 I-1 已接线):site.status != "active" 的站点**不进**
|
||||
LoadedTenant(_load_one 过滤)——停用站点的域名不进 registry 索引
|
||||
(该域名 404),site_by_slug 取不到;全停用输出空 sites(零站点校验
|
||||
只对库表形状)。
|
||||
"""
|
||||
|
||||
def __init__(self, engine: AsyncEngine):
|
||||
@@ -215,10 +223,16 @@ class DbTenantSource:
|
||||
f"{version.version} 的 config_json 缺少 {key!r} 配置组"
|
||||
f"(或其不是对象)")
|
||||
|
||||
sites = [{"slug": s.slug, "name": s.name,
|
||||
# 站点停用过滤(终审 I-1):status != "active" 的站点不进
|
||||
# LoadedTenant——其域名不进 registry 域名索引(该域名 404)、
|
||||
# site_by_slug 取不到。与"租户停用 → TenantDisabledError → 503
|
||||
# 中性页"的语义差:站点停用 = 这张门面下线,404 合理(不是服务暂停)。
|
||||
# 全部站点停用 = 输出空 sites(零站点校验只对库表形状——行数为零
|
||||
# 才是数据坏;全停用是合法运营状态,域名全 404)。
|
||||
sites = [{"slug": s.slug, "name": s.name, "status": s.status,
|
||||
"domains": list(domains_by_site.get(s.id, [])),
|
||||
"brand": dict(s.brand_overlay or {})}
|
||||
for s in sites_rows]
|
||||
for s in sites_rows if s.status == "active"]
|
||||
return LoadedTenant(
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
|
||||
Reference in New Issue
Block a user