Imported from zyang4418/ocm (
AGENTS.md). Install upstream withnpx skills add zyang4418/ocm. Copyright stays with the author.
OCM - 智慧教室管理系统
Classroom management system: Go backend + WeChat mini-program (小程序) + web console.
小程序渲染策略(重要)
- 默认 WebView 渲染。不在 app.json 设置全局
"renderer": "skyline"。 - Skyline 仅在性能关键页按页开启:长列表、重滚动、worklet 动画等场景。开启方式:在该页 page.json 加
"renderer": "skyline"(componentFramework: glass-easel已在 app.json 全局设置,继承即可)。 - 开 Skyline 的页面必须用
scroll-view承载滚动内容(Skyline 无页面级滚动),并要求navigationStyle: custom—— 本项目全站已是 custom,所以按页开 Skyline 时不需要再补导航层,顶部继续用app-navbar。 - 低版本微信/PC 端会自动 fallback 到 WebView,按页开启是安全的。
- 当前所有页面均为 WebView(mock 数据,无性能压力)。index/ai 接入真实大列表后,再按页评估开启 Skyline。
- 选型背景见
agents/TDesign-Skyline-兼容性调查报告.md:TDesign 对 Skyline 支持是部分的,Cascader/Calendar 在 Skyline 下不可用,故默认 WebView 以获得完整组件库。
TDesign 组件库
- 使用
tdesign-miniprogram(npm)。改miniapp/package.json后,须在微信开发者工具「工具 → 构建 npm」生成miniprogram_npm/,否则组件无法解析。 - 组件按页在 page.json
usingComponents引入(lazyCodeLoading: "requiredComponents"已开,未用组件自动裁剪)。 - TDesign 品牌色用
--td-brand-color(在app.wxss绑定到--color-primary),保持与设计 token 一致。 - TDesign 主要面向 WebView;在 Skyline 页用之前先查兼容性。日期/时间选择用原生
<picker mode="date|time">(原生组件不受渲染引擎影响)。
安全约束(鉴权)
- 后端因网页端需要必须公网暴露;因此
X-WX-OPENID云网关头可伪造、不可信任。 - 小程序身份通过
wx.login()+ 后端code2Session(AppSecret 仅在后端 env)解析 openid,绝不取自请求头。 - 绑定:账号
openid必须为 NULL 才允许绑定(防劫持);唯一索引防一个 openid 绑多账号。 - JWT(HS256,24h)网页端与小程序共用;
Authorization: Bearer <token>。
通信与自托管
- 小程序通过
wx.cloud.callContainer访问云托管后端(仅传输)。config.env每次调用必填,X-WX-SERVICE头指定服务名;wx.cloud.init()空参即可。 - 请求层
miniapp/utils/request.ts抽象传输(callContainer / wx.request),miniapp/config/api.ts配置切换。自托管(非微信云)仅改配置、不改代码:把transport改'http'+ 填baseUrl。 - 身份机制(wx.login/code2Session)非云专属,自托管同样可用。
小程序工程约定
- TypeScript + glass-easel。
app.js为纯 JS(require)以避免 app.ts/app.js 冲突;utils/、config/为 .ts(ES 导出,编译为 CommonJS)。 - 设计 token 在
app.wxss(page上的 CSS 变量,如--color-primary: #2B5FF6)。 - 顶部导航统一用
components/app-navbar(包一层 TDesignt-navbar):app.json全局navigationStyle: "custom",app-navbar用fixed="{{false}}"走文档流,状态栏高度、胶囊宽度、导航栏高度由t-navbar读getMenuButtonBoundingClientRect自动算,返回箭头按页面栈自动判断。页面只需在.page第一行写<app-navbar title="教室管理" />,不要再手算statusBarHeight、加padding-top或placeholder。page.json的navigationBarTitleText仅作文档兜底,运行时不生效;动态标题(先例pages/observations-form)把文案放进页面 data 传给app-navbar。 - 底部 tabBar 用微信原生:原生 tabBar 不能按权限隐藏某一项,无权限入口由页内空态兜底(先例
pages/ai)。 - CI 门禁:
.github/workflows/miniapp.yml在miniapp/**或 workflow 自身变更时跑两步——miniapp/scripts/check-navbar.py(每页必须有<app-navbar>,标题须与page.json的navigationBarTitleText一致,登录页允许空标题)和tsc --noEmit(TypeScript 按版本号内联锁定,未进 devDependencies)。脚本按自身位置定位小程序目录,从任意 cwd 都能跑;本地改完页面先跑一遍,别等 CI。miniapp/scripts/已在project.config.json的packOptions.ignore中排除,不会进入代码包。
后端(Go)
net/httpServeMux(Go 1.22+ 方法路由)。认证在backend/internal/auth。- 数据库:PostgreSQL(自 2026-10 起;不再支持 MySQL)。连接层
pgx/v5的database/sql驱动(sql.Open("pgx", …))——保留裸 stdlib 无 ORM,SQL 用$1..$n位置占位符;唯一代码内构造 DSN 的地方是db.Config.DSN()。迁移一律幂等:CREATE TABLE IF NOT EXISTS+ 表外CREATE INDEX IF NOT EXISTS(PG 无内联 KEY;索引名是 schema 级命名空间,跨表唯一——一律idx_<表名>_<用途>具名,重名会被IF NOT EXISTS静默跳过,CI 有重名检查门禁)+ON CONFLICT DO NOTHING/DO UPDATE(无INSERT IGNORE/ON DUPLICATE KEY)。主键用GENERATED BY DEFAULT AS IDENTITY(不是ALWAYS,seed 语句要显式插 id)。唯一约束照原样翻译:MySQL 具名UNIQUE KEY uq_x (…)→CONSTRAINT uq_x UNIQUE (…)(名字保留,便于将来用dbutil.DuplicateKeyName从PgError.ConstraintName区分是哪个约束冲突;该 helper 目前无调用方,但保留命名是零成本的),裸UNIQUE保持裸UNIQUE。ON CONFLICT (col)要求该列上真有唯一约束。需要一次性数据迁移时沿用 marker 表惯例:同表内INSERT … ON CONFLICT DO NOTHING抢锁 + 事务包裹,让并发启动与崩溃重放都不可能重复执行(当前无在用实例,删掉的internal/db/timezone.go是历史先例)。 - 时间契约(2026-10 起,锚定 Asia/Shanghai=UTC+8):核心不变量是列类型表达语义,而不是依赖数据库配置。①四类时间数据:真瞬时(签到窗口、码/授权/命令过期、审计
*_at)→timestamptz;日粒度(排课日期、预约日期、学期起止)→date;钟面时刻(节次起止、作息钟点)→time;循环规则("每周一第 1-2 节")→ 整数序号(weekday/period index),绝不物化成 timestamptz——循环事件是"日期→时刻"的函数,物化会把写入当时的时区规则冻进数据。②禁用 naivetimestamp(无时区):pgx 给它两端挂假标签(编码取墙钟字段、解码挂 UTC),Go 侧Equal/Before/Unix会静默错 8 小时且不报错,比 MySQL DATETIME 更隐蔽。需要"北京时间几点"时在查询里显式AT TIME ZONE 'Asia/Shanghai'或(ts AT TIME ZONE 'Asia/Shanghai')::date。③写入归属分两类(不是"SQL 一律不许写时间"):业务瞬时——签到closed_at/checked_at、预约reviewed_at、导入started_at/finished_at、听课exported_at、授权granted_at——必须由 Go 传入time.Now();created_at/updated_at这类行记账时间由服务端产生(DDLDEFAULT now()、UPDATE 里updated_at = now()),且该列只能有一个来源。判据是"这一列会不会被另一条路径用另一种时钟写同一列"——旧 MySQL 的混合墙钟就是这么来的。另外ON UPDATE CURRENT_TIMESTAMP在 PG 无对应物,建表时删掉、改表时把updated_at = now()写进该表的 UPDATE 语句。④会话锚点(卫生而非正确性):db.Config.DSN()把会话时区钉在Asia/Shanghai(URL 参数名必须是小写 GUC 名timezone;pgx 把未识别参数原样下发运行时参数,TimeZone驼峰写法会被静默忽略,已由TestDSNPinsSessionAnchor+ 集成测试TestSessionAnchorTookEffect双守)。这只让psql/pg_dump/服务端now()渲染成北京时间,即时列的含义与会话时区无关,故 PostgreSQL 服务器不需要任何时区配置(旧 MySQL 时代要求的TZ=Asia/Shanghai/time_zone=+08:00已作废)。⑤civil 锚定仍在进程内:二进制内嵌time/tzdata并在 main 最顶tz.SetLocal()(alpine/scratch 无 zoneinfo,TZenv 对 Go 静态二进制是 no-op)——"今天"、作息制度、AI 提示词、未来 cron 定时任务全按北京时间;日志 slogReplaceAttr强制 CST 渲染。 - SQL 方言注意:用户输入的包含搜索一律
ILIKE(不是LIKE——PG 的 LIKE 大小写敏感,MySQL utf8mb4 默认不敏感,写成 LIKE 会静默漏结果);now() - make_interval(days => $1)代替NOW() - INTERVAL ? DAY;(x::date + INTERVAL '1 day')::timestamptz代替DATE_ADD;CASE WHEN … THEN … ELSE … END代替IF(…);to_regclass('public.t') IS NOT NULL代替information_schema.tables … DATABASE();取新 id 用QueryRowContext(… RETURNING id).Scan(&id)代替LastInsertId()(upsert 也能RETURNING一次拿到"新插或已存在"的 id)。PG 不做跨类型隐式转换(MySQL 会静默强转),比较两侧类型不一致时要显式::;同一个$n不可复用于类型不同的两列(报inconsistent types deduced for parameter)。 - 权限链:
auth.Middleware(JWT→username)→user.LoadSubject(每请求重算)→authz.RequirePermission/RequireAny。RBAC 数据(roles/user_roles/user_permissions/user_groups等)在internal/iam;有效权限 = 直接角色授权 ∪ 组角色授权 ∪ 直接权限授权,授权可带expires_at过期;*通配仅系统 admin 角色持有,不在权限目录(API 无法授予);授权 admin 角色需操作者持有*。权限目录在代码(authz.Catalog),DB 只存权限字符串。handler 检查 permission 字符串,不检查角色名。 - 可观测性信号层(指南
docs/docs/guide/observability.mdx):internal/metrics提供 RED 中间件(链序AccessLog(metrics.Middleware(Recover(Gzip(mux)))),Metrics 与 AccessLog 同样必须外于 Recover)+ DB 池 gauge +/metrics独立监听(METRICS_ADDR未设即关闭,独立 mux 只挂 /metrics,无鉴权靠内网隔离,不得发布宿主机端口);path标签用 ServeMux 路由 pattern(r.Pattern,未匹配记unmatched),勿改回原始 URL(基数爆炸)。AccessLog给每个响应打X-Request-ID(与日志request_id同源,运维反馈错误的定位闭环)。参考监控栈 overlaydocker-compose.observability.yml(prometheus/grafana/loki/alloy/node-exporter,配置在deploy/observability/,仅 compose 情景;serverless 情景由平台托管监控消费同一信号层)。
定制层扩展点(模块注册表、Renderer 注册与 Web 注入)
- 后端模块注册表:
backend/internal/modules/——下游在该目录新增文件(package modules)以init()调modules.Register(Module{Name, Migrate, Mount}),main.go在内置模块接线完成后先统一跑 Migrate 再统一 Mount(按 Name 排序确定性执行)。文件级组装即可把下游模块编进二进制,零覆盖 main.go;下游权限继续走authz.RegisterPermissions(package init 先于 main,orphan 检查可见)。 - observation 文档后端(Renderer)注册:注册代码必须位于会被编入二进制的包——惯用位置是
backend/internal/modules/下新增的下游文件(package modules,可不调Register),其init()调observation.RegisterRendererFactory(func(db) (Renderer, error))(backend/internal/observation/renderer_registry.go),工厂内schedule.NewStore(db)+ 下游renderer.New(store)构造实现;不得把注册文件放进internal/observation/包内——实现方为拿到Renderer契约与*observation.Observation必然 import observation,同包引入即成 import 环。main.go接线听课评课时经observation.ResolveRenderer构建注入;未注册即开源默认(CRUD/提交可用,模板/导出端点报ErrRendererMissing)。时序:ResolveRenderer 在内置迁移之后、modules 迁移循环之前执行——工厂需要自己的表就自行迁移(CREATE TABLE IF NOT EXISTS惯例)。 - Web 文件注入:
src/config/appRoutes.override.tsx(导出overrideRoutes: AppRoute[])与src/config/navigation.override.ts(导出overrideEntries: NavEntry[])经import.meta.glob构建期并入,新增文件即加页面/菜单;整文件覆盖这两个数据文件的老方式继续兼容。 - 小程序品牌注入:
miniapp/config/brand.override.ts导出brandOverride: Partial<BrandConfig>,config/brand.ts合并成brand(默认 OCM);页面在data里挂brand,WXML 用{{brand.name}}/{{brand.titleCloud}}/{{brand.systemName}}/{{brand.aiName}},logo 一律走{{brand.logoImage}}(图片是唯一方案,无文字 logo 兜底;默认assets/brand/logo.png与 websrc/assets/logo.png同图,保证上游回归会覆盖图片路径)。小程序没有打包器,做不到 web 的import.meta.glob(缺失模块编译期即报错),因此覆盖文件始终存在、上游保持空对象,下游只改这一个文件。app.json/project.config.json是编译器直读的 JSON,导入不了brand——默认标题、tabBar 配色、appid 仍需在各文件里改;品牌色(含 tabbar 位图)不在注入范围内。 - 小程序类型门禁:
miniapp/tsconfig.json+cd miniapp && npx --yes --package typescript@5.9.2 tsc --noEmit。wx.request的类型联合不含PATCH(后端有PATCH /api/users/{id}/password),utils/request.ts在调用处收窄,新增方法时不要退回整体any。 - 物联网:后端模块
backend/internal/iot(数据面internal/iot/mqtt,IOT_*env 未配置即惰性、命令下发 503);契约规范iot/spec/spec.md(topic 无版本前缀、载荷带"v":1、命令必须带expires_at);设备侧 SDK/模拟器在顶层第二个 Go moduleiot/(modulegithub.com/zyang4418/ocm/iot,backend 刻意不 import 它,契约一致性靠go test -tags=integratione2e);部署 overlaydocker-compose.iot.yml。权限iot:read/manage/control,iot:control单独门控物理命令。
AI 编程工具参考(agents/)
agents/是唯一被 git 跟踪的 AI 编程辅助资料目录(各工具私有目录如.zcode/、.claude/不入库):项目级实战教训在agents/lessons.md(bug 根因/依赖行为变化/环境坑,一条一个根因),选型调查在agents/TDesign-Skyline-兼容性调查报告.md,章程与维护规则见agents/README.md。- AI 生成的分析/评审报告默认不入库;确有沉淀价值的蒸馏成条目写进
agents/lessons.md,原文件丢弃。踩坑核实后回写一条教训,过时条目删除。
OpenAPI 契约(swaggo)
- handler 注释用 swaggo 注解(
@Summary/@Param/@Success/@Router,试点见backend/internal/attendance/handler.go);backend/docs/为生成产物勿手改。全部业务模块已注解(导出 xlsx/docx 的端点除外——二进制响应无 schema 意义)。 - 改 handler/model 后须
go -C backend tool swag init -o docs --requiredByDefault并提交(勿加--parseDependency:会把 stdlibos/user拉进解析树,与internal/user.User撞名导致定义被重命名,见agents/lessons.md依赖命名冲突条);前端npm --prefix web run gen:types重生成web/src/types/api.d.ts(链路:swagger 2.0 -> swagger2openapi 转 OpenAPI 3 -> openapi-typescript)。Swagger UI 仅在APP_ENV=development(或显式设SWAGGER_ENABLED=true)时挂载在/swagger/,生产默认关闭(未设 APP_ENV 按 production 处理)。CI 有双侧漂移门禁(backend.yml 重生成docs/无 diff;web.yml 重生成api.d.ts无 diff)。 --requiredByDefault把非 omitempty 字段标为必填(TS 端必到字段不再是全可选);可空指针字段(如*time.Time)须加validate:"optional"标签豁免(后端无 validator 库,该标签仅 swaggo 消费),否则会被误标必填。json.RawMessage字段须加swaggertype覆盖,否则 swaggo 渲染成number[](底层 []byte):任意 JSON 对象用swaggertype:"object",字符串数组用swaggertype:"array,string"(见 observation/model.go、classroom/repair.go)。- 注解引用的请求/响应类型须为导出类型(swaggo 限制);未导出的先导出化(先例:auth 的
LoginRequest/UserView/LoginResponse、ai/mail/storage 的MaskedSettings),或新建命名类型(先例:attendanceScanRequest、userUserRolesInput、importerSplitResult)。
网页端(web/)
- TypeScript strict 全量落地:src 下无
.js/.jsx(allowJs已关),新文件一律.ts/.tsx。 - 页面数据类型一律引
web/src/types/api.ts的导出别名(生成 schema 的 re-export);手写补集仅限ApiError/Paged<T>/Permission(前端can()的联合类型,比生成的string[]严)与极少数非 Go 源头的类型(如 observations 模板 schema——Python 衍生,见 ObservationsPage 本地类型)。 - Carbon DataTable 行必须有 string
id:wire 无 id 的记录显式派生({...r, id: String(r.userId)}),这是类型锁死的模式。getHeaderProps/getRowPropsspread 自带 key,勿再传显式key(TS2783)。 - Carbon 组件常见坑:Tag 无
yellow色;DatePickerInput不收value(受控值放父DatePicker);MultiSelect无selectionprop,弹窗内表单回显必须用受控selectedItems——Carbon Modal 的 children 常驻挂载(仅切is-visibleclass,不随 open 重挂载),initialSelectedItems仅组件挂载时生效,弹窗打开后异步加载的数据它不感知;Dropdown的titleText为必填 prop,非空会渲染可见<label>(页面已有自定义 label 时传"",组件默认即空串、空值短路不渲染);InlineNotification无actionsprop(用ActionableNotification);charts 的scaleType须用ScaleTypes枚举(字符串字面量不过检)。 npm --prefix web run typecheck为类型门禁;build已含tsc --noEmit。- 本地无 PostgreSQL:后端业务路由等 DB 连通后才注册,冒烟看
/healthz与/swagger/;本地启动后端需APP_ENV=development(注意不是 dev)。要跑集成测试就起一个临时 PG:docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=root postgres:17-alpine,再DB_TEST_POSTGRES_DSN='postgres://postgres:root@127.0.0.1:5432/postgres?sslmode=disable' go test -tags=integration ./internal/db/...。
