跳转至

超级轻量的自用AI编程Harness框架

背景故事

之前最流行的AI Coding Harness是superpowers,后面也有很多好的开源方案 openspec、trellis等。 现有这些插件都不是纯skill,里面会自带很多的命令、脚本、hooks。但是这些命令/脚本是必要的吗?

在这里提出我自己用的更加轻量的方案。下面举一个具体场景的例子,背景是做一个自定义礼包资源管理平台,是一个前后端的全栈项目,大概功能包括管理模版(里面的视频资源,视频位置等)、可售卖道具信息配置balabala。

开发流程比较顺畅,所以把实际流程整理下来:

graph TD
    A[文字描述需求] --> B[AI 写纯 HTML 前端]
    B -->|多次加功能,文件大到难改| C[切换 React]
    C -.->|7.5rem 编辑功能说不清| X[导出独立 HTML<br/>请同事修好]
    X -.->|拿回当依据改 React| C
    C --> D["前端定稿 ≈ 需求文档(接口字段还无约束)"]
    D --> E[输出后端开发注意事项文档(交接文档)]
    E --> F["本文重点:AI 出代码骨架,而不是直接开始开发"]
    F --> G[人读骨架 → AI 调整 → 变更写回骨架]
    G -->|反复几轮| F
    G --> H[以 backend 为准开发,接入 web]

出骨架那步,没让 AI 直接把后端写出来,而是让它先生成一个骨架:目录、文件、函数名都齐全,函数体一律不实现,只写注释说明这个函数该干什么、对齐前端哪个动作、要避开哪个坑。

中间的骨架使得项目开发多了一步人可以控制的阶段,从而没那么失控。骨架这个中间态的好处很多,代码量可控,编辑起来就容易得多,不会牵一发动全身。读代码的时候也可以比较详细看到项目结构(直接看readme感觉不容易阅读进去)。想法其实和现在最流行的harness engineering是异曲同工的。

开发结束后,突然还回想起到之前有项目AI直接一下子写了一大堆,造成无法阅读,于是想到如果把这个想法反过来,也可以使用骨架来帮助阅读一个大仓库,于是有了本文记录借助骨架技巧,正向做开发,反向做阅读。

骨架长什么样

骨架是一份能通过语法检查、能被工具解析、但不会真正运行的代码:

  • 结构是真的:目录布局、文件划分、函数签名、import 关系、类型定义,全部照真实成品来。
  • 行为是假的:函数体只有一段 docstring 加一个 ...,用文字描述"该做什么"。

docstring 要铺满每一层:文件开头一段模块 docstring 讲清这个文件的职责和边界,函数 docstring 讲清这个函数该干什么。这样光靠目录树 + 每个文件的头几行,就能把整个项目读个大概。

例如一个文件长这样:

"""简单一句话概括本文件职责(来自哪个需求文档)。

两句话的更详细解释
"""

@router.post("/pipeline/{kind}", response_model=PipelineSubmitOut, status_code=201)
async def foo(kind: Kind, primary: UploadFile, ...) -> PipelineSubmitOut:
    """本函数作用:

    两句话更详细解释
    """
    ...

唯一的例外是类型层,要求写实:8 种资源的参数模型、请求响应体、数据库表全部完整实现。类型是契约,是后面所有实现要对着的靶子——骨架阶段就能导出 openapi.json、生成前端 TS 类型。配置加载、表定义同理,副本整文件照搬。

一句话分界:契约当真,行为留空。

函数作用精细程度

AI喜欢把函数的返回数据长什么样、有哪几个字段,写进 docstring,一条条列出来,看着很完备,但是信息过于冗余了,尽量通过定义的类型来传达返回数据结构,而不是再写一遍。这里的文字很宝贵。

骨架中的注释

除了基本的规约docstring,还有很多的开发时候的想法和思考,可能是一些方案选择或者个人口味、比如一些change,不能全部放在docstring里,所以这时可以用# 注释来记录这部分不适合放在docstring进行强校验的内容。

两种注释语法,两种命运。判据一句话:使用者需要的 → docstring;开发者需要的 → # 注释。

  • docstring:职责、参数/返回语义、调用方必须遵守的约束——骨架与副本逐字一致
  • # 注释[源:]/[新:] 标记、设计理由、踩坑日期、实现草图——只留骨架,不带进副本。 "该干什么"的步骤说明(上面 pipeline 里的"1. 落盘 2. 建 resource 3. 建 jobs")随代码写完而删——代码本身就是答案,留着只会在下次改动后开始撒谎。

总之注释里放的更多是代码推不出来的约束和取舍

该删该留的标准

读完注释再读代码:光看代码也能得到同样的信息,就砍掉;这条信息无论怎么盯着本文件都推不出来(它在别的仓库、在运行时事实、在一个被否掉的方案里),就留。

"""各 kind 的 params 类型 —— 与前端 types.ts 逐字段对齐(权威源在前端),字段名一字不改。"""
# 为什么命名「混搭」:anchor 系用 snake_case(char_width…,沿线上 template_config),
# 其余用 camelCase(depthThreshold / bgId…)。这不是笔误 —— Pydantic 字段名
# 必须与 TS 完全一致,生成出来的 TS 才能和 types.ts 无缝互换。

未来某个人(包括未来的 AI)会好心把命名"修正"整齐、搞崩前后端契约——这种知识盯着本文件看再久也推不出来,不写下来就等着被改掉。写的位置照旧按分界拆:"字段名一字不改"是规约,进模块 docstring;"为什么长成混搭样"是考古,下沉 # 注释。 不用担心 # 没人看——改字段名是设计变更,蓝图先行 + 整文件同步检查逼着改的人必先打开骨架里的这个文件。

方向1: 如何借助骨架法做开发

1-搭建骨架

可能是从零生成骨架,也可能是在现有骨架上搭建。不管起点如何,都根据上面的骨架结构搭建就好,只是注意有下面两个注意事项

MCP 开发时候避免docstring混用

MCP开发的时候docstring默认变成tool的描述给agent看,本文中的描述会更详细,并不适合直接给AI看,所以最好是通过mcp的装饰器里的参数分开,不要混用。

骨架不需要覆盖测试代码

因为测试代码的重要性相对更低,而且比较繁杂,因此为了保持骨架的可阅读性,所以进行取舍,骨架中不放tests/目录,也就是pytest的代码。

骨架搭建过程中还会遇到各种之前没想到的问题,所以往往一个方案需要多次讨论和调整,不用期望一次性交付。

如果随着功能迭代某个功能不用了,骨架中还需要留下这个函数吗,是注释掉,还是直接删除?先定位直接删除吧。

2-交接文档和CLAUDE.md

检查脚本

为了避免漂移(文件、函数、docstring等),还需要从代码层面进行检查,所以需要一个脚本检查是否对齐。这时从零开始的骨架搭建才需要做的一次性工作。

check_skeleton_sync.py 母本放在 skeleton/scripts/,随骨架复制进每个工作副本,在哪个副本里运行就比对哪个:实码文件整文件文本比对,其余文件做"结构投影"比对——模块/类/函数存在性、函数签名、docstring 逐字(# 注释天然被 AST 丢弃,正好只比规约不比思考);_ 开头私有 helper 允许副本多出。

check_skeleton_sync.py 全文(240 行,规则详见文件头 docstring)
"""校验骨架(设计蓝图)与当前工作副本(实装)是否对齐。

    uv run python -m scripts.check_skeleton_sync          # 报告不一致,有则 exit 1
    uv run python -m scripts.check_skeleton_sync -v       # 附带全部通过项计数

本脚本母本在 skeleton/scripts/,随骨架复制进每个工作副本;在哪个副本里运行就比对哪个。

对齐规则(详见 CLAUDE.md):

- 逐 .py 文件比对包根 `custom_iap_backend/`(iap_assets 已上提到仓库根
  shared/iap_assets,单份实码不需要对齐检查)。
- **实码文件**(IMPL_FILES)整文件文本比对:骨架里就是可运行实码,副本原样复制。
- 其余文件做「结构投影」比对(注释天然被 AST 丢弃,只比规约):
    * 模块 / 类 / 函数 的存在性
        - 骨架有、v2 无            → 报错(规约未实现)
        - v2 有、骨架无且为公共符号 → 报错(plan 外漂移);`_` 开头私有放行
    * 函数签名(参数 / 注解 / 默认值 / async / 装饰器)逐一致
    * docstring 逐字一致(仅归一化行尾空白)
    * 类属性 / 模块常量的名字 + 注解一致;骨架默认值为 `...` 时跳过值比对
      (stub 占位),否则默认值也须一致(如 schemas 的真实缺省)。
"""

from __future__ import annotations

import ast
import sys
from pathlib import Path

# ── 路径:本脚本随骨架复制进每个工作副本,母本在 skeleton/scripts/ ──
# IMPL_ROOT = 脚本所在的工作副本(v2/v3/…,名字不限);SKELETON = 固定同级蓝图目录。
IMPL_ROOT = Path(__file__).resolve().parent.parent
REPO_ROOT = IMPL_ROOT.parent
SKELETON_ROOT = REPO_ROOT / "custom-iap-backend-skeleton"

PACKAGE_ROOTS = ["custom_iap_backend"]

# 整文件文本比对(骨架里即实码,v2 原样复制)
IMPL_FILES = {
    "custom_iap_backend/config.py",
    "custom_iap_backend/packages/models.py",
}


class Finding:
    def __init__(self, file: str, kind: str, detail: str) -> None:
        self.file = file
        self.kind = kind
        self.detail = detail


def _norm_doc(doc: str | None) -> str | None:
    """归一化 docstring:整体 strip + 每行 rstrip(吸收行尾空白差异)。"""
    if doc is None:
        return None
    return "\n".join(line.rstrip() for line in doc.strip().splitlines())


def _sig(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
    prefix = "async " if isinstance(fn, ast.AsyncFunctionDef) else ""
    decos = sorted(ast.unparse(d) for d in fn.decorator_list)
    ret = ast.unparse(fn.returns) if fn.returns else ""
    return f"{prefix}({ast.unparse(fn.args)}) -> {ret} @[{'; '.join(decos)}]"


def _is_ellipsis(node: ast.expr | None) -> bool:
    return isinstance(node, ast.Constant) and node.value is Ellipsis


def project(path: Path) -> dict[str, dict]:
    """把一个 .py 文件投影成 {qualname: {...}} 的规约字典。"""
    tree = ast.parse(path.read_text(encoding="utf-8"))
    out: dict[str, dict] = {"<module>": {"doc": _norm_doc(ast.get_docstring(tree, clean=False))}}

    def rec_attr(prefix: str, name: str, anno: ast.expr | None, value: ast.expr | None) -> None:
        out[f"{prefix}{name}"] = {
            "kind": "attr",
            "anno": ast.unparse(anno) if anno is not None else None,
            "default": None if value is None
            else ("..." if _is_ellipsis(value) else ast.unparse(value)),
        }

    def is_schema_class(node: ast.ClassDef) -> bool:
        # dataclass / pydantic BaseModel / SQLModel —— 纯注解字段是契约
        if any("dataclass" in ast.unparse(d) for d in node.decorator_list):
            return True
        return any(ast.unparse(b).split(".")[-1] in {"BaseModel", "SQLModel"}
                   for b in node.bases)

    def walk(body: list[ast.stmt], prefix: str, schema_ctx: bool) -> None:
        for node in body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                # 只记函数本身;函数体内部(嵌套 helper / 局部变量)属实现,不比对
                q = f"{prefix}{node.name}"
                out[q] = {"kind": "func", "sig": _sig(node),
                          "doc": _norm_doc(ast.get_docstring(node, clean=False))}
            elif isinstance(node, ast.ClassDef):
                q = f"{prefix}{node.name}"
                bases = sorted(ast.unparse(b) for b in node.bases)
                out[q] = {"kind": "class", "bases": bases,
                          "doc": _norm_doc(ast.get_docstring(node, clean=False))}
                walk(node.body, q + ".", is_schema_class(node))   # 类体:递归取字段 + 方法
            elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
                # 常规类里「纯注解无值」是实例属性类型提示(v2 常在 __init__ 赋值),
                # 不算契约;schema 类 / 模块级 / 带值的才纳入。
                if node.value is None and prefix and not schema_ctx:
                    continue
                rec_attr(prefix, node.target.id, node.annotation, node.value)
            elif isinstance(node, ast.Assign):
                # 无注解赋值也按名字纳入(存在性对齐用;注解 None 时不比注解)
                for t in node.targets:
                    if isinstance(t, ast.Name):
                        rec_attr(prefix, t.id, None, node.value)
            elif isinstance(node, (ast.If, ast.Try)):
                # 下探条件/异常分支里的模块/类级定义(如 if/else 里赋的常量)
                for sub in (getattr(node, "body", []) + getattr(node, "orelse", [])
                            + getattr(node, "finalbody", [])):
                    walk([sub], prefix, schema_ctx)
                for h in getattr(node, "handlers", []):
                    walk(h.body, prefix, schema_ctx)

    walk(tree.body, "", False)
    return out


def _extra_allowed(qualname: str) -> bool:
    # v2 多出的下划线/dunder 成员(私有 helper、__init__ 等)属实现自由,放行
    return qualname.split(".")[-1].startswith("_")


def check_structural(rel: str, findings: list[Finding], ok: list[str]) -> None:
    sk = project(SKELETON_ROOT / rel)
    v2 = project(IMPL_ROOT / rel)

    for q in sk:
        if q not in v2:
            findings.append(Finding(rel, "缺失", f"骨架定义了 `{q}`,v2 缺失(规约未实现)"))
            continue
        s, v = sk[q], v2[q]
        if s.get("sig") is not None and s.get("sig") != v.get("sig"):
            findings.append(Finding(rel, "签名不一致",
                f"`{q}`\n      骨架: {s['sig']}\n      v2:   {v.get('sig')}"))
        if s.get("kind") == "class" and s.get("bases") != v.get("bases"):
            findings.append(Finding(rel, "基类不一致",
                f"`{q}` 骨架 {s['bases']} vs v2 {v.get('bases')}"))
        if s.get("doc") != v.get("doc"):
            findings.append(Finding(rel, "docstring 不一致",
                f"`{q}`\n      骨架: {s.get('doc')!r}\n      v2:   {v.get('doc')!r}"))
        if s.get("kind") == "attr":
            # 注解仅在两边都显式声明时才比(v2 省注解不算规约违背)
            if s.get("anno") is not None and v.get("anno") is not None \
                    and s.get("anno") != v.get("anno"):
                findings.append(Finding(rel, "属性注解不一致",
                    f"`{q}` 骨架 {s.get('anno')} vs v2 {v.get('anno')}"))
            # 骨架默认值为 `...` 是 stub 占位,值不比;否则须一致
            if s.get("default") not in (None, "...") and s.get("default") != v.get("default"):
                findings.append(Finding(rel, "属性默认值不一致",
                    f"`{q}` 骨架 {s.get('default')} vs v2 {v.get('default')}"))
        ok.append(f"{rel}::{q}")

    for q in v2:
        if q not in sk and q != "<module>" and not _extra_allowed(q):
            findings.append(Finding(rel, "多出", f"v2 有公共符号 `{q}`,骨架未声明(plan 外漂移)"))


def check_impl_file(rel: str, findings: list[Finding], ok: list[str]) -> None:
    sk = (SKELETON_ROOT / rel).read_text(encoding="utf-8")
    v2 = (IMPL_ROOT / rel).read_text(encoding="utf-8")
    if sk != v2:
        # 给出首个差异行号,便于定位
        sk_lines, v2_lines = sk.splitlines(), v2.splitlines()
        diff_at = next((i + 1 for i, (a, b) in enumerate(zip(sk_lines, v2_lines)) if a != b),
                       min(len(sk_lines), len(v2_lines)) + 1)
        findings.append(Finding(rel, "实码文件整文件不一致",
            f"首个差异在第 {diff_at} 行(实码文件须逐字一致)"))
    else:
        ok.append(f"{rel} (实码整文件)")


def iter_py_files(root: Path) -> set[str]:
    files: set[str] = set()
    for pkg in PACKAGE_ROOTS:
        base = root / pkg
        if not base.exists():
            continue
        for p in base.rglob("*.py"):
            if "__pycache__" in p.parts:
                continue
            files.add(str(p.relative_to(root)))
    return files


def main() -> int:
    verbose = "-v" in sys.argv

    if IMPL_ROOT == SKELETON_ROOT:
        print("ℹ️ 在骨架目录内无需自检——请在工作副本(custom-iap-backend-vN)里运行。")
        return 0
    if not SKELETON_ROOT.exists():
        print(f"❌ 找不到骨架目录 {SKELETON_ROOT}")
        return 2

    findings: list[Finding] = []
    ok: list[str] = []

    sk_files = iter_py_files(SKELETON_ROOT)
    v2_files = iter_py_files(IMPL_ROOT)

    for rel in sorted(sk_files - v2_files):
        findings.append(Finding(rel, "文件缺失", "骨架有此文件,v2 缺失"))
    for rel in sorted(v2_files - sk_files):
        findings.append(Finding(rel, "文件多出", "v2 有此文件,骨架未声明"))

    for rel in sorted(sk_files & v2_files):
        if rel in IMPL_FILES:
            check_impl_file(rel, findings, ok)
        else:
            check_structural(rel, findings, ok)

    if not findings:
        print(f"✅ 骨架与 v2 已对齐(比对 {len(sk_files & v2_files)} 个文件,{len(ok)} 项检查通过)")
        return 0

    by_file: dict[str, list[Finding]] = {}
    for f in findings:
        by_file.setdefault(f.file, []).append(f)

    print(f"❌ 发现 {len(findings)} 处不一致,涉及 {len(by_file)} 个文件:\n")
    for file in sorted(by_file):
        print(file)
        for f in by_file[file]:
            print(f"  ✗ [{f.kind}] {f.detail}")
        print()

    if verbose:
        print(f"(另有 {len(ok)} 项检查通过)")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

CLAUDE.md

为了让本SKILL的规范注入agent,需要写入CLAUDE.md,这个文件每次 AI 会话自动加载,只放"这个目录是什么 + 四步工作流 + 硬规则",短到不可能被略读。在 CLAUDE.md 里面记录检查方式。同样只有是从零开始的骨架搭建才需要做的一次性工作。

骨架仓库的 CLAUDE.md 全文
# 开发约定

本目录是 custom-iap-backend 的**骨架 / 设计蓝图**:函数体是 `...`docstring 是规约。实装仓库是 `../custom-iap-backend-v2/`架构、已定决策、复现步骤见 `README.md`
## 改动工作流(蓝图先行)

1. **plan**:先规划,说明改什么、为什么
2. **改骨架**:签名 + docstring 写规约;设计思考写成 `#` 注释(只留骨架)
3. **用户批准后**再同步写入 v2:写函数体,docstring 逐字照搬骨架,
   `#` 注释不带过去
4. 在 v2 跑对齐检查,必须通过:
   `uv run python -m scripts.check_skeleton_sync`

## 硬规则

- **docstring = 规约,`#` 注释 = 思考**。判据一句话:使用者/读者需要的 →
  docstring(骨架与 v2 逐字一致);实现者/考古需要的 → `#` 注释(只写在
  骨架,不同步):`[源: file:line]` 溯源、`[新: 日期]` 标记、设计理由、
  实现草图。
- 两个实码文件骨架与 v2 **整文件一致**`custom_iap_backend/config.py`  `custom_iap_backend/packages/models.py`- 发布契约住仓库根 `shared/iap_assets`(零 IO、零表,backend 与
  img-generate 共用);组装逻辑住 `packages/composer.py`(backend 私有),
  不回流共享包。

交接文档

为了总结上下文,主要是丢掉反复讨论的过程思考、写作CLAUDE.md和检查脚本,从新的上下文开始工作,我喜欢让模型写一个交接文档。

不用担心上下文缓存命中,因为之后的实装工作往往会交给次一级的模型。

所以总结

  • 背景:为什么做这次改动、中途否掉过哪些方案(决策链比结论值钱)
  • 注意事项:按踩坑代价排序的不变量(比如"tool 签名永不出现 user_id"这种一破就出事故的)
  • 后续要做的:顺序即依赖的 TODO,交付时哪些做了哪些没做

所以交付物是三件套:skeleton/ + CLAUDE.md + 交接.md。三份材料读者不同:骨架给评审契约的人,CLAUDE.md 给每次会话的 AI,交接文档给未来接手的人(包括未来的 AI 会话),谁也替不了谁。

3-实际开发

如果是从零开始开发,注意不直接在骨架上改(不然骨架不久丢失了嘛),而是复制一份出去,两个目录角色固定:

目录 角色 纪律
skeleton/ 设计蓝图 / 规约源头 只读;改设计先改这里
app/(复制出的副本) 实装工作区 所有开发在这里

改接口的流程永远是蓝图先行:人不批准就返给 AI 改骨架;批准落地后检查不过,修的是 app/ 的实码:

graph TD
    P["plan:说明改什么、为什么"] --> S["改 skeleton/:签名 + docstring 写规约,# 注释写思考"]
    S --> A{人批准?}
    A -->|不批准,返给 AI 协助改骨架| S
    A -->|批准| V["同步到 app/:写函数体,docstring 逐字照搬"]
    V --> C["check_skeleton_sync 对齐检查"]
    C -->|不通过,修 app/ 实码| V
    C -->|通过| Done([完成])

控制点就是骨架:它小到人能通读,改起来不牵一发动全身;AI 在副本里写多少行,都不影响你手里握着的那张图。

同一个函数在两边长什么样:

def get_pushable_packages(...) -> list[CustomIap]:
    """pay2 侧应可见的**全部**礼包;每次推送必须列全,不能只推增量。"""  # (1)
    # 2026-07-12 dev 实测:单推 1 条会硬删该角色其余在售礼包(audience 全量替换)
    ...
  1. 规约:使用者需要的约束,写进 docstring,两边逐字一致。下一行的 # 注释是踩坑理由——思考,只活在骨架里。
def get_pushable_packages(...) -> list[CustomIap]:
    """pay2 侧应可见的**全部**礼包;每次推送必须列全,不能只推增量。"""  # (1)
    stmt = select(CustomIap).where(CustomIap.status == "on_sale")
    return session.exec(stmt).all()
  1. docstring 逐字照搬(对齐检查盯着);# 2026-07-12 那行思考没带过来——想知道"为什么必须列全",回骨架考古。

正向骨架实例:pipeline 路由(signatures + docstring 说明 + ...
"""新建模板处理流水线总入口(backend-plan.md §12.1 契约)。

前端 Wizard「▶ 生成」→ 一次 multipart 提交:primary 素材 + 勾选的 optional
字段(每个 optional key 一个独立 part)+ title/note。
只有 dual / depth 需要真正的服务端处理;expand / drag 仅文件入库。
"""
from fastapi import APIRouter, Depends, Form, UploadFile
from sqlmodel import Session

from ..db import get_session
from ..models import Kind, PipelineSubmitOut

router = APIRouter()


@router.post("/pipeline/{kind}", response_model=PipelineSubmitOut, status_code=201)
async def submit_pipeline(
    kind: Kind,
    primary: UploadFile,
    title: str = Form("", description="缺省取 primary 文件名(去扩展名)"),
    note: str = Form(""),
    secondary: UploadFile | None = None,    # dual 可选:黑底抠图版 → 走三角测量而非 SAM
    prompts: str | None = Form(None, description="dual 可选:SAM prompts,每行一个"),
    idleFrame: UploadFile | None = None,    # drag 可选:拖入前静态帧
    session: Session = Depends(get_session),
) -> PipelineSubmitOut:
    """提交一次完整 pipeline。委托 services.pipelines.submit():

    1. 所有上传文件先走 storage.save_upload()(落盘 + OSS + files 表)拿 sha256
    2. 创建 resource:group='templates',v1 占位版本(url 空、params = 默认值),
       用户生成后进抽屉调位置(§12.7)
    3. 按 PIPELINES[kind] 建 jobs(pending):
       - dual:有 secondary → tri 管线,否则 SAM 管线(prompts 进 input_json)
       - depth:process-depth + process-compose 两个连续 job
       - expand/drag:无 job,文件 URL 直接写进 v1
    4. 立即把 jobs 逐个 submit 到线程池(前端无需再手动逐个 run)
    5. 返回 { resource_id, jobs: [job_id…] } → 前端开始轮询 /resources/{id}/jobs

    非 wizard kind(item/item-bg/icon/gift-bg)→ 422:请直接走 /resources + /upload。
    """
    ...
写实的类型层实例:8 种资源的参数模型(这一层不是骨架,是完整实现)
"""各 kind 的 params 类型 —— 与 web/src/state/types.ts 逐字段对齐(权威源在前端),字段名一字不改。

判别方式:params 本身不带 kind 字段(前端如此),靠外层 Resource.kind 判别。
反序列化 params_json 时必须用 KIND_TO_PARAMS[kind].model_validate(...),
不要指望 Union 自动挑对分支(DualParams 是 DepthParams 的子集,会误判)。
"""
# 为什么命名「混搭」:anchor 系用 snake_case(char_width…,沿线上 template_config),
# 其余用 camelCase(depthThreshold / bgId / origUrl…)。这不是笔误 —— Pydantic 字段名
# 必须与 TS 类型完全一致,openapi-typescript 生成出来的 TS 才能和 types.ts 无缝互换。
from typing import Literal, Union

from pydantic import BaseModel, ConfigDict

# ---- 基础枚举(与 types.ts 的 Kind / Group 一致)----

Kind = Literal[
    "dual", "depth", "expand", "drag",   # 模板:视频类
    "item", "item-bg",                    # 道具 / 道具背景
    "gift-bg",                            # 礼包背景(全站通用)
    "icon",                               # 礼包入口
]

Group = Literal["templates", "items", "common", "backgrounds"]

# ---- 角色/视频锚定参数(单位 = 卡宽百分比,卡宽 375px)----


class AnchorParams(BaseModel):
    """对应线上 template_config 字段;char_shift_* 正数 = 向外破框。"""

    char_width: float
    char_height: float
    char_shift_up: float
    char_shift_right: float
    title_max_width: float
    desc_max_width: float


class BgKey(BaseModel):
    """expand 色键抠底参数:RGB 键色 + 双容差(t1 全透明阈 / t2 过渡阈)。"""

    r: int
    g: int
    b: int
    t1: int
    t2: int


# ---- 8 种 kind 各自的 params ----


class DualParams(AnchorParams):
    """基础双通道(vstack RGB+alpha):只有锚定参数。"""


class DepthParams(AnchorParams):
    """深度通道分层(三通道 vstack):锚定 + 深度切割面。"""

    depthThreshold: float
    depthSoft: float
    depthNear: Literal["bright", "dark"]


class ExpandParams(BaseModel):
    """全屏扩展 + 色键:无锚定(铺满卡片),前端 canvas 逐帧抠底。"""

    opaque: bool
    bgKey: BgKey


class DragParams(AnchorParams):
    """拖拽交互:锚定 + 静态帧 + 拖拽提示文案。"""

    idleFrame: str
    dragHint: str
    interactMode: Literal["drag"]


class ItemParams(BaseModel):
    """道具:只记关联的背景资源 id(item-bg 的 Resource.id)。"""

    bgId: str


class ItemBgParams(BaseModel):
    """道具背景:无参数(TS 侧是 Record<string, never>,故禁止额外字段)。"""

    model_config = ConfigDict(extra="forbid")


class IconParams(BaseModel):
    """礼包入口 icon:无参数。"""

    model_config = ConfigDict(extra="forbid")


class GiftBgParams(BaseModel):
    """礼包背景:origUrl 记录原时间戳 URL(设为正式版时 url 会被换成 OFFICIAL_BG_URL)。"""

    origUrl: str


# ---- 联合类型 & kind → 模型映射 ----

AnyParams = Union[
    DepthParams, DragParams, DualParams,   # 注意顺序:子集类型(DualParams)放后面
    ExpandParams, ItemParams, GiftBgParams,
    ItemBgParams, IconParams,
]

# 反序列化 params_json 的唯一正确入口(见模块 docstring)
KIND_TO_PARAMS: dict[str, type[BaseModel]] = {
    "dual": DualParams,
    "depth": DepthParams,
    "expand": ExpandParams,
    "drag": DragParams,
    "item": ItemParams,
    "item-bg": ItemBgParams,
    "icon": IconParams,
    "gift-bg": GiftBgParams,
}

# ---- 常量(与 types.ts 完全一致)----

# 单版本 kind:保存 = 覆盖当前 version,不产生 vN+1;只有视频模板留版本序列
SINGLE_VERSION_KINDS: tuple[str, ...] = ("gift-bg", "item", "item-bg", "icon")

# gift-bg「正式启用」判定:某版本 url === 该常量 → 正式(后端动作 = OSS copy → background.png)
OFFICIAL_BG_URL = "https://aics-imgs.happyfactory.com/custom-iap/x3/background.png"

DEFAULT_ANCHOR = AnchorParams(
    char_width=80, char_height=106.67,
    char_shift_up=26.67, char_shift_right=4,
    title_max_width=40, desc_max_width=30,
)


def is_single_version(kind: str) -> bool:
    """kind 是否单版本(对齐 types.ts isSingleVersion)。"""
    # TODO: return kind in SINGLE_VERSION_KINDS
    ...


def default_params_for(kind: str) -> BaseModel:
    """新建资源时的默认 params(对齐 types.ts defaultParamsFor)。
    dual → DEFAULT_ANCHOR;depth → +threshold 0.55/soft 0.10/near bright;
    expand → opaque + 灰键 (123,123,123,30,58);drag → +空 idleFrame/'拖到这里';
    item → 空 bgId;其余空对象。"""
    # TODO: 按 kind 分支返回对应模型实例
    ...

骨架阅读法

在学习别人的代码仓库时,常规方法是直接让CC给本仓库来一个分析。

由于是提取别人的代码,所以docstring不需要和别人现有的docstring,而是优先保证可读性,让读者能一目了然为什么有这个函数或者文件。

一般是用于阅读学习别人的代码或者AI的大生成。

目前还在实践中,所以未来再写。