From 00a798bd85a558ff406b507957d4d0543ceac757 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:39:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ECR-017):=20AskOperations=20=E9=97=AE?= =?UTF-8?q?=E7=AD=94=E4=BC=9A=E8=AF=9D=E5=8F=AA=E8=AF=BB=E5=B9=B6=20Closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 admin.ask.read、GET /admin/ask/threads*(AskSessionView)与 admin-h5「问答」页;禁改消息/UGC/真支付。 Co-authored-by: Cursor --- .ai/domain/domain-map.md | 2 +- .ai/domain/entity-catalog.md | 2 +- .ai/product/feature-map.md | 2 +- .ai/product/feature-spec/README.md | 1 + .ai/product/feature-spec/ops-admin.md | 3 +- .../feature-spec/ops-ask-operations.md | 41 ++++++ apps/admin-h5/src/api/client.ts | 25 ++++ apps/admin-h5/src/layouts/AdminShell.vue | 1 + apps/admin-h5/src/pages/AskPage.vue | 127 ++++++++++++++++++ apps/admin-h5/src/router/index.ts | 1 + apps/api/internal/handler/admin.go | 1 + apps/api/internal/handler/admin_ask_ops.go | 57 ++++++++ apps/api/internal/integration/ask_ops_test.go | 115 ++++++++++++++++ apps/api/internal/repository/ask_ops_repo.go | 104 ++++++++++++++ apps/api/internal/service/admin/ask_ops.go | 50 +++++++ apps/api/internal/service/admin/rbac.go | 2 + .../migrations/000019_ask_ops_read.down.sql | 3 + .../api/migrations/000019_ask_ops_read.up.sql | 7 + .../BD-2026-017-ask-operations.md | 24 ++++ docs/CHANGELOG.md | 1 + docs/CODE_REVIEW/ECR-017.md | 9 ++ docs/CONTRACT_DIFF/ECR-017.yaml | 23 ++++ docs/ECR/ECR-017-ask-operations.md | 19 +++ .../ECR-017-ask-operations.md | 7 + docs/HANDOFF/ECR-017-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-017-engineer-to-reviewer.md | 3 + docs/PRODUCT_SPEC/ECR-017-ask-operations.md | 3 + docs/STATE/ECR-017.md | 6 + docs/TASKS/TASK-017-ECR017.yaml | 12 ++ docs/TEST_REPORT/ECR-017.md | 31 +++++ docs/TRACEABILITY.md | 1 + docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- proto/openapi.yaml | 39 ++++++ 33 files changed, 722 insertions(+), 5 deletions(-) create mode 100644 .ai/product/feature-spec/ops-ask-operations.md create mode 100644 apps/admin-h5/src/pages/AskPage.vue create mode 100644 apps/api/internal/handler/admin_ask_ops.go create mode 100644 apps/api/internal/integration/ask_ops_test.go create mode 100644 apps/api/internal/repository/ask_ops_repo.go create mode 100644 apps/api/internal/service/admin/ask_ops.go create mode 100644 apps/api/migrations/000019_ask_ops_read.down.sql create mode 100644 apps/api/migrations/000019_ask_ops_read.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-017-ask-operations.md create mode 100644 docs/CODE_REVIEW/ECR-017.md create mode 100644 docs/CONTRACT_DIFF/ECR-017.yaml create mode 100644 docs/ECR/ECR-017-ask-operations.md create mode 100644 docs/ENGINEERING_SPEC/ECR-017-ask-operations.md create mode 100644 docs/HANDOFF/ECR-017-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-017-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-017-ask-operations.md create mode 100644 docs/STATE/ECR-017.md create mode 100644 docs/TASKS/TASK-017-ECR017.yaml create mode 100644 docs/TEST_REPORT/ECR-017.md diff --git a/.ai/domain/domain-map.md b/.ai/domain/domain-map.md index 6ff71f2..c439728 100644 --- a/.ai/domain/domain-map.md +++ b/.ai/domain/domain-map.md @@ -37,7 +37,7 @@ | Ops_Content | ExploreConfig (partial) | Shipped Ops-C | | Account_Risk | AccountLifecycle | Spec via ECR-013B(Identity_Profile owns UserStatus) | | Content_Safety | ContentSafety · CrisisCare | Draft | -| Ask_Ops | AskOperations · AICoreConfig | Draft | +| Ask_Ops | AskOperations · AICoreConfig | AskOperations **ECR-017**(只读);AICoreConfig Draft | | Ops_CMS_NoUGC | OpsCMS | Draft | | Community | — | **Forbidden** | diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index 63d8ce6..4cf4127 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -32,7 +32,7 @@ Status: `Draft` | ModerationCase | Content_Safety | ContentSafety | 后置 | | CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 | | SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | 后置 | -| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | 后置 | +| AskSessionView / QualityFeedback | Ask_Ops | AskOperations | **ECR-017** AskSessionView 只读;QualityFeedback 后置 | | Banner / FeedSlot | Ops_CMS_NoUGC | OpsCMS | 后置 | | UGC* | Community | — | **Forbidden** | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 77b6c04..82b5dbc 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -235,7 +235,7 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。 | Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) | | Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) | | Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md`) | -| Phase D+ | **Contract-First**:**ECR-013A/B/014/015 Closed** → **ECR-016** UserIntelligence(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A/B/014/015/016 Closed** → **ECR-017** AskOperations(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | | 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 | 不计入 P1 Complete;不进入五 Tab。 diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index ab8089e..0cf0075 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -26,6 +26,7 @@ | [ops-rbac.md](ops-rbac.md) | 运营 RBAC | §7 | `admin-h5` `/me` permissions · `/admin/roles*` | Ops-D · **ECR-013A Closed** | | [ops-redemption-code.md](ops-redemption-code.md) | 兑换码 RedemptionCode | §7 | `admin-h5` `/codes` · `POST /membership/redeem` | Ops-D · **ECR-015 Closed** | | [ops-user-intelligence.md](ops-user-intelligence.md) | 用户洞察 UserIntelligence | §7 | `admin-h5` 用户详情「洞察」· `GET /admin/users/:id/insight` | Ops-D · **ECR-016 Closed** | +| [ops-ask-operations.md](ops-ask-operations.md) | 问答运营 AskOperations | §7 | `admin-h5` `/ask` · `GET /admin/ask/threads*` | Ops-D · **ECR-017 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index 68fc6b1..cc0cd87 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -213,6 +213,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **G(ECR-014 Closed)** | MembershipPlan — Spec `ops-membership-plan.md` | | **H(ECR-015 Closed)** | RedemptionCode — Spec `ops-redemption-code.md` | | **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` | -| 后置 | AskOperations / Entitlement 细权(Loop 续跑) | +| **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` | +| 后置 | Entitlement 细权 / QualityFeedback / AICoreConfig(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-ask-operations.md b/.ai/product/feature-spec/ops-ask-operations.md new file mode 100644 index 0000000..d8841a0 --- /dev/null +++ b/.ai/product/feature-spec/ops-ask-operations.md @@ -0,0 +1,41 @@ +# Feature Spec: AskOperations(Ops · ECR-017) + +> Status: `Active`(Loop continuous · **ECR-017 Closed**) +> Parent: WAVE0-FROZEN · Predecessor: ECR-016 Closed +> Capability: `AskOperations` · BC: `Ask_Ops` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +改写/删除消息正文 · SystemPrompt/Knowledge 配置 · QualityFeedback 写回 · AICoreConfig · 真支付 · UGC + +## L2 读模型 + +| 概念 | 语义 | +|------|------| +| `AskSessionView` | 线程元数据 + message_count(列表) | +| 会话详情 | 只读消息序列(role/content/created_at) | + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/ask/threads` | `admin.ask.read` | 列表(可选 `user_id`) | +| GET | `/admin/ask/threads/:id` | `admin.ask.read` | 详情含消息 | + +## Migration + +`000019`:为 `super_admin` 授予 `admin.ask.read`(无新业务表) + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | 有消息线程 → list 含该 thread · message_count≥1 | +| AC-F-02 | detail 返回 user+assistant 消息 | +| AC-S-01 | 无 Admin → 401 | +| AC-S-02 | 无 ask.read → 403 | +| AC-P-01 | list < 500ms 本机 | +| AC-O-01 | N/A 只读(无写审计强制) | + +contract_diff: `docs/CONTRACT_DIFF/ECR-017.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index fe68289..f9002ee 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -196,6 +196,31 @@ export const adminApi = { `/redemption-batches/${batchId}/codes`, ), disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`), + askThreads: (userId = '') => { + const q = userId ? `?user_id=${encodeURIComponent(userId)}` : '' + return request<{ + items: Array<{ + id: string + user_id: string + profile_id: string + scene?: string + message_count: number + created_at: string + updated_at: string + }> + }>('GET', `/ask/threads${q}`) + }, + askThread: (id: string) => + request<{ + id: string + user_id: string + profile_id: string + scene?: string + message_count: number + created_at: string + updated_at: string + messages: Array<{ id: string; role: string; content: string; created_at: string }> + }>('GET', `/ask/threads/${id}`), orders: () => request<{ items: Array<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index ae3be53..3d9aed1 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -30,6 +30,7 @@ async function onLogout() { 用户 套餐 兑换码 + 问答 订单 审计 diff --git a/apps/admin-h5/src/pages/AskPage.vue b/apps/admin-h5/src/pages/AskPage.vue new file mode 100644 index 0000000..699e268 --- /dev/null +++ b/apps/admin-h5/src/pages/AskPage.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index a326be0..30635e0 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -17,6 +17,7 @@ const router = createRouter({ { path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') }, { path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.vue') }, { path: 'codes', name: 'codes', component: () => import('@/pages/RedemptionPage.vue') }, + { path: 'ask', name: 'ask', component: () => import('@/pages/AskPage.vue') }, { path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') }, ], }, diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index dedb3ee..76e0c70 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -47,6 +47,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerMembershipPlans(authed) h.registerRedemption(authed) h.registerInsight(authed) + h.registerAskOps(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_ask_ops.go b/apps/api/internal/handler/admin_ask_ops.go new file mode 100644 index 0000000..6805514 --- /dev/null +++ b/apps/api/internal/handler/admin_ask_ops.go @@ -0,0 +1,57 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +func (h *AdminHandler) registerAskOps(authed *gin.RouterGroup) { + authed.GET("/ask/threads", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.ListAskThreads) + authed.GET("/ask/threads/:id", middleware.RequireAdminPermission(h.Svc, admin.PermAskRead), h.GetAskThread) +} + +func (h *AdminHandler) ListAskThreads(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + var userID *uuid.UUID + if q := c.Query("user_id"); q != "" { + id, err := uuid.Parse(q) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40002, "invalid user_id") + return + } + userID = &id + } + items, err := h.Svc.ListAskSessions(c.Request.Context(), userID, limit, offset) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50019, "list ask threads failed") + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) GetAskThread(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40002, "invalid thread id") + return + } + detail, err := h.Svc.GetAskSessionDetail(c.Request.Context(), id) + if errors.Is(err, admin.ErrAskThreadNotFound) { + response.Fail(c, http.StatusNotFound, 40402, "ask thread not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50020, "get ask thread failed") + return + } + response.OK(c, detail) +} diff --git a/apps/api/internal/integration/ask_ops_test.go b/apps/api/internal/integration/ask_ops_test.go new file mode 100644 index 0000000..5642119 --- /dev/null +++ b/apps/api/internal/integration/ask_ops_test.go @@ -0,0 +1,115 @@ +package integration_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func TestAskOperations(t *testing.T) { + r, pool := setupAPIPool(t) + ctx := context.Background() + tok := adminLogin(t, r, "admin", "change-me") + + _, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", code) + } + + limitedRoleID := uuid.New() + _, err := pool.Exec(ctx, ` + INSERT INTO admin_roles(id, name, system) VALUES ($1, $2, false)`, + limitedRoleID, "ask_lim_"+limitedRoleID.String()[:8]) + if err != nil { + t.Fatalf("insert role: %v", err) + } + _, err = pool.Exec(ctx, ` + INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID) + if err != nil { + t.Fatal(err) + } + hash, err := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost) + if err != nil { + t.Fatal(err) + } + limitedUser := fmt.Sprintf("asklim_%d", time.Now().UnixNano()) + _, err = pool.Exec(ctx, ` + INSERT INTO admin_accounts(username, password_hash, role_id) + VALUES ($1,$2,$3)`, limitedUser, string(hash), limitedRoleID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, limitedUser) + _, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID) + }) + + limTok := adminLogin(t, r, limitedUser, "limited-pass") + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, limTok) + if code != http.StatusForbidden { + t.Fatalf("expected 403 without ask.read, got %d", code) + } + + key := mustRegister(t, r) + env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1992-06-01", "display_name": "问", + }, key) + profileID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{ + "profile_id": profileID, "scene": "self", + }, key) + threadID := decodeData[map[string]any](t, env.Data)["id"].(string) + + _, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{ + "content": "运营可读吗", + }, key) + + start := time.Now() + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads", nil, tok) + if code != 200 || env.Code != 0 { + t.Fatalf("list http=%d code=%d msg=%s", code, env.Code, env.Message) + } + if time.Since(start) > 500*time.Millisecond { + t.Fatalf("list too slow: %v", time.Since(start)) + } + var list struct { + Items []struct { + ID string `json:"id"` + MessageCount int `json:"message_count"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &list) + found := false + for _, it := range list.Items { + if it.ID == threadID && it.MessageCount >= 1 { + found = true + break + } + } + if !found { + t.Fatalf("expected thread %s in list: %#v", threadID, list.Items) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/ask/threads/"+threadID, nil, tok) + if code != 200 { + t.Fatalf("detail http=%d msg=%s", code, env.Message) + } + var detail struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + _ = json.Unmarshal(env.Data, &detail) + if len(detail.Messages) < 2 { + t.Fatalf("expected user+assistant, got %#v", detail.Messages) + } + _ = key +} diff --git a/apps/api/internal/repository/ask_ops_repo.go b/apps/api/internal/repository/ask_ops_repo.go new file mode 100644 index 0000000..c13cb62 --- /dev/null +++ b/apps/api/internal/repository/ask_ops_repo.go @@ -0,0 +1,104 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// AskSessionView is ops read meta for one ask thread. +type AskSessionView struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProfileID uuid.UUID `json:"profile_id"` + Scene *string `json:"scene,omitempty"` + MessageCount int `json:"message_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AskMessageView is a read-only message row for ops. +type AskMessageView struct { + ID uuid.UUID `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +// ListAskSessions returns recent ask threads (optional user filter). +func (r *AdminRepo) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]AskSessionView, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + rows, err := r.Pool.Query(ctx, ` + SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at, + (SELECT count(*)::int FROM ask_messages m + WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count + FROM ask_threads t + WHERE t.deleted_at IS NULL + AND ($1::uuid IS NULL OR t.user_id=$1) + ORDER BY t.updated_at DESC + LIMIT $2 OFFSET $3`, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AskSessionView + for rows.Next() { + var s AskSessionView + if err := rows.Scan( + &s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount, + ); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// GetAskSession loads one thread meta or ErrNoRows. +func (r *AdminRepo) GetAskSession(ctx context.Context, threadID uuid.UUID) (*AskSessionView, error) { + var s AskSessionView + err := r.Pool.QueryRow(ctx, ` + SELECT t.id, t.user_id, t.profile_id, t.scene, t.created_at, t.updated_at, + (SELECT count(*)::int FROM ask_messages m + WHERE m.thread_id=t.id AND m.deleted_at IS NULL) AS message_count + FROM ask_threads t + WHERE t.id=$1 AND t.deleted_at IS NULL`, threadID, + ).Scan(&s.ID, &s.UserID, &s.ProfileID, &s.Scene, &s.CreatedAt, &s.UpdatedAt, &s.MessageCount) + if errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err != nil { + return nil, err + } + return &s, nil +} + +// ListAskMessagesForAdmin returns messages oldest-first. +func (r *AdminRepo) ListAskMessagesForAdmin(ctx context.Context, threadID uuid.UUID) ([]AskMessageView, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, role, content, created_at + FROM ask_messages + WHERE thread_id=$1 AND deleted_at IS NULL + ORDER BY created_at ASC`, threadID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AskMessageView + for rows.Next() { + var m AskMessageView + if err := rows.Scan(&m.ID, &m.Role, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} diff --git a/apps/api/internal/service/admin/ask_ops.go b/apps/api/internal/service/admin/ask_ops.go new file mode 100644 index 0000000..ba5d1b7 --- /dev/null +++ b/apps/api/internal/service/admin/ask_ops.go @@ -0,0 +1,50 @@ +package admin + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// AskSessionDetail is AskSessionView plus messages. +type AskSessionDetail struct { + repository.AskSessionView + Messages []repository.AskMessageView `json:"messages"` +} + +var ErrAskThreadNotFound = errString("ask thread not found") + +// ListAskSessions lists AskSessionView rows. +func (s *Service) ListAskSessions(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]repository.AskSessionView, error) { + items, err := s.Repo.ListAskSessions(ctx, userID, limit, offset) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.AskSessionView{} + } + return items, nil +} + +// GetAskSessionDetail loads meta + messages. +func (s *Service) GetAskSessionDetail(ctx context.Context, threadID uuid.UUID) (*AskSessionDetail, error) { + view, err := s.Repo.GetAskSession(ctx, threadID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAskThreadNotFound + } + if err != nil { + return nil, err + } + msgs, err := s.Repo.ListAskMessagesForAdmin(ctx, threadID) + if err != nil { + return nil, err + } + if msgs == nil { + msgs = []repository.AskMessageView{} + } + return &AskSessionDetail{AskSessionView: *view, Messages: msgs}, nil +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 5fd4745..bf9889a 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -24,6 +24,7 @@ const ( PermMembershipPlansWrite = "admin.membership.plans.write" PermMembershipCodesRead = "admin.membership.codes.read" PermMembershipCodesWrite = "admin.membership.codes.write" + PermAskRead = "admin.ask.read" ) var knownPermissions = map[string]struct{}{ @@ -32,6 +33,7 @@ var knownPermissions = map[string]struct{}{ PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {}, PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, + PermAskRead: {}, } var ( diff --git a/apps/api/migrations/000019_ask_ops_read.down.sql b/apps/api/migrations/000019_ask_ops_read.down.sql new file mode 100644 index 0000000..2888dca --- /dev/null +++ b/apps/api/migrations/000019_ask_ops_read.down.sql @@ -0,0 +1,3 @@ +-- ECR-017 down + +DELETE FROM admin_role_permissions WHERE code = 'admin.ask.read'; diff --git a/apps/api/migrations/000019_ask_ops_read.up.sql b/apps/api/migrations/000019_ask_ops_read.up.sql new file mode 100644 index 0000000..e0a1859 --- /dev/null +++ b/apps/api/migrations/000019_ask_ops_read.up.sql @@ -0,0 +1,7 @@ +-- ECR-017 AskOperations: admin.ask.read + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, 'admin.ask.read' +FROM admin_roles r +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-017-ask-operations.md b/docs/BACKEND_DESIGN/BD-2026-017-ask-operations.md new file mode 100644 index 0000000..dfa24d8 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-017-ask-operations.md @@ -0,0 +1,24 @@ +# Backend Design: ECR-017 AskOperations + +| ID | BD-2026-017 | +| Status | Approved | +| Coding | Loop authorized | +| Level | L2 | +| Migration | YES 000019(权限 only) | + +## Backend Change Boundary + +```text +Domain: AskSessionView (read) +App: AdminHandler → admin.Service → AdminRepo +API: GET /api/v1/admin/ask/threads[+/:id] +Permission: admin.ask.read +Migration: 000019 grant to super_admin +UI: admin-h5 /ask +``` + +## Out of boundary + +Message write/delete · SystemPrompt · Knowledge* · QualityFeedback write · UGC · Payment + +Rollback: down migration revoke permission + remove routes/UI diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 16b7c57..4643214 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- **ECR-017 Closed**:AskOperations(AskSessionView 只读 · `admin.ask.read` · admin-h5 `/ask` · migration 000019) - **ECR-016 Closed**:UserIntelligence(`GET /admin/users/:id/insight` 只读聚合 · admin-h5 洞察 Tab · Migration NO) - **ECR-015 Closed**:RedemptionCode(批次生成 · C端兑码 · 作废 · 审计) - **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表) diff --git a/docs/CODE_REVIEW/ECR-017.md b/docs/CODE_REVIEW/ECR-017.md new file mode 100644 index 0000000..c2dbc3b --- /dev/null +++ b/docs/CODE_REVIEW/ECR-017.md @@ -0,0 +1,9 @@ +# CODE_REVIEW — ECR-017 + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Loop continuous + +- Additive AskSessionView 只读;migration 000019 仅权限 +- Handler → Service → Repository;无消息写 / UGC / 真支付 +- Integration AC mapped · OpenAPI updated diff --git a/docs/CONTRACT_DIFF/ECR-017.yaml b/docs/CONTRACT_DIFF/ECR-017.yaml new file mode 100644 index 0000000..8adbc75 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-017.yaml @@ -0,0 +1,23 @@ +ecr: ECR-017 +capability: AskOperations +bounded_context: Ask_Ops +parent: WAVE0-FROZEN +predecessor: ECR-016 +change: + type: additive +breaking_change: false +migration_required: true +compatibility_notes: > + Additive admin read APIs for AskSessionView. + Migration 000019 only grants admin.ask.read to super_admin. + +apis: + - method: GET + path: /api/v1/admin/ask/threads + change: added + - method: GET + path: /api/v1/admin/ask/threads/{id} + change: added +perms: + - code: admin.ask.read + change: added diff --git a/docs/ECR/ECR-017-ask-operations.md b/docs/ECR/ECR-017-ask-operations.md new file mode 100644 index 0000000..5552ea2 --- /dev/null +++ b/docs/ECR/ECR-017-ask-operations.md @@ -0,0 +1,19 @@ +# ECR-017 + +**Title:** AskOperations(问答会话只读薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop continuous) +**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-016 Closed +**Change Level:** L2 + +## Change + +`GET /admin/ask/threads` · `GET /admin/ask/threads/:id`(AskSessionView);权限 `admin.ask.read`;admin-h5「问答」页。 + +## Forbidden + +改消息 · Prompt/Knowledge 配置 · QualityFeedback 写 · 真支付 · UGC + +## Linked + +Spec `ops-ask-operations.md` · BD-2026-017 · CONTRACT_DIFF/ECR-017.yaml · TEST_REPORT/ECR-017.md diff --git a/docs/ENGINEERING_SPEC/ECR-017-ask-operations.md b/docs/ENGINEERING_SPEC/ECR-017-ask-operations.md new file mode 100644 index 0000000..8dc0ffc --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-017-ask-operations.md @@ -0,0 +1,7 @@ +# ENGINEERING_SPEC — ECR-017 + +1. migration 000019 授予 admin.ask.read +2. AdminRepo list/get AskSessionView +3. GET /admin/ask/threads* + OpenAPI +4. admin-h5 /ask +5. Integration · Closed diff --git a/docs/HANDOFF/ECR-017-architect-to-engineer.md b/docs/HANDOFF/ECR-017-architect-to-engineer.md new file mode 100644 index 0000000..105cb8b --- /dev/null +++ b/docs/HANDOFF/ECR-017-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-017 Architect → Engineer + +Loop continuous · Approved + Coding. Migration 000019 permission only. Forbidden: 改消息/UGC/真支付/Prompt 配置. diff --git a/docs/HANDOFF/ECR-017-engineer-to-reviewer.md b/docs/HANDOFF/ECR-017-engineer-to-reviewer.md new file mode 100644 index 0000000..4d00b6e --- /dev/null +++ b/docs/HANDOFF/ECR-017-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-017 Engineer → Reviewer + +TestAskOperations PASS · admin-h5 /ask · Ready for Closed. diff --git a/docs/PRODUCT_SPEC/ECR-017-ask-operations.md b/docs/PRODUCT_SPEC/ECR-017-ask-operations.md new file mode 100644 index 0000000..e5b40dc --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-017-ask-operations.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-017 + +对齐 ops-ask-operations.md · Approved · Loop · L2 · AskSessionView 只读 diff --git a/docs/STATE/ECR-017.md b/docs/STATE/ECR-017.md new file mode 100644 index 0000000..2413496 --- /dev/null +++ b/docs/STATE/ECR-017.md @@ -0,0 +1,6 @@ +# STATE — ECR-017 + +| Status | **Closed** | +| Phase | closed | +| Spec | ops-ask-operations.md | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-017-ECR017.yaml b/docs/TASKS/TASK-017-ECR017.yaml new file mode 100644 index 0000000..93413c0 --- /dev/null +++ b/docs/TASKS/TASK-017-ECR017.yaml @@ -0,0 +1,12 @@ +id: TASK-017-ECR017 +ecr: ECR-017 +title: AskOperations implement +role: engineer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-016 +acceptance: + - Spec AC mapped + - Read-only AskSessionView + - No message write / UGC / payment diff --git a/docs/TEST_REPORT/ECR-017.md b/docs/TEST_REPORT/ECR-017.md new file mode 100644 index 0000000..2231063 --- /dev/null +++ b/docs/TEST_REPORT/ECR-017.md @@ -0,0 +1,31 @@ +# TEST_REPORT — ECR-017 AskOperations + +Date: 2026-08-07 · Loop continuous · commit: (pending) + +## Commands + +```bash +cd apps/api && go test ./internal/integration/ -run TestAskOperations -count=1 +npm run build:admin +python3 scripts/ess-validate.py --phase review --ecr ECR-017 +python3 scripts/ess-gate-check.py --ecr ECR-017 +``` + +## Results + +| Check | Result | +|-------|--------| +| TestAskOperations | PASS | +| build:admin | PASS | +| ess-validate review | PASS | + +## AC + +| ID | Evidence | +|----|----------| +| AC-F-01 | list 含 thread · message_count≥1 | +| AC-F-02 | detail ≥2 messages | +| AC-S-01 | 无 token → 401 | +| AC-S-02 | 仅 users.read → 403 | +| AC-P-01 | list < 500ms | +| AC-O-01 | N/A 只读 | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 9e275fe..106a370 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -21,3 +21,4 @@ | ECR-013B | AccountLifecycle | **Closed** | Spec ops-account-lifecycle · BD-2026-013B · TEST_REPORT · Parent WAVE0-FROZEN | | ECR-014 | MembershipPlan | **Closed** | Spec ops-membership-plan · BD-2026-014 · TEST_REPORT · Loop continuous | | ECR-016 | UserIntelligence | **Closed** | Spec ops-user-intelligence · BD-2026-016 · TEST_REPORT · CODE_REVIEW · Loop continuous | +| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 · migration 000019 · TEST_REPORT · CODE_REVIEW · Loop continuous | diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 4f3fc81..2ca0d8c 100644 --- a/docs/WAVE0/LOOP_AUTHORIZATION.md +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -30,4 +30,4 @@ Human 明文:**直接用 Loop,不用人工确认。** | Done | Next | |------|------| -| ECR-013A/B/014/015/016 Closed | **ECR-017** AskOperations 薄切片(只读会话视图优先);禁真支付/UGC | +| ECR-013A/B/014/015/016/017 Closed | **ECR-018** Entitlement 细权薄切片(或 ContentSafety);禁真支付/UGC | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 72c2cd6..7c3046c 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -359,6 +359,45 @@ paths: schema: { type: string, format: uuid } responses: { '200': { description: OK } } + /api/v1/admin/ask/threads: + get: + tags: [admin] + summary: List AskSessionView (read-only) + description: Requires admin.ask.read + parameters: + - in: query + name: user_id + schema: { type: string, format: uuid } + - in: query + name: limit + schema: { type: integer } + - in: query + name: offset + schema: { type: integer } + responses: + '200': + description: OK + '401': + description: Unauthorized + '403': + description: Forbidden + + /api/v1/admin/ask/threads/{id}: + get: + tags: [admin] + summary: Ask session detail with messages + description: Requires admin.ask.read; no message mutation + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + '404': + description: Not found + /api/v1/admin/orders: get: tags: [admin]