From 75f226cfbf67ec7d388dd142553b8a32cae96808 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 27 Aug 2026 23:56:16 +0800 Subject: [PATCH] docs(agents): update AGENTS.md and development skills for cordis architecture --- .agents/skills/cache-framework/SKILL.md | 251 ++++--------- .agents/skills/database-migration/SKILL.md | 204 +++++------ .agents/skills/file-upload/SKILL.md | 4 +- .agents/skills/new-api/SKILL.md | 303 +++++++-------- .../new-api/references/handler_example.go | 6 +- .../new-api/references/logics_example.go | 5 +- .../new-api/references/service_example.go | 6 +- .agents/skills/new-async-task/SKILL.md | 212 ++++++----- .../references/CODE-EXAMPLES.md | 345 ++++++------------ .agents/skills/new-setting/SKILL.md | 217 +++++------ .agents/skills/push-notification/SKILL.md | 12 +- AGENTS.md | 72 ++-- 12 files changed, 660 insertions(+), 977 deletions(-) diff --git a/.agents/skills/cache-framework/SKILL.md b/.agents/skills/cache-framework/SKILL.md index 620409a4..29f2f7e9 100644 --- a/.agents/skills/cache-framework/SKILL.md +++ b/.agents/skills/cache-framework/SKILL.md @@ -1,217 +1,108 @@ --- name: "cache-framework" -description: "Wavelet 项目专用:当新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、或评估高频读是否应接入缓存时必须使用。本技能说明系统标准缓存框架、参考实现、禁止写法与分布式一致性要求。" +description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的业务缓存、ctx.Cache() / contracts.CacheService 访问、三层读路径(RAM L1 + Redis L2 + DB L3)、多节点 Pub/Sub 失效同步时必须使用。" --- -# 系统三层缓存框架 +# 系统三层缓存框架与开发规范 (Cordis 插件化架构) -开始前阅读根目录 `AGENTS.md`(含 **Skill 关联索引**)。Wavelet 标准读路径为 **本地 RAM → Redis → PostgreSQL**(由快到慢),不是 DB 优先。 +本技能指导 Wavelet 在 Cordis 架构下,如何使用平台统一提供的三层缓存服务(`ctx.Cache()` 与 `contracts.CacheService`)进行高性能缓存读写与分布式失效同步。 -详细性能背景见 `docs/PERFORMANCE.md`。 +--- -## 关联 Skill +## 1. 三层读路径与标准契约 -| 关联 | 何时一并阅读 | -| :--- | :--- | -| [database-migration](../database-migration/SKILL.md) | 缓存对象对应新表/列/索引,或 seed 变更 | -| [new-setting](../new-setting/SKILL.md) | 系统配置类缓存(`GetSystemConfigByKey`、`ListSystemConfigsByKeys`) | -| [file-upload](../file-upload/SKILL.md) | 上传元数据 `upload:meta:{id}`、ingest/remove/cleanup 失效钩子 | -| [clickhouse-batchwriter](../clickhouse-batchwriter/SKILL.md) | 分析写入走 batchwriter,**不要**用本技能模式缓存 CH flush 队列 | -| [new-api](../new-api/SKILL.md) | 在 Handler 层接入 `GetXxxCached` 或评估高频读 | -| [new-async-task](../new-async-task/SKILL.md) | Worker/定时任务变更数据后必须 `Invalidate*`(如 `system:cleanup`) | - -## 标准模式(金标准) - -参考:`internal/repository/system_config_cache.go` + `GetSystemConfigByKey` / `ListSystemConfigsByKeys`。 +Wavelet 标准读路径为 **本地 RAM (L1) → Redis (L2) → Database (L3)**(由快到慢): | 层级 | 技术 | 职责 | | :--- | :--- | :--- | -| L1 本地 | `pkg/cache/ram`(Otter v2) | 进程内热数据,最低延迟 | -| L2 共享 | Redis `db.GetJSON` / `SetJSON` / `HSetJSON` + `db.PrefixedKey` | 跨节点共享,带 TTL 或写穿 | -| L3 权威 | PostgreSQL via `db.DB(ctx)` | 唯一数据源 | +| **L1 本地** | `pkg/cache/ram` (Otter) | 进程内纳秒级极速读取,抗最高频热点流量 | +| **L2 共享** | Redis 序列化缓存 | 跨节点共享,具备 TTL 与防击穿保护 | +| **L3 权威** | 关系型数据库 (PostgreSQL / SQLite) | 唯一权威数据源 | -### 读路径模板 +### 标准接口契约 (`contracts.CacheService`) ```go -func GetThingCached(ctx context.Context, key string) (Thing, error) { - ensureThingCacheListener() // 订阅 pub/sub,仅 sync.Once +type CacheService interface { + // Get 从缓存获取并反序列化至 target,若不存在返回 ErrCacheMiss + Get(ctx context.Context, key string, target any) error - if v, ok := thingRAM.GetIfPresent(key); ok { - return cloneThing(v), nil - } - if db.Redis != nil { - var v Thing - if err := db.GetJSON(ctx, redisKey(key), &v); err == nil { - thingRAM.Set(key, cloneThing(v)) - return v, nil - } - } - v, err := loadThingFromDB(ctx, key) - if err != nil { - return Thing{}, err - } - populateThingCache(ctx, v) // 回写 RAM + Redis - return v, nil + // Set 存储对象至缓存并设置 TTL + Set(ctx context.Context, key string, value any, ttl time.Duration) error + + // Delete 彻底移除缓存(清空本地 RAM、删除 Redis 并广播 Pub/Sub 通知全集群清空 RAM) + Delete(ctx context.Context, key string) error + + // GetOrSet 优先读缓存,若未命中则执行 loader 回源加载并自动回写 + GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error + + // Invalidate 是 Delete 的语义别名 + Invalidate(ctx context.Context, key string) error } ``` -### 写穿(populate) +--- -DB miss 或业务创建成功后,**必须**回写上层: +## 2. 业务使用标准范式 + +### 2.1 高性能读穿透 (`GetOrSet`) + +业务 Service 推荐优先使用 `GetOrSet`,框架底层自动完成 L1/L2 穿透、回写及并发防击穿: ```go -func populateThingCache(ctx context.Context, v Thing) { - thingRAM.Set(v.Key, cloneThing(v)) - if db.Redis != nil { - _ = db.SetJSON(ctx, redisKey(v.Key), v, cacheTTL) - } +func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) { + var order Order + cacheKey := "order:" + orderID + + err := s.cache.GetOrSet(ctx, cacheKey, &order, 10*time.Minute, func() (any, error) { + // Cache Miss: 执行 DB 回源查询 + var dbOrder Order + if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil { + return nil, err + } + return &dbOrder, nil + }) + + if err != nil { + return nil, err + } + return &order, nil } ``` -### 失效(Invalidate)— 分布式必做三步 +### 2.2 数据变更与失效广播 (`Invalidate` / `Delete`) -数据变更(Admin 更新、软删除、状态迁移)时: - -1. **本机 RAM** — `thingRAM.Invalidate(key)` 或 `InvalidateAll()` -2. **Redis** — `Del` / `HDel` 对应 key -3. **pub/sub 广播** — 通知**其他节点**清除 RAM(Redis 已由写节点清掉) +凡涉及数据创建、修改、软删除、状态变更的入口(**包含 HTTP Handler、后台 Worker 任务、定时清理任务**),必须调用缓存失效: ```go -func InvalidateThingCache(ctx context.Context, key string) error { - ensureThingCacheListener() - thingRAM.Invalidate(key) - if db.Redis != nil { - if err := db.Redis.Del(ctx, db.PrefixedKey(redisKey(key))).Err(); err != nil { - return err - } - publishThingRAMInvalidation(ctx, key) // 只广播 RAM 失效 - } - return nil +func (s *OrderService) UpdateOrderStatus(ctx context.Context, orderID string, newStatus string) error { + // 1. 更新数据库权威数据 + if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Update("status", newStatus).Error; err != nil { + return err + } + + // 2. 广播失效缓存(自动清除本机 L1、删除 Redis L2,并向集群广播 Pub/Sub 消息清空其他节点 L1) + return s.cache.Invalidate(ctx, "order:"+orderID) } ``` -### pub/sub 监听模板 +--- -```go -const thingInvalidationChannel = "domain:thing_invalidation" +## 3. 核心规则与禁止写法 (Guardrails) -func startThingCacheInvalidationListener() { - if db.Redis == nil { - return - } - go func() { - pubsub := db.Redis.Subscribe(context.Background(), thingInvalidationChannel) - defer func() { _ = pubsub.Close() }() - for msg := range pubsub.Channel() { - // 解析 payload,Invalidate RAM;勿重复 Del Redis - thingRAM.Invalidate(parsedKey) - } - }() -} -``` +1. **严禁自研本地 map 缓存**: + - 严禁在插件内编写 `sync.RWMutex + map[string]Xxx` 的裸内存缓存,无法感知多节点数据变更,必然引发多机脏读。 +2. **写路径必须全覆盖失效**: + - 不仅在 API 修改时失效,后台 Worker、定时任务执行数据清理或变更时,必须同步触发 `cache.Invalidate`。 +3. **Key 命名空间规范**: + - 缓存 Key 必须带插件命名空间前缀(如 `order:meta:{id}`、`auth:session:{token}`)。 +4. **不可在业务高频读接口中绕过缓存直查 DB**。 -- 使用 `sync.Once` 启动监听;**`ensureListener` 必须在 `db.Redis == nil` 时直接 return,不可消费 Once**(否则测试或 Redis 晚初始化时监听器永不启动)。 -- 测试可提供 `StopThingCacheListener` + 重置 `Once`(参考 `StopUploadMetaCacheListener`、`StopAuthSourceCacheListener`)。 -- 其他节点收到消息后**只清 RAM**,不再删 Redis。 +--- -## 现有实现速查 - -| 域 | 文件 | L1 | L2 | pub/sub | -| :--- | :--- | :--- | :--- | :--- | -| 系统配置 | `repository/system_config_cache.go` | `pkg/cache/store` | ❌ 无 Redis 缓存 | `system:config_broadcast` (别名 `system:config_invalidation`) ✅ | -| CAPTCHA 运行时 | `apps/cap/runtime_settings.go` | atomic.Pointer | (借配置 Redis) | 订阅 `system:config_invalidation` ✅ | -| 上传元数据 | `apps/upload/cache/meta_cache.go` | Otter | Redis JSON | `upload:meta_invalidation` ✅ | -| 上传访问白名单 | `apps/upload/cache/access_cache.go` | 进程内 TTL | (借配置读路径) | `upload:file_access_invalidation` ✅ | -| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ | -| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) | -| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del | -| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ | - -## 新增缓存工作流 - -1. **判定是否需要缓存**:高频读、低变更、可容忍短暂 TTL;写路径必须能统一失效。 -2. **选型 L1**:优先 `pkg/cache/ram.MustNew`;**禁止**自研 `map+mutex+TTL`,除非有充分理由并文档说明。 -3. **选型 L2**:小对象 `SetJSON`;配置类多条目用 Redis Hash(`HSetJSON`)。 -4. **定义 Redis key**:小写蛇形,带业务前缀(`upload:meta:{id}`);统一 `db.PrefixedKey`。 -5. **实现 Invalidate + pub/sub**:凡多实例部署可读的 RAM 缓存**必须**有失效广播。 -6. **挂载变更钩子**:在所有 DB 变更入口调用 Invalidate(含 Worker/定时任务,不只 HTTP Handler)。 -7. **测试**: - - RAM hit / Redis hit / DB fallback - - Invalidate 清 L1+L2 - - pub/sub 触发他机 RAM 失效(可用 miniredis Publish 模拟) - - `Reset*RAMCacheForTest` 仅清本机 RAM -8. 运行 `go test` 相关包 + `make code-check`。 - -## 变更钩子清单(上传元数据示例) - -| 入口 | 动作 | -| :--- | :--- | -| `ingest.persistUploadRecord` 创建成功 | `SetUploadMetaCache` | -| `ingest.Remove` / `RemoveOwned` | `InvalidateUploadMetaCache` | -| `task/cleanup.go` 软删除 pending 文件 | `InvalidateUploadMetaCache` | -| 直接 `repository.SoftDeleteUpload` | **禁止** — 必须走 `upload.Remove` | - -## 禁止写法 - -```go -// ❌ 自研 L1,与 pkg/cache/ram 重复 -var mu sync.RWMutex -var items = map[uint64]entry{} - -// ❌ 只清本机 RAM + Redis,无 pub/sub(多节点 RAM 脏读) -func Invalidate(ctx context.Context, id uint64) { - localDelete(id) - redis.Del(...) -} - -// ❌ DB 变更后忘记 Worker 路径 -// cleanup 任务删了 upload 行,但未 InvalidateUploadMetaCache - -// ❌ 在 Handler 里直接查 DB,绕过已有 GetXxxCached - -// ❌ Redis key 不用 PrefixedKey(多环境共 Redis 时冲突) - -// ❌ 在 init() 里启动 pub/sub 监听 — 与 bootstrap 规范冲突;用 sync.Once 懒启动 -``` - -## 特殊场景 - -### 敏感字段(ClientSecret) - -模型 `json:"-"` 时,Redis DTO 用独立 `*RedisRecord` struct 显式序列化字段(见 `auth_source_cache.go`)。 - -### 批量读配置 - -批量接口必须与单 key 一致走 Redis(`ListSystemConfigsByKeys` 在 RAM miss 后逐 key `HGetJSON`,再 DB `IN`)。 - -### 仅进程内、短 TTL、配置衍生 - -可用进程内快照 + 订阅上游 pub/sub(`access_cache.go`、`cap/runtime_settings.go`),不必强行 Redis L2。 - -### OAuth 用户/Token - -沿用 `oauth/cache.go`;新增逻辑调用 `SetCachedUser` / `SetCachedToken` 预热,变更调用 `InvalidateCachedUser` / `InvalidateCachedToken`。 - -## 验证清单 +## 4. 质量与测试验证 ```bash -go test ./internal/repository/... ./internal/apps/upload/cache/... +make format make code-check -``` - -- [ ] L1 使用 `pkg/cache/ram`(或已文档化的例外) -- [ ] 读路径:RAM → Redis → DB -- [ ] 写穿 populate 在 DB load / 创建成功后 -- [ ] Invalidate:RAM + Redis + Publish -- [ ] `ensureListener` + pub/sub 清他机 RAM -- [ ] 所有变更入口(含 Worker)已挂钩 -- [ ] 测试含 Invalidate 与 pub/sub - -## 相关文件 - -- L1 引擎:`pkg/cache/ram/cache.go` -- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`) -- 金标准:`internal/repository/system_config_cache.go` -- 上传元数据:`internal/apps/upload/cache/meta_cache.go` -- Auth Source:`internal/repository/auth_source_cache.go` -- 性能文档:`docs/PERFORMANCE.md` \ No newline at end of file +go test ./plugins/... +``` \ No newline at end of file diff --git a/.agents/skills/database-migration/SKILL.md b/.agents/skills/database-migration/SKILL.md index 5a528b7f..825f87a1 100644 --- a/.agents/skills/database-migration/SKILL.md +++ b/.agents/skills/database-migration/SKILL.md @@ -1,138 +1,110 @@ --- name: "database-migration" -description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。" +description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、插件自包含 Goose SQL 迁移、embed.FS 注册、PG/SQLite 双方言支持或 ClickHouse 分析库 DDL 时必须使用。" --- -# Wavelet 数据库升级操作指南 +# 数据库独立迁移与表结构开发规范 (Cordis 插件化架构) -Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。 +本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行数据库表结构设计、Goose SQL 迁移与插件嵌入式注册的唯一指导规范。 -## 基本规则 +--- -- SQL 迁移文件放在: - - `internal/infra/persistence/migrator/goose/postgres/` - - `internal/infra/persistence/migrator/goose/sqlite/` -- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。 -- 迁移文件使用 goose SQL 标记: +## 1. 核心架构:插件自包含迁移 (Self-Contained Migrations) + +在 Cordis 架构中,**彻底告别集中式单体大迁移目录**。 +每个插件在自身包内维护专属的 `migrations/` 目录,通过 Go 语言内置 `//go:embed` 打包为嵌入式文件系统,并在 `Apply(ctx *core.Context)` 时通过微内核扩展点 `ctx.Migrations().Register(...)` 自主注入。 + +``` +plugins/domain/order/ +├── plugin.go +├── models.go +└── migrations/ + ├── 20260827000001_create_orders_table.sql + └── 20260827000002_add_order_discount_column.sql +``` + +--- + +## 2. 插件迁移代码集成标准 + +### 步骤 1:在插件内嵌入并注册迁移 + +```go +package order + +import ( + "embed" + "github.com/Rain-kl/Wavelet/core" +) + +//go:embed migrations/*.sql +var orderMigrations embed.FS + +func (p *Plugin) Apply(ctx *core.Context) error { + // 注册本插件的专属迁移(系统启动时由微内核统一收集并按版本执行) + ctx.Migrations().Register("order", orderMigrations) + return nil +} +``` + +### 步骤 2:编写 Goose SQL 脚本 (`migrations/YYYYMMDDNNNN_name.sql`) ```sql -- +goose Up -... +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_orders ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + amount BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id); +-- +goose StatementEnd -- +goose Down -... +-- +goose StatementBegin +DROP TABLE IF EXISTS w_orders; +-- +goose StatementEnd ``` -- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。 -- 编辑表结构(DDL)和插入表数据(DML/Seed)不要放在同一个 SQL 文件里,必须分成两个独立的 SQL 文件完成(例如,先通过一个文件修改表结构,再通过下一个递增版本号的文件插入/初始化数据)。 -- 插入定时任务(schedules 表数据)时绝对不能指定 `id`,必须依靠数据库自增(Identity 或 AUTOINCREMENT)自动分配,防止与用户手动或后续插入的定时任务产生 ID 冲突。 -- 不要添加物理外键;关系字段使用显式索引。 -- 数据库默认值应匹配 Go model 零值或业务兜底值。 -- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。 +--- -## 新增迁移流程 +## 3. 核心设计与防线原则 (Guardrails) -1. 先确认涉及的 Go model、读写路径和前端/接口消费方。 -2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如: +1. **表单一所有者原则 (Single Owner Principle)**: + - 每张数据表归属且仅归属于一个所有者插件(如 `w_orders` 归 `order` 插件)。 + - **严禁**插件 B 跨包编写 SQL 直接读写插件 A 拥有的表;必须通过插件 A 暴露的 `contracts` 接口或事件总线进行交互。 +2. **表名前缀规范**: + - 所有表名必须带有前缀(如 `w_orders`、`w_auth_users`),杜绝跨插件表名冲突。 +3. **DDL 与 DML 分离**: + - 表结构变更(DDL)与初始数据插入(DML/Seed)必须分成两个独立的递增版本 SQL 文件。 +4. **禁止物理外键**: + - 关系字段统一显式建立单列或联合索引,禁止在数据库中创建物理外键约束。 +5. **双方言兼容性(PostgreSQL & SQLite)**: + - 自增主键:PG 用 `BIGSERIAL`,SQLite 用 `INTEGER PRIMARY KEY AUTOINCREMENT`。 + - 时间类型:PG 用 `TIMESTAMPTZ`,SQLite 用 `DATETIME`。 + - JSON 类型:PG 用 `JSONB`,SQLite 用 `JSON` 或 `TEXT`。 +6. **定时调度插入规范**: + - 若迁移中包含初始定时任务插入(`schedules` 表),绝对不能硬编码 `id`,必须依靠数据库自增分配。 -```text -202606090002_add_example_column.sql -``` +--- -3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。 -4. 写 `Up`: - - 表结构变更使用 SQL DDL。 - - 初始化/seed 数据使用 SQL `INSERT`。 - - 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。 -5. 写 `Down`: - - 能安全回滚的结构变更写反向 DDL。 - - seed 数据按 key/name 等稳定标识删除。 -6. 如果变更 API handler,运行 `make swagger`。 -7. 至少运行: +## 4. ClickHouse 分析库迁移规则 (辅助 OLAP) + +ClickHouse 作为辅助 OLAP 分析存储,采用独立迁移通道: +- 迁移文件位于专属目录(仅单方言 DDL,不创建 SQLite 镜像)。 +- 日志/分析用途表必须同时在关系型主库建回落表并接入 `logstore`。 +- 分析表高频写入统一接入 `batchwriter` 进行异步批量刷盘。 + +--- + +## 5. 质量与验证门禁 ```bash -go test ./internal/infra/persistence/migrator -go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config +make format make code-check +go test ./plugins/... ``` - -## 方言注意事项 - -- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。 -- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。 -- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。 -- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。 - -## 修改默认系统配置 - -- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。 -- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。 -- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。 - -## 验证重点 - -- goose 能在空库上完整执行。 -- `system_configs`、默认 `admin`、内置模板能按预期初始化。 -- 新增表/列与 Go model 的列名、类型和默认值兼容。 -- 前端或接口消费的公共配置值仍按字符串解析。 - -## ClickHouse 分析库(辅助 OLAP) - -ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线: - -- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。 -- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。 - -**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。 - -### 目录与职责 - -| 路径 | 职责 | -| :--- | :--- | -| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) | -| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 | -| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) | -| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`chDB` GORM 查询) | - -### 迁移入口与版本表 - -- 入口:`migrator.MigrateClickHouse()`,在 `cmd/root.go` 的 `PreRun` 中于 `migrator.Migrate()` 之后调用。 -- 仅当 `clickhouse.enabled: true` 时执行;禁用时直接跳过(见 `TestMigrateClickHouseSkipsWhenDisabled`)。 -- 版本表:`goose_clickhouse_version`,与主库 `goose_db_version` **分离**,互不影响。 -- 方言:仅 ClickHouse,**无** SQLite 镜像目录。 - -### ClickHouse 迁移规则 - -1. **DDL 只写 goose SQL**:`CREATE TABLE IF NOT EXISTS ...`,禁止 GORM `AutoMigrate`、禁止在 repository 或 handler 中建表。 -2. **无事务**:ClickHouse 不支持 goose 事务包装;每个 `Up`/`Down` 语句独立提交。 -3. **幂等 Up**:表用 `IF NOT EXISTS`;`Down` 用 `DROP TABLE IF EXISTS`。 -4. **Down 谨慎**:MergeTree 等引擎上 `DROP TABLE` 会立即删除数据,生产环境通常只前滚;仅在开发/测试需要回滚时编写 `Down`。 -5. **DDL 与 DML 分离**:与主库相同,表结构变更与数据初始化分文件、分版本号;分析表通常无 seed,批量写入由 repository 在运行时完成。 -6. **引擎与排序键**:在 SQL 中显式声明 `ENGINE`、`PARTITION BY`、`ORDER BY` 等,与查询模式对齐(例如按 `created_at` 分区)。 -7. **禁止重复 DDL**:不要在 `support-files/`、`apps` 初始化逻辑或 `repository/analytics` 中复制建表语句。 - -### 新增分析表工作流 - -按以下顺序落地,避免列名或类型漂移: - -1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。 -2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。 -3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。 -4. **Apps**:在 `internal/apps//` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。 - -### ClickHouse 验证 - -至少运行: - -```bash -go test ./internal/infra/persistence/migrator -go test ./internal/repository/analytics -make code-check -``` - -验证重点: - -- goose 能在空 ClickHouse 实例上完整执行 `Up`。 -- `internal/model/analytics` 列名、类型与 goose SQL 一致。 -- repository 读写路径不依赖 handler 内联 SQL。 -- `clickhouse.enabled: false` 时启动不报错、不执行迁移。 diff --git a/.agents/skills/file-upload/SKILL.md b/.agents/skills/file-upload/SKILL.md index a2d5aae3..53c1818b 100644 --- a/.agents/skills/file-upload/SKILL.md +++ b/.agents/skills/file-upload/SKILL.md @@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当业务需要上传文件、读取已上 # 存储引擎与文件上传开发规范 -本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。 +本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。 --- @@ -217,7 +217,7 @@ upload.RebuildUploadStats(ctx) // 从 w_uploads 全量重建统计 - **禁止**在源码目录硬编码 `uploads/test` 路径;本地文件测试用 `t.TempDir()` 或 mock backend - 覆盖:三种 Policy、Remove 后统计归零、ReadOnly 拒绝写入 -参考:[internal/apps/upload/ingest/ingest_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/upload/ingest/ingest_test.go) +参考:`internal/apps/upload/ingest/ingest_test.go` ### Handler 回归 diff --git a/.agents/skills/new-api/SKILL.md b/.agents/skills/new-api/SKILL.md index 0df48dd0..f2e9faaf 100644 --- a/.agents/skills/new-api/SKILL.md +++ b/.agents/skills/new-api/SKILL.md @@ -1,219 +1,164 @@ --- name: "new-api" -description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、路由注册时必须使用。本技能指导 apps 业务包划分、路由注册、Handler/logics 分层、Swagger 与质量门禁;纠正把一切塞进 custom.go / apps/custom 或产品伞包的错误写法。" +description: "Wavelet 项目专用:当新增或修改业务 API、Handler、服务层逻辑、插件路由注册时必须使用。本技能指导基于 Cordis 插件的 API 架构、ctx.Router() 声明式路由注册、Handler/Service 分层、Swagger 与质量门禁。" --- -# 新增业务 API 开发与路由注册规范 +# 新增业务 API 开发与路由注册规范 (Cordis 插件化架构) -本技能是 Wavelet 接口开发与路由注册的唯一指导规范。在开发任何新接口前,请按本指南做架构决策与路由注册。 +本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 HTTP API 接口开发与路由注册的唯一指导规范。 --- -## 先搞清:脚手架 vs 产品化 +## 1. 核心架构哲学:插件自包含 (Self-Contained Plugins) -Wavelet 是**通用全栈脚手架**。仓库里的 `custom` 相关代码是**示例/占位**,不是产品业务的标准落点。 +在 Cordis 架构中,**业务 API 不再集中在旧的 `internal/router/` 或 `internal/apps/` 目录**。 +所有业务能力均封装为**高内聚、扁平自包含的插件 (Plugin)**。每个插件自主管理自身的路由声明、中间件挂载、服务逻辑、数据模型与迁移脚本。 -| 层级 | 含义 | 典型包 | -| :--- | :--- | :--- | -| **平台能力** | 脚手架自带、与具体产品无关 | `oauth`、`user`、`admin/*`、`upload`、`cap`、`config`、`health`、`risk_control` | -| **产品业务** | 基于脚手架做具体产品时新增的域 | 直接落在 `internal/apps//`,与平台包**平级** | - -**一旦用脚手架开发具体产品,整个仓库就是该产品**——例如要做「消息平台」,业务模块应是 `apps/channel`、`apps/conversation`、`apps/delivery` 等,而不是先建 `apps/message` 伞包再往里塞子模块。 - ---- - -## 反模式(AI 最常踩的坑) - -### 1. 把所有业务路由塞进 `custom.go` / 路径前缀 `/custom` - -仓库中的: - -- `internal/router/v1/custom.go` -- `internal/router/root/custom.go` -- `internal/apps/custom/` - -是**演示如何挂一条示例接口**(`GET /api/v1/custom/hello`),**不是**「所有自定义业务必须写在这里」的规定。 - -| 错误 | 正确 | -| :--- | :--- | -| 新功能一律改 `v1/custom.go`,路径全是 `/api/v1/custom/...` | 按域新建 `apps//`,路由用语义化路径(如 `/api/v1/channels`),在 `router/v1/` 下用**独立注册文件**挂载 | -| 把 `custom` 包当成业务垃圾桶 | 保留或删除示例均可;真正业务用独立包名 | - -### 2. 产品伞包 + 深层子包 - -| 错误 | 正确 | -| :--- | :--- | -| `apps/message/channel`、`apps/message/inbox`、`apps/message/delivery`(先套一层产品名) | `apps/channel`、`apps/inbox`、`apps/delivery`(域模块与 `oauth`/`user` 平级) | -| `apps/myapp/...` 再嵌套所有业务 | 仓库即产品,**不要**再包一层产品根 | - -**判定**:模块名应对齐**业务能力/限界上下文**(channel、order、invoice),而不是对齐产品营销名(message-platform、myapp)。 - -### 3. 其它仍须遵守的防线 - -- 不要在 `internal/router/router.go` 里直接挂业务 Handler(只做高层委派)。 -- 不要破坏平台模块既有语义去硬塞无关业务(例如把消息逻辑塞进 `apps/user`)。 -- 错误响应使用 `response.Abort*`,禁止 `c.JSON(..., response.Err(...))`(见 `AGENTS.md`)。 - ---- - -## 路由注册模型 - -### 谁可以改 - -| 文件 | 角色 | 产品化时 | -| :--- | :--- | :--- | -| `internal/router/router.go` | 引擎、中间件、委派入口 | 一般不改;特殊全局中间件才动 | -| `internal/router/v1/v1.go` | V1 分发:调用各 `Register*Routes` | **允许**:增加对新业务注册函数的一行调用 | -| `internal/router/v1/user.go` / `admin.go` | 平台用户端 / 管理端路由 | **优先不改**;仅当扩展平台能力(OAuth、上传、用户资料)时修改 | -| `internal/router/v1/.go`(新建) | 产品业务路由注册 | **推荐落点** | -| `internal/router/v1/custom.go` | **示例** | 可删可留;**不要**把真实业务堆在这里 | -| `internal/router/root/default.go` / `frontend.go` | 文件服务、health、前端静态 | 平台级,勿塞产品 API | -| `internal/router/root/custom.go` | 根路径**示例**占位 | 仅当确需根路径回调/短链时,用**语义路径**注册,或新建 `root/.go` 并由 `root.go` 调用 | - -### 路径归属(产品 API 用语义路径) - -| 目标路径特征 | 注册位置 | 说明 | -| :--- | :--- | :--- | -| `/api/v1//...`(如 `/api/v1/channels`) | `v1/.go` 的 `RegisterRoutes`,在 `v1.go` 调用 | **产品业务默认做法** | -| `/api/v1/admin//...` | 管理端:可在 `admin.go` 增加小组,或 `v1/admin_.go` 再由 `RegisterAdminRoutes`/ `v1.go` 组装 | 需 `admin.LoginAdminRequired()` | -| `/api/v1/user/...`、`/oauth/...`、`/upload/...` 等 | `user.go` 等平台文件 | 平台能力,勿把无关产品塞进来 | -| 根路径特殊接口(Webhook、短链) | `root` 下独立注册函数 | **不要**默认塞进 `custom` 前缀 | -| `GET /f/:id`、`/api/health`、`robots.txt` | `root/default.go` | 平台,勿改用途 | - -`custom.go` 里现有的 `/api/v1/custom/...` **仅作脚手架演示**,不代表业务必须挂在 `/custom` 下。 - ---- - -## 推荐目录结构(产品业务) - -以「频道 / channel」域为例(消息平台中的一个限界上下文): +### 插件目录推荐结构 (`plugins/domain//` 或下游 `custom_plugins//`) ```text -internal/ -├── router/ -│ └── v1/ -│ ├── v1.go # [修改] 调用 RegisterChannelRoutes -│ └── channel.go # [新建] 只负责挂载 channel 路由 -└── apps/ - └── channel/ # 与 oauth、user、upload 平级 - ├── routers.go # HTTP Handlers(绑定、鉴权上下文、响应) - ├── logics.go # 纯业务:context.Context,无 gin - ├── errs.go # 模块错误文案常量(可选) - └── ... # 需要时再加 service.go、tasks.go 等 +plugins/domain/order/ +├── plugin.go # 插件入口:实现 core.Plugin,通过 ctx.Router() 挂载路由 +├── handlers.go # HTTP 控制器:参数校验、上下文提取、调用 Service、信封响应 +├── service.go # 业务服务层:纯 Go 逻辑,仅依赖 context.Context +├── models.go # GORM 数据实体定义(自带表前缀) +├── errs.go # 模块内错误常量定义(camelCase 字符串) +└── migrations/ # 专属嵌入式 Goose SQL 迁移脚本 + └── 20260827000001_create_orders_table.sql ``` -**不要**建成: - -```text -internal/apps/message/ # ❌ 产品伞包 - channel/ - inbox/ -internal/apps/custom/ # ❌ 示例包当业务垃圾桶 - channel_handler.go -``` - -模块内若复杂度高,可在**该域包内**分子目录(如 `apps/channel/handler`),但仍是一个域包,不是「产品名/子域」两层品牌结构。 - --- -## 路由注册示例 +## 2. 插件契约与路由注册流程 -### `internal/router/v1/channel.go`(产品业务) +### 步骤 1:定义插件结构并实现 `core.Plugin` + +插件必须实现 `core.Plugin` 接口: ```go -package v1 +package order import ( - "github.com/Rain-kl/Wavelet/internal/apps/channel" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/gin-gonic/gin" + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" ) -// RegisterChannelRoutes mounts channel domain APIs under /api/v1. -func RegisterChannelRoutes(apiV1Router *gin.RouterGroup) { - r := apiV1Router.Group("/channels") - r.Use(oauth.LoginRequired()) - { - r.GET("", channel.ListChannels) - r.POST("", channel.CreateChannel) - r.GET("/:id", channel.GetChannel) - } +type Plugin struct { + svc *OrderService +} + +func (p *Plugin) Name() string { + return "domain.order" +} + +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 初始化业务 Service + p.svc = NewOrderService(ctx) + + // 2. 如果需要对外暴露服务,注入 IoC 容器供其他插件消费 + // core.Provide[contracts.OrderService](ctx, p.svc) + + // 3. 注册 HTTP 路由与中间件 + p.registerRoutes(ctx) + + return nil } ``` -### `internal/router/v1/v1.go`(增加一行委派) +### 步骤 2:通过 `ctx.Router()` 挂载路由组与中间件 + +通过微内核扩展点 `ctx.Router()` 声明式挂载语义化路由与鉴权中间件: ```go -func RegisterV1Routes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) { - RegisterUserRoutes(apiV1Router, apiGroup) - RegisterAdminRoutes(apiV1Router) - RegisterChannelRoutes(apiV1Router) // 产品域 - RegisterCustomRoutes(apiV1Router) // 可选:仅保留脚手架示例 +func (p *Plugin) registerRoutes(ctx *core.Context) { + // 获取认证服务提供的标准中间件(若需要) + authSvc, _ := core.Inject[contracts.AuthService](ctx) + + // 创建带语义化版本前缀的路由组 + group := ctx.Router().Group("/api/v1/orders") + if authSvc != nil { + group.Use(authSvc.RequireAuthMiddleware()) + } + + // 绑定 Handler + group.GET("", p.handleListOrders) + group.POST("", p.handleCreateOrder) + group.GET("/:id", p.handleGetOrderDetail) + group.PUT("/:id/cancel", p.handleCancelOrder) } ``` -### 根路径 Webhook(确有需要时) +--- -在 `root` 用语义路径,例如 `POST /webhooks/stripe`,注册函数可放在 `root/webhooks.go` 或扩展现有 root 注册;**不要**为了「只能写 custom」而使用无意义的 `/custom` 前缀。 +## 3. Handler 与 Service 职责划分 + +### Handler 规范 (`handlers.go`) +Handler 负责协议接入层: +1. 参数绑定:使用 `c.ShouldBindJSON` 或 `c.ShouldBindQuery`。 +2. 提取当前登录用户信息(如 `oauth.GetCurrentUser(c)`)。 +3. 调用底层纯函数或 Service 逻辑。 +4. 错误处理:统一使用 `response.Abort*` 系列函数中断请求,禁止直接 `c.JSON(status, response.Err(...))`。 +5. 成功响应:使用 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 +6. 编写完整的 Swagger / OpenAPI 注释。 + +```go +// @Summary 创建订单 +// @Description 创建一笔新的业务订单 +// @Tags Order +// @Accept json +// @Produce json +// @Param request body CreateOrderRequest true "创建订单参数" +// @Success 200 {object} response.Envelope{data=OrderDTO} "创建成功" +// @Failure 400 {object} response.Envelope "参数绑定失败" +// @Failure 401 {object} response.Envelope "未授权" +// @Router /api/v1/orders [post] +func (p *Plugin) handleCreateOrder(c *gin.Context) { + var req CreateOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errs.ErrBindParamsFailed) + return + } + + user, ok := oauth.GetCurrentUser(c) + if !ok { + response.AbortUnauthorized(c, errs.ErrUnauthorized) + return + } + + order, err := p.svc.CreateOrder(c.Request.Context(), user.ID, req) + if err != nil { + // 底层已记录日志,此处根据业务错误码响应 + response.AbortInternal(c, errs.ErrCreateOrderFailed) + return + } + + c.JSON(http.StatusOK, response.OK(order)) +} +``` + +### Service / Logics 规范 (`service.go`) +1. 纯 Go 逻辑,第一参数为 `ctx context.Context`,返回 `(result, error)`。 +2. **严禁依赖 `*gin.Context`** 或调用 `c.JSON`/`Abort*`。 +3. 数据库操作通过 `ctx.DB()` 或受 Trace 保护的 DB 实例完成。 +4. 缓存操作通过 `ctx.Cache()` 完成。 --- -## 核心开发步骤 +## 4. 跨插件依赖与防线 (Guardrails) -### 步骤 1:划定域包名 - -- 用**业务能力**命名:`channel`、`order`、`invoice`。 -- 与现有 `apps/` 下平台包平级;禁止产品伞包。 - -### 步骤 2:库表与 model - -若涉及新表/字段:按 [database-migration](../database-migration/SKILL.md) 在 goose 迁移与 `internal/model/` 中定义。 - -### 步骤 3:`logics.go` / `service.go` - -放在 `internal/apps//`: - -- **优先**纯函数 `logics.go`:`context.Context` 入参,无 `*gin.Context`。 -- 有状态依赖时用 `service.go` 构造注入。 -- 跨模块副作用(推送、任务)经 `internal/listener` + `bootstrap`,禁止业务直接 import push(见 `push-notification`)。 - -### 步骤 4:Handler(`routers.go`) - -- `ShouldBindJSON` / `ShouldBindQuery`。 -- 成功:`c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 -- 失败:`response.AbortBadRequest` / `AbortUnauthorized` / `AbortNotFound` / `AbortInternal` 等,**禁止** `response.Err` 直接 `c.JSON`。 -- 完整 Swagger 注释;`@Router` 使用真实语义路径。 - -参考:`references/handler_example.go`、`logics_example.go`、`service_example.go`(示例域名,非强制包名 `custom`)。 - -### 步骤 5:注册路由 - -新建 `internal/router/v1/.go`,在 `v1.go` 调用;管理端按需挂到 admin 组。 +1. **严禁跨插件 import 内部实现**:插件之间不得直接 import 对方包中的具体结构体或私有逻辑。 +2. **面向契约编程**:跨插件调用一律在 `core/contracts/` 中定义 Interface,通过 `core.Provide` 注册、`core.Inject` 或 `ctx.Using` 延迟解析。 +3. **事件驱动通知**:涉及跨域状态联动(如用户注册成功、订单支付完成),统一使用 `ctx.Events().Emit(...)` 广播领域事件,由订阅方自愿监听,消除循环依赖。 --- -## 与平台路由的边界 +## 5. 质量验证门禁 -- **扩展平台能力**(用户资料字段、上传策略、OAuth 源):改对应平台 `apps/*` 与 `user.go`/`admin.go`。 -- **新产品功能**:新建 `apps/` + `router/v1/.go`,**不要**塞进 `custom` 或某个无关平台包。 -- 管理端产品配置页 API:路径宜为 `/api/v1/admin//...`,中间件与现有 admin 组一致。 - ---- - -## 质量验证门禁 - -1. `make license`(新 Go 文件许可头) -2. `make swagger`(Handler/Swagger 有变时) -3. `make format` 与 `make code-check` -4. `go test` 覆盖相关包 - ---- - -## 自检清单 - -- [ ] 未把真实业务堆进 `apps/custom` 或 `v1/custom.go` -- [ ] 未创建 `apps/<产品名>/` 伞包再塞子域 -- [ ] 业务包与 `oauth`/`user`/`upload` 平级,路径语义化(非强制 `/custom`) -- [ ] 路由在 `router/v1/.go`(或 admin 对应处)注册,并由 `v1.go` 委派 -- [ ] Handler 用 `response.Abort*` / `response.OK`,logics 不依赖 gin -- [ ] 需要时已跑 swagger / code-check +在完成 API 开发后,必须依次运行以下命令: +```bash +make license # 确保新文件具有开源许可头 +make swagger # 重新生成 Swagger 文档 +make format # 代码自动格式化 +make code-check # 静态代码质量检查 (golangci-lint) +go test ./plugins/... # 运行插件单元测试 +``` diff --git a/.agents/skills/new-api/references/handler_example.go b/.agents/skills/new-api/references/handler_example.go index f62d7f6b..42700811 100644 --- a/.agents/skills/new-api/references/handler_example.go +++ b/.agents/skills/new-api/references/handler_example.go @@ -21,9 +21,9 @@ type createChannelResponse struct { Name string `json:"name"` } -// CreateChannel 示例:产品域 Handler(应放在 internal/apps/channel/routers.go) +// CreateChannel 示例:插件内 HTTP Handler(位于 plugins/domain/channel/handlers.go) // @Summary 创建频道 -// @Description 示例:语义路径下的业务接口,而非 /api/v1/custom/... +// @Description 示例:语义路径下的业务接口 // @Tags channel // @Accept json // @Produce json @@ -39,7 +39,7 @@ func CreateChannel(c *gin.Context) { return } - // 通常结合 oauth.LoginRequired();此处仅演示从上下文取用户 + // 从请求上下文中提取认证用户 userID := int64(9527) result, err := CreateChannelLogic(c.Request.Context(), userID, req.Name) diff --git a/.agents/skills/new-api/references/logics_example.go b/.agents/skills/new-api/references/logics_example.go index 40e92984..c23959c9 100644 --- a/.agents/skills/new-api/references/logics_example.go +++ b/.agents/skills/new-api/references/logics_example.go @@ -12,13 +12,13 @@ import ( "go.uber.org/zap" ) -// channelCreated 示例 logics 返回值(真实代码可用 model 或专用 DTO) +// channelCreated 示例业务返回值(真实代码可用 model 或专用 DTO) type channelCreated struct { ID int64 Name string } -// CreateChannelLogic 示例:模块内闭环业务(放在 apps/channel/logics.go) +// CreateChannelLogic 示例:插件内业务纯函数(位于 plugins/domain/channel/logics.go) // 接收 context.Context,不依赖 gin.Context,便于单测与 Worker 复用。 func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channelCreated, error) { if name == "" { @@ -30,7 +30,6 @@ func CreateChannelLogic(ctx context.Context, userID int64, name string) (*channe zap.String("name", name), ) - // 轻量级本地逻辑;复杂持久化可进 model/repository return &channelCreated{ ID: 1, Name: fmt.Sprintf("%s (by %d)", name, userID), diff --git a/.agents/skills/new-api/references/service_example.go b/.agents/skills/new-api/references/service_example.go index 81e6b2a3..70fe4d47 100644 --- a/.agents/skills/new-api/references/service_example.go +++ b/.agents/skills/new-api/references/service_example.go @@ -12,10 +12,10 @@ import ( "go.uber.org/zap" ) -// ChannelService 示例有状态 Service(放在 internal/apps/channel/service.go) -// 需要注入 DB/客户端时使用;简单逻辑优先 logics.go 纯函数。 +// ChannelService 示例有状态 Service(位于 plugins/domain/channel/service.go) +// 需要注入 DB/缓存时使用;简单逻辑优先 logics.go 纯函数。 type ChannelService struct { - // 例如:repo ChannelRepository + // 例如:db *gorm.DB } // NewChannelService 构造函数 diff --git a/.agents/skills/new-async-task/SKILL.md b/.agents/skills/new-async-task/SKILL.md index c59e5268..d6672c9e 100644 --- a/.agents/skills/new-async-task/SKILL.md +++ b/.agents/skills/new-async-task/SKILL.md @@ -1,121 +1,157 @@ --- name: "new-async-task" -description: "Wavelet 项目专用:新增或修改 Asynq 异步任务、后台任务、定时任务、任务元数据、TaskHandler、TaskParam、PayloadValidator、AppendLog、任务重试、任务执行记录或 Admin 任务 API 时必须使用。" +description: "Wavelet 项目专用:新增或修改基于 Cordis 插件的 Asynq 异步任务、后台 Worker 消费处理器、Cron 定时调度任务与任务执行追踪时必须使用。" --- -# 异步任务开发 +# 异步任务与定时调度开发规范 (Cordis 插件化架构) -开始前阅读根目录 `AGENTS.md`。只修改任务相关链路,遵守项目路由、日志、数据库迁移和质量门禁要求。 +本技能是 Wavelet 在 Cordis 微内核与插件化架构下,进行 Asynq 异步后台任务与 Cron 定时调度开发的唯一指导规范。 -## 开始前 +--- -按任务范围检查当前实现: +## 1. 核心架构:插件内自包含任务声明 -- `internal/infra/task/handler.go`:`TaskHandler`、`TaskResult`、`PayloadValidator` -- `internal/infra/task/meta.go`:`TaskMeta`、`TaskParam` -- `internal/infra/task/executor.go`:下发、执行、日志、重试、`OnTaskCompleted` 订阅 -- `internal/infra/task/handlers/register.go`:Handler 和元数据注册(由 bootstrap 调用) -- `internal/platform/bootstrap/bootstrap.go`:任务注册与进程级装配入口 -- `internal/infra/task/worker/worker.go`:Worker 路由和队列 -- `internal/infra/task/scheduler/scheduler.go`:定时调度 -- `internal/apps/admin/task/routers.go`:Admin 任务 API -- `internal/model/task_execution.go`:执行记录和日志持久化 +在 Cordis 架构中,后台 Worker 消费与定时调度**不再集中在中心化的注册表**,而是由各个业务插件在自身的 `Apply` 方法中通过微内核扩展点直接声明。 -需要模板时阅读 [references/CODE-EXAMPLES.md](references/CODE-EXAMPLES.md)。 +### 扩展点矩阵 -## 实现要求 +| 扩展点方法 | 说明 | 适用场景 | +| :--- | :--- | :--- | +| `ctx.Task().Register(pattern, handler, opts...)` | 注册 Asynq 任务类型与消费处理器 | 异步耗时计算、队列任务、通知外发 | +| `ctx.Schedule().RegisterCron(spec, taskType, payload)` | 注册 Cron 表达式定时调度任务 | 周期统计、定时清理、健康检查 | -### 任务定义 +--- -- 在 `internal/apps//tasks.go` 定义任务类型、Admin 任务类型和 `TaskMeta`。 -- Asynq 任务类型使用 `:` 格式。 -- 完整设置 `Type`、`AsynqTask`、`Name`、`Description`、`MaxRetry`、`Queue`、`Retryable`。 -- 有参数任务必须定义 payload struct。 -- `TaskParam.Name` 必须与 payload JSON tag 一致。 -- `TaskParam` 只描述前端表单,不代替服务端校验。 +## 2. 异步任务开发全流程 -### Handler +### 步骤 1:定义任务 Payload 结构与类型常量 -- Handler 必须实现 `task.TaskHandler`。 -- 有参数任务必须实现 `task.PayloadValidator`,负责校验和标准化 Admin 下发参数。 -- `Execute` 必须再次解析 payload;不要假设入口一定经过 Admin 校验。 -- 成功返回 `&task.TaskResult{Message: ..., Detail: ...}`。 -- 失败返回 error,由任务框架处理状态和重试。 -- 不要吞掉关键错误。 -- 复杂 SQL 放到 `internal/model/` 或模块内的业务服务层(如 `internal/apps//service.go` 或 `logics.go`)。 +在插件内(如 `plugins/domain/order/tasks.go`): -### 注册 +```go +package order -- 在 `internal/infra/task/handlers/register.go` 同时注册 Handler 和 `TaskMeta`。 -- 不要在其他位置单独注册任务。 -- **禁止**在业务包 `routers.go` 或 `init()` 中调用 `task.RegisterHandler`;统一由 `bootstrap.RegisterTasks()` → `taskhandlers.Register()` 在进程启动时装配。 -- 任务完成钩子(如 push 通知)通过 `task.OnTaskCompleted` 注册,在 `bootstrap.RegisterTaskListeners()` 中装配(Worker/`all` 进程)。 +import ( + "context" + "encoding/json" + "time" -### 进程装配分工 + "github.com/hibiken/asynq" +) -| 进程 | 注册入口 | -| :--- | :--- | -| `api` | `cmd/api.go` → `bootstrap.RegisterAPI()`(含 `RegisterTasks`) | -| `worker` | `worker.StartWorker()` → `bootstrap.RegisterWorker()`(含 `RegisterTasks` + `RegisterTaskListeners`) | -| `scheduler` | `scheduler.StartScheduler()` → `bootstrap.RegisterScheduler()` | -| `all` | `cmd/all.go` → `bootstrap.RegisterAll()` | +const ( + TaskTypeOrderTimeoutCancel = "order:timeout_cancel" +) -所有 `Register*` 使用 `sync.Once`,重复调用安全。 +// OrderTimeoutPayload 定义任务入参 +type OrderTimeoutPayload struct { + OrderID string `json:"order_id"` + Reason string `json:"reason"` + CreatedAt int64 `json:"created_at"` +} +``` -### 测试 +### 步骤 2:实现任务执行处理器 (Handler) -- 依赖已注册任务类型或 Handler 的测试(如 `internal/apps/admin/task/routers_test.go`),必须在 setup 中显式调用 `bootstrap.RegisterTasks()`。 -- 不得依赖 `init()` 副作用或 import 链触发注册。 +Handler 必须接受 `ctx context.Context, t *asynq.Task`,返回 `error`: -## 日志要求 +```go +func (p *Plugin) handleOrderTimeoutCancel(ctx context.Context, t *asynq.Task) error { + var payload OrderTimeoutPayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return err // 反序列化失败,直接中断 + } -- 在 `TaskHandler.Execute` 中使用 `task.AppendLog(ctx, format, args...)`。 -- 记录任务开始、参数摘要、批次进度、关键状态、可继续错误和完成摘要。 -- 批量处理按批次记录;禁止为大循环中的每条数据写日志。 -- 不要直接修改任务日志的 Redis key 或 `w_task_executions.log`。 + // 记录任务日志 + // task.AppendLog(ctx, "开始处理订单超时关单: order_id=%s", payload.OrderID) -日志框架约束: + // 执行业务逻辑 + if err := p.svc.CancelTimeoutOrder(ctx, payload.OrderID, payload.Reason); err != nil { + // 返回 error 触发 Asynq 框架自动重试 + return err + } -- 执行状态实时写入数据库:`pending`、`running`、`succeeded`、`failed`。 -- 实时日志写入 Redis,每个任务最多保留最近 1000 行。 -- Redis 日志 TTL 为 24 小时,每次追加时刷新。 -- 查询时优先返回 Redis 日志,Redis 不存在时读取数据库。 -- 任务成功或自动重试耗尽后,将日志写入数据库并删除 Redis 缓冲。 -- 自动重试期间保留同一 taskID 的 Redis 日志。 + return nil +} +``` -## 重试要求 +### 步骤 3:在插件 `Apply` 中注册任务与定时调度 -- Handler 返回 error 以触发 Asynq 自动重试。 -- 不要在 Handler 内自行实现重复重试循环。 -- Admin 手动重试只允许: - - 原任务状态为 `failed` - - `Retryable=true` - - `RetryCount < MaxRetry` -- 修改重试行为时同时检查: - - `internal/infra/task/executor.go` - - `internal/model/task_execution.go` - - `internal/apps/admin/task/routers.go` - - 前端任务执行列表 +```go +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 注册异步任务处理器 + ctx.Task().Register( + TaskTypeOrderTimeoutCancel, + p.handleOrderTimeoutCancel, + extpoints.WithTaskRetry(3), + extpoints.WithTaskTimeout(5*time.Minute), + ) -## 定时任务 + // 2. 注册定时调度任务 (例如每天凌晨 2 点执行汇总) + ctx.Schedule().RegisterCron( + "0 2 * * *", + "order:daily_settlement", + map[string]any{"scope": "all"}, + ) -- 默认定时任务必须通过 Goose SQL 迁移写入 `schedules`。 -- PostgreSQL 和 SQLite 迁移必须同时提供。 -- 初始化 SQL 必须幂等。 -- 涉及迁移时使用 `database-migration` skill。 + return nil +} +``` -## Admin API +### 步骤 4:在业务逻辑中投递异步任务 -- Handler 放在现有 Admin task 模块或 `internal/apps/admin//`。 -- 路由只在 `internal/router/router.go` 注册。 -- 响应保持 `{ "error_msg": "", "data": ... }`。 -- 分页数据保持 `{ "total": 0, "results": [] }`。 -- Swagger 注释必须完整;API 变化后运行 `make swagger`。 +当业务需要下发延迟或异步任务时: -## 前端 +```go +func (s *OrderService) EnqueueTimeoutCheck(ctx context.Context, orderID string) error { + payloadBytes, _ := json.Marshal(OrderTimeoutPayload{ + OrderID: orderID, + Reason: "15分钟未支付自动关单", + CreatedAt: time.Now().Unix(), + }) -- 仅任务元数据变化时,优先复用现有动态任务表单,不新增页面。 -- API 调用必须通过 `frontend/lib/services/`。 -- 修改 shadcn/ui 时使用 `shadcn` skill。 -- 不使用 `any`。 -- 页面根容器使用 `w-full`,不添加页面级 `max-w-*`。 + task := asynq.NewTask( + TaskTypeOrderTimeoutCancel, + payloadBytes, + asynq.ProcessIn(15*time.Minute), // 延迟 15 分钟执行 + asynq.MaxRetry(3), + ) + + // 投递到任务客户端 + _, err := s.taskClient.EnqueueContext(ctx, task) + return err +} +``` + +--- + +## 3. 运行切面透明性 (Profile Transparency) + +Cordis 微内核支持多种启动切面(`api`、`worker`、`schedule`、`all`): +- 插件开发者**无需在插件代码中编写 `if mode == "worker"` 分支**。 +- 插件只需在 `Apply` 中把任务与调度注册进 `Context`。 +- 当进程以 `worker` 切面启动时,微内核的 `driver_asynq_worker` 驱动会自动拾取并监听已注册的任务。 +- 当进程以 `schedule` 切面启动时,`driver_asynq_cron` 驱动会自动启动调度器引擎。 + +--- + +## 4. 任务日志与重试规范 + +1. **日志记录**: + - 记录任务启动参数摘要、分批处理进度及最终完成统计。 + - 大循环处理中应按批次记录日志,禁止每条数据单独打日志刷屏。 +2. **重试机制**: + - Handler 返回 error 即自动触发 Asynq 重试策略。 + - 禁止在 Handler 内部编写裸 `for` 死循环重试。 +3. **幂等性保障**: + - 任务由于网络波动或超时可能被重复消费,业务操作必须实现幂等保护(如基于订单状态机检查或分布式锁 `ctx.DistLock()`)。 + +--- + +## 5. 质量验证 + +```bash +make format +make code-check +go test ./plugins/... +``` diff --git a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md index 212daca3..699b2bc8 100644 --- a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md +++ b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md @@ -1,277 +1,154 @@ -# Wavelet 异步任务代码示例 +# Wavelet 异步任务代码示例 (Cordis 插件化架构) -这些示例用于新增或修改 Wavelet Asynq 任务时快速套用。复制前先对照当前代码,因为任务框架可能随项目演进。 +这些示例用于在 Cordis 插件中开发或修改 Asynq 任务时快速套用。 -## 任务元数据与常量定义 +--- -在对应的业务包 `internal/apps//tasks.go` 中定义 Asynq task type、Admin task type 和 `TaskMeta`。 +## 1. 任务定义与 Handler 编写 -```go -package upload - -import ( - "github.com/Rain-kl/Wavelet/internal/task" -) - -// 异步任务类型标识。格式建议为 "{module}:{action}"。 -const CleanupUnusedUploadsTask = "upload:cleanup_unused" - -// 管理员可下发的任务类型标识。用于 Admin API 的 task_type。 -const TaskTypeCleanupUploads = "cleanup_unused_uploads" - -// CleanupUnusedUploadsMeta 任务元数据 -var CleanupUnusedUploadsMeta = task.TaskMeta{ - Type: TaskTypeCleanupUploads, - AsynqTask: CleanupUnusedUploadsTask, - Name: "清理未使用上传", - Description: "清理超过1小时未使用的上传文件", - SupportsTime: false, - MaxRetry: task.DefaultMaxRetry, - Queue: task.QueueDefault, - Retryable: true, -} -``` - -带参数任务把前端表单元数据放在 `Params`。`Name` 必须和 payload JSON tag 对齐。 - -```go -{ - Type: TaskTypeSendEmail, - AsynqTask: SendEmailTask, - Name: "发送邮件", - Description: "异步发送系统邮件", - SupportsTime: false, - MaxRetry: defaultMaxRetry, - Queue: QueueDefault, - Retryable: true, - Params: []TaskParam{ - { - Name: "to", - Label: "接收邮箱 (To)", - Type: "string", - Required: true, - Placeholder: "receiver@example.com", - Description: "接收邮件的目标邮箱地址", - }, - { - Name: "subject", - Label: "邮件主题 (Subject)", - Type: "string", - Required: true, - Placeholder: "请输入邮件主题", - Description: "发送邮件的主题标题", - }, - { - Name: "body", - Label: "邮件内容 (Body)", - Type: "text", - Required: true, - Placeholder: "请输入邮件内容", - Description: "发送邮件的内容主体", - }, - }, -} -``` - -## 无参数 Handler - -放在对应业务模块,例如 `internal/apps/upload/tasks.go`。 - -```go -package upload - -import ( - "context" - - "github.com/Rain-kl/Wavelet/internal/task" -) - -type CleanupUnusedUploadsHandler struct{} - -func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { - task.AppendLog(ctx, "开始扫描未使用上传") - - // 调用 model/service 完成业务逻辑。 - // 批量处理时按批次记录日志,不要每条记录都 AppendLog。 - - msg := "清理完成" - task.AppendLog(ctx, "%s", msg) - return &task.TaskResult{Message: msg}, nil -} -``` - -## 带参数 Handler - -实现 `PayloadValidator` 做 Admin 下发时的服务端校验和标准化。`Execute` 仍然解析 payload,因为 Scheduler 和 Retry 不一定经过 Admin 校验路径。 +在对应的业务插件中(如 `plugins/domain/user/tasks.go`): ```go package user import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" + "context" + "encoding/json" + "errors" + "fmt" + "strings" - "github.com/Rain-kl/Wavelet/internal/task" + "github.com/hibiken/asynq" ) +// 异步任务类型标识。格式推荐为 "{plugin}:{action}" +const TaskTypeSendEmail = "user:send_email" + type SendEmailPayload struct { - To string `json:"to"` - Subject string `json:"subject"` - Body string `json:"body"` + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` } -type SendEmailHandler struct{} +// Handler 处理函数 +func (p *Plugin) handleSendEmail(ctx context.Context, t *asynq.Task) error { + var req SendEmailPayload + if err := json.Unmarshal(t.Payload(), &req); err != nil { + return fmt.Errorf("解析任务参数: %w", err) + } -func (h *SendEmailHandler) ValidatePayload(payload []byte) ([]byte, error) { - if len(payload) == 0 { - return nil, errors.New("任务参数不能为空") - } + req.To = strings.TrimSpace(req.To) + req.Subject = strings.TrimSpace(req.Subject) + req.Body = strings.TrimSpace(req.Body) + if req.To == "" || req.Subject == "" || req.Body == "" { + return errors.New("to、subject、body 不能为空") + } - var req SendEmailPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("无效的 JSON 格式: %w", err) - } - - req.To = strings.TrimSpace(req.To) - req.Subject = strings.TrimSpace(req.Subject) - req.Body = strings.TrimSpace(req.Body) - if req.To == "" || req.Subject == "" || req.Body == "" { - return nil, errors.New("to、subject、body 不能为空") - } - - return json.Marshal(req) -} - -func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { - var req SendEmailPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, fmt.Errorf("解析任务参数: %w", err) - } - - task.AppendLog(ctx, "开始发送邮件到: %s", req.To) - - // 调用业务服务发送邮件。 - - msg := fmt.Sprintf("邮件成功发送至: %s", req.To) - task.AppendLog(ctx, "%s", msg) - return &task.TaskResult{Message: msg}, nil + // 执行实际邮件发送业务逻辑 + return p.emailSvc.Send(ctx, req.To, req.Subject, req.Body) } ``` -## 统一注册 +--- -在 `internal/infra/task/handlers/register.go` 注册。Admin dispatch 的 `ValidateAndNormalizePayload` 和 Worker 执行都依赖这里。 +## 2. 插件内自包含注册 (`Apply`) + +在插件的 `Apply(ctx *core.Context)` 中: ```go -package handlers +package user import ( - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/Rain-kl/Wavelet/internal/task" + "time" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/extpoints" ) -func Register() { - task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{}) - task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{}) +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 注册 Asynq 异步任务消费处理器 + ctx.Task().Register( + TaskTypeSendEmail, + p.handleSendEmail, + extpoints.WithTaskRetry(3), + extpoints.WithTaskTimeout(2*time.Minute), + ) + + // 2. 注册 Cron 调度任务(例如每天凌晨 3 点清理过期 Token) + ctx.Schedule().RegisterCron( + "0 3 * * *", + "user:cleanup_expired_tokens", + map[string]any{"scope": "expired"}, + ) + + return nil } ``` -## Cron 调度和配置 +--- -系统默认的定时任务必须通过 Goose SQL 迁移初始化插入到 `schedules` 表。 - -在 `internal/infra/persistence/migrator/goose/postgres` 下的示例: - -```sql --- +goose Up -INSERT INTO schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) -VALUES (1, '清理未使用上传', 'cleanup_unused_uploads', '0 */2 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; - --- +goose Down --- 根据业务需求决定是否需要在此删除 -``` - -对于 `sqlite` 也可以使用类似的 `INSERT INTO ... ON CONFLICT(id) DO NOTHING` 语法。数据库更新后,后端会自动热重载调度器。 - -## Handler 测试 - -带参数任务至少覆盖合法 payload、空 payload、非法 JSON、缺失必填和标准化。 +## 3. 业务中投递异步任务 ```go -func TestSendEmailHandlerValidatePayload(t *testing.T) { - tests := []struct { - name string - payload []byte - want SendEmailPayload - wantErr bool - }{ - { - name: "valid payload is normalized", - payload: []byte(`{"to":" user@example.com ","subject":" hi ","body":" body "}`), - want: SendEmailPayload{ - To: "user@example.com", - Subject: "hi", - Body: "body", - }, - }, - { - name: "empty payload", - payload: nil, - wantErr: true, - }, - { - name: "invalid json", - payload: []byte(`{`), - wantErr: true, - }, - { - name: "missing required field", - payload: []byte(`{"to":"user@example.com","subject":"","body":"body"}`), - wantErr: true, - }, - } +func (s *UserService) TriggerWelcomeEmail(ctx context.Context, toEmail, username string) error { + payload, _ := json.Marshal(SendEmailPayload{ + To: toEmail, + Subject: "欢迎加入", + Body: fmt.Sprintf("你好 %s,欢迎使用我们的平台!", username), + }) - h := &SendEmailHandler{} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotPayload, err := h.ValidatePayload(tt.payload) - if gotErr := err != nil; gotErr != tt.wantErr { - t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr) - } - if tt.wantErr { - return - } + task := asynq.NewTask( + TaskTypeSendEmail, + payload, + asynq.MaxRetry(3), + ) - var got SendEmailPayload - if err := json.Unmarshal(gotPayload, &got); err != nil { - t.Fatalf("json.Unmarshal(%s) error = %v", gotPayload, err) - } - if diff := cmp.Diff(tt.want, got); diff != "" { - t.Errorf("ValidatePayload(%s) mismatch (-want +got):\n%s", tt.payload, diff) - } - }) - } + _, err := s.taskClient.EnqueueContext(ctx, task) + return err } ``` -`Execute` 测试优先验证业务服务调用、错误返回和结果摘要;日志可只验证关键路径,避免把精确日志文本写成脆弱断言。 +--- -## Admin Dispatch 测试形状 - -Admin dispatch 测试关注通用链路是否调用了 `PayloadValidator`,不要为每种任务在 handler 里写 if 分支。 +## 4. 任务处理函数单元测试 ```go -func TestDispatchTaskValidatesPayload(t *testing.T) { - // 1. 初始化测试 DB 和 task.AsynqClient。 - // 2. 注册测试 handler: task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{}) - // 3. POST /api/v1/admin/tasks/dispatch,传入非法 payload。 - // 4. 断言响应为 400,错误信息清晰,且没有创建可执行任务。 +func TestSendEmailPayloadValidation(t *testing.T) { + tests := []struct { + name string + payload []byte + wantErr bool + }{ + { + name: "valid payload", + payload: []byte(`{"to":"user@example.com","subject":"hi","body":"welcome"}`), + wantErr: false, + }, + { + name: "empty payload", + payload: nil, + wantErr: true, + }, + { + name: "missing required fields", + payload: []byte(`{"to":"user@example.com","subject":""}`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req SendEmailPayload + err := json.Unmarshal(tt.payload, &req) + if err == nil { + if strings.TrimSpace(req.To) == "" || strings.TrimSpace(req.Subject) == "" || strings.TrimSpace(req.Body) == "" { + err = errors.New("missing fields") + } + } + if (err != nil) != tt.wantErr { + t.Fatalf("validation error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } } ``` - -需要 Redis/Asynq 时优先复用项目现有测试模式;没有现成依赖时可用 `miniredis` 初始化 `task.AsynqClient`。不要把 `internal/infra/task` 依赖塞进通用 testhelper 造成 import cycle。 diff --git a/.agents/skills/new-setting/SKILL.md b/.agents/skills/new-setting/SKILL.md index 99b9d60e..f951221e 100644 --- a/.agents/skills/new-setting/SKILL.md +++ b/.agents/skills/new-setting/SKILL.md @@ -1,159 +1,112 @@ --- name: "new-setting" -description: "Wavelet 项目专用:当新增或修改启动时设置、数据库系统设置、业务设置、公共可见配置、/admin/system 参数配置、/admin/settings 图形化设置界面,或前端公共配置消费逻辑时必须使用。本技能指导设置类型判定、SystemConfig 字段与 visibility、goose SQL 初始化/升级、热更新读取、公共配置暴露、shadcn 图形组件和验证流程。" +description: "Wavelet 项目专用:当新增或修改基于 Cordis 插件的静态配置文件绑定、动态系统/业务设置声明 (ctx.Settings)、管理台热加载设置或前端公共配置消费逻辑时必须使用。" --- -# 新增设置项 +# 插件配置与动态系统设置开发规范 (Cordis 插件化架构) -本技能覆盖 Wavelet 的设置体系。开始前先读仓库根目录 `AGENTS.md`,遵守项目级规则:HTTP 路由只在 `internal/router/router.go` 注册、API 变更后运行 `make swagger`、提交前运行 `make code-check`、不要删除 `frontend/node_modules`、`internal/util/` 不引入框架依赖。 +本技能覆盖 Wavelet 在 Cordis 架构下的静态与动态设置体系。 -如果需要在 `/admin/settings` 增加或调整图形化设置组件,同时阅读 [shadcn](../shadcn/SKILL.md)。如果只是新增 Go 读取逻辑、测试或错误处理,再按需阅读对应 `go-*` skill。 +--- -## 先判定设置类型 +## 1. 两种配置模式与选型 -Wavelet 当前有两套设置入口: +Wavelet 提供两种维度的配置能力: -- 启动时设置:来自 `config.yaml` 或环境变量,适合进程启动前必须确定、通常不热更新的基础配置。 -- 系统设置:保存于数据库 `system_configs`,经 `model.SystemConfig` 和 Redis hash 缓存读取,支持运行时热更新。管理入口是 `/admin/system` 和 `/admin/settings`。 +| 模式 | 机制 | 适用场景 | 注册/读取方式 | +| :--- | :--- | :--- | :--- | +| **静态启动配置** | `config.yaml` / 环境变量 | 进程启动前必须确定、不热更新的配置(如第三方 API Key、端口、物理路径) | `ctx.Config().Bind("plugins.", &cfg)` | +| **动态系统设置** | 数据库持久化 + 缓存 + 热加载 | 运行时可被管理员在管理控制台动态修改的业务规则、开关、阈值 | `ctx.Settings().Register(SettingSchema{...})` | -系统设置分三种使用语义: +--- -- 业务设置:`type=business`,由管理员配置,影响业务规则,例如用户额度、业务限制。 -- 系统设置:`type=system`,由管理员配置,影响平台能力、基础开关、外部服务参数。 -- 公共可见配置:附加在业务设置或系统设置之上,由 `visibility=1` 控制是否通过公开接口返回给前端使用。它不是第三种数据库 `type`,不要把 `type` 写成 `public`。 +## 2. 插件内配置声明与绑定 -业务设置和系统设置互斥:一个配置项只能选择 `business` 或 `system`。是否公开给前端由 `visibility` 决定:`0` 表示隐藏,`1` 表示 `/api/v1/config/public` 可见。 +### 2.1 静态配置绑定 (`ctx.Config().Bind`) -特殊设置组件不一定需要新增 `SystemConfig` 参数项。例如认证源设置、模板管理这类有独立模型和 API 的功能,应沿用对应领域模型,不要为了出现在 `/admin/settings` 强行创建参数配置。 +```go +type OrderStaticConfig struct { + PaymentGatewayURL string `yaml:"payment_gateway_url" json:"payment_gateway_url"` + TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"` +} -## 先定位真实链路 - -修改前快速查看这些文件,确认当前实现没有漂移: - -- `internal/model/system_configs.go`: 配置 key 常量、`SystemConfig` 模型、`GetByKey`、`GetBoolByKey`、`GetIntByKey`、`GetDecimalByKey` 等读取方法。 -- `internal/infra/persistence/migrator/goose/postgres/*.sql` 和 `internal/infra/persistence/migrator/goose/sqlite/*.sql`: `system_configs` 表结构、初始化 seed、后续升级迁移。 -- `internal/infra/persistence/migrator/migrator.go`: goose 迁移入口和 PostgreSQL/SQLite 方言选择。 -- `internal/testhelper/test_helper.go`: Go 测试用默认系统配置 seed。 -- `internal/apps/admin/system_config/routers.go`: `/api/v1/admin/system-configs` 参数表 API。 -- `internal/apps/config/routers.go`: `/api/v1/config/public` 公共配置响应。 -- `frontend/components/common/admin/system.tsx`: `/admin/system` 参数表管理界面,展示所有参数配置项。 -- `frontend/components/common/settings/system-settings.tsx`: `/admin/settings` 图形化设置页入口。 -- `frontend/components/common/settings/*-tab.tsx`: `/admin/settings` 各图形化设置分组。 -- `frontend/lib/services/admin/*`: Admin 系统配置 service 类型和 API 封装。 -- `frontend/lib/services/config/*`、`frontend/hooks/use-public-config`、`frontend/components/layout/*`: 前端公共配置消费链路。 - -## 新增数据库系统设置 - -按影响面选择步骤,不要只改 UI 或只改默认值。 - -1. 定义配置 key。 - - 在 `internal/model/system_configs.go` 添加 `ConfigKey...` 常量。 - - key 使用 lowercase snake case,例如 `search_engine_indexing_enabled`。 - - 值仍存为字符串;布尔值用 `"true"` / `"false"`,数值用十进制字符串,复杂结构用 JSON 字符串。 - -2. 初始化默认配置。 - - 如果修改初始 schema,必须同步 `internal/infra/persistence/migrator/goose/postgres/` 和 `internal/infra/persistence/migrator/goose/sqlite/` 中的 goose SQL。 - - 既有库新增配置时,新增一组时间戳递增的双 SQL 迁移文件,分别放在 PostgreSQL 和 SQLite 目录;不要回到 GORM AutoMigrate 或 Go 代码 seed。 - - 新库初始化也需要包含同一个默认 key:当前初始 seed 在 `202606090001_initial_schema.sql` 的 `INSERT INTO system_configs (...) VALUES ... ON CONFLICT (key) DO NOTHING`。 - - 设置正确的 `Type`:只能是 `"system"` 或 `"business"`。 - - 设置正确的 `Visibility`:公共可见填 `1`,内部配置填 `0`。 - - 默认值要和 Go 读取侧的零值或兜底值一致,避免首次启动和数据库缺失时行为不同。 - - 如果相关 Go 包测试依赖默认配置,同步 `internal/testhelper/test_helper.go` 的 `seedDefaultConfigs` 和公共 key 列表。 - -3. 读取配置。 - - 后端业务代码优先使用 `model.GetBoolByKey`、`model.GetIntByKey`、`model.GetDecimalByKey` 或 `SystemConfig.GetByKey`。 - - 运行时可热更新的规则不要放进 `config.Config`;启动时设置才走 `internal/infra/config/model.go` 和 `config.example.yaml`。 - - 不要在 handler 或业务代码里直接读 `os.Getenv()`。 - -4. 如果前端需要未登录或全局消费,暴露为公共可见配置。 - - 把该配置的 `visibility` 设为 `1`,`GetPublicConfig` 会通过 `model.ListVisibleSystemConfigs` 返回所有可见 key/value。 - - `/api/v1/config/public` 的 `data` 是动态对象:后端返回 `map[string]string`,前端类型是 `Record`。 - - 前端读取时按配置 key 访问,必要时在消费侧把字符串转换为 boolean/number/JSON。 - - 检查使用方的 query key,更新后需要 invalidate `["public-config"]`。 - - 只有公共配置 API 形状或注释变化时才需要更新 Swagger;单纯新增 `visibility=1` 的 key 通常不需要改 `PublicConfigResponse` 类型。 - -5. 如果管理员需要图形化配置,更新 `/admin/settings`。 - - 先阅读 shadcn skill。 - - 根据设置语义选择现有 tab:安全类进 `security-tab.tsx`,运营类进 `operation-tab.tsx`,系统基础参数进 `system-tab.tsx`,其它菜单或杂项进 `other-tab.tsx`。 - - `SystemSettingsMain` 当前通过 `AdminService.listSystemConfigs("system")` 只加载 `type=system` 的配置;`type=business` 的配置若也需要图形化入口,先确认是否要调整查询范围或放到其它 Admin 页面。 - - 新的图形组件优先放在 `frontend/components/common/settings/`,使用现有 `AdminService.updateSystemConfig`。 - - 更新成功后 invalidate `["admin", "system-configs"]`;公共可见配置还要 invalidate `["public-config"]`。 - - 使用 Sonner toast 反馈成功或失败。 - - 不使用 `any`,不要硬编码页面级 `max-w-*`,页面根容器保持 `w-full`。 - -6. `/admin/system` 参数表通常不需要新代码。 - - 只要 `SystemConfig` 默认数据存在,参数表会展示配置项。 - - `/admin/system` 偏向所有参数配置项的键值管理,不替代 `/admin/settings` 的友好图形界面。 - -## 新增启动时设置 - -只有在配置必须随进程启动确定、不能或不应热更新时,才走启动时设置。 - -1. 在 `internal/infra/config/model.go` 添加配置字段。 -2. 在 `config.example.yaml` 添加示例值和说明。 -3. 确认 Viper 现有加载逻辑能绑定该字段;需要环境变量时沿用当前命名和绑定方式。 -4. 运行时代码从 `config.Config.
.` 读取。 -5. 不要把启动时设置同步塞进 `SystemConfig`,除非产品明确需要运行时覆盖。 - -## 常见模式 - -### 布尔公共设置 - -- model key:`ConfigKeyFeatureEnabled = "feature_enabled"` -- goose SQL 默认值:`value='false'`,`type` 按语义选 `"system"` 或 `"business"`,`visibility=1`。 -- 后端读取:`model.GetBoolByKey(ctx, model.ConfigKeyFeatureEnabled)`。 -- 公共响应:`/api/v1/config/public` 的 `data.feature_enabled` 为字符串 `"true"` 或 `"false"`。 -- 前端图形控件:`Switch`,保存时写 `"true"` / `"false"`。 - -### 数值业务设置 - -- model key:`ConfigKeyMaxSomething = "max_something"`。 -- goose SQL 默认值:例如 `"5"`,`type` 通常为 `"business"`,只有前端公共消费时才设 `visibility=1`。 -- 后端读取:`model.GetIntByKey` 或 `model.GetDecimalByKey`。 -- 前端图形控件:`Input type="number"` 或合适的 shadcn 数值控件;保存前做最小必要校验,错误用 toast。 - -### JSON 设置 - -- 默认值使用合法 JSON,例如 `"{}"` 或 `"[]"`。 -- 在 model 或 service 层提供解析函数,像 `GetMenuDisplayConfig` 一样把 JSON 解析错误包装成清晰错误。 -- 前端不要直接拼接 JSON 字符串;用 `JSON.stringify` 写入,用类型化对象在组件中操作。 - -## 验证 - -根据改动范围运行最小有效验证,最后提交前必须运行项目门禁。 - -- 新增或修改系统配置默认值、visibility 或公共配置读取:至少运行相关 Go 包测试,例如: - -```bash -go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config +func (p *Plugin) Apply(ctx *core.Context) error { + var cfg OrderStaticConfig + // 从 config.yaml 中的 plugins.order 节点绑定配置 + ctx.Config().Bind("plugins.order", &cfg) + return nil +} ``` -- 新增 goose 迁移后,至少用当前数据库方言跑一次迁移;如果 SQL 同时改了 PostgreSQL 和 SQLite,尽量覆盖两种方言。涉及 schema/seed 的任务还应遵循 database-migration skill。 +### 2.2 动态设置注册 (`ctx.Settings().Register`) -- 公共配置 API 注释或 handler 签名改动后: +插件在 `Apply` 中声明其支持动态调节的 Schema: -```bash -make swagger +```go +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 注册内部业务规则设置 + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "order.auto_cancel_mins", + Default: 15, + Description: "未支付订单自动取消时间 (分钟)", + Category: "business", + Public: false, + }) + + // 2. 注册前端公共可见开关 (Public: true) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "order.invoice_enabled", + Default: true, + Description: "是否开启订单电子发票开具功能", + Category: "business", + Public: true, // 允许前端通过 /api/v1/config/public 匿名读取 + }) + + return nil +} ``` -- 前端图形设置改动后: +--- -```bash -cd frontend && pnpm typecheck && pnpm lint +## 3. Schema 核心属性说明 + +- **`Key`**:全局唯一配置键名,推荐小写点分蛇形命名(如 `domain.setting_name`)。 +- **`Default`**:默认值(支持 `bool`、`int`、`string`、`JSON 结构`)。 +- **`Category`**: + - `"business"`:业务规则、用户额度、流程开关。 + - `"system"`:系统底层调优、平台安全参数。 +- **`Public`**:布尔值。若为 `true`,会自动暴露至 `/api/v1/config/public`,供前端未登录或全局消费。 +- **`ReadOnly`**:若为 `true`,管理台仅做展示,禁止通过 API 修改。 + +--- + +## 4. 前端消费与管理台界面 + +### 4.1 前端公共配置消费 +当设置声明为 `Public: true` 时,前端可使用 `usePublicConfig` hook 消费: + +```tsx +import { usePublicConfig } from "@/hooks/use-public-config"; + +export function InvoiceButton() { + const { data: config } = usePublicConfig(); + const invoiceEnabled = config?.["order.invoice_enabled"] === "true"; + + if (!invoiceEnabled) return null; + return ; +} ``` -- 提交前: +### 4.2 管理后台热加载设置 (`/admin/settings` 与 `/admin/system`) +- **`/admin/system`**:通用参数表,自动根据所有已注册的 `SettingSchema` 渲染全量配置项的读写管理。 +- **`/admin/settings`**:图形化设置面板。如需在特定的 Tab 中提供高体验的开关/输入组件,参考 `shadcn` 技能使用标准组件进行开发,并通过 `AdminService.updateSystemConfig` 更新。 + +--- + +## 5. 质量与验证门禁 ```bash +make format make code-check +go test ./plugins/... ``` - -如涉及前端页面体验,启动本地服务并用浏览器验证 `/admin/settings` 和 `/admin/system`:配置能显示、保存、toast 反馈正常、刷新后值保持、公共配置消费方能即时或刷新后生效。 - -## 相关 Skills - -- shadcn:新增或调整 `/admin/settings` 图形化设置组件时使用。 -- database-migration:新增或修改 `system_configs` schema、默认 seed 或 goose SQL 迁移时使用。 -- go-error-handling:配置解析、缺失配置、非法值错误需要跨包返回时使用。 -- go-testing:为配置读取、公共配置 API 或 Admin 配置 API 添加测试时使用。 -- go-context:配置读取在请求链路或后台链路中传递取消和超时时使用。 diff --git a/.agents/skills/push-notification/SKILL.md b/.agents/skills/push-notification/SKILL.md index 4de0cccb..fa8f42af 100644 --- a/.agents/skills/push-notification/SKILL.md +++ b/.agents/skills/push-notification/SKILL.md @@ -5,7 +5,7 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知 # 新增消息推送与通知事件开发规范 -本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。 +本技能涵盖 Wavelet 的系统通知推送开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](../../../AGENTS.md),遵守项目级核心规则。 --- @@ -16,8 +16,8 @@ Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异 | 目录/包名 | 职责定位 | 包含内容与设计细节 | | :--- | :--- | :--- | | **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 | -| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:
1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。
2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。
3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 | -| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 | +| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:
1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。
2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。
3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 | +| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 | | **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 | | **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 | | **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。
`w_push_histories` 存放消息发送记录用于审计。 | @@ -68,8 +68,8 @@ func handleUserRegistered(ctx context.Context, event listener.UserRegistered) { > `EventTrigger.Trigger` 已内置异步 Goroutine 与 `context.WithoutCancel`;处理函数内直接调用即可,无需外层 `go func()`。 ### 步骤 2:在 `listener/` 定义域事件并在 `register.go` 装配 -1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 [admin_login.go](file:///Users/ryan/DEV/Go/Wavelet/internal/listener/admin_login.go))。 -2. 在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 中注册元数据并订阅域事件: +1. 在 `internal/listener/` 新增域事件类型、`Emit*` 与 `On*` 注册函数(参考 `internal/listener/admin_login.go`)。 +2. 在 `register.go` 中注册元数据并订阅域事件: ```go func Register() { @@ -104,7 +104,7 @@ func Register(c *gin.Context) { `Init` 中的 `SyncEvents` 会将 `user_registered` 元数据同步到 `w_push_events`,管理员即可在前端配置推送渠道。 ### 步骤 5:编写集成测试 -在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 [admin_login_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/admin_login_test.go)。 +在 `custom_events/` 或 `listener/` 包内添加测试,验证 `Emit*` → handler → `DefaultTrigger.Trigger` 全链路。测试 setup 须显式调用 `custom_events.Register()`(或 `bootstrap.RegisterPushDomainEvents()`)和 `push.SyncEvents`,参考 `admin_login_test.go`。 --- diff --git a/AGENTS.md b/AGENTS.md index 8de62500..5c988812 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,14 +72,14 @@ Strong success criteria let you loop independently. Weak criteria ("make it work | Skill | 何时使用 | | :--- | :--- | -| `new-api` | 添加或修改自定义业务 API、Handler、服务层逻辑、自定义路由注册 | -| `new-async-task` | 添加或修改 Asynq 任务、定时任务、TaskHandler、任务元数据 | -| `new-setting` | 添加或修改系统/业务/公开设置、`/admin/system` 参数或 `/admin/settings` 图形化设置 | -| `database-migration` | 数据库表结构变更、goose SQL 迁移(PG/SQLite/ClickHouse)、seed 数据 | +| `new-api` | 基于 Cordis 插件开发业务 HTTP API、通过 `ctx.Router()` 声明路由与挂载中间件 | +| `new-async-task` | 基于 Cordis 插件通过 `ctx.Task()` 与 `ctx.Schedule()` 注册 Asynq 异步任务与定时调度 | +| `new-setting` | 基于 Cordis 插件通过 `ctx.Settings()` 声明配置 Schema、绑定 YAML 配置或管理台热加载设置 | +| `database-migration` | 插件自包含 `embed.FS` 独立 Goose SQL 迁移(PG/SQLite 双方言、ClickHouse 分析库) | +| `cache-framework` | 基于 `ctx.Cache()` 与 `contracts.CacheService` 访问三层缓存(RAM L1 + Redis L2 + Pub/Sub 同步) | | `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 | -| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush、背压与写入路径改造 | -| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` 策略选型、文件访问与 `w_uploads` / 统计排查 | -| `cache-framework` | 新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、评估高频读是否应接入缓存 | +| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush 与背压策略 | +| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` / `contracts.StorageService`、文件访问与统计 | | `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 | | `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release | | `shadcn` | 添加、修改或组合 shadcn/ui 组件 | @@ -87,24 +87,37 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ## 严格遵循事项 (Guardrails) - 切勿删除 `frontend/node_modules`。 -- 保持 `internal/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。 +- 保持 `pkg/util/` 绝对纯净,禁止导入 Gin、GORM、sessions 等 Web/数据库框架包。 - 测试用例禁止硬编码相对路径创建临时目录,统一使用 Go 内置 `t.TempDir()`。 -- 所有 HTTP 路由仅在 `internal/router/router.go` 中作为高层分发注册。 - 修改 API Handler 后运行 `make swagger`,完成代码开发后必须依次运行 `make code-check` 与 `make format`。 -- 业务模块必须复用平台缓存/文件服务:文件摄取统一用 `upload.Ingest`,删除用 `upload.Remove`/`upload.RemoveOwned`;禁止直接写 `w_uploads` 或绕过 upload 域直接操作 `infra/objectstore`。 -- 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送事件、域事件监听器等),统一在 `internal/platform/bootstrap` 显式装配并在 `internal/cmd` 入口调用。 -- 核心业务模块(`oauth`、`user`)禁止直接 import `push` 或 `custom_events` 触发通知,须通过 `internal/listener` 发射域事件。 -- API 错误响应必须通过 `response.Abort*` 中断请求,由 `ErrorHandlerMiddleware` 统一写出 JSON 并记录 Trace;禁止在 Handler/中间件中直接 `c.JSON(status, response.Err(...))` 或 `200` 返回 `error_msg`。 -- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。 - - `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。 - - `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。 -- 日志/分析表(访问日志、审计流水、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。 -## 技术栈与项目目录结构 - -### 技术栈 -- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OpenTelemetry、Zap、AWS SDK v2。 -- **前端**:Next.js (App Router)、TypeScript、Tailwind CSS、pnpm、shadcn/ui。 +### Cordis 架构核心防线与分层规范 +- **微内核 (`core/`)**: + - 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。 + - **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。 +- **服务契约 (`core/contracts/`)**: + - 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。 + - **严禁**包含任何具体业务实现或 SQL 操作。 +- **自包含插件 (`plugins/`)**: + - 所有业务功能与驱动实现均以扁平自包含插件形式存在(`plugins/drivers/`、`plugins/infra/`、`plugins/domain/` 或下游 `custom_plugins/`)。 + - 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。 + - 插件内部就近组织 Handler、Service、Model 与 Migration。 +- **插件通信与依赖隔离**: + - **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。 + - **单向服务契约调用**:调用方仅面向 `core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。 + - **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。 +- **扩展点自包含注册**: + - **HTTP 路由**:插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。 + - **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。 + - **动态配置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明配置模式,通过 `ctx.Config().Bind(...)` 绑定 YAML 配置。 + - **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。 +- **表单一所有者原则 (Single Owner Principle)**: + - 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。 + - 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。 +- **平台服务复用**: + - 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。 + - 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。 + - 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。 ## 后端开发规范 @@ -113,20 +126,18 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **成功**:HTTP 200,写出 `c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 - **失败**:使用 `internal/shared/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)中断请求。 - **错误文案**:使用模块内 `errs.go` 中的 camelCase 字符串常量(如 `errBindParamsFailed`),禁止暴露底层数据库/系统错误细节给客户端。 -- **Logics 分工**:`logics.go` 只接受 `context.Context`,返回 `(result, error)`,严禁依赖 `*gin.Context` 或调用 `c.JSON`/`Abort*`。 +- **Service/Logics 分工**:业务逻辑层只接受 `context.Context`,返回 `(result, error)`,严禁依赖 `*gin.Context` 或调用 `c.JSON`/`Abort*`。 - **错误日志**:底层错误在 Handler/Logic 边界用 `pkg/logger` 打印日志,禁止使用 `_ = ...` 静默吞掉关键错误。 ### 数据库操作 -- 平台域(user、auth_source、access_token、schedule、task_execution)的持久化必须走 `internal/repository`,禁止在 `internal/model` 中调用 `db.DB` / Redis。 -- 管理员代码推荐使用 `db.DB(ctx)`(`internal/infra/persistence`,包名 `db`)保证 Trace 链路透传。 -- 禁止在 Handler 写复杂 SQL;迁移文件位于 `internal/infra/persistence/migrator/goose/`(禁止 GORM AutoMigrate)。 +- 插件数据库表结构严禁使用 GORM AutoMigrate,统一编写 Goose SQL 迁移并嵌入二进制。 - 不创建物理外键(显式建索引);Go 模型零值需与数据库默认值匹配。 - **SQL LIKE 查询防注入与转义**:所有含用户输入的模糊查询必须调用 `pkg/util.EscapeLike` 转义通配符,并显式指定 `ESCAPE '\\'` 语法(如 `Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(keyword)+"%")`),同时兼容 PostgreSQL 与 SQLite 方言并杜绝通配符注入攻击。 ### 并发与安全防护规范 - **Goroutine 安全**:禁止直接使用裸 `go func()`;统一使用 `pkg/util.Go`,确保具备未捕获 panic 恢复和调用栈日志记录能力。 -- **Pub/Sub 监听并发安全**:启动 Redis Pub/Sub 订阅监听前,必须捕获局部客户端实例(如 `redisClient := db.Redis`),禁止在 goroutine 闭包中直读可变全局 `db.Redis`;提供 `Stop*Listener` 时必须维护 `done` 通道等待 goroutine 完整退出后再重置状态,消除测试或重连时的数据竞争。 -- **Session 固定攻击防御**:用户登录/授权成功后,必须调用 `oauth.SetLoginSession`(内部执行 Session ID 轮换),防止 Session 固定攻击。 +- **Pub/Sub 监听并发安全**:启动 Redis Pub/Sub 订阅监听前,必须捕获局部客户端实例,禁止在 goroutine 闭包中直读可变全局变量;提供停止监听接口时必须维护 `done` 通道等待 goroutine 完整退出后再重置状态,消除数据竞争。 +- **Session 固定攻击防御**:用户登录/授权成功后,必须调用 Session 轮换逻辑,防止 Session 固定攻击。 - **防账户枚举与时序攻击**: - 登录失败统一返回模糊报错;当查询用户不存在时,必须调用 `pkg/util.DummyCheckPassword` 执行同等开销的 bcrypt 哈希计算,彻底消除时序侧信道攻击。 - 验证码、签名 Token 等敏感字符串比对必须使用 `crypto/subtle.ConstantTimeCompare` 常量时间比对。 @@ -146,7 +157,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - **色彩对比度**:正文、提示、徽章等小字颜色在亮色/暗色模式下必须满足 WCAG AA(对比度 ≥ 4.5:1)。 - **组件拆分与维护**: - 物理路由页面 `page.tsx` 仅维护高级骨架与布局。 - - 单文件超过 600 行或含多 Tab/大复杂区块时,必须按就近原则拆分为子组件存放在路由同级的 `components/` 局部目录中(参考 `/admin/database` 的模块化拆分结构)。 + - 单文件超过 600 行或含多 Tab/大复杂区块时,必须按就近原则拆分为子组件存放在路由同级的 `components/` 局部目录中。 - **样式与服务**: - 优先使用 shadcn/ui 的 `variant` 和全局 CSS 变量,不要在业务代码中硬编码颜色/背景。 - 前端请求统一在 `frontend/lib/services//` 中继承 `BaseService` 编写并在 `index.ts` 注册。 @@ -159,5 +170,4 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - key 使用 camelCase 分层(如 `auth.login.submit`);完整短语作为 value,禁止在组件内拼接句子。 - 新增或修改文案时必须**同步**更新 `zh-CN.json` 与 `en.json`,保持 key 树一致。 - 语言选项展示用自称:`中文` / `English`(不随当前 UI 语言翻译)。 - - 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`(除非该路径尚未迁移且不在本次改动范围)。 - - 设计说明见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。 + - 日期/数字格式化使用 locale 感知 helper(如 `formatDateTime`),禁止写死 `'zh-CN'` / `date-fns` 的 `zhCN`。