feat(ECR-014): MembershipPlan 套餐配置并 Closed
membership_plans 表、admin 套餐页、Grant/CreateOrder 读表; Loop continuous 自动 Approve/Closed。Next:ECR-015 RedemptionCode。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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 最小列表编辑页
|
||||
@@ -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<{
|
||||
|
||||
@@ -28,6 +28,7 @@ async function onLogout() {
|
||||
<RouterLink to="/analytics">数据</RouterLink>
|
||||
<RouterLink to="/content">内容</RouterLink>
|
||||
<RouterLink to="/users">用户</RouterLink>
|
||||
<RouterLink to="/plans">套餐</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Plan = {
|
||||
code: string
|
||||
title: string
|
||||
duration_days: number
|
||||
amount_cents: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const msg = ref('')
|
||||
const items = ref<Plan[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.membershipPlans()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(p: Plan) {
|
||||
msg.value = ''
|
||||
try {
|
||||
await adminApi.updateMembershipPlan(p.code, {
|
||||
title: p.title,
|
||||
duration_days: Number(p.duration_days),
|
||||
amount_cents: Number(p.amount_cents),
|
||||
active: p.active,
|
||||
})
|
||||
msg.value = `${p.code} 已保存`
|
||||
await load()
|
||||
} catch (e) {
|
||||
msg.value = e instanceof Error ? e.message : '保存失败'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>会员套餐</h1>
|
||||
<p class="muted">配置成长会员时长与标价(mock 履约读表)。</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="msg" class="muted">{{ msg }}</p>
|
||||
<div v-for="p in items" :key="p.code" class="card block">
|
||||
<h2>{{ p.code }}</h2>
|
||||
<div class="row">
|
||||
<label>标题 <input v-model="p.title" /></label>
|
||||
<label>天数 <input v-model.number="p.duration_days" type="number" min="1" /></label>
|
||||
<label>标价(分) <input v-model.number="p.amount_cents" type="number" min="0" /></label>
|
||||
<label class="chk"><input v-model="p.active" type="checkbox" /> 启用</label>
|
||||
<button
|
||||
v-if="auth.can('admin.membership.plans.write')"
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="save(p)"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; text-transform: uppercase; }
|
||||
.block { margin-bottom: 1rem; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: var(--muted); }
|
||||
input { border: 1px solid var(--line); border-radius: 8px; padding: 0.45rem 0.6rem; min-width: 6rem; }
|
||||
.chk { flex-direction: row; align-items: center; gap: 0.35rem; padding-bottom: 0.4rem; }
|
||||
</style>
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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 薄切片)
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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。
|
||||
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user