docs(agents): update AGENTS.md and development skills for cordis architecture

This commit is contained in:
ryan
2026-08-27 23:56:16 +08:00
parent 94c5aa4cd3
commit 75f226cfbf
12 changed files with 660 additions and 977 deletions
+71 -180
View File
@@ -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`
go test ./plugins/...
```
+88 -116
View File
@@ -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/<domain>/` 编排采集与入队;高频写入通过 `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` 时启动不报错、不执行迁移。
+2 -2
View File
@@ -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 回归
+124 -179
View File
@@ -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/<domain>/`,与平台包**平级** |
**一旦用脚手架开发具体产品,整个仓库就是该产品**——例如要做「消息平台」,业务模块应是 `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/<domain>/`,路由用语义化路径(如 `/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/<domain>.go`(新建) | 产品业务路由注册 | **推荐落点** |
| `internal/router/v1/custom.go` | **示例** | 可删可留;**不要**把真实业务堆在这里 |
| `internal/router/root/default.go` / `frontend.go` | 文件服务、health、前端静态 | 平台级,勿塞产品 API |
| `internal/router/root/custom.go` | 根路径**示例**占位 | 仅当确需根路径回调/短链时,用**语义路径**注册,或新建 `root/<domain>.go` 并由 `root.go` 调用 |
### 路径归属(产品 API 用语义路径)
| 目标路径特征 | 注册位置 | 说明 |
| :--- | :--- | :--- |
| `/api/v1/<domain>/...`(如 `/api/v1/channels`) | `v1/<domain>.go` 的 `Register<Domain>Routes`,在 `v1.go` 调用 | **产品业务默认做法** |
| `/api/v1/admin/<domain>/...` | 管理端:可在 `admin.go` 增加小组,或 `v1/admin_<domain>.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/<name>/` 或下游 `custom_plugins/<name>/`)
```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/<domain>/`:
- **优先**纯函数 `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/<domain>.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/<domain>` + `router/v1/<domain>.go`,**不要**塞进 `custom` 或某个无关平台包。
- 管理端产品配置页 API:路径宜为 `/api/v1/admin/<domain>/...`,中间件与现有 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/<domain>.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/... # 运行插件单元测试
```
@@ -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)
@@ -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),
@@ -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 构造函数
+124 -88
View File
@@ -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/<module>/tasks.go` 定义任务类型、Admin 任务类型和 `TaskMeta`。
- Asynq 任务类型使用 `<module>:<action>` 格式。
- 完整设置 `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/<module>/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/<module>/`。
- 路由只在 `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/...
```
@@ -1,277 +1,154 @@
# Wavelet 异步任务代码示例
# Wavelet 异步任务代码示例 (Cordis 插件化架构)
这些示例用于新增或修改 Wavelet Asynq 任务时快速套用。复制前先对照当前代码,因为任务框架可能随项目演进。
这些示例用于在 Cordis 插件中开发或修改 Asynq 任务时快速套用。
## 任务元数据与常量定义
---
在对应的业务包 `internal/apps/<module>/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。
+85 -132
View File
@@ -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.<name>", &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<string, string | undefined>`。
- 前端读取时按配置 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.<Section>.<Field>` 读取。
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 <Button>申请开票</Button>;
}
```
- 提交前:
### 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:配置读取在请求链路或后台链路中传递取消和超时时使用。
+6 -6
View File
@@ -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/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>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/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>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` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`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`。
---
+41 -31
View File
@@ -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/<name>/` 中继承 `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`。