diff --git a/.ai/product/feature-spec/ops-star-config-write.md b/.ai/product/feature-spec/ops-star-config-write.md index 91864e5..96db318 100644 --- a/.ai/product/feature-spec/ops-star-config-write.md +++ b/.ai/product/feature-spec/ops-star-config-write.md @@ -1,6 +1,6 @@ # Feature Spec: ExploreConfig · StarConfig 写面(Ops · ECR-043) -> Status: `Active`(**Spec Ready · Coding NOT STARTED**) +> Status: `Active`(**Implemented · ECR-043 Closed**) > Map: `§7` · Capability: `ExploreConfig` · BC: `Explore_Reports` > Auth: `docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md`(Human 批准 · **仅 ECR-043**) > Predecessor: ECR-035 Closed(只读)· CMS 041–042 Closed diff --git a/.ai/product/feature-spec/ops-star-config.md b/.ai/product/feature-spec/ops-star-config.md index 50e9314..469363d 100644 --- a/.ai/product/feature-spec/ops-star-config.md +++ b/.ai/product/feature-spec/ops-star-config.md @@ -1,7 +1,7 @@ # Feature Spec: ExploreConfig · StarConfig(Ops · ECR-035 只读基线) > Status: `Active`(**ECR-035 Closed** · 只读) -> **写面:** [ops-star-config-write.md](ops-star-config-write.md)(**ECR-043** · Spec Ready) +> **写面:** [ops-star-config-write.md](ops-star-config-write.md)(**ECR-043 Closed**) > Parent: WAVE0-FROZEN · Predecessor: ECR-034 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 5b390d4..c21bc80 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -595,9 +595,46 @@ export const adminApi = { privacyRequest: (id: string) => request>('GET', `/privacy/requests/${id}`), starConfigs: () => - request<{ items: Array> }>('GET', '/explore/star-configs'), + request<{ + items: Array<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }> + }>('GET', '/explore/star-configs'), starConfig: (id: string) => - request>('GET', `/explore/star-configs/${id}`), + request<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }>('GET', `/explore/star-configs/${id}`), + createStarConfig: (body: { code: string; title: string; active: boolean }) => + request<{ + id: string + code: string + title: string + active: boolean + system: boolean + updated_at: string + }>('POST', '/explore/star-configs', body), + updateStarConfig: ( + 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/star-configs/${id}`, body), rhythmConfigs: () => request<{ items: Array> }>('GET', '/explore/rhythm-configs'), rhythmConfig: (id: string) => diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 9d42f81..70434fb 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -35,6 +35,7 @@ async function onLogout() { AI 危机 CMS + 星座配置 目录仓 订单 定价 diff --git a/apps/admin-h5/src/pages/StarConfigPage.vue b/apps/admin-h5/src/pages/StarConfigPage.vue new file mode 100644 index 0000000..9d3bf76 --- /dev/null +++ b/apps/admin-h5/src/pages/StarConfigPage.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 87cfb78..bcb9ccc 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -24,6 +24,12 @@ const router = createRouter({ { path: 'ai', name: 'ai', component: () => import('@/pages/AIConfigPage.vue') }, { path: 'crisis', name: 'crisis', component: () => import('@/pages/CrisisPage.vue') }, { path: 'cms', name: 'cms', component: () => import('@/pages/CMSPage.vue') }, + { + path: 'star-configs', + name: 'star-configs', + component: () => import('@/pages/StarConfigPage.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_star_config.go b/apps/api/internal/handler/admin_star_config.go index c2858c7..5c50cc2 100644 --- a/apps/api/internal/handler/admin_star_config.go +++ b/apps/api/internal/handler/admin_star_config.go @@ -16,6 +16,8 @@ func (h *AdminHandler) registerStarConfigs(authed *gin.RouterGroup) { g := authed.Group("/explore") g.GET("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.ListStarConfigs) g.GET("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreRead), h.GetStarConfig) + g.POST("/star-configs", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.CreateStarConfig) + g.PUT("/star-configs/:id", middleware.RequireAdminPermission(h.Svc, admin.PermExploreWrite), h.UpdateStarConfig) } func (h *AdminHandler) ListStarConfigs(c *gin.Context) { @@ -44,3 +46,66 @@ func (h *AdminHandler) GetStarConfig(c *gin.Context) { } response.OK(c, row) } + +func (h *AdminHandler) CreateStarConfig(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required") + return + } + var body admin.StarConfigWriteBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40054, "invalid body") + return + } + row, err := h.Svc.CreateStarConfig(c.Request.Context(), adminID, body) + if errors.Is(err, admin.ErrInvalidStarConfig) { + response.Fail(c, http.StatusBadRequest, 40055, "invalid star config") + return + } + if errors.Is(err, admin.ErrStarConfigConflict) { + response.Fail(c, http.StatusConflict, 40912, "star config code conflict") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50052, "create star config failed") + return + } + response.OK(c, row) +} + +func (h *AdminHandler) UpdateStarConfig(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.StarConfigWriteBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40054, "invalid body") + return + } + row, err := h.Svc.UpdateStarConfig(c.Request.Context(), adminID, id, body) + if errors.Is(err, admin.ErrStarConfigNotFound) { + response.Fail(c, http.StatusNotFound, 40420, "star-config not found") + return + } + if errors.Is(err, admin.ErrInvalidStarConfig) { + response.Fail(c, http.StatusBadRequest, 40055, "invalid star config") + return + } + if errors.Is(err, admin.ErrStarConfigConflict) { + response.Fail(c, http.StatusConflict, 40912, "star config code conflict") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50053, "update star config failed") + return + } + response.OK(c, row) +} diff --git a/apps/api/internal/handler/star_config_public.go b/apps/api/internal/handler/star_config_public.go new file mode 100644 index 0000000..beed136 --- /dev/null +++ b/apps/api/internal/handler/star_config_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" +) + +// StarConfigPublicHandler serves GET /api/v1/star/configs (DeviceAuth). +type StarConfigPublicHandler struct { + Repo *repository.AdminRepo +} + +// Register mounts public star config routes. +func (h *StarConfigPublicHandler) Register(api *gin.RouterGroup) { + api.GET("/star/configs", h.ListActive) +} + +func (h *StarConfigPublicHandler) ListActive(c *gin.Context) { + if h.Repo == nil { + response.OK(c, gin.H{"items": []repository.StarConfigRow{}}) + return + } + items, err := h.Repo.ListActiveStarConfigs(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50054, "star configs failed") + return + } + if items == nil { + items = []repository.StarConfigRow{} + } + response.OK(c, gin.H{"items": items}) +} diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index aba65a7..daff489 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -101,6 +101,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { (&handler.AuthHandler{Svc: authSvc}).Register(authed) (&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed) (&handler.HomeHandler{Svc: homeSvc}).Register(authed) + (&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed) gated := authed.Group("") gated.Use(middleware.RequireRegistered(pool)) diff --git a/apps/api/internal/integration/star_config_write_test.go b/apps/api/internal/integration/star_config_write_test.go new file mode 100644 index 0000000..397f4e8 --- /dev/null +++ b/apps/api/internal/integration/star_config_write_test.go @@ -0,0 +1,117 @@ +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func TestExploreStarConfigWrite(t *testing.T) { + r, pool := setupAPIPool(t) + ctx := context.Background() + tok := adminLogin(t, r, "admin", "change-me") + devKey := fmt.Sprintf("star-cfg-%d", time.Now().UnixNano()) + + code := fmt.Sprintf("star_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/star-configs", 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 star_configs WHERE id=$1`, created.ID) + }) + + _, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/explore/star-configs", body, tok) + if httpCode != http.StatusConflict { + t.Fatalf("dup expected 409 got %d", httpCode) + } + + env, _, httpCode = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", 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/star-configs/"+created.ID, body, tok) + if httpCode != 200 { + t.Fatalf("update %d", httpCode) + } + env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", nil, devKey, 0) + _ = json.Unmarshal(env.Data, &pub) + for _, it := range pub.Items { + if it.Code == code { + t.Fatal("inactive still listed") + } + } + + // Deactivate all including seed — C-end may be empty + _, _ = pool.Exec(ctx, `UPDATE star_configs SET active=false`) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `UPDATE star_configs SET active=true WHERE system=true`) + }) + env, _, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/star/configs", 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.star_config.create','explore.star_config.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, "sc_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("scro_%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/star-configs", 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/star_config_write.go b/apps/api/internal/repository/star_config_write.go new file mode 100644 index 0000000..d97909d --- /dev/null +++ b/apps/api/internal/repository/star_config_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" +) + +// StarConfigWriteInput is create/update payload. +type StarConfigWriteInput struct { + Code string + Title string + Active bool +} + +// ListActiveStarConfigs returns active configs for C-end. +func (r *AdminRepo) ListActiveStarConfigs(ctx context.Context) ([]StarConfigRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, code, title, active, system, updated_at + FROM star_configs + WHERE active = true + ORDER BY code ASC + LIMIT 100`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StarConfigRow + for rows.Next() { + var row StarConfigRow + 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() +} + +// CreateStarConfigWithAudit inserts and audits. +func (r *AdminRepo) CreateStarConfigWithAudit( + ctx context.Context, adminID uuid.UUID, in StarConfigWriteInput, meta json.RawMessage, +) (*StarConfigRow, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + var row StarConfigRow + err = tx.QueryRow(ctx, ` + INSERT INTO star_configs(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, mapStarConfigWriteErr(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.star_config.create','star_config',$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 +} + +// UpdateStarConfigWithAudit updates and audits. +func (r *AdminRepo) UpdateStarConfigWithAudit( + ctx context.Context, adminID, id uuid.UUID, in StarConfigWriteInput, meta json.RawMessage, +) (*StarConfigRow, 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 star_configs 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 StarConfigRow + err = tx.QueryRow(ctx, ` + UPDATE star_configs + 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, mapStarConfigWriteErr(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.star_config.update','star_config',$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 mapStarConfigWriteErr(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return errString("star config code conflict") + } + return err +} + +// StarConfigCodeConflict reports unique violation. +func StarConfigCodeConflict(err error) bool { + return err != nil && strings.Contains(err.Error(), "star config code conflict") +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 9138708..f5a6ffe 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -34,6 +34,7 @@ const ( PermCMSWrite = "admin.cms.write" PermPrivacyRead = "admin.privacy.read" PermExploreRead = "admin.explore.read" + PermExploreWrite = "admin.explore.write" PermGrowthRead = "admin.growth.read" ) @@ -44,7 +45,7 @@ var knownPermissions = map[string]struct{}{ PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {}, - PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {}, + PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermExploreRead: {}, PermExploreWrite: {}, PermPrivacyRead: {}, } var ( diff --git a/apps/api/internal/service/admin/star_config_write.go b/apps/api/internal/service/admin/star_config_write.go new file mode 100644 index 0000000..73148d6 --- /dev/null +++ b/apps/api/internal/service/admin/star_config_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 ( + ErrInvalidStarConfig = errors.New("invalid star config") + ErrStarConfigConflict = errors.New("star config code conflict") + starConfigCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`) +) + +// StarConfigWriteBody is JSON for create/update. +type StarConfigWriteBody struct { + Code string `json:"code"` + Title string `json:"title"` + Active bool `json:"active"` +} + +// CreateStarConfig validates, inserts, audits. +func (s *Service) CreateStarConfig(ctx context.Context, adminID uuid.UUID, body StarConfigWriteBody) (*repository.StarConfigRow, error) { + in, err := normalizeStarConfigWrite(body) + if err != nil { + return nil, err + } + meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) + row, err := s.Repo.CreateStarConfigWithAudit(ctx, adminID, in, meta) + if repository.StarConfigCodeConflict(err) { + return nil, ErrStarConfigConflict + } + return row, err +} + +// UpdateStarConfig validates, updates, audits. +func (s *Service) UpdateStarConfig(ctx context.Context, adminID, id uuid.UUID, body StarConfigWriteBody) (*repository.StarConfigRow, error) { + in, err := normalizeStarConfigWrite(body) + if err != nil { + return nil, err + } + meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active}) + row, err := s.Repo.UpdateStarConfigWithAudit(ctx, adminID, id, in, meta) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrStarConfigNotFound + } + if repository.StarConfigCodeConflict(err) { + return nil, ErrStarConfigConflict + } + return row, err +} + +func normalizeStarConfigWrite(body StarConfigWriteBody) (repository.StarConfigWriteInput, error) { + code := strings.TrimSpace(body.Code) + title := strings.TrimSpace(body.Title) + if !starConfigCodeRe.MatchString(code) { + return repository.StarConfigWriteInput{}, ErrInvalidStarConfig + } + if title == "" || utf8.RuneCountInString(title) > 128 { + return repository.StarConfigWriteInput{}, ErrInvalidStarConfig + } + return repository.StarConfigWriteInput{Code: code, Title: title, Active: body.Active}, nil +} diff --git a/apps/api/migrations/000052_ops_star_config_write.down.sql b/apps/api/migrations/000052_ops_star_config_write.down.sql new file mode 100644 index 0000000..87501f7 --- /dev/null +++ b/apps/api/migrations/000052_ops_star_config_write.down.sql @@ -0,0 +1,5 @@ +-- ECR-043 rollback + +DELETE FROM admin_role_permissions +WHERE code = 'admin.explore.write' + AND role_id IN (SELECT id FROM admin_roles WHERE name = 'super_admin'); diff --git a/apps/api/migrations/000052_ops_star_config_write.up.sql b/apps/api/migrations/000052_ops_star_config_write.up.sql new file mode 100644 index 0000000..f72f985 --- /dev/null +++ b/apps/api/migrations/000052_ops_star_config_write.up.sql @@ -0,0 +1,7 @@ +-- ECR-043 ExploreConfig StarConfig write (additive permission) + +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/useStarProfilePage.ts b/apps/user-h5/src/composables/useStarProfilePage.ts index fbbb97c..7054b81 100644 --- a/apps/user-h5/src/composables/useStarProfilePage.ts +++ b/apps/user-h5/src/composables/useStarProfilePage.ts @@ -36,6 +36,8 @@ export function useStarProfilePage() { const loading = ref(false) const needBirth = ref(true) const error = ref('') + /** Fail-open: true until proven empty; API error keeps generation. */ + const configAvailable = ref(true) const report = ref(null) const paying = ref(false) const shareOpen = ref(false) @@ -149,7 +151,21 @@ export function useStarProfilePage() { clearDayTimer() } + async function refreshConfigGate() { + try { + const res = await api.getStarConfigs() + configAvailable.value = (res.items || []).length > 0 + } catch { + configAvailable.value = true + } + } + async function generate(y: number, m: number, d: number) { + if (!configAvailable.value) { + error.value = '星座配置暂未开放' + needBirth.value = true + return + } loading.value = true error.value = '' needBirth.value = false @@ -175,6 +191,10 @@ export function useStarProfilePage() { } async function start() { + if (!configAvailable.value) { + error.value = '星座配置暂未开放' + return + } const y = Number(year.value) const m = Number(month.value) const d = Number(day.value) @@ -188,6 +208,7 @@ export function useStarProfilePage() { async function load() { if (!(await ensureAccount(router, route.fullPath))) return + await refreshConfigGate() const reportId = String(route.query.report_id || '') if (reportId) { loading.value = true @@ -204,6 +225,11 @@ export function useStarProfilePage() { } return } + if (!configAvailable.value) { + needBirth.value = true + report.value = null + return + } loading.value = true try { const cached = await loadSelfLatest('star') @@ -268,6 +294,7 @@ export function useStarProfilePage() { return { loading, needBirth, + configAvailable, error, report, paying, diff --git a/apps/user-h5/src/pages/StarProfilePage.vue b/apps/user-h5/src/pages/StarProfilePage.vue index d289231..d141adc 100644 --- a/apps/user-h5/src/pages/StarProfilePage.vue +++ b/apps/user-h5/src/pages/StarProfilePage.vue @@ -17,8 +17,15 @@
+
+ +

星座配置暂未开放

+

运营尚未启用星座配置,请稍后再来

+ +
+ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 183f84c..94c88e4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-13 +- **ECR-043 Closed:** ExploreConfig StarConfig 薄写面(`admin.explore.write` · POST/PUT · `GET /star/configs` · `/star` 空态/失败回退 · migration `000052`)· **Continuous Loop STOP** · 禁自动 ECR-044 - **ECR-043 Spec Ready:** StarConfig 薄写面(Human 批准 Loop 仅 043)· Coding NOT STARTED · Closed 后禁自动 044 - **ECR-043 Candidate Review 收口:** 首刀 StarConfig;非 SystemPrompt - **ECR-042 Closed:** OpsCMS FeedSlot 薄写面(复用 `admin.cms.write` · `GET /home/feed-slots` · 首页槽位显隐)· 无新 migration diff --git a/docs/CODE_REVIEW/ECR-043.md b/docs/CODE_REVIEW/ECR-043.md new file mode 100644 index 0000000..62b3b29 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-043.md @@ -0,0 +1,3 @@ +# CODE_REVIEW — ECR-043 + +**Verdict:** Approve · StarConfig write only · `admin.explore.write` 加法 · 下架仅 active=false · 无 Rhythm/Card/Scale/Prompt/Knowledge/Chunk · 无 soft-delete/支付/UGC/Crisis·Handoff · Closed 后 STOP(禁自动 ECR-044) diff --git a/docs/CONTRACT_DIFF/ECR-043.yaml b/docs/CONTRACT_DIFF/ECR-043.yaml new file mode 100644 index 0000000..174912d --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-043.yaml @@ -0,0 +1,20 @@ +ecr: ECR-043 +capability: ExploreConfig +change: + type: additive +breaking_change: false +migration_required: true +apis: + - method: POST + path: /api/v1/admin/explore/star-configs + change: added + - method: PUT + path: /api/v1/admin/explore/star-configs/{id} + change: added + - method: GET + path: /api/v1/star/configs + change: added +perms: + - code: admin.explore.write + change: added +migration: 000052_ops_star_config_write diff --git a/docs/ECR/ECR-043-star-config-write.md b/docs/ECR/ECR-043-star-config-write.md index f53138e..38dd15d 100644 --- a/docs/ECR/ECR-043-star-config-write.md +++ b/docs/ECR/ECR-043-star-config-write.md @@ -1,9 +1,9 @@ # ECR-043 **Title:** ExploreConfig · StarConfig 薄写面 -**Status:** **Approved**(Human 2026-08-13)· **Coding: NOT STARTED** +**Status:** **Closed**(2026-08-13) **Change Level:** L2 -**Auth:** ExploreConfig Continuous Loop · **仅本 ECR** +**Auth:** ExploreConfig Continuous Loop · **仅本 ECR** · Closed 后 STOP **Predecessor:** ECR-035 · Candidate Review 通过 ## Change @@ -11,7 +11,7 @@ 1. Spec `ops-star-config-write` 2. `admin.explore.write` + POST/PUT star-configs + 审计 3. C 端 `GET /star/configs`;`/star` 无 active 空态、失败回退 -4. Migration `000052`(编码时)· OpenAPI · admin-h5 +4. Migration `000052` · OpenAPI · admin-h5 ## Forbidden @@ -19,13 +19,8 @@ Rhythm · Card · ScaleDefinition · SystemPrompt/Knowledge/Chunk · soft-delete ## Acceptance -见 Spec §8。 +见 Spec §8 · TEST_REPORT/ECR-043 · CODE_REVIEW Approve. ## Coding gate -```text -Spec Active · BD Approved · repo-governance-check PASS -→ Human/Loop 开码口令后才改 apps/ -``` - -本回合 **禁止业务代码**。 +Done · Closed. diff --git a/docs/HANDOFF/ECR-043-architect-to-engineer.md b/docs/HANDOFF/ECR-043-architect-to-engineer.md index 636cc3a..40d33f6 100644 --- a/docs/HANDOFF/ECR-043-architect-to-engineer.md +++ b/docs/HANDOFF/ECR-043-architect-to-engineer.md @@ -1,6 +1,6 @@ # HANDOFF — Architect → Engineer · ECR-043 -**Coding NOT STARTED** until explicit 开码. +**Coding STARTED / DONE** — Human「ECR-043 开码」. Do only StarConfig write per Spec. Perm `admin.explore.write`. Migration 000052. -After Closed: STOP — no ECR-044. +After Closed: STOP — no ECR-044. diff --git a/docs/HANDOFF/ECR-043-engineer-to-reviewer.md b/docs/HANDOFF/ECR-043-engineer-to-reviewer.md new file mode 100644 index 0000000..1979767 --- /dev/null +++ b/docs/HANDOFF/ECR-043-engineer-to-reviewer.md @@ -0,0 +1,4 @@ +# HANDOFF — Engineer → Reviewer · ECR-043 + +TestExploreStarConfigWrite PASS · repo-governance-check PASS (max 000052) · Ready Closed. +禁止自动开 ECR-044 · ExploreConfig Loop STOP. diff --git a/docs/PRODUCT_SPEC/ECR-043-star-config-write.md b/docs/PRODUCT_SPEC/ECR-043-star-config-write.md index 0314fdf..ea7e47d 100644 --- a/docs/PRODUCT_SPEC/ECR-043-star-config-write.md +++ b/docs/PRODUCT_SPEC/ECR-043-star-config-write.md @@ -1,3 +1,3 @@ # PRODUCT_SPEC — ECR-043 -对齐 `ops-star-config-write.md` · Human Approved · L2 · **仅 StarConfig** · Coding NOT STARTED. +对齐 `ops-star-config-write.md` · Human Approved · L2 · **仅 StarConfig** · **Closed**. diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 673ba10..6aa93a3 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -53,19 +53,19 @@ - **本仓:** `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-044`(禁自动开)** · **Max=`000051`**(043 编码→052) +- **Next ECR / Max Migration:** **Next=`ECR-044`(禁自动开)** · **Max=`000052`** - **Write-Wave CMS:** 041–042 Closed · STOP -- **ExploreConfig:** Auth Approved · **ECR-043 StarConfig Spec Ready · Coding NOT STARTED** · Closed 后 STOP +- **ExploreConfig:** Auth Approved · **ECR-043 Closed · STOP** · 禁自动 ECR-044 - EXP: (无) -- STATE: `docs/STATE/`(含 ECR-041) +- STATE: `docs/STATE/`(含 ECR-041…043) - 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` +- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write` · `ops-star-config-write` ## WIP(尚未单独 ECR) -- **ECR-043** StarConfig 写面:Spec/ECR/BD Approved · **禁止业务代码直至「ECR-043 开码」** +- (无 · ECR-043 Closed · Loop STOP) ## Pointers diff --git a/docs/STATE/ECR-043.md b/docs/STATE/ECR-043.md new file mode 100644 index 0000000..0da1b2c --- /dev/null +++ b/docs/STATE/ECR-043.md @@ -0,0 +1,7 @@ +# STATE — ECR-043 + +| Status | **Closed** | +| Spec | ops-star-config-write | +| Migration | 000052_ops_star_config_write | +| Closed | 2026-08-13 | +| Next | STOP · ECR-044 禁自动开 | diff --git a/docs/TEST_REPORT/ECR-043.md b/docs/TEST_REPORT/ECR-043.md new file mode 100644 index 0000000..e2e6f0d --- /dev/null +++ b/docs/TEST_REPORT/ECR-043.md @@ -0,0 +1,11 @@ +# TEST_REPORT — ECR-043 StarConfig Write + +**Commit:** (see git after Close) + +```bash +go test ./internal/integration/ -count=1 -run TestExploreStarConfigWrite +python3 scripts/repo-governance-check.py +``` + +PASS · AC create/409/C-end hide inactive/empty-all/audit/403 read-only · migration 000052 +REPO GOVERNANCE: PASS · max migration 000052 · suggested Next ECR-044(禁自动开) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index af2300b..fad7d82 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,8 +4,8 @@ | Anchor | Value | Rule | |--------|-------|------| -| **Next ECR** | `ECR-044` | **冻结:** 043 Closed 前勿占;043 Closed 后须重新 Candidate Review,禁止自动开刀 | -| **Max Migration** | `000051` | ECR-043 编码时占 `000052`;禁止凭记忆 | +| **Next ECR** | `ECR-044` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** | +| **Max Migration** | `000052` | 禁止凭记忆 | - ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。 - Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。 @@ -67,7 +67,7 @@ | WRITE-WAVE | Ops CMS 写面(041–042) | **CMS 段 Closed** | `WRITE_WAVE_AUTHORIZATION.md` | | ECR-041 | OpsCMS · Banner 薄写面 | **Closed** | Spec ops-banner-write · BD-2026-041 · migration **000051** | | ECR-042 | OpsCMS · FeedSlot 薄写面 | **Closed** | Spec ops-feed-slot-write · BD-2026-042 · 无新 migration · 复用 cms.write | -| EXPLORE-WRITE | ExploreConfig 写面授权 | **Approved · 仅 ECR-043** | `EXPLORE_CONFIG_WRITE_AUTHORIZATION.md` | -| ECR-043 | ExploreConfig · StarConfig 薄写面 | **Approved · Coding NOT STARTED** | Spec ops-star-config-write · BD-2026-043 · Closed 后 STOP | +| 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 | > **Migration:** 合并后 `000015`–`000023` 曾撞号,已重编号至 `000050`。以 `apps/api/migrations/` 与 `docs/MIGRATION_RENUMBER.md` 为准(文档中旧号引用可能滞后)。 diff --git a/docs/WAVE0/ECR-043-CANDIDATE_REVIEW.md b/docs/WAVE0/ECR-043-CANDIDATE_REVIEW.md index 0706c39..5186314 100644 --- a/docs/WAVE0/ECR-043-CANDIDATE_REVIEW.md +++ b/docs/WAVE0/ECR-043-CANDIDATE_REVIEW.md @@ -1,6 +1,6 @@ # ECR-043 Candidate Review — ExploreConfig 写面 -> **RESOLVED 2026-08-13:** 首刀 = **StarConfig** · ExploreConfig Loop **仅 ECR-043** · Spec Ready · Coding NOT STARTED +> **RESOLVED 2026-08-13:** 首刀 = **StarConfig** · ExploreConfig Loop **仅 ECR-043** · **Closed · STOP** > 权威 Spec:`.ai/product/feature-spec/ops-star-config-write.md` · Auth:`EXPLORE_CONFIG_WRITE_AUTHORIZATION.md` **Date:** 2026-08-13 diff --git a/docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md b/docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md index 06d10e4..dcd115a 100644 --- a/docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md +++ b/docs/WAVE0/EXPLORE_CONFIG_WRITE_AUTHORIZATION.md @@ -4,9 +4,9 @@ |-------|-------| | Date | 2026-08-13 | | Authorizer | Human | -| Status | **Approved** | +| Status | **Approved · ECR-043 Closed · Loop STOP** | | Scope | **ECR-043 · StarConfig 薄写面 ONLY** | -| Continuous Loop | **Allowed inside ECR-043 only** | +| Continuous Loop | **Finished · STOP** | | After Closed | **STOP** · 禁止自动 ECR-044 | ## Human 拍板(2026-08-13) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a91d0ed..4452060 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -302,6 +302,15 @@ export function createClient(opts: CreateClientOptions) { active: boolean }> }>(`/api/v1/home/feed-slots?placement=${encodeURIComponent(placement)}`), + getStarConfigs: () => + call<{ + items: Array<{ + id: string + code: string + title: string + active: boolean + }> + }>('/api/v1/star/configs'), getHomeDailyTips: () => call('/api/v1/home/daily-tips'), } } diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 1ec5967..e502a3e 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -1182,6 +1182,21 @@ paths: description: Unauthorized '403': description: Forbidden + post: + tags: [admin] + summary: Create StarConfig + description: Requires admin.explore.write · ECR-043 + responses: + '200': + description: OK + '400': + description: Invalid + '401': + description: Unauthorized + '403': + description: Forbidden + '409': + description: Code conflict /api/v1/admin/explore/star-configs/{id}: get: @@ -1197,6 +1212,26 @@ paths: description: OK '404': description: Not found + put: + tags: [admin] + summary: Update StarConfig + description: Requires admin.explore.write · deactivate via active=false · ECR-043 + 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/explore/rhythm-configs: get: @@ -1540,6 +1575,15 @@ paths: '200': description: OK + /api/v1/star/configs: + get: + tags: [system] + summary: Active StarConfig list for C-end (ECR-043) + description: DeviceAuth · only active=true · ordered by code + responses: + '200': + description: OK + /api/v1/home/daily-tips: get: tags: [system]