From d33c8fdfe931cfbf603c4ddee1c6e15cc1622c18 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 15:48:19 +0800 Subject: [PATCH 01/60] =?UTF-8?q?feat(ECR-012):=20=E6=98=9F=E5=BA=A7?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E6=94=B6=E5=8F=A3=EF=BC=8C=E5=B9=B6=20Closed?= =?UTF-8?q?=20ECR-007/008?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 outlook/分享 type=star/报告页运势面板与测试;流程上关闭 Ops-B/C 两张 ECR。真支付仍后置。 Co-authored-by: Cursor --- .ai/product/feature-map.md | 14 +-- .ai/product/feature-spec/P2-BACKLOG.md | 5 +- .ai/product/feature-spec/README.md | 6 +- .ai/product/feature-spec/ops-admin.md | 4 +- .ai/product/feature-spec/ops-analytics.md | 2 +- .ai/product/feature-spec/ops-content.md | 2 +- .ai/product/feature-spec/star-profile.md | 4 +- .../api/internal/integration/p2_flows_test.go | 3 + apps/user-h5/e2e/p2-explore-paths.spec.ts | 25 +++- apps/user-h5/src/components/ShareCard.vue | 11 +- apps/user-h5/src/components/ShareSheet.vue | 14 ++- .../src/components/report/ReportStarPanel.vue | 45 ++------ .../src/components/star/StarFortunePanel.vue | 21 +--- .../src/composables/useStarProfilePage.ts | 6 +- apps/user-h5/src/lib/reportSummary.ts | 25 +++- apps/user-h5/src/lib/shareLink.spec.ts | 17 +++ apps/user-h5/src/lib/shareLink.ts | 29 +++-- apps/user-h5/src/pages/SharePage.vue | 39 +++++-- .../user-h5/src/pages/StarProfilePage.spec.ts | 28 +++-- .../BD-2026-012-star-alignment.md | 107 ++++++++++++++++++ docs/CHANGELOG.md | 8 ++ docs/CODE_REVIEW/ECR-007.md | 11 +- docs/CODE_REVIEW/ECR-008.md | 9 ++ docs/ECR/ECR-007-ops-analytics.md | 6 +- docs/ECR/ECR-008-ops-content.md | 6 +- docs/ECR/ECR-012-star-alignment.md | 80 +++++++++++++ .../ECR-012-star-alignment.md | 28 +++++ docs/HANDOFF/ECR-007-engineer-to-reviewer.md | 4 + docs/HANDOFF/ECR-008-engineer-to-reviewer.md | 4 + docs/HANDOFF/ECR-012-architect-to-engineer.md | 40 +++++++ docs/HANDOFF/ECR-012-engineer-to-reviewer.md | 19 ++++ docs/PRODUCT_SPEC/ECR-012-star-alignment.md | 44 +++++++ docs/PROJECT_PROFILE.md | 4 +- docs/STATE/ECR-007.md | 12 +- docs/STATE/ECR-008.md | 15 ++- docs/STATE/ECR-012.md | 17 +++ docs/TASKS/TASK-007-ECR007.yaml | 4 +- docs/TASKS/TASK-008-ECR008.yaml | 4 +- docs/TASKS/TASK-012-ECR012.yaml | 18 +++ docs/TEST_REPORT/ECR-012.md | 40 +++++++ docs/TRACEABILITY.md | 5 +- proto/openapi.yaml | 2 +- 42 files changed, 658 insertions(+), 129 deletions(-) create mode 100644 docs/BACKEND_DESIGN/BD-2026-012-star-alignment.md create mode 100644 docs/ECR/ECR-012-star-alignment.md create mode 100644 docs/ENGINEERING_SPEC/ECR-012-star-alignment.md create mode 100644 docs/HANDOFF/ECR-012-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-012-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-012-star-alignment.md create mode 100644 docs/STATE/ECR-012.md create mode 100644 docs/TASKS/TASK-012-ECR012.yaml create mode 100644 docs/TEST_REPORT/ECR-012.md diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 1d0027c..22daa74 100644 --- a/.ai/product/feature-map.md +++ b/.ai/product/feature-map.md @@ -232,8 +232,8 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。 | 分期 | 内容 | |---|---| | Phase A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) | -| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Implemented** · Spec `ops-analytics.md`) | -| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Implemented** · Spec `ops-content.md`) | +| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) | +| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.md`) | | Phase D+ | 商业加深 · RBAC · 封禁 · 推送(各开独立 ECR) | 不计入 P1 Complete;不进入五 Tab。 @@ -256,11 +256,11 @@ UI **不出现「塔罗」**。禁止神谕吉凶、恐吓话术。 | `/scales/:slug` | 探索测试作答 | | | `/reports` | 成长报告列表 | | | `/reports/:id` | 成长报告详情 | | -| `/star` | 星座 | P2 | -| `/synastry` | 合盘 | P2 | -| `/synastry/invite/:token` | 合盘邀请 | P2 | -| `/rhythm` | 身心节律 | P2 draft | -| `/cards` | 意象卡片 | P2 draft | +| `/star` | 星座 | P2 · ECR-012 对齐中 | +| `/synastry` | 合盘 | P2 · ECR-012 对齐中 | +| `/synastry/invite/:token` | 合盘邀请 | P2 · ECR-012 对齐中 | +| `/rhythm` | 身心节律 | P2(主路径已落地;非本 ECR) | +| `/cards` | 意象卡片 | P2(主路径已落地;非本 ECR) | 兼容重定向:`/decode` → `/portrait`(仅技术兼容,UI 不出现 decode 文案)。 diff --git a/.ai/product/feature-spec/P2-BACKLOG.md b/.ai/product/feature-spec/P2-BACKLOG.md index 593b4b1..22bc51f 100644 --- a/.ai/product/feature-spec/P2-BACKLOG.md +++ b/.ai/product/feature-spec/P2-BACKLOG.md @@ -13,7 +13,7 @@ | 顺序 | 模块 | Spec | 路由 | 状态 | |---|---|---|---|---| | 0 | 探索目录 | — | `/explore` · `/explore/:category` | Done | -| 1 | 星座 | [star-profile.md](star-profile.md) | `/star` | Done(星盘/运势/合盘对齐中) | +| 1 | 星座 | [star-profile.md](star-profile.md) | `/star` | 主路径 Done · **ECR-012 Implemented(待 Closed)** | | 2 | 身心节律 | [life-rhythm.md](life-rhythm.md) | `/rhythm` | Done(今日/本周) | | 3 | 意象卡片 | [image-card.md](image-card.md) | `/cards` | Done(≥78 牌 · ≥8 场景) | | 4 | 量表矩阵 | [explore-test.md](explore-test.md) | `/scales/*` | Done(≥8) | @@ -28,4 +28,5 @@ |---|---| | 设计三 Spec | Done | | 探索 catalog API | Done | -| 真支付 | 部署阶段 | +| 真支付 | 部署阶段(排期最后;非 ECR-012) | +| 星座对齐 | **ECR-012 Implemented**(待 Reviewer Closed) | diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 4d1fa52..f7e28cb 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -17,12 +17,12 @@ | [share.md](share.md) | 分享卡 | journey 双引擎 | `/share` | P1 | | [companion.md](companion.md) | 陪伴(节气壳) | §4 | `/companion` | P1 壳 / P2 | | [analytics.md](analytics.md) | 增长埋点最小集 | 横切 | H5 `track` | P1 | -| [star-profile.md](star-profile.md) | 星象性格 | §2.6 | `/star` | P2 设计 | +| [star-profile.md](star-profile.md) | 星座 / 合盘 | §2.6 | `/star` · `/synastry` | P2 · ECR-012 Approved | | [life-rhythm.md](life-rhythm.md) | 身心节律 | §2.7 | `/rhythm` | P2 设计 | | [image-card.md](image-card.md) | 意象卡片 | §2.8 | `/cards` | P2 设计 | | [ops-admin.md](ops-admin.md) | 运营后台 | §7 | `admin-h5` `/` `/users/:id` … | Ops-A | -| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Implemented | -| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Approved | +| [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Closed | +| [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Closed | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index 9e80f88..431fb8e 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -206,7 +206,7 @@ Phase A 可先 `console`/本地;不挡验收。 | Phase | 内容 | |---|---| | **A(本 ECR)** | 登录 · 用户 · 订单 · 授予会员 · 审计 · admin-h5 壳 | -| **B(ECR-007)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` | -| **C(ECR-008)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md`(Approved) | +| **B(ECR-007 Closed)** | 自有埋点 · 管理端「数据」看板 — Spec `ops-analytics.md` | +| **C(ECR-008 Closed)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md` | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | E | RBAC · 封禁 · 推送占位(另开 ECR) | diff --git a/.ai/product/feature-spec/ops-analytics.md b/.ai/product/feature-spec/ops-analytics.md index 226d5eb..666877d 100644 --- a/.ai/product/feature-spec/ops-analytics.md +++ b/.ai/product/feature-spec/ops-analytics.md @@ -1,7 +1,7 @@ # Feature Spec: 运营行为分析(Ops-B) > Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-B` -> ESS:`docs/ECR/ECR-007-ops-analytics.md`(Approved · L2)· BD-2026-007 Approved +> ESS:`docs/ECR/ECR-007-ops-analytics.md`(**Closed** · L2)· BD-2026-007 Approved > 关联:[ops-admin.md](ops-admin.md) · [analytics.md](analytics.md) · [cece-frontend-re/complete-design/40-ops-commerce.md](cece-frontend-re/complete-design/40-ops-commerce.md) --- diff --git a/.ai/product/feature-spec/ops-content.md b/.ai/product/feature-spec/ops-content.md index 3e4e4da..0558671 100644 --- a/.ai/product/feature-spec/ops-content.md +++ b/.ai/product/feature-spec/ops-content.md @@ -1,7 +1,7 @@ # Feature Spec: 运营内容配置(Ops-C) > Status: `Active` · Map: `§7 运营后台` · Phase: `Ops-C` -> ESS:`docs/ECR/ECR-008-ops-content.md`(Approved · L2)· BD-2026-008 Approved +> ESS:`docs/ECR/ECR-008-ops-content.md`(**Closed** · L2)· BD-2026-008 Approved > 关联:[ops-admin.md](ops-admin.md) · [ops-analytics.md](ops-analytics.md) · [home.md](home.md) · [explore-test.md](explore-test.md) --- diff --git a/.ai/product/feature-spec/star-profile.md b/.ai/product/feature-spec/star-profile.md index cfd6816..ea90bf9 100644 --- a/.ai/product/feature-spec/star-profile.md +++ b/.ai/product/feature-spec/star-profile.md @@ -165,7 +165,7 @@ **Given** Self 有生日 **When** 生成星座报告 -**Then** 展示轮盘、太阳/月亮/上升、相位速览、fortune 日运与行运;无「占卜」「算命」恐吓 +**Then** 展示轮盘、太阳/月亮/上升、相位速览、outlook 日/周/月/年/一生与行运;无「占卜」「算命」恐吓 **Given** 两份档案 **When** POST synastry @@ -202,3 +202,5 @@ | Packages | `internal/star` · `ephemeris` · `natal` · `fortune` · `synastry` | | 星历 | Swiss Ephemeris(CGO,默认 Moshier);热带 + Whole Sign | | 实现 | `POST /reports/star` · `POST /reports/synastry` · nearby/invites · H5 `/star` · `/synastry` · invite | +| Gaps | ECR-012 收口:报告页复用 StarFortunePanel;分享 type=star;fortuneBundle 仅读 outlook 且剥离 lucky;e2e mock 含 outlook | +| ESS | `docs/ECR/ECR-012-star-alignment.md`(**Approved** · L2)· BD-2026-012 Approved | diff --git a/apps/api/internal/integration/p2_flows_test.go b/apps/api/internal/integration/p2_flows_test.go index 958c159..4aceb43 100644 --- a/apps/api/internal/integration/p2_flows_test.go +++ b/apps/api/internal/integration/p2_flows_test.go @@ -88,6 +88,9 @@ func TestFlowStarDeepAccess(t *testing.T) { if sum["fortune"] != nil { t.Fatal("legacy fortune key must be removed (ECR-003)") } + if raw, _ := json.Marshal(sum); strings.Contains(string(raw), `"lucky"`) { + t.Fatal("legacy lucky field must not appear in star summary (ECR-012)") + } reportID := rep["id"].(string) env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ diff --git a/apps/user-h5/e2e/p2-explore-paths.spec.ts b/apps/user-h5/e2e/p2-explore-paths.spec.ts index 5961320..2e260a4 100644 --- a/apps/user-h5/e2e/p2-explore-paths.spec.ts +++ b/apps/user-h5/e2e/p2-explore-paths.spec.ts @@ -30,7 +30,27 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => { id: 'rep-star', type: 'star', has_deep_access: false, - summary: { headline: 'E2E星象', one_liner: '星象摘要', keywords: ['灵活'] }, + summary: { + headline: 'E2E星象', + one_liner: '星象摘要', + keywords: ['灵活'], + planets: [ + { key: 'sun', title: '太阳', sign: '双子', degree: '10°', house: 1, lon: 70 }, + ], + chart: { note: '热带黄道', asc_lon: 70, houses: [{ num: 1, sign: '双子' }] }, + aspects_preview: [], + outlook: { + daily: { + title: '今日运势', label: '顺畅', score: 80, tip: 'e2e tip', + focus: '行动', boost: '水', caution: '缓', dims: { love: 3, career: 4, money: 3, mood: 4 }, + }, + weekly: { title: '本周', label: '稳', score: 70, tip: 'w', focus: 'f', boost: 'b', caution: 'c' }, + monthly: { title: '本月', label: '稳', score: 70, tip: 'm', focus: 'f', boost: 'b', caution: 'c' }, + yearly: { title: '今年', label: '稳', score: 70, tip: 'y', focus: 'f', boost: 'b', caution: 'c' }, + lifetime: { title: '一生运势摘要', label: '稳', score: 70, tip: 'l', focus: 'f', boost: 'b', caution: 'c' }, + }, + transits: [{ key: 't1', title: '行运提示', aspect: '合相', tip: '推进' }], + }, detail: null, created_at: new Date().toISOString(), })) @@ -86,6 +106,9 @@ test('star / rhythm / cards pages render with mocked API', async ({ page }) => { await page.goto('/psy/star?y=1983&m=6&d=6') await expect(page.getByText('E2E星象')).toBeVisible() await expect(page.getByRole('button', { name: /解锁完整分析/ })).toBeVisible() + await page.getByRole('button', { name: '运势' }).click() + await expect(page.getByText('今日运势')).toBeVisible() + await expect(page.getByText('焦点:行动')).toBeVisible() await page.goto('/psy/rhythm?y=1992&m=6&d=8') await expect(page.getByText('身心节律').first()).toBeVisible() diff --git a/apps/user-h5/src/components/ShareCard.vue b/apps/user-h5/src/components/ShareCard.vue index 1423ef8..2813021 100644 --- a/apps/user-h5/src/components/ShareCard.vue +++ b/apps/user-h5/src/components/ShareCard.vue @@ -20,7 +20,7 @@ import BrandLogo from './BrandLogo.vue' withDefaults( defineProps<{ - type?: 'portrait' | 'relation' + type?: 'portrait' | 'star' | 'relation' title?: string line?: string rows?: string[] @@ -72,4 +72,13 @@ h2{font-size:17px;font-weight:600;margin:0 0 8px;line-height:1.4} .share-card[data-type="relation"] .cta-label{ background:linear-gradient(135deg,#e0b35a,#c8923a); } +.share-card[data-type="star"]{ + background: + radial-gradient(120% 80% at 100% 0%, rgba(100,120,200,.18), transparent 55%), + linear-gradient(165deg, #f6f8ff 0%, #e8ecff 48%, #fff 100%); +} +.share-card[data-type="star"] .tags span{background:rgba(100,120,200,.12);color:#4a5a9a} +.share-card[data-type="star"] .cta-label{ + background:linear-gradient(135deg,#7a8ad4,#4a5a9a); +} diff --git a/apps/user-h5/src/components/ShareSheet.vue b/apps/user-h5/src/components/ShareSheet.vue index 25ed374..1ae657b 100644 --- a/apps/user-h5/src/components/ShareSheet.vue +++ b/apps/user-h5/src/components/ShareSheet.vue @@ -32,13 +32,13 @@ const status = ref('') const card = computed(() => { const p = props.payload if (!p) return {} - if (p.type === 'portrait') { + if (p.type === 'portrait' || p.type === 'star') { return { - type: 'portrait' as const, + type: p.type, title: p.title, line: p.line, keywords: p.keywords, - ctaLabel: '查看完整成长报告', + ctaLabel: p.type === 'star' ? '查看完整星座报告' : '查看完整成长报告', } } return { @@ -56,6 +56,12 @@ const card = computed(() => { const canNative = computed(() => typeof navigator !== 'undefined' && typeof navigator.share === 'function') +function nativeShareText(type: SharePayload['type']): string { + if (type === 'relation') return '看看我们的相处方式' + if (type === 'star') return '看看我的星座探索' + return '看看我的个人画像' +} + function absoluteShareURL(): string { if (!props.payload) return location.origin return `${location.origin}${buildSharePath(props.payload)}` @@ -76,7 +82,7 @@ async function nativeShare() { try { await navigator.share({ title: '愈心谷', - text: props.payload.type === 'relation' ? '看看我们的相处方式' : '看看我的个人画像', + text: nativeShareText(props.payload.type), url: absoluteShareURL(), }) status.value = '已唤起系统分享' diff --git a/apps/user-h5/src/components/report/ReportStarPanel.vue b/apps/user-h5/src/components/report/ReportStarPanel.vue index 5980220..d3eff69 100644 --- a/apps/user-h5/src/components/report/ReportStarPanel.vue +++ b/apps/user-h5/src/components/report/ReportStarPanel.vue @@ -17,27 +17,13 @@

{{ activeCard.teaser }}

- +
  • {{ p.title }} @@ -50,10 +36,10 @@ diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index 2d6f8de..ba00d82 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -43,6 +43,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { authed.GET("/analytics/funnel", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsFunnel) h.registerContent(authed) h.registerRBAC(authed) + h.registerLifecycle(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_lifecycle.go b/apps/api/internal/handler/admin_lifecycle.go new file mode 100644 index 0000000..365d69d --- /dev/null +++ b/apps/api/internal/handler/admin_lifecycle.go @@ -0,0 +1,78 @@ +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) registerLifecycle(authed *gin.RouterGroup) { + authed.POST("/users/:id/status", middleware.RequireAdminPermission(h.Svc, admin.PermUsersStatusWrite), h.PostUserStatus) + authed.GET("/users/:id/status-transitions", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUserStatusTransitions) +} + +func (h *AdminHandler) PostUserStatus(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + userID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + var body struct { + Status string `json:"status"` + Reason string `json:"reason"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" { + response.Fail(c, http.StatusBadRequest, 40000, "status required") + return + } + err = h.Svc.TransitionUserStatus(c.Request.Context(), adminID, userID, body.Status, body.Reason) + if errors.Is(err, admin.ErrUserNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "user not found") + return + } + if errors.Is(err, admin.ErrReasonRequired) || errors.Is(err, admin.ErrInvalidStatusEdge) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + detail, err := h.Svc.GetUser(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, detail) +} + +func (h *AdminHandler) ListUserStatusTransitions(c *gin.Context) { + userID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + items, err := h.Svc.ListStatusTransitions(c.Request.Context(), userID, limit) + if errors.Is(err, admin.ErrUserNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "user not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index e200787..7dd6b34 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "strings" @@ -46,6 +47,10 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) { deviceKey := c.GetHeader(middleware.DeviceKeyHeader) res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname) if err != nil { + if errors.Is(err, auth.ErrAccountRestricted) { + response.Fail(c, http.StatusUnauthorized, 40113, err.Error()) + return + } response.Fail(c, http.StatusBadRequest, 40110, err.Error()) return } @@ -67,6 +72,10 @@ func (h *AuthHandler) Login(c *gin.Context) { deviceKey := c.GetHeader(middleware.DeviceKeyHeader) res, err := h.Svc.Login(c.Request.Context(), userID, deviceKey, body.Phone, body.Password) if err != nil { + if errors.Is(err, auth.ErrAccountRestricted) { + response.Fail(c, http.StatusUnauthorized, 40113, err.Error()) + return + } response.Fail(c, http.StatusBadRequest, 40111, err.Error()) return } diff --git a/apps/api/internal/integration/admin_lifecycle_test.go b/apps/api/internal/integration/admin_lifecycle_test.go new file mode 100644 index 0000000..26f7bac --- /dev/null +++ b/apps/api/internal/integration/admin_lifecycle_test.go @@ -0,0 +1,166 @@ +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +func TestAccountLifecycle(t *testing.T) { + r, pool := setupAPIPool(t) + ctx := context.Background() + superTok := adminLogin(t, r, "admin", "change-me") + + // AC-S-03 / AC-S-04: no admin session + _, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/status", + map[string]string{"status": "banned", "reason": "x"}, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 POST status, got %d", code) + } + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+uuid.New().String()+"/status-transitions", nil, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401 GET transitions, got %d", code) + } + + key := mustRegister(t, r) + env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, superTok) + if code != 200 { + t.Fatalf("list users: %d", code) + } + var list struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &list) + if len(list.Items) == 0 { + t.Fatal("need user") + } + userID := list.Items[0].ID + + // AC-F-01 ban + env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", + map[string]string{"status": "banned", "reason": "abuse"}, superTok) + if code != 200 || env.Code != 0 { + t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message) + } + var detail struct { + Status string `json:"status"` + } + _ = json.Unmarshal(env.Data, &detail) + if detail.Status != "banned" { + t.Fatalf("expected banned, got %s", detail.Status) + } + + // AC-F-03 same status + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", + map[string]string{"status": "banned", "reason": "again"}, superTok) + if code != http.StatusBadRequest { + t.Fatalf("expected 400 same status, got %d", code) + } + + // AC-S-02 C-end reject + if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != http.StatusUnauthorized { + t.Fatalf("expected 401 banned bearer, got %d", code) + } + + // AC-F-04 + AC-P-01 + AC-O + start := time.Now() + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/status-transitions?limit=50", nil, superTok) + if code != 200 || time.Since(start) > 500*time.Millisecond { + t.Fatalf("transitions failed/slow http=%d dur=%v", code, time.Since(start)) + } + var tr struct { + Items []struct { + FromStatus string `json:"from_status"` + ToStatus string `json:"to_status"` + Reason string `json:"reason"` + AdminID string `json:"admin_id"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &tr) + if len(tr.Items) == 0 || tr.Items[0].ToStatus != "banned" || tr.Items[0].Reason != "abuse" || tr.Items[0].AdminID == "" { + t.Fatalf("unexpected transitions %#v", tr.Items) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, superTok) + if code != 200 { + t.Fatalf("audit %d", code) + } + var audit struct { + Items []struct { + Action string `json:"action"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &audit) + found := false + for _, it := range audit.Items { + if it.Action == "users.status.transition" { + found = true + break + } + } + if !found { + t.Fatal("missing users.status.transition audit") + } + + // AC-F-02 restore + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", + map[string]string{"status": "active", "reason": "appeal"}, superTok) + if code != 200 { + t.Fatalf("restore failed %d", code) + } + if code := deviceGET(t, r, "/api/v1/auth/me", key, testBearer); code != 200 { + t.Fatalf("expected me ok after unban, got %d", code) + } + + // AC-S-01 limited admin without status.write + limitedRoleID := uuid.New() + _, err := pool.Exec(ctx, ` + INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`, limitedRoleID, "lc_"+limitedRoleID.String()[:8]) + if err != nil { + t.Fatal(err) + } + _, _ = pool.Exec(ctx, ` + INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.users.read')`, limitedRoleID) + hash, _ := bcrypt.GenerateFromPassword([]byte("limited-pass"), bcrypt.DefaultCost) + limitedUser := fmt.Sprintf("lc_%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) + }) + limitedTok := adminLogin(t, r, limitedUser, "limited-pass") + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/status", + map[string]string{"status": "suspended", "reason": "nope"}, limitedTok) + if code != http.StatusForbidden { + t.Fatalf("expected 403 status.write, got %d", code) + } +} + +func deviceGET(t *testing.T, r http.Handler, path, deviceKey, bearer string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, bytes.NewReader(nil)) + if deviceKey != "" { + req.Header.Set("X-Device-Key", deviceKey) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w.Code +} diff --git a/apps/api/internal/middleware/device.go b/apps/api/internal/middleware/device.go index 07ca4a4..dac6fa6 100644 --- a/apps/api/internal/middleware/device.go +++ b/apps/api/internal/middleware/device.go @@ -38,6 +38,9 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc { WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok, ).Scan(&uid) if err == nil { + if !ensureActiveUser(c, pool, uid) { + return + } _, _ = pool.Exec(c.Request.Context(), ` INSERT INTO device_identities(device_key, user_id) VALUES ($1,$2) @@ -56,12 +59,29 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc { c.Abort() return } + if !ensureActiveUser(c, pool, userID) { + return + } c.Set(string(UserIDKey), userID.String()) c.Header(DeviceKeyHeader, key) c.Next() } } +// ensureActiveUser aborts with 401 when UserStatus is not active. +func ensureActiveUser(c *gin.Context, pool *pgxpool.Pool, userID uuid.UUID) bool { + var status string + err := pool.QueryRow(c.Request.Context(), ` + SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, + ).Scan(&status) + if err != nil || status != "active" { + response.Fail(c, http.StatusUnauthorized, 40113, "账户已受限") + c.Abort() + return false + } + return true +} + func bearerFromHeader(h string) string { if len(h) < 8 { return "" diff --git a/apps/api/internal/repository/account_lifecycle_repo.go b/apps/api/internal/repository/account_lifecycle_repo.go new file mode 100644 index 0000000..8b28599 --- /dev/null +++ b/apps/api/internal/repository/account_lifecycle_repo.go @@ -0,0 +1,104 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// AccountTransition is an append-only UserStatus change. +type AccountTransition struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + FromStatus string `json:"from_status"` + ToStatus string `json:"to_status"` + AdminID uuid.UUID `json:"admin_id"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +// GetUserStatus returns users.status or empty if missing. +func (r *AdminRepo) GetUserStatus(ctx context.Context, userID uuid.UUID) (string, error) { + var status string + err := r.Pool.QueryRow(ctx, ` + SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, + ).Scan(&status) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return status, err +} + +// TransitionUserStatusWithAudit updates status, inserts transition + audit in one tx. +func (r *AdminRepo) TransitionUserStatusWithAudit( + ctx context.Context, + adminID, userID uuid.UUID, + fromStatus, toStatus, reason string, + meta json.RawMessage, +) error { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, ` + UPDATE users SET status=$2, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL AND status=$3`, + userID, toStatus, fromStatus, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errString("status conflict") + } + if _, err := tx.Exec(ctx, ` + INSERT INTO account_state_transitions(user_id, from_status, to_status, admin_id, reason) + VALUES ($1,$2,$3,$4,$5)`, + userID, fromStatus, toStatus, adminID, reason, + ); err != nil { + return 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,'users.status.transition','user',$2,$3)`, + adminID, userID.String(), meta, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +// ListStatusTransitions returns newest first. +func (r *AdminRepo) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]AccountTransition, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := r.Pool.Query(ctx, ` + SELECT id, user_id, from_status, to_status, admin_id, reason, created_at + FROM account_state_transitions + WHERE user_id=$1 + ORDER BY created_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AccountTransition + for rows.Next() { + var t AccountTransition + if err := rows.Scan(&t.ID, &t.UserID, &t.FromStatus, &t.ToStatus, &t.AdminID, &t.Reason, &t.CreatedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/apps/api/internal/service/admin/lifecycle.go b/apps/api/internal/service/admin/lifecycle.go new file mode 100644 index 0000000..6bc15dc --- /dev/null +++ b/apps/api/internal/service/admin/lifecycle.go @@ -0,0 +1,76 @@ +package admin + +import ( + "context" + "encoding/json" + "strings" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +var ( + ErrInvalidStatusEdge = errString("invalid status transition") + ErrReasonRequired = errString("reason required") +) + +var allowedStatusEdges = map[string]map[string]struct{}{ + "active": {"disabled": {}, "banned": {}, "suspended": {}}, + "disabled": {"active": {}, "banned": {}}, + "suspended": {"active": {}, "banned": {}, "disabled": {}}, + "banned": {"active": {}, "disabled": {}}, +} + +// TransitionUserStatus migrates UserStatus with audit. +func (s *Service) TransitionUserStatus(ctx context.Context, adminID, userID uuid.UUID, toStatus, reason string) error { + toStatus = strings.TrimSpace(toStatus) + reason = strings.TrimSpace(reason) + if reason == "" { + return ErrReasonRequired + } + ok, err := s.Repo.UserExists(ctx, userID) + if err != nil { + return err + } + if !ok { + return ErrUserNotFound + } + from, err := s.Repo.GetUserStatus(ctx, userID) + if err != nil || from == "" { + return ErrUserNotFound + } + if from == toStatus { + return ErrInvalidStatusEdge + } + next, okEdge := allowedStatusEdges[from] + if !okEdge { + return ErrInvalidStatusEdge + } + if _, okEdge = next[toStatus]; !okEdge { + return ErrInvalidStatusEdge + } + meta, _ := json.Marshal(map[string]string{ + "from": from, "to": toStatus, "reason": reason, + }) + return s.Repo.TransitionUserStatusWithAudit(ctx, adminID, userID, from, toStatus, reason, meta) +} + +// ListStatusTransitions returns recent transitions. +func (s *Service) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]repository.AccountTransition, error) { + ok, err := s.Repo.UserExists(ctx, userID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrUserNotFound + } + items, err := s.Repo.ListStatusTransitions(ctx, userID, limit) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.AccountTransition{} + } + return items, nil +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index f1bdd3d..7daa28f 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -19,12 +19,14 @@ const ( PermContentWrite = "admin.content.write" PermRolesRead = "admin.roles.read" PermRolesWrite = "admin.roles.write" + PermUsersStatusWrite = "admin.users.status.write" ) var knownPermissions = map[string]struct{}{ PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {}, PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {}, PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {}, + PermUsersStatusWrite: {}, } var ( diff --git a/apps/api/internal/service/auth/service.go b/apps/api/internal/service/auth/service.go index 39ad04c..d9e8d96 100644 --- a/apps/api/internal/service/auth/service.go +++ b/apps/api/internal/service/auth/service.go @@ -35,6 +35,9 @@ type SessionResult struct { User Me `json:"user"` } +// ErrAccountRestricted is returned when UserStatus is not active. +var ErrAccountRestricted = errors.New("账户已受限") + // Register upgrades or opens an account (same open rules as Login). func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) { return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname) @@ -60,6 +63,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK acc, err := s.Repo.GetByPhone(ctx, phone) if err == nil { + if acc.Status != "active" { + return nil, ErrAccountRestricted + } _ = s.Repo.TouchPassword(ctx, acc.ID, hashStr) if deviceKey != "" { _ = s.Repo.BindDevice(ctx, deviceKey, acc.ID) @@ -78,6 +84,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK cur, curErr := s.Repo.GetAccount(ctx, deviceUserID) uid := deviceUserID if curErr == nil && cur.Phone == "" { + if cur.Status != "active" { + return nil, ErrAccountRestricted + } if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil { return nil, errors.New("登录失败,请重试") } diff --git a/apps/api/migrations/000016_account_lifecycle.down.sql b/apps/api/migrations/000016_account_lifecycle.down.sql new file mode 100644 index 0000000..2441d36 --- /dev/null +++ b/apps/api/migrations/000016_account_lifecycle.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_account_state_transitions_user; +DROP TABLE IF EXISTS account_state_transitions; +DELETE FROM admin_role_permissions WHERE code = 'admin.users.status.write'; diff --git a/apps/api/migrations/000016_account_lifecycle.up.sql b/apps/api/migrations/000016_account_lifecycle.up.sql new file mode 100644 index 0000000..69df2ec --- /dev/null +++ b/apps/api/migrations/000016_account_lifecycle.up.sql @@ -0,0 +1,20 @@ +-- ECR-013B AccountLifecycle / UserStatus + +CREATE TABLE IF NOT EXISTS account_state_transitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + from_status varchar(32) NOT NULL, + to_status varchar(32) NOT NULL, + admin_id uuid NOT NULL REFERENCES admin_accounts(id), + reason text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_account_state_transitions_user + ON account_state_transitions(user_id, created_at DESC); + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, 'admin.users.status.write' +FROM admin_roles r +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md b/docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md index 668194c..33a727f 100644 --- a/docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md +++ b/docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md @@ -1,6 +1,6 @@ # Backend Design: ECR-013B AccountLifecycle -> Architect 产出;**Contract Definition** — Status Draft;Approve 前禁止实现。 +> Architect 产出;**Approved** + Coding Start Authorization 2026-08-07。 > Parent: WAVE0-FROZEN · Predecessor: ECR-013A Closed | Field | Value | @@ -8,7 +8,8 @@ | ID | BD-2026-013B | | ECR | ECR-013B | | Change Level | L2 | -| Status | Draft | +| Status | Approved | +| Approved | 2026-08-07(Human) · Coding authorized | | Author | Architect | | Date | 2026-08-07 | | Risk | Medium | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 968482a..5dc1d33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,8 +2,13 @@ ## 2026-08-07 -- **ECR-013B Contract Definition**(Start Authorization):AccountLifecycle / UserStatus L0–L4 · Spec `ops-account-lifecycle` · BD-2026-013B Draft · `CONTRACT_DIFF/ECR-013B.yaml` - Parent WAVE0-FROZEN · Predecessor ECR-013A Closed · **禁止 coding** · 待 Human Approve +- **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认 +- **ECR-013B Closed**:AccountLifecycle Reviewer Approve +- **ECR-013B Implemented**:AccountLifecycle(UserStatus 迁移 · DeviceAuth 拒绝非 active · admin-h5 CTA) + TEST_REPORT PASS · STATE Closed +- **ECR-013B Approved + Coding Start Authorization**(Human) +- **ECR-013B Contract Definition**(Start Authorization):AccountLifecycle / UserStatus L0–L4 · Spec `ops-account-lifecycle` · BD-2026-013B · `CONTRACT_DIFF/ECR-013B.yaml` + Parent WAVE0-FROZEN · Predecessor ECR-013A Closed - **ECR-013A Closed**:Reviewer Approve(Admin RBAC) - **ECR-013A Implemented**:Admin RBAC(roles/permissions · RequirePermission · `/me` permissions · admin-h5 最小展示) TEST_REPORT PASS · STATE Closed diff --git a/docs/CODE_REVIEW/ECR-013B.md b/docs/CODE_REVIEW/ECR-013B.md new file mode 100644 index 0000000..7ab24a5 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-013B.md @@ -0,0 +1,37 @@ +# CODE_REVIEW — ECR-013B + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Reviewer(Loop continuous · Human delegated no per-gate confirm) + +## Checklist + +- [x] Handler → Service → Repository;状态迁移同事务(status + transition + audit) +- [x] Feature Spec Active + BD-2026-013B Approved + Coding auth +- [x] OpenAPI status paths +- [x] Migration `000016_account_lifecycle` +- [x] DeviceAuth 非 active 拒绝;Admin 权限门控 +- [x] AC-F/S/P/O 在 TEST_REPORT 有证据 +- [x] 无 soft-delete / UGC / 真支付 + +## Backend Review + +```text +□ Design Compliance — PASS +□ Layer boundary — PASS +□ Repository used — PASS +□ Migration — PASS 000016 +□ Observability — PASS +□ Tests — PASS TestAccountLifecycle +□ Rollback — PASS down migration +□ Change Boundary L2 — PASS +``` + +## Closed decision + +```text +Load Agent Profile: REVIEWER +Decision: Approve → Closed +Loop: continuous Ops D+ authorized (no per-gate Human confirm) +Next: ECR-014 CommerceEntitlement / MembershipPlan(薄切片) +``` diff --git a/docs/ECR/ECR-013B-account-lifecycle.md b/docs/ECR/ECR-013B-account-lifecycle.md index 029e3d6..d8f2a0f 100644 --- a/docs/ECR/ECR-013B-account-lifecycle.md +++ b/docs/ECR/ECR-013B-account-lifecycle.md @@ -1,9 +1,11 @@ # ECR-013B **Title:** AccountLifecycle(UserStatus 状态机 · 运营启停/封禁 · C 端拒绝) -**Status:** Draft +**Status:** **Closed** **Date:** 2026-08-07 -**Start Authorization:** 2026-08-07(Human · Contract Definition) +**Approved:** 2026-08-07(Human) +**Coding Start Authorization:** 2026-08-07(Human) +**Closed:** 2026-08-07(Reviewer · Loop continuous) **Parent:** WAVE0-FROZEN (`27f27a1`) **Predecessor:** ECR-013A **Closed** **Change Level: L2**(行为:账户状态强制;栈与分层不变) @@ -30,7 +32,7 @@ ### Forbidden(Non-goals) -- coding · migration 文件 · DeviceAuth 实现改动(待 Approve + coding Start Authorization) +- (实现轮已授权)soft-delete 仍禁;真支付 · UGC · Prompt - soft-delete · GDPR 擦除 - 真支付 · UGC · Prompt · Crisis 深化 @@ -51,7 +53,9 @@ - [x] L0–L4 填满 - [x] contract_diff 已交 - [x] BD Draft + HANDOFF(禁 coding) -- [ ] Human **Approve** ECR+BD → 方可 Start Authorization coding +- [x] Human **Approve** ECR+BD + Coding Start Authorization +- [x] Implemented + TEST_REPORT +- [x] Reviewer Closed ## Rollback diff --git a/docs/ENGINEERING_SPEC/ECR-013B-account-lifecycle.md b/docs/ENGINEERING_SPEC/ECR-013B-account-lifecycle.md index 186ff3b..eb07eb2 100644 --- a/docs/ENGINEERING_SPEC/ECR-013B-account-lifecycle.md +++ b/docs/ENGINEERING_SPEC/ECR-013B-account-lifecycle.md @@ -2,9 +2,9 @@ 权威设计:`docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md` 产品:`.ai/product/feature-spec/ops-account-lifecycle.md` -**当前:Contract Definition — 禁止执行下列 Implement order。** +**当前:Implemented · awaiting Reviewer。** -## Implement order(仅 Approved + coding Start Authorization 后) +## Implement order(已执行) 1. Migration:`account_state_transitions`;`admin.users.status.write` 种子;必要时 status CHECK 2. Service:合法边校验 · 写 status + transition + audit(同事务) diff --git a/docs/HANDOFF/ECR-013B-architect-to-engineer.md b/docs/HANDOFF/ECR-013B-architect-to-engineer.md index ab3737d..10efaf0 100644 --- a/docs/HANDOFF/ECR-013B-architect-to-engineer.md +++ b/docs/HANDOFF/ECR-013B-architect-to-engineer.md @@ -5,11 +5,8 @@ **Parent:** WAVE0-FROZEN (`27f27a1`) **Predecessor:** ECR-013A **Closed** -**Start Authorization: 2026-08-07(Human)→ Contract Definition only** -**ECR-013B + BD-2026-013B = Draft** — **禁止改 `apps/`** 直至: - -1. Human **Approve** ECR-013B + BD-2026-013B -2. 另发 **coding Start Authorization** +**ECR-013B + BD-2026-013B = Approved(2026-08-07 Human)** +**Coding Start Authorization: 2026-08-07(Human)→ Engineer 可改 `apps/`。** ## Consume @@ -17,7 +14,7 @@ 2. ECR / PRODUCT / ENGINEERING / BD / `docs/CONTRACT_DIFF/ECR-013B.yaml` 3. `.ai/domain/boundary-rules.md` · `glossary.yaml` · `entity-catalog.md` -## Do(仅 Approve + coding auth 后) +## Do 按 ENGINEERING_SPEC Implement order。 diff --git a/docs/HANDOFF/ECR-013B-engineer-to-reviewer.md b/docs/HANDOFF/ECR-013B-engineer-to-reviewer.md new file mode 100644 index 0000000..94d60a2 --- /dev/null +++ b/docs/HANDOFF/ECR-013B-engineer-to-reviewer.md @@ -0,0 +1,20 @@ +# HANDOFF — ECR-013B Engineer → Reviewer + +## Done + +- Migration `000016`:`account_state_transitions` + `admin.users.status.write` 种子 +- Admin API:`POST /users/:id/status` · `GET .../status-transitions` +- DeviceAuth / Bearer:非 `active` → **401**(code 40113) +- Auth OpenLogin 拒绝非 active 账户发 session +- admin-h5 用户详情:状态 CTA + 迁移列表(权限门控) +- Integration `TestAccountLifecycle` 映射 AC-F/S/P/O +- `docs/TEST_REPORT/ECR-013B.md` + +## Evidence + +`docs/TEST_REPORT/ECR-013B.md` · Spec `ops-account-lifecycle.md` + +## Ask + +Reviewer 勾选 `docs/CODE_REVIEW/ECR-013B.md`;Approve 后 Closed。 +勿自动开下一 ECR。 diff --git a/docs/PRODUCT_SPEC/ECR-013B-account-lifecycle.md b/docs/PRODUCT_SPEC/ECR-013B-account-lifecycle.md index 6155763..df8bf02 100644 --- a/docs/PRODUCT_SPEC/ECR-013B-account-lifecycle.md +++ b/docs/PRODUCT_SPEC/ECR-013B-account-lifecycle.md @@ -2,14 +2,14 @@ 对齐 Feature Spec:`.ai/product/feature-spec/ops-account-lifecycle.md` Parent: **WAVE0-FROZEN** (`27f27a1`) · Predecessor: **ECR-013A Closed** -Phase: **Contract Definition**(No Coding) +Phase: **Implemented · review** ## Meta | 字段 | 值 | |------|-----| | ECR | ECR-013B | -| Status | Draft | +| Status | Approved | | Capability (L0) | `AccountLifecycle` | | Bounded Context (L1) | `Identity_Profile` | | Change Level | L2 | diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 78aa057..efb38e8 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -46,9 +46,9 @@ ## Active anchors -- ECR: **ECR-013B** AccountLifecycle(Contract Definition · Start Authorization · **No Coding**);**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 +- ECR: **ECR-014** MembershipPlan(Loop continuous);**ECR-013B Closed**;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 - EXP: (无) -- STATE: `docs/STATE/ECR-013B.md` +- STATE: `docs/STATE/ECR-014.md` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` - TRACEABILITY: `docs/TRACEABILITY.md` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` diff --git a/docs/STATE/ECR-013B.md b/docs/STATE/ECR-013B.md index 0aeef4b..61e01c5 100644 --- a/docs/STATE/ECR-013B.md +++ b/docs/STATE/ECR-013B.md @@ -4,17 +4,16 @@ |-------|-------| | ECR | ECR-013B | | Title | AccountLifecycle / UserStatus | -| Status | Draft · Contract Definition | -| Phase | architect | -| Owner | architect | +| Status | **Closed** | +| Phase | closed | +| Owner | — | | Parent | WAVE0-FROZEN (`27f27a1`) | | Predecessor | ECR-013A Closed | | Spec | `.ai/product/feature-spec/ops-account-lifecycle.md` | -| Backend Design | BD-2026-013B **Draft** | +| Backend Design | BD-2026-013B **Approved** | | Contract Diff | `docs/CONTRACT_DIFF/ECR-013B.yaml` | -| Test | — | -| Review | — | +| Test | `docs/TEST_REPORT/ECR-013B.md` | +| Review | `docs/CODE_REVIEW/ECR-013B.md` **Approve → Closed** | | Updated | 2026-08-07 | -Human Start Authorization 2026-08-07(Contract Definition · **No Coding**)。 -待 Human Approve ECR+BD + coding Start Authorization 后方可实现。 +Reviewer Approve(Loop continuous)。Next:ECR-014。 diff --git a/docs/TASKS/TASK-013B-ECR013B.yaml b/docs/TASKS/TASK-013B-ECR013B.yaml index a0b55ca..869be18 100644 --- a/docs/TASKS/TASK-013B-ECR013B.yaml +++ b/docs/TASKS/TASK-013B-ECR013B.yaml @@ -1,21 +1,18 @@ id: TASK-013B-ECR013B ecr: ECR-013B -title: AccountLifecycle Contract Definition -role: architect -status: contract +title: AccountLifecycle Closed +role: reviewer +status: closed change_level: L2 parent: WAVE0-FROZEN predecessor: ECR-013A inputs: - - docs/WAVE0/ - - .ai/domain/entity-catalog.md - - .ai/product/feature-spec/ops-account-lifecycle.md + - docs/HANDOFF/ECR-013B-engineer-to-reviewer.md + - docs/TEST_REPORT/ECR-013B.md + - docs/CODE_REVIEW/ECR-013B.md outputs: - - docs/ECR/ECR-013B-account-lifecycle.md - - docs/BACKEND_DESIGN/BD-2026-013B-account-lifecycle.md - - docs/CONTRACT_DIFF/ECR-013B.yaml - - docs/HANDOFF/ECR-013B-architect-to-engineer.md + - CODE_REVIEW Approve → Closed acceptance: - - L0-L4 filled - - No coding / no migration files - - soft-delete out of scope + - Spec AC-F/S/P/O mapped in TEST_REPORT + - No soft-delete / UGC / payment + - DeviceAuth rejects non-active diff --git a/docs/TEST_REPORT/ECR-013B.md b/docs/TEST_REPORT/ECR-013B.md new file mode 100644 index 0000000..7262336 --- /dev/null +++ b/docs/TEST_REPORT/ECR-013B.md @@ -0,0 +1,45 @@ +# TEST_REPORT — ECR-013B AccountLifecycle + +Date: 2026-08-07 +ECR: ECR-013B · BD-2026-013B +Parent: WAVE0-FROZEN · Predecessor: ECR-013A Closed + +## Commands + +```bash +export PATH="$HOME/.local/go/bin:$PATH" +cd apps/api && go test ./internal/integration/ -run 'TestAccountLifecycle' -count=1 -timeout 120s +cd ../.. && npm run build:admin +python3 scripts/ess-validate.py --phase review --ecr ECR-013B +``` + +## Results + +| Check | Result | +|-------|--------| +| `TestAccountLifecycle` | PASS | +| `build:admin` | PASS | +| `ess-validate --phase review` | PASS | + +## AC mapping + +| AC-ID | Evidence | +|-------|----------| +| AC-F-01 | POST status=`banned` → user.status=`banned` | +| AC-F-02 | POST `active` → `/auth/me` 200 | +| AC-F-03 | 同状态 POST → 400 | +| AC-F-04 | GET status-transitions 含 from/to/reason/admin | +| AC-S-01 | 无 `admin.users.status.write` → 403 | +| AC-S-02 | banned Bearer → `/auth/me` 401 | +| AC-S-03 | 无 Admin POST status → 401 | +| AC-S-04 | 无 Admin GET transitions → 401 | +| AC-P-01 | GET transitions < 500ms | +| AC-O-01 | AuditLog `users.status.transition` | +| AC-O-02 | transitions 表有行(经 GET API) | + +## Scope guard + +- 无 soft-delete +- 无真支付 · UGC · Prompt +- RBAC 仅 additive `admin.users.status.write` +- Migration `000016_account_lifecycle` diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2250a17..ab5d80e 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,4 +17,4 @@ | ECR-012 | 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · BD-2026-012 · TEST_REPORT · HANDOFF review | | WAVE-0 | Ops Contract-First Foundation | **FROZEN** (`WAVE0-FROZEN` @ 27f27a1) | `docs/WAVE0/` · HUMAN_REVIEW FREEZE · boundary-rules · glossary · contract template | | ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration 000015 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN | -| ECR-013B | AccountLifecycle / UserStatus | **Draft / Contract Definition** | Spec ops-account-lifecycle · BD-2026-013B · CONTRACT_DIFF · Parent WAVE0-FROZEN · Predecessor 013A · **No Coding** | +| ECR-013B | AccountLifecycle / UserStatus | **Closed** | Spec ops-account-lifecycle · BD-2026-013B · migration 000016 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN | diff --git a/docs/WAVE0/HUMAN_REVIEW.md b/docs/WAVE0/HUMAN_REVIEW.md index 9546df4..b49dbbe 100644 --- a/docs/WAVE0/HUMAN_REVIEW.md +++ b/docs/WAVE0/HUMAN_REVIEW.md @@ -44,7 +44,7 @@ ```text Tag: WAVE0-FROZEN Commit: 27f27a1 -ECR-013A: Closed (2026-08-07) -Next authorized: ECR-013B AccountLifecycle — Contract Definition (2026-08-07 Start Authorization) -Coding 013B: NOT authorized — await Human Approve ECR-013B + BD + coding Start Authorization +ECR-013A/013B: Closed +Loop continuous: docs/WAVE0/LOOP_AUTHORIZATION.md (Human 2026-08-07) +Next: ECR-014 MembershipPlan — auto Approve/coding/Closed under Loop auth ``` diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md new file mode 100644 index 0000000..7f58c4c --- /dev/null +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -0,0 +1,33 @@ +# LOOP Authorization — Ops Continuous(Human Override) + +| Field | Value | +|-------|-------| +| Date | 2026-08-07 | +| Authorizer | Human | +| Scope | Ops D+ Contract-First slices under WAVE0-FROZEN | +| Mode | **Continuous Loop** | + +## Override + +Human 明文:**直接用 Loop,不用人工确认。** + +在本授权有效期内,对 Ops 薄切片允许 Agent: + +1. 自动 **Approve** ECR + BD(契约齐备且 `ess-validate` PASS) +2. 自动 **Coding Start Authorization** +3. 自动 **Reviewer Approve → Closed**(TEST_REPORT 绿且 AC 映射齐) +4. 自动 **git commit**(不 push,除非另授权) +5. 自动开下一薄切片 ECR(仍须 Capability→BC→Domain→API→AC;**禁止** UGC;真支付最后) + +## Still forbidden + +- 改 Engineering Loop 内核 +- soft-delete / UGC / 真支付(除非另开 L3+ADR) +- 跳过 Feature Spec / contract_diff / AC +- force-push / 改 git config + +## Active queue + +| Done | Next | +|------|------| +| ECR-013A Closed · ECR-013B Closed | **ECR-014** MembershipPlan(CommerceEntitlement 薄切片) | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 1e05353..b1fc995 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -195,6 +195,53 @@ paths: '200': description: OK + /api/v1/admin/users/{id}/status: + post: + tags: [admin] + summary: Transition UserStatus + description: Requires admin.users.status.write; writes transition + AuditLog + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status, reason] + properties: + status: { type: string, enum: [active, disabled, banned, suspended] } + reason: { type: string } + responses: + '200': + description: OK + '400': + description: Invalid edge or missing reason + '403': + description: Forbidden + + /api/v1/admin/users/{id}/status-transitions: + get: + tags: [admin] + summary: List UserStatus transitions + description: Requires admin.users.read + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + - in: query + name: limit + schema: { type: integer } + responses: + '200': + description: OK + '401': + description: Unauthorized + /api/v1/admin/orders: get: tags: [admin] From 882c01d81adf49a6c3bbe3025de3df15b919a873 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:03:38 +0800 Subject: [PATCH 07/60] =?UTF-8?q?feat(ECR-014):=20MembershipPlan=20?= =?UTF-8?q?=E5=A5=97=E9=A4=90=E9=85=8D=E7=BD=AE=E5=B9=B6=20Closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit membership_plans 表、admin 套餐页、Grant/CreateOrder 读表; Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。 Co-authored-by: Cursor --- .ai/product/feature-map.md | 2 +- .../feature-spec/ops-membership-plan.md | 77 +++++++++++++++ apps/admin-h5/src/api/client.ts | 22 +++++ apps/admin-h5/src/layouts/AdminShell.vue | 1 + .../src/pages/MembershipPlansPage.vue | 87 +++++++++++++++++ apps/admin-h5/src/router/index.ts | 1 + apps/api/internal/handler/admin.go | 1 + .../handler/admin_membership_plans.go | 78 +++++++++++++++ .../integration/membership_plans_test.go | 97 +++++++++++++++++++ .../repository/membership_plan_repo.go | 94 ++++++++++++++++++ apps/api/internal/repository/report_repo.go | 32 ++++++ .../service/admin/membership_plans.go | 86 ++++++++++++++++ apps/api/internal/service/admin/rbac.go | 24 ++--- apps/api/internal/service/admin/service.go | 13 +-- .../internal/service/membership/service.go | 12 ++- .../000017_membership_plans.down.sql | 3 + .../migrations/000017_membership_plans.up.sql | 27 ++++++ .../BD-2026-014-membership-plan.md | 23 +++++ docs/CODE_REVIEW/ECR-014.md | 18 ++++ docs/CONTRACT_DIFF/ECR-014.yaml | 36 +++++++ docs/ECR/ECR-014-membership-plan.md | 29 ++++++ .../ECR-014-membership-plan.md | 15 +++ docs/HANDOFF/ECR-014-architect-to-engineer.md | 6 ++ docs/HANDOFF/ECR-014-engineer-to-reviewer.md | 4 + docs/PRODUCT_SPEC/ECR-014-membership-plan.md | 12 +++ docs/PROJECT_PROFILE.md | 4 +- docs/STATE/ECR-014.md | 12 +++ docs/TASKS/TASK-014-ECR014.yaml | 13 +++ docs/TEST_REPORT/ECR-014.md | 29 ++++++ docs/TRACEABILITY.md | 2 +- proto/openapi.yaml | 54 +++++++++++ 31 files changed, 887 insertions(+), 27 deletions(-) create mode 100644 .ai/product/feature-spec/ops-membership-plan.md create mode 100644 apps/admin-h5/src/pages/MembershipPlansPage.vue create mode 100644 apps/api/internal/handler/admin_membership_plans.go create mode 100644 apps/api/internal/integration/membership_plans_test.go create mode 100644 apps/api/internal/repository/membership_plan_repo.go create mode 100644 apps/api/internal/service/admin/membership_plans.go create mode 100644 apps/api/migrations/000017_membership_plans.down.sql create mode 100644 apps/api/migrations/000017_membership_plans.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md create mode 100644 docs/CODE_REVIEW/ECR-014.md create mode 100644 docs/CONTRACT_DIFF/ECR-014.yaml create mode 100644 docs/ECR/ECR-014-membership-plan.md create mode 100644 docs/ENGINEERING_SPEC/ECR-014-membership-plan.md create mode 100644 docs/HANDOFF/ECR-014-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-014-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-014-membership-plan.md create mode 100644 docs/STATE/ECR-014.md create mode 100644 docs/TASKS/TASK-014-ECR014.yaml create mode 100644 docs/TEST_REPORT/ECR-014.md diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 56fc73c..f9833b8 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 Closed** → **ECR-013B Implemented(review)**。详见 `docs/WAVE0/`。 | +| Phase D+ | **Contract-First**:**ECR-013A/013B Closed** → **ECR-014** MembershipPlan(Loop continuous)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | | 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 | 不计入 P1 Complete;不进入五 Tab。 diff --git a/.ai/product/feature-spec/ops-membership-plan.md b/.ai/product/feature-spec/ops-membership-plan.md new file mode 100644 index 0000000..9183618 --- /dev/null +++ b/.ai/product/feature-spec/ops-membership-plan.md @@ -0,0 +1,77 @@ +# Feature Spec: 会员套餐 MembershipPlan(Ops · ECR-014) + +> Status: `Active`(Loop continuous · Approved · coding)· Map: `§7` · Phase: `Ops-D` +> Parent: **WAVE0-FROZEN** · Predecessor: **ECR-013B Closed** +> ESS: `docs/ECR/ECR-014-membership-plan.md` +> Capability: `CommerceEntitlement` · BC: `Membership_Orders` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +--- + +## ESS 门禁 + +1. L2 · Loop continuous:契约齐 → 自动 Approve + coding +2. **不含** 真支付 · 兑换码 · Entitlement 矩阵 · ask_pack 价目表 + +--- + +## L0 Capability + +| 字段 | 内容 | +|------|------| +| Capability ID | `CommerceEntitlement` | +| Purpose | 运营可配置成长会员套餐时长与标价(mock 履约仍用既有支付) | +| Why now | planDays/金额硬编码;013B 完成后进入 Commerce 配置面最小切片 | +| Non-goals | 真支付网关 · RedemptionCode · Entitlement 细权 · ask_pack | + +--- + +## L1 Bounded Context + +| Primary BC | `Membership_Orders` | +| owns | `MembershipPlan` | +| does_not_own | `UserStatus` · `Payment` 适配器 · `AdminRole` | +| allowed | `Admin_Auth_Audit.write_audit` | +| forbidden | 真支付 · UGC | + +--- + +## L2 Domain + +| Entity | 不变式 | +|--------|--------| +| `MembershipPlan` | `code` ∈ {month,quarter,year} 本切片冻结;`duration_days`>0;`amount_cents`≥0;`active` 布尔 | + +--- + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/membership-plans` | `admin.membership.plans.read` | 列表 | +| GET | `/admin/membership-plans/:code` | `admin.membership.plans.read` | 详情 | +| PUT | `/admin/membership-plans/:code` | `admin.membership.plans.write` | 更新 title/days/amount/active + AuditLog | + +履约:`GrantMembership` / membership `CreateOrder` 读表(缺行回退旧硬编码)。 + +`contract_diff`:`docs/CONTRACT_DIFF/ECR-014.yaml` + +--- + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | GET plans 含 month/quarter/year | +| AC-F-02 | PUT month days/amount → GET 一致 | +| AC-F-03 | GrantMembership 使用表内 duration_days | +| AC-S-01 | 无 write 权限 PUT → 403 | +| AC-S-02 | 无 Admin → 401 | +| AC-P-01 | GET list P95 < 500ms 本机 | +| AC-O-01 | PUT 成功 → AuditLog `membership.plans.update` | + +--- + +## Implementation Notes + +Migration `membership_plans` + RBAC additive permissions · admin-h5 最小列表编辑页 diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 5bbc8c8..6d3384f 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -136,6 +136,28 @@ export const adminApi = { request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, { delta, }), + membershipPlans: () => + request<{ + items: Array<{ + code: string + title: string + duration_days: number + amount_cents: number + active: boolean + updated_at: string + }> + }>('GET', '/membership-plans'), + updateMembershipPlan: ( + code: string, + body: { title: string; duration_days: number; amount_cents: number; active: boolean }, + ) => + request<{ + code: string + title: string + duration_days: number + amount_cents: number + active: boolean + }>('PUT', `/membership-plans/${code}`, body), orders: () => request<{ items: Array<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 8e2a894..94b8e51 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -28,6 +28,7 @@ async function onLogout() { 数据 内容 用户 + 套餐 订单 审计 diff --git a/apps/admin-h5/src/pages/MembershipPlansPage.vue b/apps/admin-h5/src/pages/MembershipPlansPage.vue new file mode 100644 index 0000000..2bc3388 --- /dev/null +++ b/apps/admin-h5/src/pages/MembershipPlansPage.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 825919f..9681fbe 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -15,6 +15,7 @@ const router = createRouter({ { path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') }, { path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') }, { path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') }, + { path: 'plans', name: 'plans', component: () => import('@/pages/MembershipPlansPage.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 ba00d82..1aace97 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -44,6 +44,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerContent(authed) h.registerRBAC(authed) h.registerLifecycle(authed) + h.registerMembershipPlans(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_membership_plans.go b/apps/api/internal/handler/admin_membership_plans.go new file mode 100644 index 0000000..475c0df --- /dev/null +++ b/apps/api/internal/handler/admin_membership_plans.go @@ -0,0 +1,78 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "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) registerMembershipPlans(authed *gin.RouterGroup) { + authed.GET("/membership-plans", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.ListMembershipPlans) + authed.GET("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansRead), h.GetMembershipPlan) + authed.PUT("/membership-plans/:code", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipPlansWrite), h.PutMembershipPlan) +} + +func (h *AdminHandler) ListMembershipPlans(c *gin.Context) { + items, err := h.Svc.ListMembershipPlans(c.Request.Context()) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) GetMembershipPlan(c *gin.Context) { + plan, err := h.Svc.GetMembershipPlan(c.Request.Context(), c.Param("code")) + if errors.Is(err, admin.ErrPlanNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "plan not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, plan) +} + +func (h *AdminHandler) PutMembershipPlan(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + var body struct { + Title string `json:"title"` + DurationDays int `json:"duration_days"` + AmountCents int `json:"amount_cents"` + Active *bool `json:"active"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid body") + return + } + active := true + if body.Active != nil { + active = *body.Active + } + plan, err := h.Svc.UpdateMembershipPlan( + c.Request.Context(), adminID, c.Param("code"), body.Title, body.DurationDays, body.AmountCents, active, + ) + if errors.Is(err, admin.ErrPlanNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "plan not found") + return + } + if errors.Is(err, admin.ErrInvalidPlanU) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, plan) +} diff --git a/apps/api/internal/integration/membership_plans_test.go b/apps/api/internal/integration/membership_plans_test.go new file mode 100644 index 0000000..fd3989b --- /dev/null +++ b/apps/api/internal/integration/membership_plans_test.go @@ -0,0 +1,97 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestMembershipPlans(t *testing.T) { + r, _ := setupAPIPool(t) + tok := adminLogin(t, r, "admin", "change-me") + + _, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", code) + } + + start := time.Now() + env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans", nil, tok) + if code != 200 || time.Since(start) > 500*time.Millisecond { + t.Fatalf("list plans http=%d dur=%v msg=%s", code, time.Since(start), env.Message) + } + var list struct { + Items []struct { + Code string `json:"code"` + DurationDays int `json:"duration_days"` + AmountCents int `json:"amount_cents"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &list) + if len(list.Items) < 3 { + t.Fatalf("expected 3 plans, got %#v", list.Items) + } + + env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership-plans/month", map[string]any{ + "title": "月卡测", "duration_days": 30, "amount_cents": 2600, "active": true, + }, tok) + if code != 200 { + t.Fatalf("put failed %d %s", code, env.Message) + } + var plan struct { + DurationDays int `json:"duration_days"` + AmountCents int `json:"amount_cents"` + } + _ = json.Unmarshal(env.Data, &plan) + if plan.DurationDays != 30 || plan.AmountCents != 2600 { + t.Fatalf("unexpected plan %#v", plan) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership-plans/month", nil, tok) + if code != 200 { + t.Fatalf("get %d", code) + } + _ = json.Unmarshal(env.Data, &plan) + if plan.DurationDays != 30 { + t.Fatalf("get mismatch %#v", plan) + } + + _ = mustRegister(t, r) + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, tok) + var users struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &users) + if len(users.Items) == 0 { + t.Fatal("need user") + } + env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+users.Items[0].ID+"/membership/grant", + map[string]string{"plan": "month"}, tok) + if code != 200 { + t.Fatalf("grant %d %s", code, env.Message) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok) + if code != 200 { + t.Fatalf("audit %d", code) + } + var audit struct { + Items []struct { + Action string `json:"action"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &audit) + found := false + for _, it := range audit.Items { + if it.Action == "membership.plans.update" { + found = true + break + } + } + if !found { + t.Fatal("missing membership.plans.update audit") + } +} diff --git a/apps/api/internal/repository/membership_plan_repo.go b/apps/api/internal/repository/membership_plan_repo.go new file mode 100644 index 0000000..2250d14 --- /dev/null +++ b/apps/api/internal/repository/membership_plan_repo.go @@ -0,0 +1,94 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// MembershipPlanRow is a configurable growth membership SKU. +type MembershipPlanRow struct { + Code string `json:"code"` + Title string `json:"title"` + DurationDays int `json:"duration_days"` + AmountCents int `json:"amount_cents"` + Active bool `json:"active"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListMembershipPlans returns all plans ordered by code. +func (r *AdminRepo) ListMembershipPlans(ctx context.Context) ([]MembershipPlanRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT code, title, duration_days, amount_cents, active, updated_at + FROM membership_plans ORDER BY code`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MembershipPlanRow + for rows.Next() { + var p MembershipPlanRow + if err := rows.Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// GetMembershipPlan loads one plan by code. +func (r *AdminRepo) GetMembershipPlan(ctx context.Context, code string) (*MembershipPlanRow, error) { + var p MembershipPlanRow + err := r.Pool.QueryRow(ctx, ` + SELECT code, title, duration_days, amount_cents, active, updated_at + FROM membership_plans WHERE code=$1`, code, + ).Scan(&p.Code, &p.Title, &p.DurationDays, &p.AmountCents, &p.Active, &p.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +// UpdateMembershipPlanWithAudit updates mutable fields and audits. +func (r *AdminRepo) UpdateMembershipPlanWithAudit( + ctx context.Context, + adminID uuid.UUID, + code, title string, + days, amountCents int, + active bool, + meta json.RawMessage, +) error { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, ` + UPDATE membership_plans + SET title=$2, duration_days=$3, amount_cents=$4, active=$5, updated_at=now() + WHERE code=$1`, code, title, days, amountCents, active) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errString("plan not found") + } + 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,'membership.plans.update','membership_plan',$2,$3)`, + adminID, code, meta, + ); err != nil { + return err + } + return tx.Commit(ctx) +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index 3e5fe88..b652bc7 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -316,6 +316,38 @@ func AskPackAmountCents(plan string) int { } } +// MembershipPlanAmountCents returns catalog price or fallback for membership plans. +func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) { + var amount int + var active bool + err := r.Pool.QueryRow(ctx, ` + SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan, + ).Scan(&amount, &active) + if errors.Is(err, pgx.ErrNoRows) { + return membershipAmountFallback(plan), nil + } + if err != nil { + return 0, err + } + if !active { + return 0, errString("plan inactive") + } + return amount, nil +} + +func membershipAmountFallback(plan string) int { + switch plan { + case "month": + return 2500 + case "quarter": + return 6800 + case "year": + return 19800 + default: + return 2500 + } +} + var errMissingReport = errString("report_id required for deep_access") type errString string diff --git a/apps/api/internal/service/admin/membership_plans.go b/apps/api/internal/service/admin/membership_plans.go new file mode 100644 index 0000000..76b9a23 --- /dev/null +++ b/apps/api/internal/service/admin/membership_plans.go @@ -0,0 +1,86 @@ +package admin + +import ( + "context" + "encoding/json" + "strings" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +var ( + ErrPlanNotFound = errString("plan not found") + ErrInvalidPlanU = errString("invalid plan update") +) + +// ListMembershipPlans returns catalog. +func (s *Service) ListMembershipPlans(ctx context.Context) ([]repository.MembershipPlanRow, error) { + items, err := s.Repo.ListMembershipPlans(ctx) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.MembershipPlanRow{} + } + return items, nil +} + +// GetMembershipPlan returns one plan. +func (s *Service) GetMembershipPlan(ctx context.Context, code string) (*repository.MembershipPlanRow, error) { + p, err := s.Repo.GetMembershipPlan(ctx, code) + if err != nil { + return nil, err + } + if p == nil { + return nil, ErrPlanNotFound + } + return p, nil +} + +// UpdateMembershipPlan updates mutable fields. +func (s *Service) UpdateMembershipPlan( + ctx context.Context, adminID uuid.UUID, code, title string, days, amount int, active bool, +) (*repository.MembershipPlanRow, error) { + code = strings.TrimSpace(code) + title = strings.TrimSpace(title) + if title == "" || days <= 0 || amount < 0 { + return nil, ErrInvalidPlanU + } + if _, err := s.GetMembershipPlan(ctx, code); err != nil { + return nil, err + } + meta, _ := json.Marshal(map[string]any{ + "title": title, "duration_days": days, "amount_cents": amount, "active": active, + }) + if err := s.Repo.UpdateMembershipPlanWithAudit(ctx, adminID, code, title, days, amount, active, meta); err != nil { + return nil, err + } + return s.GetMembershipPlan(ctx, code) +} + +// PlanDurationDays resolves grant length from catalog with hardcoded fallback. +func (s *Service) PlanDurationDays(ctx context.Context, plan string) (int, error) { + p, err := s.Repo.GetMembershipPlan(ctx, plan) + if err != nil { + return 0, err + } + if p != nil && p.Active && p.DurationDays > 0 { + return p.DurationDays, nil + } + return planDaysFallback(plan) +} + +func planDaysFallback(plan string) (int, error) { + switch plan { + case "month": + return 31, nil + case "quarter": + return 92, nil + case "year": + return 366, nil + default: + return 0, ErrInvalidPlan + } +} diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 7daa28f..75ef951 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -10,23 +10,25 @@ import ( // Permission catalog frozen in ECR-013A Spec. const ( - PermUsersRead = "admin.users.read" - PermMembershipGrant = "admin.users.membership.grant" - PermAskQuotaGrant = "admin.users.ask_quota.grant" - PermOrdersRead = "admin.orders.read" - PermAuditRead = "admin.audit.read" - PermAnalyticsRead = "admin.analytics.read" - PermContentWrite = "admin.content.write" - PermRolesRead = "admin.roles.read" - PermRolesWrite = "admin.roles.write" - PermUsersStatusWrite = "admin.users.status.write" + PermUsersRead = "admin.users.read" + PermMembershipGrant = "admin.users.membership.grant" + PermAskQuotaGrant = "admin.users.ask_quota.grant" + PermOrdersRead = "admin.orders.read" + PermAuditRead = "admin.audit.read" + PermAnalyticsRead = "admin.analytics.read" + PermContentWrite = "admin.content.write" + PermRolesRead = "admin.roles.read" + PermRolesWrite = "admin.roles.write" + PermUsersStatusWrite = "admin.users.status.write" + PermMembershipPlansRead = "admin.membership.plans.read" + PermMembershipPlansWrite = "admin.membership.plans.write" ) var knownPermissions = map[string]struct{}{ PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {}, PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {}, PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {}, - PermUsersStatusWrite: {}, + PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, } var ( diff --git a/apps/api/internal/service/admin/service.go b/apps/api/internal/service/admin/service.go index 94ceab4..9e06bff 100644 --- a/apps/api/internal/service/admin/service.go +++ b/apps/api/internal/service/admin/service.go @@ -181,7 +181,7 @@ type GrantInput struct { // GrantMembership extends membership and writes audit. func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error { - days, err := planDays(plan) + days, err := s.PlanDurationDays(ctx, plan) if err != nil { return err } @@ -230,16 +230,7 @@ func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repos } func planDays(plan string) (int, error) { - switch plan { - case "month": - return 31, nil - case "quarter": - return 92, nil - case "year": - return 366, nil - default: - return 0, ErrInvalidPlan - } + return planDaysFallback(plan) } func newToken() (string, error) { diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go index befc34e..4a4f8ee 100644 --- a/apps/api/internal/service/membership/service.go +++ b/apps/api/internal/service/membership/service.go @@ -34,7 +34,17 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr amount := 990 plan := in.Plan if in.Kind == "membership" { - amount = 2500 + if plan == "" { + plan = "month" + } + a, err := s.Reports.MembershipPlanAmountCents(ctx, plan) + if err != nil { + return uuid.Nil, err + } + if a <= 0 { + return uuid.Nil, errors.New("invalid membership plan") + } + amount = a } if in.Kind == "ask_pack" { if plan == "" { diff --git a/apps/api/migrations/000017_membership_plans.down.sql b/apps/api/migrations/000017_membership_plans.down.sql new file mode 100644 index 0000000..99faa56 --- /dev/null +++ b/apps/api/migrations/000017_membership_plans.down.sql @@ -0,0 +1,3 @@ +DELETE FROM admin_role_permissions +WHERE code IN ('admin.membership.plans.read', 'admin.membership.plans.write'); +DROP TABLE IF EXISTS membership_plans; diff --git a/apps/api/migrations/000017_membership_plans.up.sql b/apps/api/migrations/000017_membership_plans.up.sql new file mode 100644 index 0000000..70dae5d --- /dev/null +++ b/apps/api/migrations/000017_membership_plans.up.sql @@ -0,0 +1,27 @@ +-- ECR-014 MembershipPlan + +CREATE TABLE IF NOT EXISTS membership_plans ( + code varchar(32) PRIMARY KEY, + title varchar(64) NOT NULL, + duration_days int NOT NULL CHECK (duration_days > 0), + amount_cents int NOT NULL CHECK (amount_cents >= 0), + active boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO membership_plans(code, title, duration_days, amount_cents, active) +VALUES + ('month', '月卡', 31, 2500, true), + ('quarter', '季卡', 92, 6800, true), + ('year', '年卡', 366, 19800, true) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, p.code +FROM admin_roles r +CROSS JOIN (VALUES + ('admin.membership.plans.read'), + ('admin.membership.plans.write') +) AS p(code) +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md b/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md new file mode 100644 index 0000000..f83a652 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-014-membership-plan.md @@ -0,0 +1,23 @@ +# Backend Design: ECR-014 MembershipPlan + +| ID | BD-2026-014 | +| ECR | ECR-014 | +| Status | Approved | +| Coding | Authorized(Loop) | +| Change Level | L2 | +| Risk | Low | + +## Change Boundary + +```text +Domain: MembershipPlan +App: admin + membership CreateOrder amount/days +Infra: migration + repo +API: /admin/membership-plans* +Migration: YES +Tests: integration +``` + +## Rollback + +down migration;回退硬编码 planDays/amount diff --git a/docs/CODE_REVIEW/ECR-014.md b/docs/CODE_REVIEW/ECR-014.md new file mode 100644 index 0000000..aa4c2d3 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-014.md @@ -0,0 +1,18 @@ +# CODE_REVIEW — ECR-014 + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Loop continuous + +## Checklist + +- [x] Spec + BD Approved · migration 000017 +- [x] Admin GET/PUT membership-plans · OpenAPI +- [x] Grant/CreateOrder 读表 +- [x] TEST_REPORT AC 映射 +- [x] 无真支付 · 兑换码 · ask_pack + +```text +Decision: Approve → Closed +Next: ECR-015(Commerce 兑换码或 UserIntelligence 薄切片) +``` diff --git a/docs/CONTRACT_DIFF/ECR-014.yaml b/docs/CONTRACT_DIFF/ECR-014.yaml new file mode 100644 index 0000000..88a6ec7 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-014.yaml @@ -0,0 +1,36 @@ +ecr: ECR-014 +capability: CommerceEntitlement +bounded_context: Membership_Orders +parent: WAVE0-FROZEN +predecessor: ECR-013B +change: + type: additive +breaking_change: false +migration_required: true +compatibility_notes: > + New membership_plans table seeded with month/quarter/year. + Grant/CreateOrder read duration/amount from table with hardcoded fallback. + Additive RBAC permissions. + +entities: + - name: MembershipPlan + before: null + after: + fields: [code, title, duration_days, amount_cents, active, updated_at] + codes: [month, quarter, year] + +apis: + - method: GET + path: /api/v1/admin/membership-plans + change: added + - method: GET + path: /api/v1/admin/membership-plans/{code} + change: added + - method: PUT + path: /api/v1/admin/membership-plans/{code} + change: added + +security_impact: + - "admin.membership.plans.read/write" +observability_impact: + - "AuditLog membership.plans.update" diff --git a/docs/ECR/ECR-014-membership-plan.md b/docs/ECR/ECR-014-membership-plan.md new file mode 100644 index 0000000..7711fa8 --- /dev/null +++ b/docs/ECR/ECR-014-membership-plan.md @@ -0,0 +1,29 @@ +# ECR-014 + +**Title:** MembershipPlan(会员套餐配置薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop continuous) +**Parent:** WAVE0-FROZEN (`27f27a1`) +**Predecessor:** ECR-013B **Closed** +**Change Level: L2** + +## Change + +1. Spec `ops-membership-plan.md` +2. Entity `MembershipPlan` 表 + 种子 month/quarter/year +3. Admin GET/PUT `/membership-plans*` +4. Grant / CreateOrder(membership) 读表 +5. `docs/CONTRACT_DIFF/ECR-014.yaml` +6. **不含** 真支付 · 兑换码 · Entitlement · ask_pack + +## Scope Forbidden + +UGC · 真支付 · soft-delete User · 新建 plan code(本切片冻结三码) + +## Acceptance + +见 Spec L4。 + +## Linked + +- Spec / PRODUCT / ENGINEERING / BD-2026-014 / CONTRACT_DIFF / HANDOFF / STATE / TASK diff --git a/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md b/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md new file mode 100644 index 0000000..4727225 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-014-membership-plan.md @@ -0,0 +1,15 @@ +# ENGINEERING_SPEC — ECR-014 MembershipPlan + +**Approved · coding(Loop)** + +## Implement order + +1. Migration `membership_plans` + permission 种子 +2. Repo/service/admin handlers +3. Wire GrantMembership + membership CreateOrder +4. OpenAPI · admin-h5 最小页 +5. Integration AC · TEST_REPORT · Closed + +## Constraints + +函数≤50 · 文件≤400 · 无真支付 · 无新 plan code diff --git a/docs/HANDOFF/ECR-014-architect-to-engineer.md b/docs/HANDOFF/ECR-014-architect-to-engineer.md new file mode 100644 index 0000000..d656bb7 --- /dev/null +++ b/docs/HANDOFF/ECR-014-architect-to-engineer.md @@ -0,0 +1,6 @@ +# HANDOFF — ECR-014 Architect → Engineer + +**Loop continuous** · Approved + Coding authorized. + +Consume Spec/BD/CONTRACT_DIFF。Do ENGINEERING_SPEC order。 +Forbidden: 真支付 · 兑换码 · ask_pack · 新 plan code。 diff --git a/docs/HANDOFF/ECR-014-engineer-to-reviewer.md b/docs/HANDOFF/ECR-014-engineer-to-reviewer.md new file mode 100644 index 0000000..b0ab82e --- /dev/null +++ b/docs/HANDOFF/ECR-014-engineer-to-reviewer.md @@ -0,0 +1,4 @@ +# HANDOFF — ECR-014 Engineer → Reviewer + +Done: membership_plans · admin API/H5 · Grant/CreateOrder 读表 · TEST_REPORT. +Loop continuous → Reviewer Closed in same cycle. diff --git a/docs/PRODUCT_SPEC/ECR-014-membership-plan.md b/docs/PRODUCT_SPEC/ECR-014-membership-plan.md new file mode 100644 index 0000000..8992934 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-014-membership-plan.md @@ -0,0 +1,12 @@ +# PRODUCT_SPEC — ECR-014 MembershipPlan + +对齐 `.ai/product/feature-spec/ops-membership-plan.md` +Status: Approved · Loop continuous + +| ECR | ECR-014 | +| Capability | CommerceEntitlement | +| BC | Membership_Orders | +| Change Level | L2 | + +Outcome:运营可改会员套餐时长与标价;mock 履约读表。 +Out:真支付 · 兑换码 · ask_pack。 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index efb38e8..0a450b8 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -46,9 +46,9 @@ ## Active anchors -- ECR: **ECR-014** MembershipPlan(Loop continuous);**ECR-013B Closed**;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 +- ECR: **ECR-014 Closed**;Next **ECR-015** RedemptionCode(Loop continuous);013A/B Closed;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 - EXP: (无) -- STATE: `docs/STATE/ECR-014.md` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` +- STATE: `docs/STATE/ECR-014.md`(Closed)· next ECR-015 · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` - TRACEABILITY: `docs/TRACEABILITY.md` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` diff --git a/docs/STATE/ECR-014.md b/docs/STATE/ECR-014.md new file mode 100644 index 0000000..2e79f3c --- /dev/null +++ b/docs/STATE/ECR-014.md @@ -0,0 +1,12 @@ +# STATE — ECR-014 + +| Field | Value | +|-------|-------| +| ECR | ECR-014 | +| Status | **Closed** | +| Phase | closed | +| Parent | WAVE0-FROZEN | +| Predecessor | ECR-013B Closed | +| Test | TEST_REPORT/ECR-014.md | +| Review | CODE_REVIEW Approve → Closed | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-014-ECR014.yaml b/docs/TASKS/TASK-014-ECR014.yaml new file mode 100644 index 0000000..bbd13f4 --- /dev/null +++ b/docs/TASKS/TASK-014-ECR014.yaml @@ -0,0 +1,13 @@ +id: TASK-014-ECR014 +ecr: ECR-014 +title: MembershipPlan Closed +role: reviewer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-013B +outputs: + - CODE_REVIEW Approve → Closed +acceptance: + - Spec AC mapped + - No true payment diff --git a/docs/TEST_REPORT/ECR-014.md b/docs/TEST_REPORT/ECR-014.md new file mode 100644 index 0000000..68f7b4c --- /dev/null +++ b/docs/TEST_REPORT/ECR-014.md @@ -0,0 +1,29 @@ +# TEST_REPORT — ECR-014 MembershipPlan + +Date: 2026-08-07 · Loop continuous + +## Commands + +```bash +cd apps/api && go test ./internal/integration/ -run TestMembershipPlans -count=1 +npm run build:admin +``` + +## Results + +| Check | Result | +|-------|--------| +| TestMembershipPlans | PASS | +| build:admin | PASS | + +## AC + +| ID | Evidence | +|----|----------| +| AC-F-01 | GET plans ≥3 codes | +| AC-F-02 | PUT month → GET 一致 | +| AC-F-03 | grant month 200(读表 duration) | +| AC-S-01 | RequirePermission middleware(同 013A 模式) | +| AC-S-02 | 无 token → 401 | +| AC-P-01 | GET < 500ms | +| AC-O-01 | AuditLog membership.plans.update | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index ab5d80e..24803bb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,4 +17,4 @@ | ECR-012 | 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · BD-2026-012 · TEST_REPORT · HANDOFF review | | WAVE-0 | Ops Contract-First Foundation | **FROZEN** (`WAVE0-FROZEN` @ 27f27a1) | `docs/WAVE0/` · HUMAN_REVIEW FREEZE · boundary-rules · glossary · contract template | | ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration 000015 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN | -| ECR-013B | AccountLifecycle / UserStatus | **Closed** | Spec ops-account-lifecycle · BD-2026-013B · migration 000016 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN | +| ECR-014 | MembershipPlan | **Closed** | Spec ops-membership-plan · BD-2026-014 · migration 000017 · TEST_REPORT · CODE_REVIEW · Loop continuous | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index b1fc995..cf49e5a 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -242,6 +242,60 @@ paths: '401': description: Unauthorized + /api/v1/admin/membership-plans: + get: + tags: [admin] + summary: List membership plans + description: Requires admin.membership.plans.read + responses: + '200': + description: OK + '403': + description: Forbidden + + /api/v1/admin/membership-plans/{code}: + get: + tags: [admin] + summary: Get membership plan + parameters: + - in: path + name: code + required: true + schema: { type: string } + responses: + '200': + description: OK + '404': + description: Not found + put: + tags: [admin] + summary: Update membership plan + description: Requires admin.membership.plans.write + parameters: + - in: path + name: code + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [title, duration_days, amount_cents] + properties: + title: { type: string } + duration_days: { type: integer } + amount_cents: { type: integer } + active: { type: boolean } + responses: + '200': + description: OK + '400': + description: Invalid + '403': + description: Forbidden + /api/v1/admin/orders: get: tags: [admin] From 0e26aabef85fb768097baa112356206e5e4f947f Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:04:43 +0800 Subject: [PATCH 08/60] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20ECR-014=20Cl?= =?UTF-8?q?osed=20=E4=B8=8E=20Loop=20=E9=98=9F=E5=88=97=E6=8C=87=E5=90=91?= =?UTF-8?q?=20ECR-015?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .ai/product/feature-spec/README.md | 2 +- .ai/product/feature-spec/ops-admin.md | 3 ++- docs/CHANGELOG.md | 1 + docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 9de4cc2..04d62a6 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -24,7 +24,7 @@ | [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Closed | | [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Closed | | [ops-rbac.md](ops-rbac.md) | 运营 RBAC | §7 | `admin-h5` `/me` permissions · `/admin/roles*` | Ops-D · **ECR-013A Closed** | -| [ops-account-lifecycle.md](ops-account-lifecycle.md) | 账户生命周期 / UserStatus | §7 | `admin-h5` 用户详情 status CTA · C 端拒绝 | Ops-D · **ECR-013B Closed** | +| [ops-membership-plan.md](ops-membership-plan.md) | 会员套餐 MembershipPlan | §7 | `admin-h5` `/plans` | Ops-D · **ECR-014 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index dfd577e..dfd2cc2 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -210,6 +210,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **C(ECR-008 Closed)** | 首页宫格 CRUD · 测评上下架 — Spec `ops-content.md` | | **E(ECR-013A Closed)** | Admin RBAC — Spec `ops-rbac.md` · Parent WAVE0-FROZEN | | **F(ECR-013B Closed)** | AccountLifecycle — Spec `ops-account-lifecycle.md` | -| **G(ECR-014)** | MembershipPlan — CommerceEntitlement 薄切片(Loop continuous) | +| **G(ECR-014 Closed)** | MembershipPlan — Spec `ops-membership-plan.md` | +| 后置 | RedemptionCode / UserIntelligence / Entitlement 细权(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5dc1d33..480208d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表) - **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认 - **ECR-013B Closed**:AccountLifecycle Reviewer Approve - **ECR-013B Implemented**:AccountLifecycle(UserStatus 迁移 · DeviceAuth 拒绝非 active · admin-h5 CTA) diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 7f58c4c..88b7bae 100644 --- a/docs/WAVE0/LOOP_AUTHORIZATION.md +++ b/docs/WAVE0/LOOP_AUTHORIZATION.md @@ -30,4 +30,4 @@ Human 明文:**直接用 Loop,不用人工确认。** | Done | Next | |------|------| -| ECR-013A Closed · ECR-013B Closed | **ECR-014** MembershipPlan(CommerceEntitlement 薄切片) | +| ECR-013A/B/014 Closed | **ECR-015** RedemptionCode(兑换码薄切片;仍禁真支付) | From 1eeb0b00e7c13da2b6ae46c716836f34322ab4f9 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:16:13 +0800 Subject: [PATCH 09/60] =?UTF-8?q?feat(ECR-015):=20RedemptionCode=20?= =?UTF-8?q?=E5=85=91=E6=8D=A2=E7=A0=81=E5=B9=B6=20Closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批次生成/作废、C 端兑码延长会员;admin-h5 /codes。 Loop continuous。Next:ECR-016 UserIntelligence。 Co-authored-by: Cursor --- .ai/domain/entity-catalog.md | 2 +- .../feature-spec/ops-redemption-code.md | 43 +++++ apps/admin-h5/src/api/client.ts | 16 ++ apps/admin-h5/src/layouts/AdminShell.vue | 1 + apps/admin-h5/src/pages/RedemptionPage.vue | 131 ++++++++++++++ apps/admin-h5/src/router/index.ts | 1 + apps/api/internal/handler/admin.go | 1 + apps/api/internal/handler/admin_redemption.go | 99 +++++++++++ apps/api/internal/handler/report.go | 32 ++++ .../internal/integration/redemption_test.go | 100 +++++++++++ .../repository/commerce_redeem_repo.go | 125 +++++++++++++ .../internal/repository/redemption_repo.go | 168 ++++++++++++++++++ apps/api/internal/repository/report_repo.go | 32 ---- apps/api/internal/service/admin/rbac.go | 3 + apps/api/internal/service/admin/redemption.go | 96 ++++++++++ .../internal/service/membership/service.go | 10 ++ .../000018_redemption_codes.down.sql | 4 + .../migrations/000018_redemption_codes.up.sql | 35 ++++ .../BD-2026-015-redemption-code.md | 17 ++ docs/CHANGELOG.md | 1 + docs/CODE_REVIEW/ECR-015.md | 14 ++ docs/CONTRACT_DIFF/ECR-015.yaml | 28 +++ docs/ECR/ECR-015-redemption-code.md | 19 ++ .../ECR-015-redemption-code.md | 8 + docs/HANDOFF/ECR-015-architect-to-engineer.md | 4 + docs/HANDOFF/ECR-015-engineer-to-reviewer.md | 3 + docs/PRODUCT_SPEC/ECR-015-redemption-code.md | 4 + docs/PROJECT_PROFILE.md | 4 +- docs/STATE/ECR-015.md | 9 + docs/TASKS/TASK-015-ECR015.yaml | 11 ++ docs/TEST_REPORT/ECR-015.md | 22 +++ docs/TRACEABILITY.md | 2 +- proto/openapi.yaml | 64 +++++++ 33 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 .ai/product/feature-spec/ops-redemption-code.md create mode 100644 apps/admin-h5/src/pages/RedemptionPage.vue create mode 100644 apps/api/internal/handler/admin_redemption.go create mode 100644 apps/api/internal/integration/redemption_test.go create mode 100644 apps/api/internal/repository/commerce_redeem_repo.go create mode 100644 apps/api/internal/repository/redemption_repo.go create mode 100644 apps/api/internal/service/admin/redemption.go create mode 100644 apps/api/migrations/000018_redemption_codes.down.sql create mode 100644 apps/api/migrations/000018_redemption_codes.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md create mode 100644 docs/CODE_REVIEW/ECR-015.md create mode 100644 docs/CONTRACT_DIFF/ECR-015.yaml create mode 100644 docs/ECR/ECR-015-redemption-code.md create mode 100644 docs/ENGINEERING_SPEC/ECR-015-redemption-code.md create mode 100644 docs/HANDOFF/ECR-015-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-015-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-015-redemption-code.md create mode 100644 docs/STATE/ECR-015.md create mode 100644 docs/TASKS/TASK-015-ECR015.yaml create mode 100644 docs/TEST_REPORT/ECR-015.md diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index 3b1f66b..a07efae 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -20,7 +20,7 @@ Status: `Draft` | PsychologicalTagSet | Identity_Profile | UserIntelligence | 未来读模型 | | MembershipPlan | Membership_Orders | CommerceEntitlement | 配置面后置 ECR | | Entitlement | Membership_Orders | CommerceEntitlement | 后置 | -| RedemptionCode | Membership_Orders | CommerceEntitlement | 后置 | +| RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** | | Membership | Membership_Orders | CommerceEntitlement | 已存在 | | Order | Membership_Orders | CommerceEntitlement | 已存在 | | DeepAccess | Membership_Orders | CommerceEntitlement | 已存在 | diff --git a/.ai/product/feature-spec/ops-redemption-code.md b/.ai/product/feature-spec/ops-redemption-code.md new file mode 100644 index 0000000..be5f44d --- /dev/null +++ b/.ai/product/feature-spec/ops-redemption-code.md @@ -0,0 +1,43 @@ +# Feature Spec: 兑换码 RedemptionCode(Ops · ECR-015) + +> Status: `Active`(Loop continuous · Approved · coding) +> Parent: WAVE0-FROZEN · Predecessor: ECR-014 Closed +> Capability: `CommerceEntitlement` · BC: `Membership_Orders` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +真支付 · Entitlement 细权 · ask_pack 兑换 · UGC + +## L2 + +| Entity | 不变式 | +|--------|--------| +| `RedemptionBatch` | label · plan_code∈membership_plans · quantity 1..100 | +| `RedemptionCode` | code 唯一;status unused→redeemed\|disabled;redeemed 不可再兑 | + +## L3 API + +| Method | Path | Auth | 语义 | +|--------|------|------|------| +| POST | `/admin/redemption-batches` | `admin.membership.codes.write` | 批量生成 | +| GET | `/admin/redemption-batches` | `admin.membership.codes.read` | 批次列表 | +| GET | `/admin/redemption-batches/:id/codes` | `admin.membership.codes.read` | 码列表 | +| POST | `/admin/redemption-codes/:id/disable` | `admin.membership.codes.write` | 作废 unused | +| POST | `/membership/redeem` | DeviceAuth+已注册 | 兑码→延长会员 | + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | POST batch quantity=3 → 3 unused codes | +| AC-F-02 | C端 redeem → membership active;码=redeemed | +| AC-F-03 | 再兑同一码 → 400 | +| AC-F-04 | disable unused → status=disabled;兑 → 400 | +| AC-S-01 | 无 write → POST batch 403 | +| AC-S-02 | 未登录兑码 → 401 | +| AC-P-01 | GET batches < 500ms | +| AC-O-01 | 生成 AuditLog `redemption.batch.create` | +| AC-O-02 | 兑换可追溯 redeemed_by | + +contract_diff: `docs/CONTRACT_DIFF/ECR-015.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 6d3384f..7401b3c 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -158,6 +158,22 @@ export const adminApi = { amount_cents: number active: boolean }>('PUT', `/membership-plans/${code}`, body), + createRedemptionBatch: (body: { label: string; plan_code: string; quantity: number }) => + request<{ + batch: { id: string; label: string; plan_code: string; quantity: number } + codes: Array<{ id: string; code: string; status: string }> + }>('POST', '/redemption-batches', body), + redemptionBatches: () => + request<{ items: Array<{ id: string; label: string; plan_code: string; quantity: number; created_at: string }> }>( + 'GET', + '/redemption-batches', + ), + redemptionCodes: (batchId: string) => + request<{ items: Array<{ id: string; code: string; status: string; plan_code: string }> }>( + 'GET', + `/redemption-batches/${batchId}/codes`, + ), + disableRedemptionCode: (id: string) => request<{ ok: boolean }>('POST', `/redemption-codes/${id}/disable`), orders: () => request<{ items: Array<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 94b8e51..ae3be53 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -29,6 +29,7 @@ async function onLogout() { 内容 用户 套餐 + 兑换码 订单 审计 diff --git a/apps/admin-h5/src/pages/RedemptionPage.vue b/apps/admin-h5/src/pages/RedemptionPage.vue new file mode 100644 index 0000000..82fd5ca --- /dev/null +++ b/apps/admin-h5/src/pages/RedemptionPage.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts index 9681fbe..a326be0 100644 --- a/apps/admin-h5/src/router/index.ts +++ b/apps/admin-h5/src/router/index.ts @@ -16,6 +16,7 @@ const router = createRouter({ { path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') }, { 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: '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 1aace97..e344ea9 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerRBAC(authed) h.registerLifecycle(authed) h.registerMembershipPlans(authed) + h.registerRedemption(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_redemption.go b/apps/api/internal/handler/admin_redemption.go new file mode 100644 index 0000000..21ede3c --- /dev/null +++ b/apps/api/internal/handler/admin_redemption.go @@ -0,0 +1,99 @@ +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) registerRedemption(authed *gin.RouterGroup) { + authed.POST("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.CreateRedemptionBatch) + authed.GET("/redemption-batches", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionBatches) + authed.GET("/redemption-batches/:id/codes", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesRead), h.ListRedemptionCodes) + authed.POST("/redemption-codes/:id/disable", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipCodesWrite), h.DisableRedemptionCode) +} + +func (h *AdminHandler) CreateRedemptionBatch(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + var body struct { + Label string `json:"label"` + PlanCode string `json:"plan_code"` + Quantity int `json:"quantity"` + } + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid body") + return + } + batch, codes, err := h.Svc.CreateRedemptionBatch(c.Request.Context(), adminID, body.Label, body.PlanCode, body.Quantity) + if errors.Is(err, admin.ErrBadBatchQty) || errors.Is(err, admin.ErrPlanNotFound) || errors.Is(err, admin.ErrInvalidPlanU) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"batch": batch, "codes": codes}) +} + +func (h *AdminHandler) ListRedemptionBatches(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + items, err := h.Svc.ListRedemptionBatches(c.Request.Context(), limit) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) ListRedemptionCodes(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + items, err := h.Svc.ListRedemptionCodes(c.Request.Context(), id) + if errors.Is(err, admin.ErrBatchNotFound) { + response.Fail(c, http.StatusNotFound, 40400, "batch not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *AdminHandler) DisableRedemptionCode(c *gin.Context) { + adminID, ok := middleware.AdminIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid") + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, "invalid id") + return + } + err = h.Svc.DisableRedemptionCode(c.Request.Context(), adminID, id) + if errors.Is(err, admin.ErrCodeDisable) { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"ok": true}) +} diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go index ba24c53..91ddc1b 100644 --- a/apps/api/internal/handler/report.go +++ b/apps/api/internal/handler/report.go @@ -37,6 +37,7 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) { rg.GET("/reports/latest", h.GetLatest) rg.GET("/reports/:id", h.Get) rg.GET("/membership/me", h.GetMembership) + rg.POST("/membership/redeem", h.RedeemCode) rg.POST("/orders", h.CreateOrder) rg.POST("/orders/:id/pay-mock", h.PayMock) } @@ -257,6 +258,37 @@ func (h *ReportHandler) GetMembership(c *gin.Context) { response.OK(c, me) } +// RedeemCode handles POST /membership/redeem. +func (h *ReportHandler) RedeemCode(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + msvc, ok := h.requireMembership(c) + if !ok { + return + } + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" { + response.Fail(c, http.StatusBadRequest, 40000, "code required") + return + } + plan, err := msvc.Redeem(c.Request.Context(), userID, body.Code) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40000, err.Error()) + return + } + me, err := msvc.Get(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50000, err.Error()) + return + } + response.OK(c, gin.H{"plan": plan, "membership": me}) +} + // CreateOrder handles POST /orders. func (h *ReportHandler) CreateOrder(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) diff --git a/apps/api/internal/integration/redemption_test.go b/apps/api/internal/integration/redemption_test.go new file mode 100644 index 0000000..e350f0a --- /dev/null +++ b/apps/api/internal/integration/redemption_test.go @@ -0,0 +1,100 @@ +package integration_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestRedemptionCodes(t *testing.T) { + r, _ := setupAPIPool(t) + tok := adminLogin(t, r, "admin", "change-me") + + _, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches", + map[string]any{"label": "t", "plan_code": "month", "quantity": 2}, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", code) + } + + start := time.Now() + env, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-batches", + map[string]any{"label": "ops-test", "plan_code": "month", "quantity": 3}, tok) + if code != 200 { + t.Fatalf("create batch http=%d msg=%s", code, env.Message) + } + var created struct { + Batch struct { + ID string `json:"id"` + } `json:"batch"` + Codes []struct { + ID string `json:"id"` + Code string `json:"code"` + Status string `json:"status"` + } `json:"codes"` + } + _ = json.Unmarshal(env.Data, &created) + if len(created.Codes) != 3 { + t.Fatalf("want 3 codes, got %#v", created.Codes) + } + raw := created.Codes[0].Code + disableID := created.Codes[2].ID + + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches", nil, tok) + if code != 200 || time.Since(start) > 500*time.Millisecond { + t.Fatalf("list batches http=%d dur=%v", code, time.Since(start)) + } + + _, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/redemption-batches/"+created.Batch.ID+"/codes", nil, tok) + if code != 200 { + t.Fatalf("list codes %d", code) + } + + key := mustRegister(t, r) + _, _, httpCode := doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": raw}, key, 0) + if httpCode != 200 { + t.Fatalf("redeem http=%d", httpCode) + } + + _, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": raw}, key, 40000) + if httpCode != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 re-redeem, got %d", httpCode) + } + + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/redemption-codes/"+disableID+"/disable", nil, tok) + if code != 200 { + t.Fatalf("disable %d", code) + } + _, _, httpCode = doJSONExpect(t, r, http.MethodPost, "/api/v1/membership/redeem", + map[string]string{"code": created.Codes[2].Code}, key, 40000) + if httpCode != http.StatusBadRequest { + t.Fatalf("expected HTTP 400 disabled, got %d", httpCode) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, tok) + if code != 200 { + t.Fatalf("audit %d", code) + } + var audit struct { + Items []struct { + Action string `json:"action"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &audit) + found := false + for _, it := range audit.Items { + if it.Action == "redemption.batch.create" { + found = true + break + } + } + if !found { + t.Fatal("missing redemption.batch.create audit") + } + + if code := deviceGET(t, r, "/api/v1/membership/me", "dev_orphan_"+time.Now().Format("150405"), ""); code != http.StatusUnauthorized { + t.Fatalf("expected 401 unregistered membership, got %d", code) + } +} diff --git a/apps/api/internal/repository/commerce_redeem_repo.go b/apps/api/internal/repository/commerce_redeem_repo.go new file mode 100644 index 0000000..2934b6a --- /dev/null +++ b/apps/api/internal/repository/commerce_redeem_repo.go @@ -0,0 +1,125 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// MembershipPlanAmountCents returns catalog price or fallback for membership plans. +func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) { + var amount int + var active bool + err := r.Pool.QueryRow(ctx, ` + SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan, + ).Scan(&amount, &active) + if errors.Is(err, pgx.ErrNoRows) { + return membershipAmountFallback(plan), nil + } + if err != nil { + return 0, err + } + if !active { + return 0, errString("plan inactive") + } + return amount, nil +} + +func membershipAmountFallback(plan string) int { + switch plan { + case "month": + return 2500 + case "quarter": + return 6800 + case "year": + return 19800 + default: + return 2500 + } +} + +// MembershipPlanDurationDays returns catalog days or fallback. +func (r *ReportRepo) MembershipPlanDurationDays(ctx context.Context, plan string) (int, error) { + var days int + var active bool + err := r.Pool.QueryRow(ctx, ` + SELECT duration_days, active FROM membership_plans WHERE code=$1`, plan, + ).Scan(&days, &active) + if errors.Is(err, pgx.ErrNoRows) { + return membershipDaysFallback(plan), nil + } + if err != nil { + return 0, err + } + if !active || days <= 0 { + return membershipDaysFallback(plan), nil + } + return days, nil +} + +func membershipDaysFallback(plan string) int { + switch plan { + case "month": + return 31 + case "quarter": + return 92 + case "year": + return 366 + default: + return 31 + } +} + +// RedeemCode applies an unused redemption code to user membership. +func (r *ReportRepo) RedeemCode(ctx context.Context, userID uuid.UUID, rawCode string) (plan string, err error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return "", err + } + defer tx.Rollback(ctx) + + var codeID uuid.UUID + var status string + err = tx.QueryRow(ctx, ` + SELECT id, plan_code, status FROM redemption_codes + WHERE code=$1 FOR UPDATE`, rawCode, + ).Scan(&codeID, &plan, &status) + if errors.Is(err, pgx.ErrNoRows) { + return "", errString("invalid code") + } + if err != nil { + return "", err + } + if status != "unused" { + return "", errString("code not redeemable") + } + days, err := r.MembershipPlanDurationDays(ctx, plan) + if err != nil { + return "", err + } + if _, err := tx.Exec(ctx, ` + UPDATE redemption_codes + SET status='redeemed', redeemed_by=$2, redeemed_at=now() + WHERE id=$1 AND status='unused'`, codeID, userID); err != nil { + return "", err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left) + VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100) + ON CONFLICT (user_id) DO UPDATE SET + plan=EXCLUDED.plan, status='active', + expires_at=(CASE + WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now() + THEN memberships.expires_at ELSE now() + END) + ($3 * interval '1 day'), + ask_quota_left=100, updated_at=now()`, + userID, plan, days); err != nil { + return "", err + } + if err := tx.Commit(ctx); err != nil { + return "", err + } + return plan, nil +} diff --git a/apps/api/internal/repository/redemption_repo.go b/apps/api/internal/repository/redemption_repo.go new file mode 100644 index 0000000..1fc5966 --- /dev/null +++ b/apps/api/internal/repository/redemption_repo.go @@ -0,0 +1,168 @@ +package repository + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// RedemptionBatch is a generation batch of codes. +type RedemptionBatch struct { + ID uuid.UUID `json:"id"` + Label string `json:"label"` + PlanCode string `json:"plan_code"` + Quantity int `json:"quantity"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +// RedemptionCodeRow is one redeemable code. +type RedemptionCodeRow struct { + ID uuid.UUID `json:"id"` + BatchID uuid.UUID `json:"batch_id"` + Code string `json:"code"` + PlanCode string `json:"plan_code"` + Status string `json:"status"` + RedeemedBy *uuid.UUID `json:"redeemed_by,omitempty"` + RedeemedAt *time.Time `json:"redeemed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateRedemptionBatchWithCodes inserts batch + codes + audit. +func (r *AdminRepo) CreateRedemptionBatchWithCodes( + ctx context.Context, + adminID uuid.UUID, + label, planCode string, + codes []string, + meta json.RawMessage, +) (*RedemptionBatch, []RedemptionCodeRow, error) { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return nil, nil, err + } + defer tx.Rollback(ctx) + + var b RedemptionBatch + err = tx.QueryRow(ctx, ` + INSERT INTO redemption_batches(label, plan_code, quantity, created_by) + VALUES ($1,$2,$3,$4) + RETURNING id, label, plan_code, quantity, created_by, created_at`, + label, planCode, len(codes), adminID, + ).Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt) + if err != nil { + return nil, nil, err + } + + out := make([]RedemptionCodeRow, 0, len(codes)) + for _, code := range codes { + var row RedemptionCodeRow + err = tx.QueryRow(ctx, ` + INSERT INTO redemption_codes(batch_id, code, plan_code, status) + VALUES ($1,$2,$3,'unused') + RETURNING id, batch_id, code, plan_code, status, created_at`, + b.ID, code, planCode, + ).Scan(&row.ID, &row.BatchID, &row.Code, &row.PlanCode, &row.Status, &row.CreatedAt) + if err != nil { + return nil, nil, err + } + out = append(out, row) + } + 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,'redemption.batch.create','redemption_batch',$2,$3)`, + adminID, b.ID.String(), meta, + ); err != nil { + return nil, nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + return &b, out, nil +} + +// ListRedemptionBatches newest first. +func (r *AdminRepo) ListRedemptionBatches(ctx context.Context, limit int) ([]RedemptionBatch, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := r.Pool.Query(ctx, ` + SELECT id, label, plan_code, quantity, created_by, created_at + FROM redemption_batches ORDER BY created_at DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []RedemptionBatch + for rows.Next() { + var b RedemptionBatch + if err := rows.Scan(&b.ID, &b.Label, &b.PlanCode, &b.Quantity, &b.CreatedBy, &b.CreatedAt); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// ListRedemptionCodesByBatch returns codes for a batch. +func (r *AdminRepo) ListRedemptionCodesByBatch(ctx context.Context, batchID uuid.UUID) ([]RedemptionCodeRow, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, batch_id, code, plan_code, status, redeemed_by, redeemed_at, created_at + FROM redemption_codes WHERE batch_id=$1 ORDER BY created_at`, batchID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRedemptionCodes(rows) +} + +// DisableRedemptionCode marks unused code disabled. +func (r *AdminRepo) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, ` + UPDATE redemption_codes SET status='disabled' + WHERE id=$1 AND status='unused'`, codeID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errString("code not unused") + } + meta, _ := json.Marshal(map[string]string{"code_id": codeID.String()}) + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta) + VALUES ($1,'redemption.code.disable','redemption_code',$2,$3)`, + adminID, codeID.String(), meta, + ); err != nil { + return err + } + return tx.Commit(ctx) +} + +func scanRedemptionCodes(rows pgx.Rows) ([]RedemptionCodeRow, error) { + var out []RedemptionCodeRow + for rows.Next() { + var c RedemptionCodeRow + if err := rows.Scan(&c.ID, &c.BatchID, &c.Code, &c.PlanCode, &c.Status, &c.RedeemedBy, &c.RedeemedAt, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// BatchExists reports whether batch id exists. +func (r *AdminRepo) BatchExists(ctx context.Context, id uuid.UUID) (bool, error) { + var ok bool + err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM redemption_batches WHERE id=$1)`, id).Scan(&ok) + return ok, err +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go index b652bc7..3e5fe88 100644 --- a/apps/api/internal/repository/report_repo.go +++ b/apps/api/internal/repository/report_repo.go @@ -316,38 +316,6 @@ func AskPackAmountCents(plan string) int { } } -// MembershipPlanAmountCents returns catalog price or fallback for membership plans. -func (r *ReportRepo) MembershipPlanAmountCents(ctx context.Context, plan string) (int, error) { - var amount int - var active bool - err := r.Pool.QueryRow(ctx, ` - SELECT amount_cents, active FROM membership_plans WHERE code=$1`, plan, - ).Scan(&amount, &active) - if errors.Is(err, pgx.ErrNoRows) { - return membershipAmountFallback(plan), nil - } - if err != nil { - return 0, err - } - if !active { - return 0, errString("plan inactive") - } - return amount, nil -} - -func membershipAmountFallback(plan string) int { - switch plan { - case "month": - return 2500 - case "quarter": - return 6800 - case "year": - return 19800 - default: - return 2500 - } -} - var errMissingReport = errString("report_id required for deep_access") type errString string diff --git a/apps/api/internal/service/admin/rbac.go b/apps/api/internal/service/admin/rbac.go index 75ef951..5fd4745 100644 --- a/apps/api/internal/service/admin/rbac.go +++ b/apps/api/internal/service/admin/rbac.go @@ -22,6 +22,8 @@ const ( PermUsersStatusWrite = "admin.users.status.write" PermMembershipPlansRead = "admin.membership.plans.read" PermMembershipPlansWrite = "admin.membership.plans.write" + PermMembershipCodesRead = "admin.membership.codes.read" + PermMembershipCodesWrite = "admin.membership.codes.write" ) var knownPermissions = map[string]struct{}{ @@ -29,6 +31,7 @@ var knownPermissions = map[string]struct{}{ PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {}, PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {}, PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {}, + PermMembershipCodesRead: {}, PermMembershipCodesWrite: {}, } var ( diff --git a/apps/api/internal/service/admin/redemption.go b/apps/api/internal/service/admin/redemption.go new file mode 100644 index 0000000..37d7c2f --- /dev/null +++ b/apps/api/internal/service/admin/redemption.go @@ -0,0 +1,96 @@ +package admin + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +var ( + ErrBadBatchQty = errString("quantity must be 1..100") + ErrCodeDisable = errString("code not unused") + ErrBatchNotFound = errString("batch not found") +) + +// CreateRedemptionBatch generates codes for a membership plan. +func (s *Service) CreateRedemptionBatch( + ctx context.Context, adminID uuid.UUID, label, planCode string, qty int, +) (*repository.RedemptionBatch, []repository.RedemptionCodeRow, error) { + label = strings.TrimSpace(label) + planCode = strings.TrimSpace(planCode) + if label == "" || qty < 1 || qty > 100 { + return nil, nil, ErrBadBatchQty + } + if _, err := s.GetMembershipPlan(ctx, planCode); err != nil { + return nil, nil, err + } + codes := make([]string, 0, qty) + seen := map[string]struct{}{} + for len(codes) < qty { + c, err := newRedemptionCode() + if err != nil { + return nil, nil, err + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + codes = append(codes, c) + } + meta, _ := json.Marshal(map[string]any{"label": label, "plan": planCode, "quantity": qty}) + return s.Repo.CreateRedemptionBatchWithCodes(ctx, adminID, label, planCode, codes, meta) +} + +// ListRedemptionBatches lists recent batches. +func (s *Service) ListRedemptionBatches(ctx context.Context, limit int) ([]repository.RedemptionBatch, error) { + items, err := s.Repo.ListRedemptionBatches(ctx, limit) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.RedemptionBatch{} + } + return items, nil +} + +// ListRedemptionCodes lists codes in a batch. +func (s *Service) ListRedemptionCodes(ctx context.Context, batchID uuid.UUID) ([]repository.RedemptionCodeRow, error) { + ok, err := s.Repo.BatchExists(ctx, batchID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrBatchNotFound + } + items, err := s.Repo.ListRedemptionCodesByBatch(ctx, batchID) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.RedemptionCodeRow{} + } + return items, nil +} + +// DisableRedemptionCode voids an unused code. +func (s *Service) DisableRedemptionCode(ctx context.Context, adminID, codeID uuid.UUID) error { + err := s.Repo.DisableRedemptionCode(ctx, adminID, codeID) + if err != nil && err.Error() == "code not unused" { + return ErrCodeDisable + } + return err +} + +func newRedemptionCode() (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "YXG-" + strings.ToUpper(hex.EncodeToString(b)), nil +} diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go index 4a4f8ee..c2ba6a6 100644 --- a/apps/api/internal/service/membership/service.go +++ b/apps/api/internal/service/membership/service.go @@ -4,6 +4,7 @@ package membership import ( "context" "errors" + "strings" "time" "github.com/google/uuid" @@ -58,6 +59,15 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount) } +// Redeem applies a redemption code for the current registered user. +func (s *Service) Redeem(ctx context.Context, userID uuid.UUID, code string) (string, error) { + code = strings.TrimSpace(code) + if code == "" { + return "", errors.New("code required") + } + return s.Reports.RedeemCode(ctx, userID, code) +} + // PayMock completes mock payment. func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { return s.Reports.PayMock(ctx, userID, orderID) diff --git a/apps/api/migrations/000018_redemption_codes.down.sql b/apps/api/migrations/000018_redemption_codes.down.sql new file mode 100644 index 0000000..d91f2cd --- /dev/null +++ b/apps/api/migrations/000018_redemption_codes.down.sql @@ -0,0 +1,4 @@ +DELETE FROM admin_role_permissions +WHERE code IN ('admin.membership.codes.read', 'admin.membership.codes.write'); +DROP TABLE IF EXISTS redemption_codes; +DROP TABLE IF EXISTS redemption_batches; diff --git a/apps/api/migrations/000018_redemption_codes.up.sql b/apps/api/migrations/000018_redemption_codes.up.sql new file mode 100644 index 0000000..de045e1 --- /dev/null +++ b/apps/api/migrations/000018_redemption_codes.up.sql @@ -0,0 +1,35 @@ +-- ECR-015 RedemptionCode + +CREATE TABLE IF NOT EXISTS redemption_batches ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + label varchar(64) NOT NULL, + plan_code varchar(32) NOT NULL REFERENCES membership_plans(code), + quantity int NOT NULL CHECK (quantity > 0 AND quantity <= 100), + created_by uuid NOT NULL REFERENCES admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS redemption_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + batch_id uuid NOT NULL REFERENCES redemption_batches(id) ON DELETE CASCADE, + code varchar(32) NOT NULL UNIQUE, + plan_code varchar(32) NOT NULL REFERENCES membership_plans(code), + status varchar(16) NOT NULL DEFAULT 'unused' + CHECK (status IN ('unused','redeemed','disabled')), + redeemed_by uuid NULL REFERENCES users(id), + redeemed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_redemption_codes_batch ON redemption_codes(batch_id); +CREATE INDEX IF NOT EXISTS idx_redemption_codes_status ON redemption_codes(status); + +INSERT INTO admin_role_permissions(role_id, code) +SELECT r.id, p.code +FROM admin_roles r +CROSS JOIN (VALUES + ('admin.membership.codes.read'), + ('admin.membership.codes.write') +) AS p(code) +WHERE r.name = 'super_admin' +ON CONFLICT DO NOTHING; diff --git a/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md b/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md new file mode 100644 index 0000000..5f1d0df --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-015-redemption-code.md @@ -0,0 +1,17 @@ +# Backend Design: ECR-015 RedemptionCode + +| ID | BD-2026-015 | +| Status | Approved | +| Coding | Loop authorized | +| Level | L2 | + +## Boundary + +```text +Domain: RedemptionBatch, RedemptionCode +App: admin + membership.Redeem +API: /admin/redemption-* , POST /membership/redeem +Migration: YES 000018 +``` + +Rollback: down migration diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 480208d..6b8b58d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- **ECR-015 Closed**:RedemptionCode(批次生成 · C端兑码 · 作废 · 审计) - **ECR-014 Closed**:MembershipPlan(套餐表 · admin 配置 · Grant/CreateOrder 读表) - **LOOP continuous**:`docs/WAVE0/LOOP_AUTHORIZATION.md` — Human 授权免逐闸确认 - **ECR-013B Closed**:AccountLifecycle Reviewer Approve diff --git a/docs/CODE_REVIEW/ECR-015.md b/docs/CODE_REVIEW/ECR-015.md new file mode 100644 index 0000000..f57d59a --- /dev/null +++ b/docs/CODE_REVIEW/ECR-015.md @@ -0,0 +1,14 @@ +# CODE_REVIEW — ECR-015 + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Loop continuous + +- [x] Migration 000018 · Spec AC · 无真支付/UGC +- [x] Admin batches/codes · C端 /membership/redeem +- [x] TEST_REPORT PASS + +```text +Decision: Approve → Closed +Next: ECR-016 UserIntelligence 薄切片(只读用户洞察看板)或停 +``` diff --git a/docs/CONTRACT_DIFF/ECR-015.yaml b/docs/CONTRACT_DIFF/ECR-015.yaml new file mode 100644 index 0000000..e0e6400 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-015.yaml @@ -0,0 +1,28 @@ +ecr: ECR-015 +capability: CommerceEntitlement +bounded_context: Membership_Orders +parent: WAVE0-FROZEN +predecessor: ECR-014 +change: + type: additive +breaking_change: false +migration_required: true +compatibility_notes: > + New redemption_batches / redemption_codes. + C-end POST /membership/redeem extends membership without payment. + Additive RBAC admin.membership.codes.read/write. + +entities: + - name: RedemptionBatch + before: null + after: { fields: [id, label, plan_code, quantity, created_by, created_at] } + - name: RedemptionCode + before: null + after: { fields: [id, batch_id, code, plan_code, status, redeemed_by, redeemed_at] } + +apis: + - { method: POST, path: /api/v1/admin/redemption-batches, change: added } + - { method: GET, path: /api/v1/admin/redemption-batches, change: added } + - { method: GET, path: /api/v1/admin/redemption-batches/{id}/codes, change: added } + - { method: POST, path: /api/v1/admin/redemption-codes/{id}/disable, change: added } + - { method: POST, path: /api/v1/membership/redeem, change: added } diff --git a/docs/ECR/ECR-015-redemption-code.md b/docs/ECR/ECR-015-redemption-code.md new file mode 100644 index 0000000..4f3fd1c --- /dev/null +++ b/docs/ECR/ECR-015-redemption-code.md @@ -0,0 +1,19 @@ +# ECR-015 + +**Title:** RedemptionCode(兑换码薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop continuous) +**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-014 Closed +**Change Level:** L2 + +## Change + +批次生成兑换码 → C 端兑码延长 MembershipPlan 对应时长 · 可作废 · 审计 + +## Forbidden + +真支付 · UGC · ask_pack 兑换 · soft-delete + +## Linked + +Spec `ops-redemption-code.md` · BD-2026-015 · CONTRACT_DIFF/ECR-015.yaml diff --git a/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md b/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md new file mode 100644 index 0000000..32878bd --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-015-redemption-code.md @@ -0,0 +1,8 @@ +# ENGINEERING_SPEC — ECR-015 + +Approved · coding(Loop) + +1. Migration batches+codes + RBAC permissions +2. Admin generate/list/disable · membership Redeem +3. OpenAPI · admin-h5 最小页 +4. Integration AC · Closed diff --git a/docs/HANDOFF/ECR-015-architect-to-engineer.md b/docs/HANDOFF/ECR-015-architect-to-engineer.md new file mode 100644 index 0000000..b316f01 --- /dev/null +++ b/docs/HANDOFF/ECR-015-architect-to-engineer.md @@ -0,0 +1,4 @@ +# HANDOFF — ECR-015 Architect → Engineer + +Loop continuous · Approved + Coding authorized. +Do Spec order. Forbidden: 真支付 · UGC · ask_pack redeem. diff --git a/docs/HANDOFF/ECR-015-engineer-to-reviewer.md b/docs/HANDOFF/ECR-015-engineer-to-reviewer.md new file mode 100644 index 0000000..9f06b6c --- /dev/null +++ b/docs/HANDOFF/ECR-015-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-015 Engineer → Reviewer + +Done · Loop continuous Closed. diff --git a/docs/PRODUCT_SPEC/ECR-015-redemption-code.md b/docs/PRODUCT_SPEC/ECR-015-redemption-code.md new file mode 100644 index 0000000..f85c6d0 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-015-redemption-code.md @@ -0,0 +1,4 @@ +# PRODUCT_SPEC — ECR-015 + +对齐 Spec ops-redemption-code.md · Approved · Loop continuous +Capability CommerceEntitlement · BC Membership_Orders · L2 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 0a450b8..451bd5a 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -46,9 +46,9 @@ ## Active anchors -- ECR: **ECR-014 Closed**;Next **ECR-015** RedemptionCode(Loop continuous);013A/B Closed;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 +- ECR: **ECR-015 Closed**;Next **ECR-016** UserIntelligence(Loop);013A/B/014 Closed;**ECR-013A Closed**;**WAVE0-FROZEN** @ 27f27a1 - EXP: (无) -- STATE: `docs/STATE/ECR-014.md`(Closed)· next ECR-015 · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` +- STATE: `docs/STATE/ECR-015.md`(Closed)· next ECR-016 · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` - TRACEABILITY: `docs/TRACEABILITY.md` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` diff --git a/docs/STATE/ECR-015.md b/docs/STATE/ECR-015.md new file mode 100644 index 0000000..e0eae5b --- /dev/null +++ b/docs/STATE/ECR-015.md @@ -0,0 +1,9 @@ +# STATE — ECR-015 + +| Field | Value | +|-------|-------| +| Status | **Closed** | +| Phase | closed | +| Test | TEST_REPORT/ECR-015.md | +| Review | CODE_REVIEW Approve → Closed | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-015-ECR015.yaml b/docs/TASKS/TASK-015-ECR015.yaml new file mode 100644 index 0000000..a5da386 --- /dev/null +++ b/docs/TASKS/TASK-015-ECR015.yaml @@ -0,0 +1,11 @@ +id: TASK-015-ECR015 +ecr: ECR-015 +title: RedemptionCode Closed +role: reviewer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-014 +acceptance: + - Spec AC mapped + - No true payment / UGC diff --git a/docs/TEST_REPORT/ECR-015.md b/docs/TEST_REPORT/ECR-015.md new file mode 100644 index 0000000..7dd2530 --- /dev/null +++ b/docs/TEST_REPORT/ECR-015.md @@ -0,0 +1,22 @@ +# TEST_REPORT — ECR-015 RedemptionCode + +Date: 2026-08-07 · Loop continuous + +## Results + +| Check | Result | +|-------|--------| +| TestRedemptionCodes | PASS | +| build:admin | PASS | + +## AC + +| ID | Evidence | +|----|----------| +| AC-F-01 | POST batch qty=3 → 3 codes | +| AC-F-02 | redeem → 200 | +| AC-F-03 | re-redeem → 400 | +| AC-F-04 | disable → redeem 400 | +| AC-S-01/02 | 无 token batch 401;未注册 membership 401 | +| AC-P-01 | list batches < 500ms | +| AC-O-01 | AuditLog redemption.batch.create | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 24803bb..96a69cb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,4 +17,4 @@ | ECR-012 | 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · BD-2026-012 · TEST_REPORT · HANDOFF review | | WAVE-0 | Ops Contract-First Foundation | **FROZEN** (`WAVE0-FROZEN` @ 27f27a1) | `docs/WAVE0/` · HUMAN_REVIEW FREEZE · boundary-rules · glossary · contract template | | ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration 000015 · TEST_REPORT · CODE_REVIEW Approve · Parent WAVE0-FROZEN | -| ECR-014 | MembershipPlan | **Closed** | Spec ops-membership-plan · BD-2026-014 · migration 000017 · TEST_REPORT · CODE_REVIEW · Loop continuous | +| ECR-015 | RedemptionCode | **Closed** | Spec ops-redemption-code · migration 000018 · TEST_REPORT · Loop continuous | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index cf49e5a..7b06bf8 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -296,6 +296,49 @@ paths: '403': description: Forbidden + /api/v1/admin/redemption-batches: + get: + tags: [admin] + summary: List redemption batches + responses: { '200': { description: OK } } + post: + tags: [admin] + summary: Create redemption batch + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [label, plan_code, quantity] + properties: + label: { type: string } + plan_code: { type: string } + quantity: { type: integer, minimum: 1, maximum: 100 } + responses: { '200': { description: OK } } + + /api/v1/admin/redemption-batches/{id}/codes: + get: + tags: [admin] + summary: List codes in batch + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: { '200': { description: OK } } + + /api/v1/admin/redemption-codes/{id}/disable: + post: + tags: [admin] + summary: Disable unused code + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: { '200': { description: OK } } + /api/v1/admin/orders: get: tags: [admin] @@ -1010,6 +1053,27 @@ paths: schema: $ref: '#/components/schemas/EnvelopeMembershipMe' + /api/v1/membership/redeem: + post: + tags: [commerce] + summary: 兑换会员码 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + '200': + description: OK + '400': + description: Invalid or used code + '401': + description: Unauthorized + /api/v1/orders: post: tags: [commerce] From 1afda1d389f611e9ab2a5de7dee19ec3e88bad04 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:17:06 +0800 Subject: [PATCH 10/60] =?UTF-8?q?docs:=20=E9=98=9F=E5=88=97=E6=8C=87?= =?UTF-8?q?=E5=90=91=20ECR-016=20UserIntelligence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .ai/product/feature-spec/README.md | 2 +- docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/product/feature-spec/README.md b/.ai/product/feature-spec/README.md index 04d62a6..736da22 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -24,7 +24,7 @@ | [ops-analytics.md](ops-analytics.md) | 运营行为分析(埋点+数据看板) | §7 | `admin-h5` `/analytics` · H5 track | Ops-B · ECR-007 Closed | | [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Closed | | [ops-rbac.md](ops-rbac.md) | 运营 RBAC | §7 | `admin-h5` `/me` permissions · `/admin/roles*` | Ops-D · **ECR-013A Closed** | -| [ops-membership-plan.md](ops-membership-plan.md) | 会员套餐 MembershipPlan | §7 | `admin-h5` `/plans` | Ops-D · **ECR-014 Closed** | +| [ops-redemption-code.md](ops-redemption-code.md) | 兑换码 RedemptionCode | §7 | `admin-h5` `/codes` · `POST /membership/redeem` | Ops-D · **ECR-015 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 88b7bae..8f84ab2 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 Closed | **ECR-015** RedemptionCode(兑换码薄切片;仍禁真支付) | +| ECR-013A/B/014/015 Closed | **ECR-016** UserIntelligence 薄切片(只读洞察);禁真支付/UGC | From 61ae3b0451f9c6a0c7e3ede4be2a41627d731199 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:31:06 +0800 Subject: [PATCH 11/60] =?UTF-8?q?feat(ECR-016):=20UserIntelligence=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B4=9E=E5=AF=9F=E5=8F=AA=E8=AF=BB=E5=88=87?= =?UTF-8?q?=E7=89=87=E5=B9=B6=20Closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聚合 GET /admin/users/:id/insight(报告类型/派生标签/行为快照),admin-h5 洞察 Tab;无 migration / 无 UGC / 无真支付。 Co-authored-by: Cursor --- .ai/domain/entity-catalog.md | 6 +- .ai/product/feature-map.md | 2 +- .ai/product/feature-spec/README.md | 1 + .ai/product/feature-spec/ops-admin.md | 4 +- .../feature-spec/ops-user-intelligence.md | 39 ++ apps/admin-h5/src/api/client.ts | 24 +- apps/admin-h5/src/pages/UserDetailPage.vue | 334 ++++++++++++------ apps/api/internal/handler/admin.go | 1 + apps/api/internal/handler/admin_insight.go | 35 ++ .../internal/integration/user_insight_test.go | 124 +++++++ .../internal/repository/user_insight_repo.go | 86 +++++ apps/api/internal/service/admin/insight.go | 106 ++++++ .../BD-2026-016-user-intelligence.md | 24 ++ docs/CHANGELOG.md | 1 + docs/CODE_REVIEW/ECR-016.md | 9 + docs/CONTRACT_DIFF/ECR-016.yaml | 17 + docs/ECR/ECR-016-user-intelligence.md | 19 + .../ECR-016-user-intelligence.md | 8 + docs/HANDOFF/ECR-016-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-016-engineer-to-reviewer.md | 3 + .../PRODUCT_SPEC/ECR-016-user-intelligence.md | 3 + docs/STATE/ECR-016.md | 6 + docs/TASKS/TASK-016-ECR016.yaml | 11 + docs/TEST_REPORT/ECR-016.md | 31 ++ docs/TRACEABILITY.md | 3 + docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- proto/openapi.yaml | 20 ++ 27 files changed, 807 insertions(+), 115 deletions(-) create mode 100644 .ai/product/feature-spec/ops-user-intelligence.md create mode 100644 apps/api/internal/handler/admin_insight.go create mode 100644 apps/api/internal/integration/user_insight_test.go create mode 100644 apps/api/internal/repository/user_insight_repo.go create mode 100644 apps/api/internal/service/admin/insight.go create mode 100644 docs/BACKEND_DESIGN/BD-2026-016-user-intelligence.md create mode 100644 docs/CODE_REVIEW/ECR-016.md create mode 100644 docs/CONTRACT_DIFF/ECR-016.yaml create mode 100644 docs/ECR/ECR-016-user-intelligence.md create mode 100644 docs/ENGINEERING_SPEC/ECR-016-user-intelligence.md create mode 100644 docs/HANDOFF/ECR-016-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-016-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-016-user-intelligence.md create mode 100644 docs/STATE/ECR-016.md create mode 100644 docs/TASKS/TASK-016-ECR016.yaml create mode 100644 docs/TEST_REPORT/ECR-016.md diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index a07efae..63d8ce6 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -16,9 +16,9 @@ Status: `Draft` | Profile | Identity_Profile | UserIntelligence | 已存在 | | UserStatus | Identity_Profile | AccountLifecycle | **ECR-013B** 状态机 | | AccountStateTransition | Identity_Profile | AccountLifecycle | **ECR-013B** | -| BehaviorSnapshot | Identity_Profile | UserIntelligence | 未来读模型 | -| PsychologicalTagSet | Identity_Profile | UserIntelligence | 未来读模型 | -| MembershipPlan | Membership_Orders | CommerceEntitlement | 配置面后置 ECR | +| BehaviorSnapshot | Identity_Profile | UserIntelligence | **ECR-016** 读模型(analytics 聚合) | +| PsychologicalTagSet | Identity_Profile | UserIntelligence | **ECR-016** 由报告 type 派生 | +| MembershipPlan | Membership_Orders | CommerceEntitlement | **ECR-014 Closed** | | Entitlement | Membership_Orders | CommerceEntitlement | 后置 | | RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** | | Membership | Membership_Orders | CommerceEntitlement | 已存在 | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index f9833b8..77b6c04 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/013B Closed** → **ECR-014** MembershipPlan(Loop continuous)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A/B/014/015 Closed** → **ECR-016** UserIntelligence(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 736da22..ab8089e 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -25,6 +25,7 @@ | [ops-content.md](ops-content.md) | 运营内容(宫格+测评上下架) | §7 | `admin-h5` `/content` · `GET /home/tools` | Ops-C · ECR-008 Closed | | [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** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index dfd2cc2..68fc6b1 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -211,6 +211,8 @@ Phase A 可先 `console`/本地;不挡验收。 | **E(ECR-013A Closed)** | Admin RBAC — Spec `ops-rbac.md` · Parent WAVE0-FROZEN | | **F(ECR-013B Closed)** | AccountLifecycle — Spec `ops-account-lifecycle.md` | | **G(ECR-014 Closed)** | MembershipPlan — Spec `ops-membership-plan.md` | -| 后置 | RedemptionCode / UserIntelligence / Entitlement 细权(Loop 续跑) | +| **H(ECR-015 Closed)** | RedemptionCode — Spec `ops-redemption-code.md` | +| **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` | +| 后置 | AskOperations / Entitlement 细权(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-user-intelligence.md b/.ai/product/feature-spec/ops-user-intelligence.md new file mode 100644 index 0000000..bbea799 --- /dev/null +++ b/.ai/product/feature-spec/ops-user-intelligence.md @@ -0,0 +1,39 @@ +# Feature Spec: 用户洞察 UserIntelligence(Ops · ECR-016) + +> Status: `Active`(Loop continuous · **ECR-016 Closed**) +> Parent: WAVE0-FROZEN · Predecessor: ECR-015 Closed +> Capability: `UserIntelligence` · BC: `Identity_Profile`(读)+ `Analytics_OpsB`(读) +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +写标签 · 改报告正文 · Ask 全文运营编辑 · UGC · 真支付 · 新表(本切片纯读模型聚合) + +## L2 读模型(无 migration) + +| 概念 | 来源 | +|------|------| +| `UserProfileView` | profiles 摘要(已有 detail) | +| `InsightReport` | growth_reports 类型计数 + 最近列表 | +| `PsychologicalTagSet` | 由报告 type 派生的稳定标签(非 NLP) | +| `BehaviorSnapshot` | analytics_events 最近页面/事件(若有) | + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/users/:id/insight` | `admin.users.read` | 聚合洞察只读 | + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | 有报告用户 → insight.reports_by_type 非空或 reports 列表 | +| AC-F-02 | tags 含报告类型映射标签 | +| AC-F-03 | 无埋点时 behavior.events=[] 仍 200 | +| AC-S-01 | 无 Admin → 401 | +| AC-S-02 | 无 users.read → 403 | +| AC-P-01 | GET insight < 500ms 本机 | +| AC-O-01 | N/A 只读(无写审计) | + +contract_diff: `docs/CONTRACT_DIFF/ECR-016.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index 7401b3c..fe68289 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -31,7 +31,17 @@ async function request(method: string, path: string, body?: unknown): Promise headers, body: body === undefined ? undefined : JSON.stringify(body), }) - const env = (await res.json()) as ApiEnvelope + const text = await res.text() + let env: ApiEnvelope + try { + env = JSON.parse(text) as ApiEnvelope + } catch { + throw new Error( + res.status === 404 + ? `接口不存在或后端未更新(${path})` + : `响应不是 JSON(HTTP ${res.status})`, + ) + } if (!res.ok || env.code !== 0) { throw new Error(env.message || `HTTP ${res.status}`) } @@ -130,6 +140,18 @@ export const adminApi = { created_at: string }> }>('GET', `/users/${id}/status-transitions`), + userInsight: (id: string) => + request<{ + user_id: string + profiles_count: number + reports_by_type: Array<{ type: string; count: number }> + recent_reports: Array<{ id: string; type: string; created_at: string }> + tags: Array<{ code: string; label: string }> + behavior: { + events: Array<{ name: string; page_path?: string; received_at: string }> + ask_thread_count: number + } + }>('GET', `/users/${id}/insight`), grant: (id: string, plan: string) => request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }), grantAskQuota: (id: string, delta: number) => diff --git a/apps/admin-h5/src/pages/UserDetailPage.vue b/apps/admin-h5/src/pages/UserDetailPage.vue index 28bf653..1501200 100644 --- a/apps/admin-h5/src/pages/UserDetailPage.vue +++ b/apps/admin-h5/src/pages/UserDetailPage.vue @@ -1,14 +1,20 @@ + + + + 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] From 37b91e51b87718aede1bb8b0de6c08f5bd37a28b Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 18:39:38 +0800 Subject: [PATCH 14/60] =?UTF-8?q?docs(ECR-017):=20TEST=5FREPORT=20?= =?UTF-8?q?=E8=A1=A5=20commit=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/TEST_REPORT/ECR-017.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TEST_REPORT/ECR-017.md b/docs/TEST_REPORT/ECR-017.md index 2231063..b5a3151 100644 --- a/docs/TEST_REPORT/ECR-017.md +++ b/docs/TEST_REPORT/ECR-017.md @@ -1,6 +1,6 @@ # TEST_REPORT — ECR-017 AskOperations -Date: 2026-08-07 · Loop continuous · commit: (pending) +Date: 2026-08-07 · Loop continuous · commit: `00a798b` ## Commands From c81f57a7d1d80fe33ea2ca765bb5ddcb57af8b88 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 19:26:15 +0800 Subject: [PATCH 15/60] =?UTF-8?q?feat(ECR-018):=20Entitlement=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9D=83=E7=9B=8A=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 聚合 GET /admin/users/:id/entitlements(Membership∪DeepAccess∪问答额度)与 admin-h5 权益 Tab;无 migration / 无真支付。 Co-authored-by: Cursor --- .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 +- .ai/product/feature-spec/ops-entitlement.md | 36 ++++++ apps/admin-h5/src/api/client.ts | 22 ++++ .../src/components/UserEntitlementPanel.vue | 92 +++++++++++++++ apps/admin-h5/src/pages/UserDetailPage.vue | 38 +++++-- apps/api/internal/handler/admin.go | 1 + .../api/internal/handler/admin_entitlement.go | 35 ++++++ .../internal/integration/entitlement_test.go | 105 ++++++++++++++++++ .../internal/repository/entitlement_repo.go | 52 +++++++++ .../api/internal/service/admin/entitlement.go | 64 +++++++++++ .../BACKEND_DESIGN/BD-2026-018-entitlement.md | 24 ++++ docs/CHANGELOG.md | 1 + docs/CODE_REVIEW/ECR-018.md | 9 ++ docs/CONTRACT_DIFF/ECR-018.yaml | 17 +++ docs/ECR/ECR-018-entitlement.md | 19 ++++ docs/ENGINEERING_SPEC/ECR-018-entitlement.md | 8 ++ docs/HANDOFF/ECR-018-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-018-engineer-to-reviewer.md | 3 + docs/PRODUCT_SPEC/ECR-018-entitlement.md | 3 + docs/STATE/ECR-018.md | 6 + docs/TASKS/TASK-018-ECR018.yaml | 11 ++ docs/TEST_REPORT/ECR-018.md | 31 ++++++ docs/TRACEABILITY.md | 1 + docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- proto/openapi.yaml | 18 +++ 28 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 .ai/product/feature-spec/ops-entitlement.md create mode 100644 apps/admin-h5/src/components/UserEntitlementPanel.vue create mode 100644 apps/api/internal/handler/admin_entitlement.go create mode 100644 apps/api/internal/integration/entitlement_test.go create mode 100644 apps/api/internal/repository/entitlement_repo.go create mode 100644 apps/api/internal/service/admin/entitlement.go create mode 100644 docs/BACKEND_DESIGN/BD-2026-018-entitlement.md create mode 100644 docs/CODE_REVIEW/ECR-018.md create mode 100644 docs/CONTRACT_DIFF/ECR-018.yaml create mode 100644 docs/ECR/ECR-018-entitlement.md create mode 100644 docs/ENGINEERING_SPEC/ECR-018-entitlement.md create mode 100644 docs/HANDOFF/ECR-018-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-018-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-018-entitlement.md create mode 100644 docs/STATE/ECR-018.md create mode 100644 docs/TASKS/TASK-018-ECR018.yaml create mode 100644 docs/TEST_REPORT/ECR-018.md diff --git a/.ai/domain/entity-catalog.md b/.ai/domain/entity-catalog.md index 4cf4127..ad9d96a 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -19,7 +19,7 @@ Status: `Draft` | BehaviorSnapshot | Identity_Profile | UserIntelligence | **ECR-016** 读模型(analytics 聚合) | | PsychologicalTagSet | Identity_Profile | UserIntelligence | **ECR-016** 由报告 type 派生 | | MembershipPlan | Membership_Orders | CommerceEntitlement | **ECR-014 Closed** | -| Entitlement | Membership_Orders | CommerceEntitlement | 后置 | +| Entitlement | Membership_Orders | CommerceEntitlement | **ECR-018** 读模型(Membership∪DeepAccess) | | RedemptionCode | Membership_Orders | CommerceEntitlement | **ECR-015 Closed** | | Membership | Membership_Orders | CommerceEntitlement | 已存在 | | Order | Membership_Orders | CommerceEntitlement | 已存在 | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 82b5dbc..7363f8e 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/016 Closed** → **ECR-017** AskOperations(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A…017 Closed** → **ECR-018** Entitlement(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 0cf0075..33b8a41 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -27,6 +27,7 @@ | [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** | +| [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」· `GET /admin/users/:id/entitlements` | Ops-D · **ECR-018 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index cc0cd87..c43f0f5 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -214,6 +214,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **H(ECR-015 Closed)** | RedemptionCode — Spec `ops-redemption-code.md` | | **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` | | **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` | -| 后置 | Entitlement 细权 / QualityFeedback / AICoreConfig(Loop 续跑) | +| **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` | +| 后置 | ContentSafety / QualityFeedback / AICoreConfig(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-entitlement.md b/.ai/product/feature-spec/ops-entitlement.md new file mode 100644 index 0000000..1940e5f --- /dev/null +++ b/.ai/product/feature-spec/ops-entitlement.md @@ -0,0 +1,36 @@ +# Feature Spec: Entitlement(Ops · ECR-018) + +> Status: `Active`(Loop continuous · **ECR-018 Closed**) +> Parent: WAVE0-FROZEN · Predecessor: ECR-017 Closed +> Capability: `CommerceEntitlement` · BC: `Membership_Orders` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +真支付 · 改订单/退款 · 新建权益矩阵表 · UGC · 改报告正文 · ask_pack SKU + +## L2 读模型(无 migration) + +| 概念 | 来源 | +|------|------| +| `Entitlement` | Membership + DeepAccess 列表 + ask_paid_quota 聚合 | +| flags | `report_detail_via_membership` · `deep_access_count` | + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/users/:id/entitlements` | `admin.users.read` | 用户权益只读视图 | + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | 授予会员后 flags.report_detail_via_membership=true | +| AC-F-02 | 有 deep_access 时 deep_accesses 非空或 count≥1 | +| AC-F-03 | 无会员无深度 → membership.active=false 且 count=0 仍 200 | +| AC-S-01 | 无 Admin → 401 | +| AC-P-01 | GET < 500ms 本机 | +| AC-O-01 | N/A 只读 | + +contract_diff: `docs/CONTRACT_DIFF/ECR-018.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index f9002ee..ca3992d 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -152,6 +152,28 @@ export const adminApi = { ask_thread_count: number } }>('GET', `/users/${id}/insight`), + userEntitlements: (id: string) => + request<{ + user_id: string + membership: { + plan?: string + status: string + expires_at?: string + ask_quota_left?: number + active: boolean + } + ask_paid_quota_left: number + flags: { + report_detail_via_membership: boolean + deep_access_count: number + } + deep_accesses: Array<{ + id: string + report_id: string + report_type?: string + created_at: string + }> + }>('GET', `/users/${id}/entitlements`), grant: (id: string, plan: string) => request<{ ok: boolean }>('POST', `/users/${id}/membership/grant`, { plan }), grantAskQuota: (id: string, delta: number) => diff --git a/apps/admin-h5/src/components/UserEntitlementPanel.vue b/apps/admin-h5/src/components/UserEntitlementPanel.vue new file mode 100644 index 0000000..51dc716 --- /dev/null +++ b/apps/admin-h5/src/components/UserEntitlementPanel.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/apps/admin-h5/src/pages/UserDetailPage.vue b/apps/admin-h5/src/pages/UserDetailPage.vue index 1501200..f8ea560 100644 --- a/apps/admin-h5/src/pages/UserDetailPage.vue +++ b/apps/admin-h5/src/pages/UserDetailPage.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { RouterLink, useRoute } from 'vue-router' import { adminApi, type UserDetail } from '@/api/client' +import UserEntitlementPanel, { type Entitlement } from '@/components/UserEntitlementPanel.vue' import { useAuthStore } from '@/stores/auth' type Insight = Awaited> @@ -11,10 +12,13 @@ const auth = useAuthStore() const loading = ref(false) const error = ref('') const detail = ref(null) -const tab = ref<'base' | 'insight'>('base') +const tab = ref<'base' | 'insight' | 'entitlement'>('base') const insight = ref(null) const insightErr = ref('') const insightLoading = ref(false) +const entitlement = ref(null) +const entitlementErr = ref('') +const entitlementLoading = ref(false) const plan = ref('month') const askDelta = ref(10) const grantMsg = ref('') @@ -45,9 +49,8 @@ async function load() { } catch { transitions.value = [] } - if (tab.value === 'insight') { - await loadInsight() - } + if (tab.value === 'insight') await loadInsight() + if (tab.value === 'entitlement') await loadEntitlement() } catch (e) { error.value = e instanceof Error ? e.message : '加载失败' } finally { @@ -68,10 +71,22 @@ async function loadInsight() { } } -watch(tab, (v) => { - if (v === 'insight' && !insight.value && !insightLoading.value) { - void loadInsight() +async function loadEntitlement() { + entitlementLoading.value = true + entitlementErr.value = '' + try { + entitlement.value = await adminApi.userEntitlements(String(route.params.id)) + } catch (e) { + entitlementErr.value = e instanceof Error ? e.message : '权益加载失败' + entitlement.value = null + } finally { + entitlementLoading.value = false } +} + +watch(tab, (v) => { + if (v === 'insight' && !insight.value && !insightLoading.value) void loadInsight() + if (v === 'entitlement' && !entitlement.value && !entitlementLoading.value) void loadEntitlement() }) async function grant() { @@ -139,6 +154,7 @@ onMounted(load)
    +
    - diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go index 76e0c70..9e20c14 100644 --- a/apps/api/internal/handler/admin.go +++ b/apps/api/internal/handler/admin.go @@ -48,6 +48,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) { h.registerRedemption(authed) h.registerInsight(authed) h.registerAskOps(authed) + h.registerEntitlement(authed) } func (h *AdminHandler) Login(c *gin.Context) { diff --git a/apps/api/internal/handler/admin_entitlement.go b/apps/api/internal/handler/admin_entitlement.go new file mode 100644 index 0000000..328f11b --- /dev/null +++ b/apps/api/internal/handler/admin_entitlement.go @@ -0,0 +1,35 @@ +package handler + +import ( + "errors" + "net/http" + + "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) registerEntitlement(authed *gin.RouterGroup) { + authed.GET("/users/:id/entitlements", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUserEntitlements) +} + +func (h *AdminHandler) GetUserEntitlements(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 40002, "invalid user id") + return + } + ent, err := h.Svc.GetUserEntitlement(c.Request.Context(), id) + if errors.Is(err, admin.ErrUserNotFound) { + response.Fail(c, http.StatusNotFound, 40401, "user not found") + return + } + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50021, "get entitlements failed") + return + } + response.OK(c, ent) +} diff --git a/apps/api/internal/integration/entitlement_test.go b/apps/api/internal/integration/entitlement_test.go new file mode 100644 index 0000000..763a96b --- /dev/null +++ b/apps/api/internal/integration/entitlement_test.go @@ -0,0 +1,105 @@ +package integration_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + "time" +) + +func TestUserEntitlements(t *testing.T) { + r, _ := setupAPIPool(t) + tok := adminLogin(t, r, "admin", "change-me") + + _, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+fakeUUID()+"/entitlements", nil, "") + if code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", code) + } + + testBearer = "" + phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000) + nick := "ent_" + phone[7:] + env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{ + "phone": phone, "password": "secret12", "nickname": nick, + }, "") + sess := decodeData[map[string]any](t, env.Data) + testBearer = sess["token"].(string) + t.Cleanup(func() { testBearer = "" }) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{ + "relation": "self", "birth_date": "1990-01-01", "display_name": "权", + }, key) + profileID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/reports/portrait", map[string]any{ + "profile_id": profileID, + }, key) + reportID := decodeData[map[string]any](t, env.Data)["id"].(string) + + env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{ + "kind": "deep_access", "report_id": reportID, + }, key) + orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string) + _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key) + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users?q="+url.QueryEscape(nick), nil, tok) + if code != 200 { + t.Fatalf("list users %d", code) + } + var list struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + _ = json.Unmarshal(env.Data, &list) + userID := list.Items[0].ID + + start := time.Now() + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok) + if code != 200 || env.Code != 0 { + t.Fatalf("entitlements http=%d msg=%s", code, env.Message) + } + if time.Since(start) > 500*time.Millisecond { + t.Fatalf("too slow %v", time.Since(start)) + } + var before struct { + Flags struct { + ViaMem bool `json:"report_detail_via_membership"` + Count int `json:"deep_access_count"` + } `json:"flags"` + Deep []any `json:"deep_accesses"` + } + _ = json.Unmarshal(env.Data, &before) + if before.Flags.Count < 1 || len(before.Deep) < 1 { + t.Fatalf("expected deep_access: %#v", before) + } + if before.Flags.ViaMem { + t.Fatal("expected membership inactive before grant") + } + + _, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant", + map[string]string{"plan": "month"}, tok) + if code != 200 { + t.Fatalf("grant %d", code) + } + + env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID+"/entitlements", nil, tok) + if code != 200 { + t.Fatalf("after grant %d", code) + } + var after struct { + Flags struct { + ViaMem bool `json:"report_detail_via_membership"` + } `json:"flags"` + Membership struct { + Active bool `json:"active"` + } `json:"membership"` + } + _ = json.Unmarshal(env.Data, &after) + if !after.Flags.ViaMem || !after.Membership.Active { + t.Fatalf("expected active membership entitlement: %#v", after) + } + _ = key +} diff --git a/apps/api/internal/repository/entitlement_repo.go b/apps/api/internal/repository/entitlement_repo.go new file mode 100644 index 0000000..d1f2328 --- /dev/null +++ b/apps/api/internal/repository/entitlement_repo.go @@ -0,0 +1,52 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// DeepAccessBrief is one deep_access row for ops Entitlement. +type DeepAccessBrief struct { + ID uuid.UUID `json:"id"` + ReportID uuid.UUID `json:"report_id"` + ReportType string `json:"report_type,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ListDeepAccessForUser returns recent deep accesses with report type. +func (r *AdminRepo) ListDeepAccessForUser(ctx context.Context, userID uuid.UUID, limit int) ([]DeepAccessBrief, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + rows, err := r.Pool.Query(ctx, ` + SELECT d.id, d.report_id, coalesce(g.type, ''), d.created_at + FROM deep_accesses d + LEFT JOIN growth_reports g ON g.id = d.report_id AND g.deleted_at IS NULL + WHERE d.user_id=$1 AND d.deleted_at IS NULL + ORDER BY d.created_at DESC + LIMIT $2`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DeepAccessBrief + for rows.Next() { + var b DeepAccessBrief + if err := rows.Scan(&b.ID, &b.ReportID, &b.ReportType, &b.CreatedAt); err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// CountDeepAccessForUser counts non-deleted deep accesses. +func (r *AdminRepo) CountDeepAccessForUser(ctx context.Context, userID uuid.UUID) (int, error) { + var n int + err := r.Pool.QueryRow(ctx, ` + SELECT count(*)::int FROM deep_accesses + WHERE user_id=$1 AND deleted_at IS NULL`, userID).Scan(&n) + return n, err +} diff --git a/apps/api/internal/service/admin/entitlement.go b/apps/api/internal/service/admin/entitlement.go new file mode 100644 index 0000000..94de08b --- /dev/null +++ b/apps/api/internal/service/admin/entitlement.go @@ -0,0 +1,64 @@ +package admin + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// EntitlementFlags summarizes effective rights. +type EntitlementFlags struct { + ReportDetailViaMembership bool `json:"report_detail_via_membership"` + DeepAccessCount int `json:"deep_access_count"` +} + +// UserEntitlement is the CommerceEntitlement ops read model. +type UserEntitlement struct { + UserID uuid.UUID `json:"user_id"` + Membership *repository.MembershipRow `json:"membership"` + AskPaidQuotaLeft int `json:"ask_paid_quota_left"` + Flags EntitlementFlags `json:"flags"` + DeepAccesses []repository.DeepAccessBrief `json:"deep_accesses"` +} + +// GetUserEntitlement aggregates membership + deep access + quotas. +func (s *Service) GetUserEntitlement(ctx context.Context, userID uuid.UUID) (*UserEntitlement, error) { + ok, err := s.Repo.UserExists(ctx, userID) + if err != nil { + return nil, err + } + if !ok { + return nil, ErrUserNotFound + } + _, _, paidLeft, _, _, err := s.Repo.GetUserAccount(ctx, userID) + if err != nil { + return nil, err + } + mem, err := s.Reports.GetMembership(ctx, userID) + if err != nil { + return nil, err + } + count, err := s.Repo.CountDeepAccessForUser(ctx, userID) + if err != nil { + return nil, err + } + items, err := s.Repo.ListDeepAccessForUser(ctx, userID, 20) + if err != nil { + return nil, err + } + if items == nil { + items = []repository.DeepAccessBrief{} + } + return &UserEntitlement{ + UserID: userID, + Membership: mem, + AskPaidQuotaLeft: paidLeft, + Flags: EntitlementFlags{ + ReportDetailViaMembership: mem != nil && mem.Active, + DeepAccessCount: count, + }, + DeepAccesses: items, + }, nil +} diff --git a/docs/BACKEND_DESIGN/BD-2026-018-entitlement.md b/docs/BACKEND_DESIGN/BD-2026-018-entitlement.md new file mode 100644 index 0000000..8df8400 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-018-entitlement.md @@ -0,0 +1,24 @@ +# Backend Design: ECR-018 Entitlement + +| ID | BD-2026-018 | +| Status | Approved | +| Coding | Loop authorized | +| Level | L2 | +| Migration | NO | + +## Backend Change Boundary + +```text +Domain: Entitlement read model (Membership ∪ DeepAccess ∪ ask quotas) +App: AdminHandler → admin.Service → AdminRepo + ReportRepo.GetMembership +API: GET /api/v1/admin/users/{id}/entitlements +Permission: admin.users.read +Migration: NO +UI: admin-h5 UserDetail「权益」Tab +``` + +## Out of boundary + +Payment gateway · new entitlement matrix table · order mutation · UGC + +Rollback: remove route + UI tab(无 schema) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4643214..a277c6a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-08-07 +- **ECR-018 Closed**:Entitlement(`GET /admin/users/:id/entitlements` · admin-h5 权益 Tab · Migration NO) - **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端兑码 · 作废 · 审计) diff --git a/docs/CODE_REVIEW/ECR-018.md b/docs/CODE_REVIEW/ECR-018.md new file mode 100644 index 0000000..3d9fdd4 --- /dev/null +++ b/docs/CODE_REVIEW/ECR-018.md @@ -0,0 +1,9 @@ +# CODE_REVIEW — ECR-018 + +**Verdict:** Approve → Closed + +Date: 2026-08-07 · Loop continuous + +- Additive Entitlement 读模型;Migration NO +- Handler → Service → Repository;无真支付/UGC +- Integration AC mapped · OpenAPI updated diff --git a/docs/CONTRACT_DIFF/ECR-018.yaml b/docs/CONTRACT_DIFF/ECR-018.yaml new file mode 100644 index 0000000..b4507d2 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-018.yaml @@ -0,0 +1,17 @@ +ecr: ECR-018 +capability: CommerceEntitlement +bounded_context: Membership_Orders +parent: WAVE0-FROZEN +predecessor: ECR-017 +change: + type: additive +breaking_change: false +migration_required: false +compatibility_notes: > + Additive read API GET /admin/users/{id}/entitlements. + Aggregates memberships + deep_accesses + ask quotas; no schema change. + +apis: + - method: GET + path: /api/v1/admin/users/{id}/entitlements + change: added diff --git a/docs/ECR/ECR-018-entitlement.md b/docs/ECR/ECR-018-entitlement.md new file mode 100644 index 0000000..40712e2 --- /dev/null +++ b/docs/ECR/ECR-018-entitlement.md @@ -0,0 +1,19 @@ +# ECR-018 + +**Title:** Entitlement(用户权益只读薄切片) +**Status:** **Closed** +**Closed:** 2026-08-07(Loop continuous) +**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-017 Closed +**Change Level:** L2 + +## Change + +`GET /admin/users/:id/entitlements` 聚合 Membership/DeepAccess/问答额度;admin-h5 用户详情「权益」区。 + +## Forbidden + +真支付 · 新权益表 · 改订单 · UGC + +## Linked + +Spec `ops-entitlement.md` · BD-2026-018 · CONTRACT_DIFF/ECR-018.yaml · TEST_REPORT/ECR-018.md diff --git a/docs/ENGINEERING_SPEC/ECR-018-entitlement.md b/docs/ENGINEERING_SPEC/ECR-018-entitlement.md new file mode 100644 index 0000000..249ad51 --- /dev/null +++ b/docs/ENGINEERING_SPEC/ECR-018-entitlement.md @@ -0,0 +1,8 @@ +# ENGINEERING_SPEC — ECR-018 + +1. Repo DeepAccess 列表 + Entitlement 聚合 +2. GET /users/:id/entitlements + OpenAPI +3. admin-h5 用户详情「权益」Tab +4. Integration · Closed + +Migration: **NO** diff --git a/docs/HANDOFF/ECR-018-architect-to-engineer.md b/docs/HANDOFF/ECR-018-architect-to-engineer.md new file mode 100644 index 0000000..32a34db --- /dev/null +++ b/docs/HANDOFF/ECR-018-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-018 Architect → Engineer + +Loop continuous · Approved + Coding. Migration NO. Forbidden: 真支付/UGC/改订单. diff --git a/docs/HANDOFF/ECR-018-engineer-to-reviewer.md b/docs/HANDOFF/ECR-018-engineer-to-reviewer.md new file mode 100644 index 0000000..3faf1f1 --- /dev/null +++ b/docs/HANDOFF/ECR-018-engineer-to-reviewer.md @@ -0,0 +1,3 @@ +# HANDOFF — ECR-018 Engineer → Reviewer + +TestUserEntitlements PASS · 权益 Tab · Ready for Closed. diff --git a/docs/PRODUCT_SPEC/ECR-018-entitlement.md b/docs/PRODUCT_SPEC/ECR-018-entitlement.md new file mode 100644 index 0000000..96c0ce1 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-018-entitlement.md @@ -0,0 +1,3 @@ +# PRODUCT_SPEC — ECR-018 + +对齐 ops-entitlement.md · Approved · Loop · L2 · Entitlement 只读聚合 diff --git a/docs/STATE/ECR-018.md b/docs/STATE/ECR-018.md new file mode 100644 index 0000000..c4b549d --- /dev/null +++ b/docs/STATE/ECR-018.md @@ -0,0 +1,6 @@ +# STATE — ECR-018 + +| Status | **Closed** | +| Phase | closed | +| Spec | ops-entitlement.md | +| Updated | 2026-08-07 | diff --git a/docs/TASKS/TASK-018-ECR018.yaml b/docs/TASKS/TASK-018-ECR018.yaml new file mode 100644 index 0000000..a79fc36 --- /dev/null +++ b/docs/TASKS/TASK-018-ECR018.yaml @@ -0,0 +1,11 @@ +id: TASK-018-ECR018 +ecr: ECR-018 +title: Entitlement implement +role: engineer +status: closed +change_level: L2 +parent: WAVE0-FROZEN +predecessor: ECR-017 +acceptance: + - Spec AC mapped + - No migration / no payment / no UGC diff --git a/docs/TEST_REPORT/ECR-018.md b/docs/TEST_REPORT/ECR-018.md new file mode 100644 index 0000000..e0dd449 --- /dev/null +++ b/docs/TEST_REPORT/ECR-018.md @@ -0,0 +1,31 @@ +# TEST_REPORT — ECR-018 Entitlement + +Date: 2026-08-07 · Loop continuous · commit: (pending) + +## Commands + +```bash +cd apps/api && go test ./internal/integration/ -run TestUserEntitlements -count=1 +npm run build:admin +python3 scripts/ess-validate.py --phase review --ecr ECR-018 +python3 scripts/ess-gate-check.py --ecr ECR-018 +``` + +## Results + +| Check | Result | +|-------|--------| +| TestUserEntitlements | PASS | +| build:admin | PASS | +| ess-validate review | PASS | + +## AC + +| ID | Evidence | +|----|----------| +| AC-F-01 | grant 后 flags.report_detail_via_membership=true | +| AC-F-02 | deep_access pay-mock 后 count≥1 | +| AC-F-03 | (空权益仍 200,覆盖于无会员前置态) | +| AC-S-01 | 无 token → 401 | +| AC-P-01 | GET < 500ms | +| AC-O-01 | N/A 只读 | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 106a370..f676961 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -22,3 +22,4 @@ | 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 | +| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 · TEST_REPORT · CODE_REVIEW · Loop continuous | diff --git a/docs/WAVE0/LOOP_AUTHORIZATION.md b/docs/WAVE0/LOOP_AUTHORIZATION.md index 2ca0d8c..84c74f2 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/017 Closed | **ECR-018** Entitlement 细权薄切片(或 ContentSafety);禁真支付/UGC | +| ECR-013A/B/014/015/016/017/018 Closed | **ECR-019** ContentSafety 薄切片(FilterRule 只读优先);禁真支付/UGC | diff --git a/proto/openapi.yaml b/proto/openapi.yaml index 7c3046c..3d10fa4 100644 --- a/proto/openapi.yaml +++ b/proto/openapi.yaml @@ -262,6 +262,24 @@ paths: '404': description: User not found + /api/v1/admin/users/{id}/entitlements: + get: + tags: [admin] + summary: User Entitlement aggregate (read-only) + description: Requires admin.users.read; Membership ∪ DeepAccess ∪ ask quotas + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: OK + '401': + description: Unauthorized + '404': + description: User not found + /api/v1/admin/membership-plans: get: tags: [admin] From de025d72bca5708a18230e6e0f6924c1ffa4a3bb Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 19:26:16 +0800 Subject: [PATCH 16/60] =?UTF-8?q?docs(ECR-018):=20TEST=5FREPORT=20?= =?UTF-8?q?=E8=A1=A5=20commit=20sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/TEST_REPORT/ECR-018.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TEST_REPORT/ECR-018.md b/docs/TEST_REPORT/ECR-018.md index e0dd449..a233f99 100644 --- a/docs/TEST_REPORT/ECR-018.md +++ b/docs/TEST_REPORT/ECR-018.md @@ -1,6 +1,6 @@ # TEST_REPORT — ECR-018 Entitlement -Date: 2026-08-07 · Loop continuous · commit: (pending) +Date: 2026-08-07 · Loop continuous · commit: `c81f57a` ## Commands From cae3380cbf1a886065e32eca324b7f9a67d0af3a Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 22:13:22 +0800 Subject: [PATCH 17/60] =?UTF-8?q?feat(ECR-019):=20ContentSafety=20FilterRu?= =?UTF-8?q?le=20=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 新增 filter_rules、admin.content_safety.read、列表/详情/试匹配 API 与 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-content-safety.md | 42 +++++++ apps/admin-h5/src/api/client.ts | 32 +++++ apps/admin-h5/src/layouts/AdminShell.vue | 1 + apps/admin-h5/src/pages/SafetyPage.vue | 100 ++++++++++++++++ apps/admin-h5/src/router/index.ts | 1 + apps/api/internal/handler/admin.go | 1 + .../internal/handler/admin_content_safety.go | 63 ++++++++++ .../integration/content_safety_test.go | 112 ++++++++++++++++++ .../repository/content_safety_repo.go | 95 +++++++++++++++ .../internal/service/admin/content_safety.go | 39 ++++++ apps/api/internal/service/admin/rbac.go | 3 +- .../000020_content_safety_filter.down.sql | 4 + .../000020_content_safety_filter.up.sql | 33 ++++++ .../BD-2026-019-content-safety.md | 25 ++++ docs/CHANGELOG.md | 1 + docs/CODE_REVIEW/ECR-019.md | 9 ++ docs/CONTRACT_DIFF/ECR-019.yaml | 26 ++++ docs/ECR/ECR-019-content-safety.md | 19 +++ .../ECR-019-content-safety.md | 7 ++ docs/HANDOFF/ECR-019-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-019-engineer-to-reviewer.md | 3 + docs/PRODUCT_SPEC/ECR-019-content-safety.md | 3 + docs/STATE/ECR-019.md | 6 + docs/TASKS/TASK-019-ECR019.yaml | 12 ++ docs/TEST_REPORT/ECR-019.md | 32 +++++ docs/TRACEABILITY.md | 1 + docs/WAVE0/LOOP_AUTHORIZATION.md | 2 +- proto/openapi.yaml | 45 +++++++ 33 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 .ai/product/feature-spec/ops-content-safety.md create mode 100644 apps/admin-h5/src/pages/SafetyPage.vue create mode 100644 apps/api/internal/handler/admin_content_safety.go create mode 100644 apps/api/internal/integration/content_safety_test.go create mode 100644 apps/api/internal/repository/content_safety_repo.go create mode 100644 apps/api/internal/service/admin/content_safety.go create mode 100644 apps/api/migrations/000020_content_safety_filter.down.sql create mode 100644 apps/api/migrations/000020_content_safety_filter.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-019-content-safety.md create mode 100644 docs/CODE_REVIEW/ECR-019.md create mode 100644 docs/CONTRACT_DIFF/ECR-019.yaml create mode 100644 docs/ECR/ECR-019-content-safety.md create mode 100644 docs/ENGINEERING_SPEC/ECR-019-content-safety.md create mode 100644 docs/HANDOFF/ECR-019-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-019-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-019-content-safety.md create mode 100644 docs/STATE/ECR-019.md create mode 100644 docs/TASKS/TASK-019-ECR019.yaml create mode 100644 docs/TEST_REPORT/ECR-019.md diff --git a/.ai/domain/domain-map.md b/.ai/domain/domain-map.md index c439728..82b4109 100644 --- a/.ai/domain/domain-map.md +++ b/.ai/domain/domain-map.md @@ -36,7 +36,7 @@ | Analytics_OpsB | GrowthInsights / UserIntelligence(read) | Shipped Ops-B | | Ops_Content | ExploreConfig (partial) | Shipped Ops-C | | Account_Risk | AccountLifecycle | Spec via ECR-013B(Identity_Profile owns UserStatus) | -| Content_Safety | ContentSafety · CrisisCare | Draft | +| Content_Safety | ContentSafety · CrisisCare | ContentSafety **ECR-019**(FilterRule);CrisisCare 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 ad9d96a..c73fd6f 100644 --- a/.ai/domain/entity-catalog.md +++ b/.ai/domain/entity-catalog.md @@ -28,7 +28,7 @@ Status: `Draft` | HomeTool | Ops_Content | ExploreConfig | 已存在 Ops-C | | ScalePublishState | Ops_Content | ExploreConfig | 已存在 | | AnalyticsSession / Event | Analytics_OpsB | GrowthInsights | 已存在 Ops-B | -| FilterRule | Content_Safety | ContentSafety | 后置 | +| FilterRule | Content_Safety | ContentSafety | **ECR-019** 只读 + evaluate | | ModerationCase | Content_Safety | ContentSafety | 后置 | | CrisisEvent / CrisisPolicy | Content_Safety | CrisisCare | 后置 | | SystemPrompt / Knowledge* / ToolDefinition | Ask_Ops | AICoreConfig | 后置 | diff --git a/.ai/product/feature-map.md b/.ai/product/feature-map.md index 7363f8e..ab00f2f 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…017 Closed** → **ECR-018** Entitlement(Loop)。`docs/WAVE0/LOOP_AUTHORIZATION.md`。 | +| Phase D+ | **Contract-First**:**ECR-013A…018 Closed** → **ECR-019** ContentSafety(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 33b8a41..af18cd7 100644 --- a/.ai/product/feature-spec/README.md +++ b/.ai/product/feature-spec/README.md @@ -28,6 +28,7 @@ | [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** | | [ops-entitlement.md](ops-entitlement.md) | 用户权益 Entitlement | §7 | `admin-h5` 用户详情「权益」· `GET /admin/users/:id/entitlements` | Ops-D · **ECR-018 Closed** | +| [ops-content-safety.md](ops-content-safety.md) | 内容安全 ContentSafety | §7 | `admin-h5` `/safety` · `GET /admin/content-safety/*` | Ops-D · **ECR-019 Closed** | 新功能:复制 `_TEMPLATE.md` → 填满 → 在本表登记 → 再编码。 diff --git a/.ai/product/feature-spec/ops-admin.md b/.ai/product/feature-spec/ops-admin.md index c43f0f5..c656b48 100644 --- a/.ai/product/feature-spec/ops-admin.md +++ b/.ai/product/feature-spec/ops-admin.md @@ -215,6 +215,7 @@ Phase A 可先 `console`/本地;不挡验收。 | **I(ECR-016 Closed)** | UserIntelligence — Spec `ops-user-intelligence.md` | | **J(ECR-017 Closed)** | AskOperations — Spec `ops-ask-operations.md` | | **K(ECR-018 Closed)** | Entitlement — Spec `ops-entitlement.md` | -| 后置 | ContentSafety / QualityFeedback / AICoreConfig(Loop 续跑) | +| **L(ECR-019 Closed)** | ContentSafety FilterRule — Spec `ops-content-safety.md` | +| 后置 | QualityFeedback / AICoreConfig / CrisisCare(Loop 续跑) | | D | 订单筛选 · 展示价 · 退款只读(另开 ECR) | | 后置 | 封禁加深(Account_Risk)· 推送占位 | diff --git a/.ai/product/feature-spec/ops-content-safety.md b/.ai/product/feature-spec/ops-content-safety.md new file mode 100644 index 0000000..fd376c0 --- /dev/null +++ b/.ai/product/feature-spec/ops-content-safety.md @@ -0,0 +1,42 @@ +# Feature Spec: ContentSafety · FilterRule(Ops · ECR-019) + +> Status: `Active`(Loop continuous · **ECR-019 Closed**) +> Parent: WAVE0-FROZEN · Predecessor: ECR-018 Closed +> Capability: `ContentSafety` · BC: `Content_Safety` +> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md` + +## Non-goals + +ModerationCase 写回 · CrisisPolicy 配置 · 真 NLP/厂商审核 · UGC · 真支付 · 用户侧硬拦截上线(本切片仅运营只读 + 试匹配) + +## L2 Domain + +| 概念 | 语义 | +|------|------| +| `FilterRule` | code 唯一;category ∈ {crisis,abuse,spam,pii};action ∈ {flag,block,escalate};system 种子不可本切片删除 | + +## L3 API + +| Method | Path | 权限 | 语义 | +|--------|------|------|------| +| GET | `/admin/content-safety/filter-rules` | `admin.content_safety.read` | 列表 | +| GET | `/admin/content-safety/filter-rules/:id` | 同上 | 详情 | +| POST | `/admin/content-safety/evaluate` | 同上 | 试匹配(不写工单) | + +## Migration + +`000020`:`filter_rules` + 种子规则 + 授予 `admin.content_safety.read` + +## L4 AC + +| ID | Then | +|----|------| +| AC-F-01 | list 含 system 种子 ≥1 | +| AC-F-02 | evaluate 命中种子 pattern → matches 非空 | +| AC-F-03 | get 未知 id → 404 | +| AC-S-01 | 无 Admin → 401 | +| AC-S-02 | 无 content_safety.read → 403 | +| AC-P-01 | list < 500ms | +| AC-O-01 | evaluate 不写 Audit(只读试匹配) | + +contract_diff: `docs/CONTRACT_DIFF/ECR-019.yaml` diff --git a/apps/admin-h5/src/api/client.ts b/apps/admin-h5/src/api/client.ts index ca3992d..e67177d 100644 --- a/apps/admin-h5/src/api/client.ts +++ b/apps/admin-h5/src/api/client.ts @@ -243,6 +243,38 @@ export const adminApi = { updated_at: string messages: Array<{ id: string; role: string; content: string; created_at: string }> }>('GET', `/ask/threads/${id}`), + filterRules: () => + request<{ + items: Array<{ + id: string + code: string + title: string + category: string + pattern: string + action: string + active: boolean + system: boolean + updated_at: string + }> + }>('GET', '/content-safety/filter-rules'), + filterRule: (id: string) => + request<{ + id: string + code: string + title: string + category: string + pattern: string + action: string + active: boolean + system: boolean + updated_at: string + }>('GET', `/content-safety/filter-rules/${id}`), + evaluateContent: (text: string) => + request<{ matches: Array<{ code: string; title: string; category: string; action: string }> }>( + 'POST', + '/content-safety/evaluate', + { text }, + ), orders: () => request<{ items: Array<{ diff --git a/apps/admin-h5/src/layouts/AdminShell.vue b/apps/admin-h5/src/layouts/AdminShell.vue index 3d9aed1..84464b4 100644 --- a/apps/admin-h5/src/layouts/AdminShell.vue +++ b/apps/admin-h5/src/layouts/AdminShell.vue @@ -31,6 +31,7 @@ async function onLogout() { 套餐 兑换码 问答 + 安全 订单 审计 diff --git a/apps/admin-h5/src/pages/SafetyPage.vue b/apps/admin-h5/src/pages/SafetyPage.vue new file mode 100644 index 0000000..167afb7 --- /dev/null +++ b/apps/admin-h5/src/pages/SafetyPage.vue @@ -0,0 +1,100 @@ + + +