超级轻量的自用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 在副本里写多少行,都不影响你手里握着的那张图。
同一个函数在两边长什么样:
正向骨架实例: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的大生成。
目前还在实践中,所以未来再写。