diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index afe8b9b..6e14337 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -53,7 +53,8 @@ | [ops-star-config-write.md](ops-star-config-write.md) | StarConfig **写面** | §7 | admin 写 · `GET /star/configs` | ExploreConfig · **ECR-043** | | [ops-rhythm-config.md](ops-rhythm-config.md) | ExploreConfig RhythmConfig(只读) | §7 | `/admin/explore/rhythm-configs*` | Ops · **ECR-036** | | [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** | -| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** | +| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck(只读) | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** | +| [ops-image-card-deck-write.md](ops-image-card-deck-write.md) | ImageCardDeck **写面** | §7 | admin 写 · `GET /cards/decks` | ExploreConfig · **ECR-045** | | [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** | | [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** | | [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition | §7 | `/admin/explore/scales*` | Ops · **ECR-040** | diff --git a/.ai/product/feature-spec/ops-image-card-deck-write.md b/.ai/product/feature-spec/ops-image-card-deck-write.md new file mode 100644 index 0000000..9a76da8 --- /dev/null +++ b/.ai/product/feature-spec/ops-image-card-deck-write.md @@ -0,0 +1,95 @@ +# Feature Spec: ExploreConfig · ImageCardDeck 写面(Ops · ECR-045) + +> Status: `Active`(**Implemented · ECR-045 Closed**) +> Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports` +> Auth: `docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md`(Human 批准 · **仅 ECR-045**) +> Predecessor: ECR-037 Closed(只读)· ECR-044 Closed(Rhythm 写) +> 关联:[ops-image-card-deck.md](ops-image-card-deck.md) + +--- + +## 1. 目标 + +运营可 **创建 / 更新 / 上下架** `ImageCardDeck`(牌组目录);写操作鉴权 + 审计;C 端可读 active deck 并影响 `/cards` 可用性;失败回退「现网可抽」。 + +**Closed 后立即 STOP**,不自动 ECR-046。 + +## 2. In / Out + +| In | Out | +|----|-----| +| Admin POST/PUT `/admin/explore/image-card-decks*` | **牌面/单卡内容 CRUD** · Star / Rhythm / Scale | +| 复用 `admin.explore.write` | SystemPrompt · Knowledge · Chunk | +| 复用表 `image_card_decks` | soft-delete · 真支付 · UGC | +| C 端 GET active decks | Crisis / Handoff 写 | +| `/cards`:无 active → 空态禁抽卡 CTA;失败 → 回退可抽 | 自动 ECR-046 | +| 审计 | 新权限子系统 | + +## 3. Domain + +| 字段 | 规则 | +|------|------| +| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$`;`system=true` 不可改 code | +| `title` | 必填 · ≤128 | +| `active` | `false` = 下架(唯一下架手段) | +| `system` | 种子行;禁止物理删除 | + +## 4. API + +### Admin + +| Method | Path | 权限 | +|--------|------|------| +| GET | `/admin/explore/image-card-decks` · `/{id}` | `admin.explore.read` | +| POST | `/admin/explore/image-card-decks` | `admin.explore.write` | +| PUT | `/admin/explore/image-card-decks/{id}` | `admin.explore.write` | + +无 DELETE。 + +### C 端 + +| Method | Path | Auth | 语义 | +|--------|------|------|------| +| GET | `/api/v1/cards/decks` | DeviceAuth | 仅 `active=true`;按 code 排序 | + +## 5. C 端生效 + +| 条件 | user-h5 `/cards` | +|------|------------------| +| ≥1 active | 保持现网抽卡 | +| 成功且 0 active | 「意象牌组暂未开放」空态;禁用抽卡 CTA | +| 请求失败 | **回退**不阻断抽卡 | + +## 6. Permission / Migration + +- Migration `000054_ops_image_card_deck_write`:幂等确认 `super_admin` → `admin.explore.write` +- 编码时占号(Max=`000053`) + +## 7. UI + +| 端 | 行为 | +|----|------| +| admin-h5 意象牌组页 | 列表 + 新建/编辑/上下架(仅 Deck) | +| user-h5 `/cards` | 按 §5 | + +## 8. AC + +| ID | Then | +|----|------| +| AC-F-01 | POST → list/get 可见 | +| AC-F-02 | PUT active=false → C 端不含 | +| AC-F-03 | 有 active → C 端非空 | +| AC-F-04 | 重复 code → 409 | +| AC-S-01/02 | 401;仅 read → POST 403 | +| AC-A-01 | 写产生审计 | +| AC-C-01 | 全下架后空态/禁 CTA | +| AC-C-02 | API 失败回退可抽 | +| AC-O-01 | 无牌面写路径 · 无 soft-delete | + +## 9. Closed 后 + +**立即 STOP**。ECR-046 须新 Candidate Review + Human 拍板。 + +## 10. Trace + +ECR-045 · BD-2026-045 · CONTRACT_DIFF · ECR-045_WRITE_AUTHORIZATION diff --git a/.ai/product/feature-spec/ops-image-card-deck.md b/.ai/product/feature-spec/ops-image-card-deck.md index 70b897a..0c5cbe6 100644 --- a/.ai/product/feature-spec/ops-image-card-deck.md +++ b/.ai/product/feature-spec/ops-image-card-deck.md @@ -1,6 +1,7 @@ # Feature Spec: ExploreConfig · ImageCardDeck(Ops · ECR-037) > Status: `Active`(Loop continuous · **ECR-037 Closed**) +> **写面:** [ops-image-card-deck-write.md](ops-image-card-deck-write.md)(**ECR-045 Closed**) > Parent: WAVE0-FROZEN · Predecessor: ECR-036 Closed > Capability: `ExploreConfig` · BC: `Explore_Reports` > 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 90b74b7..3fb2c03 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -677,9 +677,46 @@ export const adminApi = { updated_at: string }>('PUT', `/explore/rhythm-configs/${id}`, body), imageCardDecks: () => - request<{ items: Array> }>('GET', '/explore/image-card-decks'), + request<{ + items: Array<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }> + }>('GET', '/explore/image-card-decks'), imageCardDeck: (id: string) => - request>('GET', `/explore/image-card-decks/${id}`), + request<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }>('GET', `/explore/image-card-decks/${id}`), + createImageCardDeck: (body: { code: string; title: string; active: boolean }) => + request<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }>('POST', '/explore/image-card-decks', body), + updateImageCardDeck: ( + id: string, + body: { code: string; title: string; active: boolean }, + ) => + request<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }>('PUT', `/explore/image-card-decks/${id}`, body), reportTemplates: () => request<{ items: Array> }>('GET', '/growth/report-templates'), reportTemplate: (id: string) => diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index aacb32f..4c8b6ff 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -37,6 +37,7 @@ async function onLogout() { CMS 星座配置 节律配置 + 意象牌组 目录仓 订单 定价 diff --git a/apps/admin-h5/src/pages/ImageCardDeckPage.vue b/apps/admin-h5/src/pages/ImageCardDeckPage.vue new file mode 100644 index 0000000..2948b1b --- /dev/null +++ b/apps/admin-h5/src/pages/ImageCardDeckPage.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 3e7f859..92eedc1 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -36,6 +36,12 @@ const router = createRouter({ component: () => import('@/pages/RhythmConfigPage.vue'), meta: { permission: 'admin.explore.read' }, }, + { + path: 'image-card-decks', + name: 'image-card-decks', + component: () => import('@/pages/ImageCardDeckPage.vue'), + meta: { permission: 'admin.explore.read' }, + }, { path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') }, { path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') }, { path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } }, diff --git a/apps/api/internal/handler/admin_image_card_deck.go b/apps/api/internal/handler/admin_image_card_deck.go index 4b64221..55f322c 100644 --- a/apps/api/internal/handler/admin_image_card_deck.go +++ b/apps/api/internal/handler/admin_image_card_deck.go @@ -16,6 +16,8 @@ func (h *AdminHandler) registerImageCardDecks(authed *gin.RouterGroup) { g := authed.Group("/explore") g.GET("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListImageCardDecks) g.GET("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetImageCardDeck) + g.POST("/image-card-decks", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateImageCardDeck) + g.PUT("/image-card-decks/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateImageCardDeck) } func (h *AdminHandler) ListImageCardDecks(c *gin.Context) { @@ -44,3 +46,66 @@ func (h *AdminHandler) GetImageCardDeck(c *gin.Context) { } response.OK(c, row) } + +func (h *AdminHandler) CreateImageCardDeck(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") + return + } + var body admin.ImageCardDeckWriteBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40054, "invalid body") + return + } + row, err := h.Svc.CreateImageCardDeck(c.Request.Context(), adminID, body) + if errors.Is(err, admin.ErrInvalidImageCardDeck) { + response.Fail(c, http.StatusBadRequest, 40055, "invalid image card deck") + return + } + if errors.Is(err, admin.ErrImageCardDeckConflict) { + response.Fail(c, http.StatusConflict, 40912, "image card deck code conflict") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50052, "create image card deck failed") + return + } + response.OK(c, row) +} + +func (h *AdminHandler) UpdateImageCardDeck(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40002, "invalid id") + return + } + var body admin.ImageCardDeckWriteBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40054, "invalid body") + return + } + row, err := h.Svc.UpdateImageCardDeck(c.Request.Context(), adminID, id, body) + if errors.Is(err, admin.ErrImageCardDeckNotFound) { + response.Fail(c, http.StatusNotFound, 40420, "image-card-deck not found") + return + } + if errors.Is(err, admin.ErrInvalidImageCardDeck) { + response.Fail(c, http.StatusBadRequest, 40055, "invalid image card deck") + return + } + if errors.Is(err, admin.ErrImageCardDeckConflict) { + response.Fail(c, http.StatusConflict, 40912, "image card deck code conflict") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50053, "update image card deck failed") + return + } + response.OK(c, row) +} diff --git a/apps/api/internal/handler/image_card_deck_public.go b/apps/api/internal/handler/image_card_deck_public.go new file mode 100644 index 0000000..3207da5 --- /dev/null +++ b/apps/api/internal/handler/image_card_deck_public.go @@ -0,0 +1,36 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ImageCardDeckPublicHandler serves GET /api/v1/cards/decks (DeviceAuth). +type ImageCardDeckPublicHandler struct { + Repo *repository.AdminRepo +} + +// Register mounts public image card deck routes. +func (h *ImageCardDeckPublicHandler) Register(api *gin.RouterGroup) { + api.GET("/cards/decks", h.ListActive) +} + +func (h *ImageCardDeckPublicHandler) ListActive(c *gin.Context) { + if h.Repo == nil { + response.OK(c, gin.H{"items": []repository.ImageCardDeckRow{}}) + return + } + items, err := h.Repo.ListActiveImageCardDecks(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50054, "card decks failed") + return + } + if items == nil { + items = []repository.ImageCardDeckRow{} + } + response.OK(c, gin.H{"items": items}) +} diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index b80f406..a5042b4 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -103,6 +103,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { (&handler.HomeHandler{Svc: homeSvc}).Register(authed) (&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed) (&handler.RhythmConfigPublicHandler{Repo: adminRepo}).Register(authed) + (&handler.ImageCardDeckPublicHandler{Repo: adminRepo}).Register(authed) gated := authed.Group("") gated.Use(middleware.RequireRegistered(pool)) diff --git a/apps/api/internal/integration/image_card_deck_write_test.go b/apps/api/internal/integration/image_card_deck_write_test.go new file mode 100644 index 0000000..fc2d961 --- /dev/null +++ b/apps/api/internal/integration/image_card_deck_write_test.go @@ -0,0 +1,116 @@ +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func TestExploreImageCardDeckWrite(t *testing.T) { + r, pool := setupAPIPool(t) + ctx := context.Background() + tok := adminLogin(t, r, "admin", "change-me") + devKey := fmt.Sprintf("deck-cfg-%d", time.Now().UnixNano()) + + code := fmt.Sprintf("deck_w_%d", time.Now().UnixNano()%1_000_000) + body := map[string]any{"code": code, "title": "测试意象牌组", "active": true} + env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", body, tok) + if httpCode != 200 || env.Code != 0 { + t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message) + } + var created struct { + ID string `json:"id"` + Code string `json:"code"` + } + _ = json.Unmarshal(env.Data, &created) + if created.ID == "" { + t.Fatal("bad create") + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM image_card_decks WHERE id=$1`, created.ID) + }) + + _, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", body, tok) + if httpCode != http.StatusConflict { + t.Fatalf("dup expected 409 got %d", httpCode) + } + + env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0) + if httpCode != 200 { + t.Fatalf("public %d", httpCode) + } + var pub struct { + Items []struct { + Code string `json:"code"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &pub) + found := false + for _, it := range pub.Items { + if it.Code == code { + found = true + break + } + } + if !found { + t.Fatalf("missing active %#v", pub.Items) + } + + body["active"] = false + _, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/explore/image-card-decks/"+created.ID, body, tok) + if httpCode != 200 { + t.Fatalf("update %d", httpCode) + } + env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0) + _ = json.Unmarshal(env.Data, &pub) + for _, it := range pub.Items { + if it.Code == code { + t.Fatal("inactive still listed") + } + } + + _, _ = pool.Exec(ctx, `UPDATE image_card_decks SET active=false`) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `UPDATE image_card_decks SET active=true WHERE system=true`) + }) + env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/cards/decks", nil, devKey, 0) + _ = json.Unmarshal(env.Data, &pub) + if len(pub.Items) != 0 { + t.Fatalf("expected empty after all inactive, got %#v", pub.Items) + } + + var n int + _ = pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM admin_audit_logs + WHERE action IN ('explore.image_card_deck.create','explore.image_card_deck.update') AND target_id=$1`, + created.ID).Scan(&n) + if n < 2 { + t.Fatalf("audit %d", n) + } + + limitedRoleID := uuid.New() + _, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`, + limitedRoleID, "dc_ro_"+limitedRoleID.String()[:8]) + _, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.explore.read')`, limitedRoleID) + hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost) + roUser := fmt.Sprintf("dcro_%d", time.Now().UnixNano()) + _, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`, + roUser, string(hash), limitedRoleID) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser) + _, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID) + }) + roTok := adminLogin(t, r, roUser, "ro-pass") + _, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/image-card-decks", map[string]any{ + "code": "x_ro", "title": "no", "active": true, + }, roTok) + if httpCode != http.StatusForbidden { + t.Fatalf("expected 403 got %d", httpCode) + } +} diff --git a/apps/api/internal/repository/image_card_deck_write.go b/apps/api/internal/repository/image_card_deck_write.go new file mode 100644 index 0000000..dc81532 --- /dev/null +++ b/apps/api/internal/repository/image_card_deck_write.go @@ -0,0 +1,139 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// ImageCardDeckWriteInput is create/update payload. +type ImageCardDeckWriteInput struct { + Code string + Title string + Active bool +} + +// ListActiveImageCardDecks returns active decks for C-end. +func (r *AdminRepo) ListActiveImageCardDecks(ctx context.Context) ([]ImageCardDeckRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, code, title, active, system, updated_at + FROM image_card_decks + WHERE active = true + ORDER BY code ASC + LIMIT 100`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ImageCardDeckRow + for rows.Next() { + var row ImageCardDeckRow + if err := rows.Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +// CreateImageCardDeckWithAudit inserts and audits. +func (r *AdminRepo) CreateImageCardDeckWithAudit( + ctx context.Context, adminID uuid.UUID, in ImageCardDeckWriteInput, meta json.RawMessage, +) (*ImageCardDeckRow, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + var row ImageCardDeckRow + err = tx.QueryRow(ctx, ` + INSERT INTO image_card_decks(code, title, active, system) + VALUES ($1,$2,$3,false) + RETURNING id, code, title, active, system, updated_at`, + in.Code, in.Title, in.Active, + ).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt) + if err != nil { + return nil, mapImageCardDeckWriteErr(err) + } + if meta == nil { + meta = json.RawMessage(`{}`) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) + VALUES ($1,'explore.image_card_deck.create','image_card_deck',$2,$3)`, + adminID, row.ID.String(), meta, + ); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &row, nil +} + +// UpdateImageCardDeckWithAudit updates and audits. +func (r *AdminRepo) UpdateImageCardDeckWithAudit( + ctx context.Context, adminID, id uuid.UUID, in ImageCardDeckWriteInput, meta json.RawMessage, +) (*ImageCardDeckRow, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + var system bool + var oldCode string + err = tx.QueryRow(ctx, `SELECT system, code FROM image_card_decks WHERE id=$1`, id).Scan(&system, &oldCode) + if errors.Is(err, pgx.ErrNoRows) { + return nil, pgx.ErrNoRows + } + if err != nil { + return nil, err + } + code := in.Code + if system { + code = oldCode + } + var row ImageCardDeckRow + err = tx.QueryRow(ctx, ` + UPDATE image_card_decks + SET code=$2, title=$3, active=$4, updated_at=now() + WHERE id=$1 + RETURNING id, code, title, active, system, updated_at`, + id, code, in.Title, in.Active, + ).Scan(&row.ID, &row.Code, &row.Title, &row.Active, &row.System, &row.UpdatedAt) + if err != nil { + return nil, mapImageCardDeckWriteErr(err) + } + if meta == nil { + meta = json.RawMessage(`{}`) + } + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) + VALUES ($1,'explore.image_card_deck.update','image_card_deck',$2,$3)`, + adminID, id.String(), meta, + ); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return &row, nil +} + +func mapImageCardDeckWriteErr(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return errString("image card deck code conflict") + } + return err +} + +// ImageCardDeckCodeConflict reports unique violation. +func ImageCardDeckCodeConflict(err error) bool { + return err != nil && strings.Contains(err.Error(), "image card deck code conflict") +} diff --git a/apps/api/internal/service/admin/image_card_deck_write.go b/apps/api/internal/service/admin/image_card_deck_write.go new file mode 100644 index 0000000..86308c3 --- /dev/null +++ b/apps/api/internal/service/admin/image_card_deck_write.go @@ -0,0 +1,71 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "regexp" + "strings" + "unicode/utf8" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +var ( + ErrInvalidImageCardDeck = errors.New("invalid image card deck") + ErrImageCardDeckConflict = errors.New("image card deck code conflict") + imageCardDeckCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`) +) + +// ImageCardDeckWriteBody is JSON for create/update. +type ImageCardDeckWriteBody struct { + Code string `json:"code"` + Title string `json:"title"` + Active bool `json:"active"` +} + +// CreateImageCardDeck validates, inserts, audits. +func (s *Service) CreateImageCardDeck(ctx context.Context, adminID uuid.UUID, body ImageCardDeckWriteBody) (*repository.ImageCardDeckRow, error) { + in, err := normalizeImageCardDeckWrite(body) + if err != nil { + return nil, err + } + meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) + row, err := s.Repo.CreateImageCardDeckWithAudit(ctx, adminID, in, meta) + if repository.ImageCardDeckCodeConflict(err) { + return nil, ErrImageCardDeckConflict + } + return row, err +} + +// UpdateImageCardDeck validates, updates, audits. +func (s *Service) UpdateImageCardDeck(ctx context.Context, adminID, id uuid.UUID, body ImageCardDeckWriteBody) (*repository.ImageCardDeckRow, error) { + in, err := normalizeImageCardDeckWrite(body) + if err != nil { + return nil, err + } + meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) + row, err := s.Repo.UpdateImageCardDeckWithAudit(ctx, adminID, id, in, meta) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrImageCardDeckNotFound + } + if repository.ImageCardDeckCodeConflict(err) { + return nil, ErrImageCardDeckConflict + } + return row, err +} + +func normalizeImageCardDeckWrite(body ImageCardDeckWriteBody) (repository.ImageCardDeckWriteInput, error) { + code := strings.TrimSpace(body.Code) + title := strings.TrimSpace(body.Title) + if !imageCardDeckCodeRe.MatchString(code) { + return repository.ImageCardDeckWriteInput{}, ErrInvalidImageCardDeck + } + if title == "" || utf8.RuneCountInString(title) > 128 { + return repository.ImageCardDeckWriteInput{}, ErrInvalidImageCardDeck + } + return repository.ImageCardDeckWriteInput{Code: code, Title: title, Active: body.Active}, nil +} diff --git a/apps/api/migrations/000054_ops_image_card_deck_write.down.sql b/apps/api/migrations/000054_ops_image_card_deck_write.down.sql new file mode 100644 index 0000000..1e552ae --- /dev/null +++ b/apps/api/migrations/000054_ops_image_card_deck_write.down.sql @@ -0,0 +1,2 @@ +-- ECR-045 rollback marker (permission shared — do not revoke explore.write) +SELECT 1; diff --git a/apps/api/migrations/000054_ops_image_card_deck_write.up.sql b/apps/api/migrations/000054_ops_image_card_deck_write.up.sql new file mode 100644 index 0000000..87895b1 --- /dev/null +++ b/apps/api/migrations/000054_ops_image_card_deck_write.up.sql @@ -0,0 +1,7 @@ +-- ECR-045 ExploreConfig ImageCardDeck write (reuse admin.explore.write) + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, 'admin.explore.write' +FROM admin_roles r +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/apps/user-h5/src/composables/useImageCardPage.ts b/apps/user-h5/src/composables/useImageCardPage.ts index 70d71d2..c7f0259 100644 --- a/apps/user-h5/src/composables/useImageCardPage.ts +++ b/apps/user-h5/src/composables/useImageCardPage.ts @@ -27,6 +27,8 @@ export function useImageCardPage() { const paying = ref(false) const error = ref('') const bootError = ref('') + /** Fail-open until proven empty. */ + const configAvailable = ref(true) const cards = ref<{ id: string; title: string; imagery: string; prompt: string; tip: string }[]>([]) const report = ref(null) const questionInput = ref('') @@ -52,21 +54,38 @@ export function useImageCardPage() { track('cards_scene_selected', { scene: key }) } + async function refreshConfigGate() { + try { + const res = await api.getCardDecks() + configAvailable.value = (res.items || []).length > 0 + } catch { + configAvailable.value = true + } + } + function onPromptClick(row: { label: string; scene: string }) { + if (!configAvailable.value) { + error.value = '意象牌组暂未开放' + return + } selectScene(row.scene) questionInput.value = row.label if (birthFilled()) { - draw() + void draw() } else { showBirth.value = true } } function sendDraw() { + if (!configAvailable.value) { + error.value = '意象牌组暂未开放' + return + } if (questionInput.value.trim()) { scene.value = questionInput.value.trim() } - draw() + void draw() } async function refreshQuota() { @@ -75,6 +94,8 @@ export function useImageCardPage() { async function boot() { if (!(await ensureAccount(router, route.fullPath))) return + await refreshConfigGate() + if (!configAvailable.value) return try { const [sc] = await Promise.all([api.listImageCardScenes(), refreshQuota()]) scenes.value = sc.items || [] @@ -87,6 +108,10 @@ export function useImageCardPage() { } async function draw() { + if (!configAvailable.value) { + error.value = '意象牌组暂未开放' + return + } if (!(await ensureAccount(router, route.fullPath))) return error.value = '' const y = Number(year.value) @@ -145,6 +170,7 @@ export function useImageCardPage() { return { scene, + configAvailable, quotaLabel, year, month, diff --git a/apps/user-h5/src/pages/ImageCardPage.vue b/apps/user-h5/src/pages/ImageCardPage.vue index c0e49e7..3c3f245 100644 --- a/apps/user-h5/src/pages/ImageCardPage.vue +++ b/apps/user-h5/src/pages/ImageCardPage.vue @@ -11,41 +11,50 @@
- +
+ +

意象牌组暂未开放

+

运营尚未启用意象牌组,请稍后再来

+ +
- +

意象卡片用于投射与自我反思,不判定好坏,也不预测未来。部分内容由 AI 生成,仅供参考。

{{ error }}

diff --git a/docs/BACKEND_DESIGN/BD-2026-045-image-card-deck-write.md b/docs/BACKEND_DESIGN/BD-2026-045-image-card-deck-write.md new file mode 100644 index 0000000..033bbed --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-045-image-card-deck-write.md @@ -0,0 +1,23 @@ +# Backend Design: ECR-045 ImageCardDeck Write + +| Field | Value | +|-------|-------| +| ID | BD-2026-045 | +| Status | **Approved · Implemented** | +| Coding | **Done · Closed** | +| Level | L2 | +| Migration | `000054_ops_image_card_deck_write` | + +## Boundary + +```text +POST/PUT /api/v1/admin/explore/image-card-decks* +GET /api/v1/cards/decks +Permission: admin.explore.write (reuse) +Table: image_card_decks +Audit: explore.image_card_deck.create|update +``` + +## Out + +牌面/单卡 · Star · Rhythm · Scale · AICore · soft-delete · payment · UGC · auto ECR-046 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c41472f..63c7520 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026-08-13 +- **ECR-045 Closed:** ExploreConfig ImageCardDeck 薄写面(复用 `admin.explore.write` · POST/PUT · `GET /cards/decks` · `/cards` 空态/失败回退 · migration `000054` · **无牌面编辑**)· **Loop STOP** · 禁自动 ECR-046 +- **ECR-045 Candidate Review Approved:** 首刀 = **ImageCardDeck**(不预设 SystemPrompt;禁牌面内容编辑)· Spec/Loop/Coding **未开** · Auth 草案 PENDING - **ECR-044 Closed:** ExploreConfig RhythmConfig 薄写面(复用 `admin.explore.write` · POST/PUT · `GET /rhythm/configs` · `/rhythm` 空态/失败回退 · migration `000053`)· **Continuous Loop STOP** · 禁自动 ECR-045 - **ECR-044 Candidate Review Approved:** 首刀 = **RhythmConfig**(不预设 SystemPrompt)· Spec/Loop/Coding **未开** · Auth 草案 PENDING - **ECR-043 Closed:** ExploreConfig StarConfig 薄写面(`admin.explore.write` · POST/PUT · `GET /star/configs` · `/star` 空态/失败回退 · migration `000052`)· **Continuous Loop STOP** · 禁自动 ECR-044 diff --git a/docs/CODE_REVIEW/ECR-045.md b/docs/CODE_REVIEW/ECR-045.md new file mode 100644 index 0000000..4540d6f --- /dev/null +++ b/docs/CODE_REVIEW/ECR-045.md @@ -0,0 +1,3 @@ +# CODE_REVIEW — ECR-045 + +**Verdict:** Approve · ImageCardDeck 目录薄写 only · 无牌面/单卡 CRUD · 复用 explore.write · Closed 后 STOP · 禁自动 ECR-046 · ECR-008 Live Promote 仍 BLOCKED diff --git a/docs/CONTRACT_DIFF/ECR-045.yaml b/docs/CONTRACT_DIFF/ECR-045.yaml new file mode 100644 index 0000000..c0a415c --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-045.yaml @@ -0,0 +1,20 @@ +ecr: ECR-045 +capability: ExploreConfig +change: + type: additive +breaking_change: false +migration_required: true +apis: + - method: POST + path: /api/v1/admin/explore/image-card-decks + change: added + - method: PUT + path: /api/v1/admin/explore/image-card-decks/{id} + change: added + - method: GET + path: /api/v1/cards/decks + change: added +perms: + - code: admin.explore.write + change: reused +migration: 000054_ops_image_card_deck_write diff --git a/docs/ECR/ECR-045-image-card-deck-write.md b/docs/ECR/ECR-045-image-card-deck-write.md new file mode 100644 index 0000000..86e0627 --- /dev/null +++ b/docs/ECR/ECR-045-image-card-deck-write.md @@ -0,0 +1,21 @@ +# ECR-045 + +**Title:** ExploreConfig · ImageCardDeck 薄写面 +**Status:** **Closed**(2026-08-13) +**Change Level:** L2 +**Auth:** `ECR-045_WRITE_AUTHORIZATION.md` · Closed 后 STOP + +## Change + +1. Spec `ops-image-card-deck-write` +2. 复用 `admin.explore.write` + POST/PUT image-card-decks + 审计 +3. C 端 `GET /cards/decks`;`/cards` 空态/失败回退 +4. Migration `000054` · OpenAPI · admin-h5 + +## Forbidden + +牌面内容编辑 · Star/Rhythm/Scale · Prompt/Knowledge/Chunk · soft-delete · 支付 · UGC · Crisis/Handoff · **自动 ECR-046** + +## Acceptance + +TEST_REPORT/ECR-045 · CODE_REVIEW Approve · Done · Closed. diff --git a/docs/HANDOFF/ECR-045-architect-to-engineer.md b/docs/HANDOFF/ECR-045-architect-to-engineer.md new file mode 100644 index 0000000..45be861 --- /dev/null +++ b/docs/HANDOFF/ECR-045-architect-to-engineer.md @@ -0,0 +1,6 @@ +# HANDOFF — Architect → Engineer · ECR-045 + +**Coding AUTHORIZED** — Human「Write Auth → Spec → 开码」. + +ImageCardDeck write only. Reuse `admin.explore.write`. Migration 000054. +No card-face content edit. After Closed: STOP — no ECR-046. diff --git a/docs/HANDOFF/ECR-045-engineer-to-reviewer.md b/docs/HANDOFF/ECR-045-engineer-to-reviewer.md new file mode 100644 index 0000000..a7b1998 --- /dev/null +++ b/docs/HANDOFF/ECR-045-engineer-to-reviewer.md @@ -0,0 +1,4 @@ +# HANDOFF — Engineer → Reviewer · ECR-045 + +TestExploreImageCardDeckWrite PASS · repo-governance PASS (max 000054) · Ready Closed. +禁止自动开 ECR-046 · Loop STOP · 无牌面内容编辑。 diff --git a/docs/PRODUCT_SPEC/ECR-045-image-card-deck-write.md b/docs/PRODUCT_SPEC/ECR-045-image-card-deck-write.md new file mode 100644 index 0000000..928496b --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-045-image-card-deck-write.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-045 + +对齐 `ops-image-card-deck-write.md` · **仅 ImageCardDeck** · **Closed**. diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 6ac4e46..55aa4fe 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -53,20 +53,21 @@ - **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context - **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040;分叉已固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`(见 TRACEABILITY) -- **Next ECR / Max Migration:** **Next=`ECR-045`(禁自动开)** · **Max=`000053`** +- **Next ECR / Max Migration:** **Next=`ECR-046`(禁自动开)** · **Max=`000054`** - **Write-Wave CMS:** 041–042 Closed · STOP -- **ExploreConfig:** ECR-043/044 Closed · **Loop STOP** · Prompt/Knowledge/Chunk STOP +- **ExploreConfig:** ECR-043/044/045 Closed · **Loop STOP** · Prompt/Knowledge/Chunk STOP - **Release:** ECR-008 Live Promote **BLOCKED**(独立门禁 · 勿与 Write-Wave 混谈) - EXP: (无) -- STATE: `docs/STATE/`(含 ECR-041…044) +- STATE: `docs/STATE/`(含 ECR-041…045) - TRACEABILITY: `docs/TRACEABILITY.md` · 门禁 `scripts/repo-governance-check.py` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` - Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**) -- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write` · `ops-star-config-write` · `ops-rhythm-config-write` +- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write` · `ops-star-config-write` · `ops-rhythm-config-write` · `ops-image-card-deck-write` ## WIP(尚未单独 ECR) -- (无 · ECR-044 Closed · Loop STOP) +- (无 · ECR-045 Closed · Loop STOP) +- BD-2026-044 状态补丁:工作区可暂留 · **不**自动提交 ## Pointers diff --git a/docs/STATE/ECR-045.md b/docs/STATE/ECR-045.md new file mode 100644 index 0000000..bec59f1 --- /dev/null +++ b/docs/STATE/ECR-045.md @@ -0,0 +1,7 @@ +# STATE — ECR-045 + +| Status | **Closed** | +| Spec | ops-image-card-deck-write | +| Migration | 000054_ops_image_card_deck_write | +| Closed | 2026-08-13 | +| Next | STOP · ECR-046 禁自动开 | diff --git a/docs/TEST_REPORT/ECR-045.md b/docs/TEST_REPORT/ECR-045.md new file mode 100644 index 0000000..b48059b --- /dev/null +++ b/docs/TEST_REPORT/ECR-045.md @@ -0,0 +1,11 @@ +# TEST_REPORT — ECR-045 ImageCardDeck Write + +**Commit:** (pending Close) + +```bash +go test ./internal/integration/ -count=1 -run TestExploreImageCardDeckWrite +python3 scripts/repo-governance-check.py +``` + +PASS · AC create/409/C-end hide/empty/audit/403 · migration 000054 +REPO GOVERNANCE: PASS · max 000054 · Next ECR-046(禁自动开) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9bf2d50..8c89a6b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,8 +4,8 @@ | Anchor | Value | Rule | |--------|-------|------| -| **Next ECR** | `ECR-045` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** | -| **Max Migration** | `000053` | 禁止凭记忆 | +| **Next ECR** | `ECR-046` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** | +| **Max Migration** | `000054` | 禁止凭记忆 | - ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。 - Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。 @@ -70,5 +70,6 @@ | EXPLORE-WRITE | ExploreConfig 写面授权 | **Approved · 仅 ECR-043 · Closed 后 STOP** | `EXPLORE_CONFIG_WRITE_AUTHORIZATION.md` | | ECR-043 | ExploreConfig · StarConfig 薄写面 | **Closed** | Spec ops-star-config-write · BD-2026-043 · migration **000052** · Closed 后 STOP · 禁自动 ECR-044 | | ECR-044 | ExploreConfig · RhythmConfig 薄写面 | **Closed** | Spec ops-rhythm-config-write · BD-2026-044 · migration **000053** · Closed 后 STOP · 禁自动 ECR-045 | +| ECR-045 | ExploreConfig · ImageCardDeck 薄写面 | **Closed** | Spec ops-image-card-deck-write · BD-2026-045 · migration **000054** · Closed 后 STOP · 禁自动 ECR-046 · 无牌面编辑 | > **Migration:** 合并后 `000015`–`000023` 曾撞号,已重编号至 `000050`。以 `apps/api/migrations/` 与 `docs/MIGRATION_RENUMBER.md` 为准(文档中旧号引用可能滞后)。 diff --git a/docs/WAVE0/ECR-045-CANDIDATE_REVIEW.md b/docs/WAVE0/ECR-045-CANDIDATE_REVIEW.md new file mode 100644 index 0000000..e66fa8b --- /dev/null +++ b/docs/WAVE0/ECR-045-CANDIDATE_REVIEW.md @@ -0,0 +1,80 @@ +# ECR-045 Candidate Review — ExploreConfig 写面(续) + +> **RESOLVED 2026-08-13(Human: Candidate Review → Auth/Spec/开码 → Closed):** +> 首刀 = **ImageCardDeck** · **Closed** · Loop **STOP** · 禁自动 ECR-046 +> Continuous Loop / Spec / Coding:本刀已完成并收口 + +**Date:** 2026-08-13 +**Mode:** Candidate Review **Approved** · Spec **Closed** · Coding **Closed** +**Anchors:** Next=`ECR-046`(禁自动开)· Max Migration=`000054` + +## 锁定现状(评审输入) + +| 项 | 状态 | +|----|------| +| ECR-041 Banner | **CLOSED** | +| ECR-042 FeedSlot | **CLOSED** | +| ECR-043 StarConfig | **CLOSED** · migration **000052** | +| ECR-044 RhythmConfig | **CLOSED** · migration **000053** · origin/main 齐平 | +| Continuous Loop | **STOP**(044 Auth 已耗尽 · 不得继承) | +| ECR-008 RELEASE BLOCK | **独立** Live Promote BLOCKED · **勿与本刀混谈** | +| 支付 / UGC / soft-delete / Crisis·Handoff | **硬 STOP** | +| Prompt / Knowledge / Chunk | **硬 STOP** | +| BD-2026-044 docs 补丁 | 工作区可暂留 · **不**为本评审自动提交 | + +## 四刀收益(继续 ExploreConfig 同构) + +```text +041–044 已验证闭环: + Admin 写 → explore/cms.write → 审计 → C 端 active 读 → 空态/失败回退 + +下一刀:ImageCardDeck(ExploreConfig 队列剩余同构项) + 复用 admin.explore.write · POST/PUT · active=false 下架 + +AICoreConfig(SystemPrompt 等)→ 仍排除 +ScaleDefinition → 与 ECR-008 content.write 重叠风险 → 慎选/本刀不做 +``` + +## 概念候选(只选一个 · 拆 ECR) + +| 优先 | 概念 | 只读基线 | 判定 | +|------|------|----------|------| +| **★ 决议** | **ImageCardDeck** | ECR-037 · `image_card_decks` | 与 Star/Rhythm **同构**(code/title/active/system);C 端可对标意象牌入口供给显隐;**禁止牌面/卡牌内容编辑** | +| △ 慎选 | ScaleDefinition | ECR-040 | ECR-008 已有上下架;须先定义增量,否则重复 | +| ✕ 排除 | SystemPrompt / Knowledge / Chunk | — | **硬 STOP** · 不预设 | +| ✕ 排除 | Star / Rhythm 再写 | 043–044 | 已 Closed · 禁止回头扩 | +| ✕ 排除 | Banner/FeedSlot | 041–042 | CMS Write-Wave **STOP** | + +## 决议(Human Candidate Review approve) + +**ECR-045 = ExploreConfig · ImageCardDeck 薄写面(候选已定 · Spec 未写)** + +拟议边界(Spec 阶段细化,不得越界): + +- 复用表 `image_card_decks`;**复用** `admin.explore.write` +- Admin POST/PUT;下架仅 `active=false`;无 DELETE / soft-delete +- C 端:active deck list + 意象牌相关页空态/失败回退(对齐 043/044 哲学) +- Migration:编码时占 **000054**(现 Max=`000053`) +- **明确禁止:** 牌面/单卡 CRUD · Star/Rhythm 再写 · Scale · Prompt · Knowledge · Chunk · 支付 · UGC · Crisis/Handoff +- Continuous Loop:**仅**在单独 Auth 批准且 **仅限 ECR-045**;Closed 后立即 STOP · **禁自动 ECR-046** + +## 硬 STOP(评审即生效) + +| STOP | 说明 | +|------|------| +| 自动 Continuous Loop | 044 Auth **不可继承**;须新 Auth | +| 牌面内容编辑 / UGC 卡面 | 排除 | +| SystemPrompt / Knowledge / Chunk | 排除 | +| 支付 / soft-delete / Crisis·Handoff | 排除 | +| 合并 Deck+Scale+Prompt | 排除 | +| ECR-008 Live Promote | **另一条 Release Gate** · 保持 BLOCKED | + +## 下一步(仍不写码) + +1. ~~Human:Candidate Review approve~~ **Done**(首刀 = ImageCardDeck) +2. Human:批准 **ECR-045 专用** Write Authorization(Continuous Loop 仅 045) +3. Spec `ops-image-card-deck-write` · ECR-045 · BD → Approve +4. 开码口令后才改 `apps/` +5. Closed → **STOP** · 再谈 ECR-046 须新 Candidate Review + +**本回合交付 = Candidate Review RESOLVED;无 Spec · 无 Auth Loop · 无 `apps/`。** diff --git a/docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md b/docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md new file mode 100644 index 0000000..b05920d --- /dev/null +++ b/docs/WAVE0/ECR-045_WRITE_AUTHORIZATION.md @@ -0,0 +1,27 @@ +# ExploreConfig Write Authorization — ECR-045 only + +| Field | Value | +|-------|-------| +| Date | 2026-08-13 | +| Authorizer | Human | +| Status | **Approved · ECR-045 Closed · Loop STOP** | +| Scope | **ECR-045 · ImageCardDeck 薄写面 ONLY** | +| Continuous Loop | **Finished · STOP** | +| After Closed | **STOP** · 禁止自动 ECR-046 | + +## Human 拍板(2026-08-13) + +1. Candidate Review = ImageCardDeck +2. Write Auth → Spec → 开码 +3. 复用 `admin.explore.write` · **无牌面内容编辑** +4. Continuous Loop **仅限 ECR-045** + +## Hard STOP + +牌面/单卡 CRUD · Star/Rhythm/Scale · Prompt / Knowledge / Chunk · 支付 · UGC · soft-delete · Crisis/Handoff · Closed 后自动下一刀 · ECR-008 Live Promote 解禁 + +## Refs + +Candidate Review: `ECR-045-CANDIDATE_REVIEW.md` +Spec: `.ai/product/feature-spec/ops-image-card-deck-write.md` +ECR: `docs/ECR/ECR-045-image-card-deck-write.md` diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5fa68d4..8accbc9 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -320,6 +320,15 @@ export function createClient(opts: CreateClientOptions) { active: boolean }> }>('/api/v1/rhythm/configs'), + getCardDecks: () => + call<{ + items: Array<{ + id: string + code: string + title: string + active: boolean + }> + }>('/api/v1/cards/decks'), getHomeDailyTips: () => call('/api/v1/home/daily-tips'), } } diff --git a/proto/openapi.yaml b/proto/openapi.yaml index bc9d713..cddf8b1 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -1308,6 +1308,21 @@ paths: description: Unauthorized '403': description: Forbidden + post: + tags: [admin] + summary: Create ImageCardDeck + description: Requires admin.explore.write · ECR-045 · deck catalog only · no card-face edit + responses: + '200': + description: OK + '400': + description: Invalid + '401': + description: Unauthorized + '403': + description: Forbidden + '409': + description: Code conflict /api/v1/admin/explore/image-card-decks/{id}: get: @@ -1323,6 +1338,26 @@ paths: description: OK '404': description: Not found + put: + tags: [admin] + summary: Update ImageCardDeck + description: Requires admin.explore.write · deactivate via active=false · ECR-045 + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + '400': + description: Invalid + '403': + description: Forbidden + '404': + description: Not found + '409': + description: Code conflict /api/v1/admin/growth/report-templates: get: @@ -1628,6 +1663,15 @@ paths: '200': description: OK + /api/v1/cards/decks: + get: + tags: [system] + summary: Active ImageCardDeck list for C-end (ECR-045) + description: DeviceAuth · only active=true · ordered by code · deck catalog only + responses: + '200': + description: OK + /api/v1/home/daily-tips: get: tags: [system]