From c81f57a7d1d80fe33ea2ca765bb5ddcb57af8b88 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 7 Aug 2026 19:26:15 +0800 Subject: [PATCH] =?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]