Files
OpenFlare/docs/superpowers/plans/2026-08-30-openflare-cordis-alignment.md
T
ryan dd333643f2 docs(cordis): 添加与 Wavelet 对齐的实施计划
按 W1–W9、装配根、删平台副本、stamp 升级与 git merge 拆成
带测试的任务,金标准 v3.5.4 只读。
2026-08-30 10:37:13 +08:00

1269 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenFlare Cordis 对齐 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 OpenFlare 控制面与 Wavelet Cordis 同构装配,删除全部平台副本,用 git merge 接入 Wavelet 上游,并保证金标准 v3.5.4 库可升级且测试全绿。
**Architecture:** 先在 Wavelet 补齐通用扩展点 W1–W9;再把 `core/pkg/plugins` 同步进 OpenFlare;将 `cmd` 改为 `newWaveletApp` + `server.New()` + `WithMigrationBaseline`;`server` 只留 `of_*` 业务;历史库经 Baseline stamp,不再执行 76 条混合链。对齐完成后再做一次 unrelated merge。
**Tech Stack:** Go 1.25、Gin、GORM、goose v3、Cobra、PostgreSQL/SQLite、可选 ClickHouse。
**规格来源:** `docs/superpowers/specs/2026-08-30-openflare-cordis-alignment-design.md`
## Global Constraints
- 禁止修改 `/Users/ryan/Code/Go/OpenFlare`(金标准,`9f79fb99` / v3.5.4)。
- 禁止修改 `frontend/` 源码。
- 禁止在 OpenFlare `server` 注册 `/api/health`、`/api/v1/user/self`、`/api/cap/*`。
- 禁止把 OpenFlare / `of_` / 边缘节点写进 Wavelet。
- 禁止 rebase / filter-repo / force-push 改写 OpenFlare 历史。
- 禁止把 pow 抽到 `pkg/pow`;禁止第二套关系库 migrator。
- Wavelet 工作在 `/Users/ryan/Code/Go/Wavelet`;OpenFlare 工作在 `/Users/ryan/Code/Go/OpenFlare-cordis`。
- 执行期隔离工作区按 `using-git-worktrees` 在动手时创建,本计划不预先建。
---
## 文件结构
| 仓库 | 路径 | 职责 |
| :--- | :--- | :--- |
| Wavelet | `backend/core/extpoints/router.go`、`scoped_extpoints.go` | W1 HandleRaw / BasePath |
| Wavelet | `backend/core/contracts/captcha.go` | `CaptchaService` |
| Wavelet | `backend/core/contracts/config_public.go` | `PublicConfigProvider` |
| Wavelet | `backend/core/contracts/push.go` | `PushRegistry` + `PushEventMeta` |
| Wavelet | `backend/core/app.go`、`context.go` | `WithMigrationBaseline` |
| Wavelet | `backend/plugins/domain/cap/plugin.go` | Provide CaptchaService;挂 `/api/cap/*` |
| Wavelet | `backend/plugins/domain/user/plugin.go` | 消费 CaptchaService;`GET /self` |
| Wavelet | `backend/plugins/domain/system/plugin.go` | `GET /api/health`;PublicConfigProvider |
| Wavelet | `backend/plugins/domain/upload/plugin.go` | 补挂 `/my` 等用户路由 |
| Wavelet | `backend/plugins/domain/message_gateway/` | 实现 PushRegistry |
| Wavelet | `backend/plugins/drivers/driver_http/` | `RedirectTrailingSlash` |
| Wavelet | `backend/cmd/app.go` | gooseEngine:Baseline + advisory lock |
| OpenFlare | `backend/cmd/{app,api,all,worker,scheduler,root}.go` | 与 Wavelet 同构 + `server.New()` |
| OpenFlare | `backend/OpenFlare/plugins/server/plugin.go` | 只注册 `of_*` 业务 |
| OpenFlare | `backend/OpenFlare/plugins/server/stamp/stamp.go` | Baseline stamp 实现 |
| OpenFlare | `backend/OpenFlare/plugins/server/migrations/{postgres,sqlite}/00001_initial.sql` | 仅 `of_*` |
| OpenFlare | `.gitattributes` | `merge=ours` 规则 |
---
### Task 1: 金标准 L1(只读,失败即停)
**Files:** 无(只读 `/Users/ryan/Code/Go/OpenFlare`)
**Interfaces:**
- Consumes: 金标准树 `9f79fb99`
- Produces: 确认 `go test ./...` 在金标准上为绿,作为后续门禁前提
- [ ] **Step 1: 确认 HEAD**
```bash
git -C /Users/ryan/Code/Go/OpenFlare rev-parse HEAD
git -C /Users/ryan/Code/Go/OpenFlare status --short
```
Expected: `9f79fb99...`,工作区空。若不是,停下来问人,不要改金标准。
- [ ] **Step 2: 跑金标准测试**
```bash
cd /Users/ryan/Code/Go/OpenFlare && go test ./...
```
Expected: exit 0。失败则本计划停止。
- [ ] **Step 3: 不提交**
本任务无代码变更。
---
### Task 2: Wavelet W1 HandleRaw
**Files:**
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router.go`(接口 + HandleRaw)
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/scoped_extpoints.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router_raw_test.go`
- Test: 同上
**Interfaces:**
- Consumes: 无
- Produces: `RouterExtension.HandleRaw(method, path string, handlers ...any) RouteDefinition`;`BasePath() string`
参考实现已在 OpenFlare-cordis(不要发明第二套语义):
`/Users/ryan/Code/Go/OpenFlare-cordis/backend/core/extpoints/router.go` 的 `addRoute` / `HandleRaw` / `joinPathPreservingTrailing` / `ensureLeadingSlash`,以及 `scoped_extpoints.go` 的 `HandleRaw`。
- [ ] **Step 1: 在 Wavelet 写入失败测试**
把下面文件写到 `/Users/ryan/Code/Go/Wavelet/backend/core/extpoints/router_raw_test.go`(与 OpenFlare 仓同文):
```go
package extpoints
import "testing"
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
r := &RouterRegistry{}
g := r.Group("/api/v1/nodes")
if got := g.BasePath(); got != "/api/v1/nodes" {
t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes")
}
slashless := g.Handle("GET", "")
slashed := g.HandleRaw("GET", "/")
if slashless.Path != "/api/v1/nodes" {
t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes")
}
if slashed.Path != "/api/v1/nodes/" {
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
}
if slashed.ID == slashless.ID {
t.Error("HandleRaw must allocate its own route ID")
}
if got := len(r.Routes()); got != 2 {
t.Errorf("registry routes = %d, want 2", got)
}
}
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/extpoints/ -run TestHandleRaw -count=1
```
Expected: FAIL(`HandleRaw` undefined 或 `BasePath` undefined)。
- [ ] **Step 3: 实现**
在 `RouterExtension` 增加 `HandleRaw` 与 `BasePath`。`Handle` 继续 `cleanPath`。`HandleRaw` 走 `ensureLeadingSlash` / `joinPathPreservingTrailing`,**不**剥尾斜杠。`RouterRegistry` 抽出 `addRoute`。`scopedRouterExtension.HandleRaw` 与 `Handle` 一样登记 `OnDispose` → `UnregisterByID`。从 OpenFlare-cordis 对应文件拷语义,不要改行为。
- [ ] **Step 4: 测试通过**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/extpoints/ ./core/ -count=1
```
Expected: PASS。
- [ ] **Step 5: 提交(Wavelet 仓)**
```bash
cd /Users/ryan/Code/Go/Wavelet
git add backend/core/extpoints/router.go backend/core/extpoints/router_raw_test.go backend/core/scoped_extpoints.go
git commit -m "feat(core): add HandleRaw and BasePath for trailing-slash routes"
```
---
### Task 3: Wavelet W2 CaptchaService + `/api/cap/*` + user 门禁
**Files:**
- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/captcha.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/cap/plugin.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/cap/plugin_captcha_contract_test.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin_captcha_test.go`
**Interfaces:**
- Consumes: 现有 `cap.VerifyMiddleware`、`Challenge`、`Redeem`
- Produces:
```go
package contracts
type CaptchaService interface {
VerifyMiddleware(scope string) any
ChallengeHandler() any
RedeemHandler() any
}
```
- [ ] **Step 1: 写契约测试(cap 插件 Apply 后能 Inject CaptchaService)**
```go
package cap
import (
"context"
"testing"
"Wavelet/core"
"Wavelet/core/contracts"
)
func TestApplyProvidesCaptchaService(t *testing.T) {
ctx := core.NewContext(context.Background())
if err := New().Apply(ctx); err != nil {
t.Fatal(err)
}
svc, err := core.Inject[contracts.CaptchaService](ctx)
if err != nil || svc == nil {
t.Fatalf("Inject CaptchaService: svc=%v err=%v", svc, err)
}
if svc.ChallengeHandler() == nil || svc.RedeemHandler() == nil {
t.Fatal("handlers must be non-nil")
}
if svc.VerifyMiddleware("login") == nil {
t.Fatal("VerifyMiddleware(login) must be non-nil")
}
}
func TestApplyRegistersUnversionedCapRoutes(t *testing.T) {
ctx := core.NewContext(context.Background())
if err := New().Apply(ctx); err != nil {
t.Fatal(err)
}
want := map[string]bool{
"POST /api/v1/cap/challenge": false,
"POST /api/cap/challenge": false,
"POST /api/cap/redeem": false,
}
for _, rd := range ctx.Router().Routes() {
key := rd.Method + " " + rd.Path
if _, ok := want[key]; ok {
want[key] = true
}
}
for key, ok := range want {
if !ok {
t.Errorf("missing route %s", key)
}
}
}
```
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/cap/ -run TestApplyProvidesCaptchaService -count=1
```
Expected: FAIL(CaptchaService 未定义或未 Provide)。
- [ ] **Step 3: 实现契约与 cap 插件**
`captcha.go` 只含接口,无 OpenFlare 字样。
cap 内未导出适配:
```go
type captchaService struct{}
func (captchaService) VerifyMiddleware(scope string) any {
return VerifyMiddleware(GetDefaultManager(), scope)
}
func (captchaService) ChallengeHandler() any { return Challenge }
func (captchaService) RedeemHandler() any { return Redeem }
```
`Apply` 中:`core.Provide[contracts.CaptchaService](ctx, captchaService{})`。在现有 `/api/v1/cap` 组之外再:
```go
legacy := ctx.Router().Group("/api/cap")
legacy.POST("/challenge", Challenge)
legacy.POST("/redeem", Redeem)
ctx.Router().RegisterWhitelist("/api/cap/challenge", "/api/cap/redeem")
```
原 `/api/v1/cap` 组保留。
- [ ] **Step 4: user 插件消费 CaptchaService**
在 `user/plugin.go` 注册 login/register/send-email-code 时:
```go
passThrough := gin.HandlerFunc(func(c *gin.Context) { c.Next() })
loginCap, registerCap, emailCap := passThrough, passThrough, passThrough
if capSvc, err := core.Inject[contracts.CaptchaService](ctx); err == nil && capSvc != nil {
if mw, ok := capSvc.VerifyMiddleware("login").(gin.HandlerFunc); ok {
loginCap = mw
}
if mw, ok := capSvc.VerifyMiddleware("register").(gin.HandlerFunc); ok {
registerCap = mw
}
if mw, ok := capSvc.VerifyMiddleware("send_email_code").(gin.HandlerFunc); ok {
emailCap = mw
}
}
userGroup.POST("/login", loginCap, Login)
userGroup.POST("/register", registerCap, Register)
userGroup.POST("/send-email-code", emailCap, SendEmailCode)
```
无 CaptchaService 时行为与现在相同(裸路由)。user 的 `Inject()` 不要把 CaptchaService 变成硬依赖。
user 测试:Apply 且未 Provide captcha 时三条路由仍在;Provide 假 CaptchaService 后 login 的 handler 链长度大于 1。
- [ ] **Step 5: 跑测试**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/cap/ ./plugins/domain/user/ ./core/contracts/ -count=1
```
Expected: PASS。
- [ ] **Step 6: 提交**
```bash
cd /Users/ryan/Code/Go/Wavelet
git add backend/core/contracts/captcha.go backend/plugins/domain/cap backend/plugins/domain/user
git commit -m "feat(cap): expose CaptchaService and unversioned /api/cap routes"
```
---
### Task 4: Wavelet W3 PublicConfigProvider
**Files:**
- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/config_public.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/plugin.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/public_config_test.go`
**Interfaces:**
- Produces:
```go
package contracts
import "context"
type PublicConfigProvider interface {
PublicConfig(ctx context.Context) (any, error)
}
```
- [ ] **Step 1: 失败测试**
```go
package system
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"Wavelet/core"
"Wavelet/core/contracts"
"github.com/gin-gonic/gin"
)
type stubPublic struct{ payload any }
func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil }
func TestPublicConfigUsesProviderWhenPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
core.Provide[contracts.PublicConfigProvider](ctx, stubPublic{payload: map[string]string{"k": "v"}})
if err := New().Apply(ctx); err != nil {
t.Fatal(err)
}
var handler gin.HandlerFunc
for _, rd := range ctx.Router().Routes() {
if rd.Method == "GET" && rd.Path == "/api/v1/config/public" {
handler = rd.Handlers[0].(gin.HandlerFunc)
}
}
if handler == nil {
t.Fatal("public config route missing")
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil)
handler(c)
var body struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if string(body.Data) != `{"k":"v"}` && string(body.Data) != `{"k": "v"}` {
t.Fatalf("data = %s, want flat map", body.Data)
}
}
```
再加一个无 provider 的测试:响应 JSON 含 `"configs"` 与 `"app"`(保持 Wavelet 默认)。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ -run TestPublicConfig -count=1
```
Expected: FAIL。
- [ ] **Step 3: 实现**
`GET /api/v1/config/public` handler 内:
```go
if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil {
data, err := p.PublicConfig(c.Request.Context())
if err != nil {
logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err)
response.AbortInternal(c, "public config unavailable")
return
}
c.JSON(http.StatusOK, response.OK(data))
return
}
// 原 {configs, app} 默认分支
```
`Inject` 在请求时解析,这样 server 后挂 Provide 仍生效(若 Apply 顺序导致 Provide 晚于 system.Apply,改用 `core.When` 存到局部变量,或在 handler 里每次 `Inject`)。**必须在 handler 里 Inject**,不要在 `Apply` 时缓存成 nil。
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/core/contracts/config_public.go backend/plugins/domain/system
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(system): allow PublicConfigProvider to replace public config payload"
```
---
### Task 5: Wavelet W4 RedirectTrailingSlash
**Files:**
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/config.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/engine.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/drivers/driver_http/engine_slash_test.go`
**Interfaces:**
- Produces: `httpAppConfig.RedirectTrailingSlash bool`,tag `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH" default:"true"`
- [ ] **Step 1: 失败测试**
```go
package driver_http
import "testing"
func TestBuildEngineDefaultRedirectsTrailingSlash(t *testing.T) {
eng, err := BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{})
if err != nil {
t.Fatal(err)
}
if !eng.RedirectTrailingSlash {
t.Fatal("default RedirectTrailingSlash must be true")
}
}
func TestBuildEngineCanDisableRedirectTrailingSlash(t *testing.T) {
eng, err := BuildEngineWithConfig(httpAppConfig{RedirectTrailingSlash: false}, httpRedisConfig{})
if err != nil {
t.Fatal(err)
}
if eng.RedirectTrailingSlash {
t.Fatal("RedirectTrailingSlash must honor false")
}
}
```
注意:`httpAppConfig` 里 bool 零值是 false,与 default true 冲突。测试里「默认」必须走 `BuildEngineWithConfig` 在 **未显式设 false 且 Bind 之后** 的行为。实现上用 `*bool` 或单独 `redirectTrailingSlashSet`。推荐:
```go
RedirectTrailingSlash *bool `config:"redirect_trailing_slash" env:"APP_REDIRECT_TRAILING_SLASH"`
```
`BuildEngineWithConfig`:`redirect := true`;若 `cfg.RedirectTrailingSlash != nil { redirect = *cfg.RedirectTrailingSlash }`。测试「默认」传 `httpAppConfig{}`(nil → true);「关闭」传 `ptr(false)`。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/drivers/driver_http/ -run TestBuildEngine -count=1
```
Expected: FAIL。
- [ ] **Step 3: 在 `BuildEngineWithConfig` 里 `r.RedirectTrailingSlash = redirect`,默认 true。**
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/drivers/driver_http/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/drivers/driver_http
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(http): make trailing-slash redirect configurable"
```
---
### Task 6: Wavelet W5 upload 用户路由补挂
**Files:**
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/upload/plugin.go`(约 123–129 行)
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/upload/plugin_test.go`(若无路由测试则创建)
**Interfaces:**
- Consumes: 已有 `handler.ListMyFiles`、`handler.UpdateMyFile`、`handler.DownloadFile`、`handler.BatchDownloadFiles`
- Produces: 用户组额外路径 `GET /api/v1/upload/my`、`PUT /api/v1/upload/:id`、`GET /api/v1/upload/download/:id`、`POST /api/v1/upload/download/batch`
- [ ] **Step 1: 写失败测试**(Apply 后检查 Routes 包含上述四条,且仍含 `GET /api/v1/upload` 与 `POST /api/v1/upload/batch-download`)
Apply 需要 AuthService / DB 等。若现有 `plugin_test.go` 已有假依赖,跟它走。没有则用 `core.Provide` 三个 stub:`contracts.DBService`、`contracts.StorageService`、`contracts.AuthService`(`RequireAuthMiddleware` 返回 `gin.HandlerFunc` pass-through)。
- [ ] **Step 2: 跑测试确认缺路由**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/upload/ -run TestUserUploadRoutes -count=1
```
Expected: FAIL missing `/my`。
- [ ] **Step 3: 在 `uploadGroup` 增加**
```go
uploadGroup.GET("/my", handler.ListMyFiles)
uploadGroup.PUT("/:id", handler.UpdateMyFile)
uploadGroup.GET("/download/:id", handler.DownloadFile)
uploadGroup.POST("/download/batch", handler.BatchDownloadFiles)
```
原 `GET ""`、`POST /batch-download` 不动。
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/upload/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/domain/upload
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(upload): mount existing my/update/download routes on user API"
```
---
### Task 7: Wavelet W6 PushRegistry
**Files:**
- Create: `/Users/ryan/Code/Go/Wavelet/backend/core/contracts/push.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/plugin.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/service/push.go`(适配器,不改注册语义)
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/message_gateway/plugin_registry_test.go`
**Interfaces:**
- Produces:
```go
package contracts
import "context"
type PushNotificationTemplate struct {
Title string
Content string
Level string
Ext map[string]any
}
type PushEventMeta struct {
Key string
Name string
Description string
DefaultTemplate PushNotificationTemplate
}
type PushRegistry interface {
RegisterBuiltInEvent(meta PushEventMeta)
SyncEvents(ctx context.Context) error
}
```
- [ ] **Step 1: 失败测试** — Apply 后 `Inject[PushRegistry]` 成功;`RegisterBuiltInEvent` 一个 key 后再 `GetBuiltInEvents`(或 registry 再读)能看到该 key。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/message_gateway/ -run TestPushRegistry -count=1
```
Expected: FAIL。
- [ ] **Step 3: 实现** — message_gateway 内部把 `PushEventMeta` 转成现有 `model.EventMetadata` 再调 `RegisterBuiltInEvent` / `SyncEvents`。`Apply` 里 `core.Provide[contracts.PushRegistry](ctx, ...)`。禁止在契约里出现 `openflare`。
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/message_gateway/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/core/contracts/push.go backend/plugins/domain/message_gateway
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(message_gateway): expose PushRegistry contract"
```
---
### Task 8: Wavelet W7 + W8 health 与 `/user/self`
**Files:**
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/plugin.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/plugin.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/system/health_route_test.go`
- Create: `/Users/ryan/Code/Go/Wavelet/backend/plugins/domain/user/self_route_test.go`
**Interfaces:**
- Produces: `GET /api/health` → `response.OKNil()`;`GET /api/v1/user/self` 使用 `loginMW` + 返回当前用户(与 profile 同源,handler 调 `AuthService.GetCurrentUser` 或现有 UserInfo 等价实现)
- [ ] **Step 1: 失败测试**
system:Apply 后 Routes 含 `GET /api/health`,用 httptest 调 handler,body 的 `error_msg` 为空且 `data` 为 `null`。`GET /healthz` 仍在。
user:Apply 后 Routes 含 `GET /api/v1/user/self`,且该路由的 middleware/handler 数量 ≥ login 保护的 `/profile`(必须带鉴权)。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ ./plugins/domain/user/ -run 'TestHealth|TestSelf' -count=1
```
Expected: FAIL missing routes。
- [ ] **Step 3: 实现**
```go
ctx.Router().GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, response.OKNil())
})
ctx.Router().RegisterWhitelist("/api/health")
```
`/healthz` 保持 `{status: ok}`,不要改。
user:
```go
userGroup.GET("/self", loginMW, Self)
```
`Self` 用 `contracts.AuthService.GetCurrentUser`(已有 SetAuthService)写成与 `UpdateProfile` 同文件的小 handler,JSON `response.OK(user)`。
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./plugins/domain/system/ ./plugins/domain/user/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/plugins/domain/system backend/plugins/domain/user
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(platform): add GET /api/health and GET /api/v1/user/self"
```
---
### Task 9: Wavelet W9 MigrationBaseline + 锁
**Files:**
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/app.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/context.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/core/app_test.go`
- Modify: `/Users/ryan/Code/Go/Wavelet/backend/cmd/app.go`(`gooseEngine.Migrate`)
**Interfaces:**
- Produces:
```go
func WithMigrationBaseline(fn func(*Context) error) AppOption
func (c *Context) MigrationBaseline() func(*Context) error
```
语义:`Prepare` 把 App 上的 fn 拷到 root Context;`gooseEngine.Migrate` 在第一次成功 `CreateVersionTable` 之后、任何 `provider.Up` 之前调用 `ctx.MigrationBaseline()`(nil 则跳过)。引擎与选项名禁止产品名。
- [ ] **Step 1: 失败测试(core)**
```go
func TestWithMigrationBaselineRunsBeforeEngineMigrate(t *testing.T) {
var order []string
engine := MigrationRunner(func(ctx *Context, _ []MigrationEntry) error {
order = append(order, "engine")
if ctx.MigrationBaseline() == nil {
t.Fatal("baseline must be visible on context inside Migrate")
}
return ctx.MigrationBaseline()(ctx)
})
app := NewApp(
WithMigrationEngine(engine),
WithMigrationBaseline(func(*Context) error {
order = append(order, "baseline")
return nil
}),
)
// 注册一个空迁移条目的测试插件,使 RunMigrations 不会因 entries==0 直接返回
// ...
}
```
若现有测试插件难写:最小插件 `Name() string` + `Apply` 里 `ctx.Migrations().Register("t", someFS)`。也可用不走完整 Prepare、只测 `WithMigrationBaseline` 把 fn 放到 `app.ctx`:调用内部 `Prepare` 后 `app.Context().MigrationBaseline() != nil`。
另测:fn 返回 error 时 `RunMigrations` 返回该 error,且自定义 engine 的 `Migrate` **不被调用**——注意 spec 是表创建之后、Up 之前。core 层只负责把 fn 放到 Context;**调用点在 gooseEngine**。core 测试断言 Prepare 后 Context 上能取到 fn。cmd 测试断言顺序 `create-table → baseline → up`。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/ -run TestWithMigrationBaseline -count=1
```
Expected: FAIL。
- [ ] **Step 3: 实现**
`App` 加字段 `migrationBaseline func(*Context) error`。`WithMigrationBaseline` 写入。`Prepare` 成功装好 ctx 后:`a.ctx.setMigrationBaseline(a.migrationBaseline)`(未导出 setter)。
`gooseEngine.Migrate`:解析 DB 后,用与 `CreateVersionTable` 相同的 DDL **先建一次** `w_schema_versions`(已有 IF NOT EXISTS),然后:
```go
if fn := ctx.MigrationBaseline(); fn != nil {
if err := fn(ctx); err != nil {
return fmt.Errorf("migration baseline: %w", err)
}
}
```
再进入现有 per-plugin `Up` 循环。
锁:Postgres 在 baseline 与 Up 外包 `pg_advisory_lock(0x77617665)`(任意稳定 int64,注释写明用途),`defer pg_advisory_unlock`。SQLite 不额外加锁(单写者)。
- [ ] **Step 4: 测试通过并提交**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./core/ ./cmd/ -count=1
git -C /Users/ryan/Code/Go/Wavelet add backend/core backend/cmd/app.go
git -C /Users/ryan/Code/Go/Wavelet commit -m "feat(core): add WithMigrationBaseline hook before goose Up"
```
- [ ] **Step 5: Wavelet 全量测试**
```bash
cd /Users/ryan/Code/Go/Wavelet/backend && go test ./...
```
Expected: PASS。失败先修,再进入 OpenFlare 任务。
---
### Task 10: 同步框架进 OpenFlare-cordis
**Files:**
- Modify: `backend/{core,pkg,plugins}`(rsync)
- Modify: `backend/OpenFlare/upstream-patches.md`(清空条目)
**Interfaces:**
- Consumes: Task 2–9 已进 Wavelet 工作树
- Produces: OpenFlare `backend/{core,pkg,plugins}` 与 Wavelet 对应目录零差(排除 uploads/dist/data/*.db)
- [ ] **Step 1: 同步**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis
scripts/sync-upstream.sh /Users/ryan/Code/Go/Wavelet/backend
scripts/sync-upstream.sh --check /Users/ryan/Code/Go/Wavelet/backend
```
Expected: `--check` 无 `>f`/`<f` 内容差异。
- [ ] **Step 2: 清空补丁清单**
`backend/OpenFlare/upstream-patches.md` 改为只保留标题与一句「当前无本地内核补丁」。删除过时的 HandleRaw 说明。
- [ ] **Step 3: 编译**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./core/... ./pkg/... ./plugins/...
```
Expected: PASS。OpenFlare 业务测试此步允许仍绿或因接口变化失败;失败留到 Task 12 修,但 **上游包必须绿**。
- [ ] **Step 4: 提交**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis
git add backend/core backend/pkg backend/plugins backend/OpenFlare/upstream-patches.md
git commit -m "chore(cordis): sync Wavelet core/pkg/plugins after W1-W9"
```
---
### Task 11: 重导金标准夹具
**Files:**
- Modify: `docs/superpowers/specs/baseline/{schema-A.sql,versions-A.txt,routes.txt,routes-engine.txt}`(仅当与金标准现跑不一致)
**Interfaces:**
- Consumes: `/Users/ryan/Code/Go/OpenFlare` 二进制
- Produces: 与金标准现跑一致的夹具
- [ ] **Step 1: 用金标准导出 schema 与版本**
在 **临时目录** 跑金标准(不要写金标准树):
```bash
GOLD=/Users/ryan/Code/Go/OpenFlare
TMP=$(mktemp -d)
(cd "$GOLD" && go build -o "$TMP/of-baseline" .)
OF_DB_PATH="$TMP/a.db" timeout 25 "$TMP/of-baseline" api >"$TMP/a.log" 2>&1 || true
sqlite3 "$TMP/a.db" "SELECT name,sql FROM sqlite_master WHERE type='table' ORDER BY name;" > /tmp/schema-A.sql
sqlite3 "$TMP/a.db" "SELECT version_id FROM goose_db_version ORDER BY version_id;" > /tmp/versions-A.txt || \
sqlite3 "$TMP/a.db" "SELECT version FROM goose_db_version ORDER BY version;" > /tmp/versions-A.txt
```
与 `docs/superpowers/specs/baseline/` 对比。不一致则以金标准为准更新夹具。版本最大应为 `202608090003`。
- [ ] **Step 2: 提交夹具(仅有 diff 时)**
```bash
git add docs/superpowers/specs/baseline
git commit -m "docs(cordis): refresh baseline fixtures from OpenFlare v3.5.4"
```
无 diff 则不提交。
---
### Task 12: OpenFlare cmd 同构 + server 只挂业务
**Files:**
- Create: `backend/cmd/app.go`(从 Wavelet `backend/cmd/app.go` 拷贝后改)
- Modify: `backend/cmd/{api.go,all.go,worker.go,scheduler.go,root.go,banner.go}`
- Delete: `backend/cmd/bootstrap.go`、`backend/cmd/http_server.go`
- Modify: `backend/OpenFlare/plugins/server/plugin.go`
- Modify: `backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go`
- Modify: `backend/OpenFlare/plugins/server/router/v1/v1.go`、`openflare/*.go`(不再调 RegisterUserRoutes/RegisterAdminRoutes)
- Modify: `config.example.yaml`(`app.redirect_trailing_slash: false` 或与 W4 字段对齐)
**Interfaces:**
- Consumes: Wavelet `newWaveletApp` 插件列表;`core.WithMigrationBaseline`(stamp 函数可先用 `func(*core.Context) error { return nil }` 占位,Task 14 替换成真 stamp)
- Produces: `func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App`
- [ ] **Step 1: 写失败测试 `backend/cmd/app_test.go`**
```go
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, p := range app.Plugins() { // 若 Plugins() 未导出,改为检查 ctx.Router 路径
names[p.Name()] = true
}
for _, n := range []string{"user", "auth", "cap", "admin", "server"} {
if !names[n] {
t.Errorf("missing plugin %s", n)
}
}
}
```
若 `App` 没有导出插件列表,改为 `Prepare` 后从 `app.Context().Router().Routes()` 断言同时存在 `GET /api/v1/user/self`(Wavelet user)与一条 OpenFlare 业务路径(例如 `GET /api/v1/d/nodes` 或现网 zone 路径——以 `docs/superpowers/specs/baseline/routes-engine.txt` 里 `of_` 控制台路径为准)。**不得**再由 server 注册 `POST /api/cap/challenge` 作为「server 自己的」——这条应来自 cap 插件。
另测:`GET /api/health` 存在。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestNewOpenFlareApp -count=1
```
Expected: FAIL(没有 `newOpenFlareApp`)。
- [ ] **Step 3: 实现装配根**
1. 复制 Wavelet `cmd/app.go` 为 OpenFlare `cmd/app.go`(保留 `gooseEngine`)。
2. `newOpenFlareApp` = `newWaveletApp` 的 Use 列表,在 domain 之后、`driver_http` 之前插入 `ofserver.New()`。
3. `core.NewApp` 增加 `core.WithMigrationBaseline(stamp.Legacy)`——此时 stamp 可返回 nil。
4. `driver_http.New()` **不要** `WithEngine`。
5. `api.go`/`all.go`/`worker.go`/`scheduler.go` 改为 `runProfileApp(...)`,与 Wavelet 逐字同一调用。
6. `root.go` 改为 Wavelet 的 hostConfig + idgen.Init;去掉 `PreRun` migrator。
7. 删除 `bootstrap.go`、`http_server.go`。
`server.Apply`:
```go
func (p *Plugin) Apply(ctx *core.Context) error {
var auth contracts.AuthService
if err := core.Using[contracts.AuthService](ctx, func(s contracts.AuthService) { auth = s }); err != nil {
return err
}
ofrouter.RegisterV1Routes(ctx.Router().Group("/api/v1"), auth)
ofrouter.RegisterRoutes(ctx.Router().Group("/api/v1"), auth)
return nil
}
```
删除 `RegisterUserRoutes` / `RegisterAdminRoutes` / `RegisterRootRoutes` 对平台路径的调用。`apiutil.AdminMiddlewares` 改为接收 `contracts.AuthService`:
```go
func AdminMiddlewares(auth contracts.AuthService) []any {
return []any{auth.RequireAuthMiddleware(), auth.RequireAdminMiddleware()}
}
```
updater 路由改挂在 OpenFlare 自己的 admin 组上(`/api/v1/admin/update*`),中间件用 `AdminMiddlewares(auth)`,不要 import 已删的 admin handler 包。
`config.example.yaml` 增加 `app.redirect_trailing_slash: false`(若 W4 把 key 放在 app 下;若放在独立 http 前缀则以实际 tag 为准)。DeclareConfig 在 driver_http,OpenFlare 只要 yaml 有该键。
- [ ] **Step 4: 测试通过**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -count=1
```
Expected: PASS。此时 `go test ./OpenFlare/...` 会因删除平台路由引用而红,下一任务修。
- [ ] **Step 5: 提交**
```bash
git add backend/cmd backend/OpenFlare/plugins/server/plugin.go backend/OpenFlare/plugins/server/openflare/apiutil backend/OpenFlare/plugins/server/router config.example.yaml
git commit -m "feat(cmd): assemble control plane via Wavelet plugins plus server"
```
---
### Task 13: 删除平台副本并改 import
**Files:**
- Delete: `backend/OpenFlare/plugins/server/{oauth,cap,user,upload,config,health,listener}`
- Delete: `backend/OpenFlare/plugins/server/pkg/{cap,push}`
- Delete: `backend/OpenFlare/plugins/server/admin/` 下除 `updater/` 外全部
- Delete: `backend/OpenFlare/plugins/server/infra/`(config/persistence/task/objectstore/diskcache;CH 小函数先挪走再删)
- Delete: `backend/OpenFlare/plugins/server/model` 与 `repository` 中 `w_*` 实体(users、uploads、auth_source、system_configs、push_*、task_execution、templates、schedule、access_token、analytics/user_access_log)
- Modify: 所有仍 import 上述包的 `openflare/**`、`admin/updater`、测试
- Modify: `platform/bootstrap` — 删除或缩成空;任务注册改到 `server.Apply` 的 `ctx.Task()` / `ctx.Schedule()`
- Create: `backend/OpenFlare/plugins/server/publicconfig/provider.go`
**Interfaces:**
- Consumes: `contracts.AuthService`、`CaptchaService`(不要在 server 再挂 cap)、`PushRegistry`、`PublicConfigProvider`、`contracts.DBService`、`StorageService`、`TaskService`
- Produces: `publicconfig.Provider` 实现 `PublicConfigProvider`,`PublicConfig` 返回 `map[string]string`(visibility=1 的 key/value,与金标准 `GetPublicConfig` 相同)
- [ ] **Step 1: 写失败测试 `plugin_parity_test.go` 改为子集断言**
当前测试要求精确相等且只 `server.New().Apply`。改成:
```go
func TestPluginRoutesContainGoldenBaseline(t *testing.T) {
app := newOpenFlareAppForTest(t) // 与 cmd 测试同一装配,可用 sqlite + WithConfigValues
if err := app.Prepare(); err != nil {
t.Fatal(err)
}
got := routeSet(app.Context())
want := loadBaseline(t)
for k := range want {
if !got[k] {
t.Errorf("missing golden route %s", k)
}
}
}
```
`newOpenFlareAppForTest` 必须在 `cmd` 或 `server` 测试里能引用装配函数。若 `cmd` 测试不能 import 业务,把装配函数放 `cmd`,parity 测试移到 `backend/cmd/parity_test.go`。
先提交测试改动并确认:**在平台副本仍在、Apply 已不挂平台路由时**,缺 `POST /api/v1/user/login` 等会失败——等 Wavelet 插件挂上后应变绿。
- [ ] **Step 2: 跑 parity 看缺哪些金标准路径**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestPluginRoutesContainGoldenBaseline -count=1
```
记下 missing 列表。缺 login/cap/health/self/upload/my 说明 Wavelet 插件没挂上;缺 `/api/v1/d/...` 说明 OpenFlare 业务没挂上。
- [ ] **Step 3: 按 missing 修 Apply / 契约中间件,然后删副本**
替换规则(全文 grep):
| 旧 | 新 |
| :--- | :--- |
| `oauth.LoginRequired()` | `auth.RequireAuthMiddleware().(gin.HandlerFunc)` |
| `admin.LoginAdminRequired()` | `auth.RequireAdminMiddleware().(gin.HandlerFunc)` |
| `db.DB(ctx)` | `contracts.DBService.GORM().WithContext(ctx)`(经 Inject/字段) |
| `config.Config.*` | `ctx.Config().Bind` / `String`/`Bool` |
| `push.RegisterBuiltInEvent` | `PushRegistry.RegisterBuiltInEvent` |
| `repository` 的 user/upload/w_* | 禁止;改走契约或删调用 |
`server.Apply` 增加:
```go
core.Provide[contracts.PublicConfigProvider](ctx, publicconfig.New(ctx))
if pr, err := core.Inject[contracts.PushRegistry](ctx); err == nil {
for _, meta := range ofevents.All() {
pr.RegisterBuiltInEvent(meta)
}
_ = pr.SyncEvents(ctx.GoContext())
}
ctx.Migrations().Register("server", ofMigrations)
```
openflare 异步任务:现 `infra/task/handlers` 里属于 OF 的 handler 迁到 `openflare/tasks`,在 `Apply` 里 `ctx.Task().Register`。平台任务(user:send_email_code 等)不要再注册。
删目录后 `go test ./OpenFlare/...` 以编译器为准修到绿。
- [ ] **Step 4: 测试**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ ./OpenFlare/...
```
Expected: PASS。`rg 'OpenFlare/plugins/server/(oauth|cap|user|upload|config|health)\\b' backend/OpenFlare` 无匹配。
- [ ] **Step 5: 提交**
```bash
git add -A backend/OpenFlare backend/cmd
git commit -m "refactor(server): drop Wavelet-duplicated domains and use contracts"
```
---
### Task 14: stamp + `of_*` 压缩迁移 + 删除关系库 migrator
**Files:**
- Create: `backend/OpenFlare/plugins/server/stamp/stamp.go`
- Create: `backend/OpenFlare/plugins/server/stamp/stamp_test.go`
- Create: `backend/OpenFlare/plugins/server/migrations/postgres/00001_initial.sql`
- Create: `backend/OpenFlare/plugins/server/migrations/sqlite/00001_initial.sql`
- Modify: `backend/cmd/app.go`(`WithMigrationBaseline(stamp.Legacy)`)
- Create: `backend/OpenFlare/plugins/server/chmigrate/chmigrate.go`(从旧 `migrator.MigrateClickHouse` 抽出,只处理 `of_node_*` 文件)
- Delete: `backend/OpenFlare/plugins/server/infra/persistence/migrator/`(关系库部分;CH sql 挪到 `chmigrate/goose/clickhouse`,去掉 `create_user_access_logs`)
**Interfaces:**
- Consumes: `core.Context` + `contracts.DBService`
- Produces: `func Legacy(ctx *core.Context) error`
`Legacy` 行为:
1. Inject `DBService`;拿 `sql.DB`。
2. 探测 `goose_db_version` 是否存在。不存在则 return nil(新装)。
3. `SELECT COALESCE(MAX(version_id),0)` 或 goose 默认列名 `version`(金标准用 goose 默认表:列是 `version_id` 还是 `version` 以 Task 11 导出为准,代码按实表扫描 `information_schema`/`pragma`)。
4. 插入 `w_schema_versions (plugin_id, version_id)`:`('openflare/legacy', 0)`、`('openflare/legacy', maxVer)`、`('server', 1)`,全部 `ON CONFLICT DO NOTHING`。
5. 若 `maxVer >= 202607120002 && maxVer < 202607130001`,调用 `zone.ImportLegacyTx`。金标准 `202608090003` 跳过。
6. 幂等:再调一次零 INSERT。
- [ ] **Step 1: 写失败测试(用 `t.TempDir()` sqlite)**
建一张假 `goose_db_version`(版本 `202608090003`)和空的 `w_schema_versions`(用与 Wavelet 相同的 DDL)。调用 `Legacy`。断言三行 stamp 存在;再调一次行数不变。另测:无 `goose_db_version` 时不写 `openflare/legacy`。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./OpenFlare/plugins/server/stamp/ -count=1
```
Expected: FAIL。
- [ ] **Step 3: 实现 stamp;cmd 传入 `WithMigrationBaseline(stamp.Legacy)`。**
生成 `of_*` initial:从 Task 11 的 `$TMP/a.db`(或金标准跑出来的库)抽取:
```bash
sqlite3 "$TMP/a.db" "SELECT sql FROM sqlite_master WHERE type='table' AND name LIKE 'of_%' ORDER BY name;"
```
写成 sqlite `00001_initial.sql`(每条 `CREATE TABLE IF NOT EXISTS`,加 `-- +goose Up`)。postgres 目录用同一批表,类型按现有 OF postgres 历史链的最终形态(对照金标准若只有 sqlite 夹具,postgres 从 `migrator/goose/postgres` 里 of_* 的最终 CREATE/ALTER 手工收敛成一张 IF NOT EXISTS,**禁止 DROP**)。双方言版本号都是 `00001`。
CH:复制 `goose/clickhouse` 到 `chmigrate`,删除用户访问日志那个文件。`server.Apply` 在 API profile 下调用 `chmigrate.Up`(保留旧错误日志行为)。
删除 `migrator.Migrate` 与 Cobra PreRun。76 条 sql **不再 embed**。可留在 git 里但不要 `go:embed`。
- [ ] **Step 4: 测试通过**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./OpenFlare/plugins/server/stamp/ ./cmd/ -count=1
```
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add backend/OpenFlare/plugins/server/stamp backend/OpenFlare/plugins/server/migrations backend/OpenFlare/plugins/server/chmigrate backend/cmd
git commit -m "feat(server): stamp legacy goose versions and own of_* migrations only"
```
---
### Task 15: L3 升级测试(金标准库 → Cordis)
**Files:**
- Create: `backend/cmd/upgrade_from_golden_test.go`
**Interfaces:**
- Consumes: 金标准二进制(只读调用)、`newOpenFlareApp`
- Produces: 自动化 A→C 升级断言
- [ ] **Step 1: 写失败测试**
测试内:
1. `t.TempDir()`。
2. `exec.Command` 构建金标准:`go build -o $tmp/gold /Users/ryan/Code/Go/OpenFlare`(module 路径按金标准 `main.go`)。
3. 设 `OF_DB_PATH`/`CONFIG` 指向 temp sqlite,跑金标准 `api` 数秒直到 `goose` 成功(看日志或轮询表 `of_nodes` 存在)。超时失败。
4. 抽样 `INSERT` 一行可识别数据到 `of_zones` 或读 seed 行数。
5. 对 **同一 db 文件** `newOpenFlareApp(ProfileAPI)` + `Prepare()`(不要 `Run` 以免占端口;若 Prepare 含迁移即足够)。
6. 断言:`w_schema_versions` 有 `openflare/legacy` 与 `server`;`goose_db_version` max 仍为 `202608090003`;抽样行仍在;`of_*` 表列不丢。
7. 第二次 `Prepare()` 不增加 `openflare/legacy` 行数。
Postgres:若 CI/本机有 `DATABASE`,用 `t.Skip` 以外的短测试连本机 PG;无 PG 则 sqlite 必跑,PG 用 `go test -tags pgupgrade` 可选。规格要求 Postgres 必做——测试读 `TEST_PG_DSN`,未设则 `t.Fatal` 在 `TestUpgradePostgresFromGolden` **除非** 文档写明开发机需提供。计划要求:sqlite 子测试无条件跑;Postgres 子测试在 `TEST_PG_DSN` 为空时 Skip,并在 Task 16 的人工清单里用 docker postgres 跑一遍(下面 Step 4)。
- [ ] **Step 2: 跑测试确认失败(stamp 未接好会红)**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./cmd/ -run TestUpgradeFromGolden -count=1 -timeout 120s
```
- [ ] **Step 3: 修到 PASS**
- [ ] **Step 4: 本地 Postgres**
```bash
docker run -d --name of-pg-test -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16
TEST_PG_DSN='postgres://postgres:test@127.0.0.1:5432/postgres?sslmode=disable' \
go test ./cmd/ -run TestUpgradePostgresFromGolden -count=1 -timeout 180s
```
Expected: PASS。
- [ ] **Step 5: 提交**
```bash
git add backend/cmd/upgrade_from_golden_test.go
git commit -m "test(cmd): upgrade sqlite/postgres from OpenFlare v3.5.4 golden"
```
---
### Task 16: L2 全量门禁
**Files:** 无新功能文件;修测试/swagger 直到绿
- [ ] **Step 1: Cordis 全测**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis/backend && go test ./...
```
Expected: PASS。修到绿。
- [ ] **Step 2: swagger 超集**
```bash
cd /Users/ryan/Code/Go/OpenFlare-cordis && make swagger
python3 - <<'PY'
import json
gold=json.load(open("/Users/ryan/Code/Go/OpenFlare/docs/swagger.json"))
new=json.load(open("docs/swagger.json"))
def ops(d):
s=set()
for p,v in d.get("paths",{}).items():
for m in v:
if m in ("get","post","put","delete","patch","head","options"):
s.add(f"{m.upper()} {p}")
return s
missing=sorted(ops(gold)-ops(new))
if missing:
raise SystemExit("missing: "+"\n".join(missing))
print("ok", len(ops(gold)), "golden ops present; extra", len(ops(new)-ops(gold)))
PY
```
Expected: 无 missing。`GET /api/v1/config/public` 的成功响应仍是扁平 map(查 swagger schema / 手写测试打 handler)。
- [ ] **Step 3: 前端零 diff**
```bash
git -C /Users/ryan/Code/Go/OpenFlare-cordis diff --name-only -- frontend/
```
Expected: 空。
- [ ] **Step 4: 金标准树未被修改**
```bash
git -C /Users/ryan/Code/Go/OpenFlare status --short
```
Expected: 空。
- [ ] **Step 5: 提交本任务产生的测试/swagger 修复**(若有)
---
### Task 17: git merge 接入 Wavelet 上游
**Files:**
- Modify: `.gitattributes`
- Delete: `scripts/sync-upstream.sh`(接线成功后)
- Modify: `backend/OpenFlare/README.md`(同步方式改为 `git merge wavelet/main`)
**Interfaces:**
- Consumes: Task 16 全绿;Wavelet 含 W1–W9 的 main/工作树
- Produces: `git merge-base HEAD wavelet/main` 非空
- [ ] **Step 1: 写 `.gitattributes` 追加**
```
backend/OpenFlare/** merge=ours
frontend/** merge=ours
docs/changelog/** merge=ours
docs/superpowers/** merge=ours
```
先提交这一文件。
```bash
git add .gitattributes
git commit -m "chore(git): keep OpenFlare-owned paths on merge"
```
- [ ] **Step 2: 添加 remote 并第一次 merge**
```bash
git remote add wavelet /Users/ryan/Code/Go/Wavelet 2>/dev/null || git remote set-url wavelet /Users/ryan/Code/Go/Wavelet
git fetch wavelet
git merge --allow-unrelated-histories wavelet/main
```
冲突:`backend/{core,pkg,plugins}` 以 Wavelet 为准;`frontend/`、`backend/OpenFlare/`、`docs/superpowers/`、`docs/changelog/` 保持 OpenFlare(`git checkout --ours`)。`backend/cmd` 三路解决:保留 `server.New()`、`WithMigrationBaseline`、agent/relay/flared。不要 rebase。
- [ ] **Step 3: 验证历史**
```bash
git merge-base HEAD wavelet/main # 非空
git log --first-parent -5 # 仍是 OpenFlare 提交叙事
git diff --name-only -- frontend/ # 相对 merge 前应无意外
cd backend && go test ./cmd/ ./OpenFlare/plugins/server/stamp/ -count=1
```
- [ ] **Step 4: 删除 rsync 脚本并提交 merge**
```bash
git rm scripts/sync-upstream.sh
git add backend
git commit --no-edit # 若 merge 已产生 commit 则再 chore 删脚本
```
若 Step 2 的 merge 已提交,则:
```bash
git rm scripts/sync-upstream.sh
git commit -m "chore(cordis): drop rsync sync after git upstream is connected"
```
---
## 自检(对照 spec)
| Spec | Task |
| :--- | :--- |
| D1 契约冻结 / 不改前端 | 16 Step 2–3 |
| D2 删全部平台副本 | 13 |
| D3 stamp | 14、15 |
| D4 health/self/cap 在 Wavelet | 3、8;12 禁止 server 再挂 |
| D5 git merge | 17 |
| D6 不承担同类职责 | 12–14 |
| D7 金标准只读 | 1、16 Step 4 |
| D8 先对齐再 merge | 16 在 17 前 |
| W1–W9 | 2–9 |
| cmd 同构 | 12 |
| L1/L2/L3 | 1、16、15 |
| 不做 pow / 第二套 migrator / 改 agent CLI | 全局约束 |
无 TBD。`CaptchaService` / `PublicConfigProvider` / `PushRegistry` / `WithMigrationBaseline` / `stamp.Legacy` 名称前后一致。