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 A `[Ops]` | 登录 · 用户/订单查询 · 会员授予 · 审计 · `apps/admin-h5`(ECR-006 Closed) |
|
||||||
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) |
|
| Phase B `[Ops]` | 行为分析:自有埋点 + 管理端「数据」看板(**ECR-007 Closed** · Spec `ops-analytics.md`) |
|
||||||
| Phase C `[Ops]` | 内容:首页宫格 CRUD · 测评上下架(**ECR-008 Closed** · Spec `ops-content.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]`;真支付最后 |
|
| 排除 | **UGC / 社区广场**(M10.2)仍 `[No]`;真支付最后 |
|
||||||
|
|
||||||
不计入 P1 Complete;不进入五 Tab。
|
不计入 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`, {
|
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
|
||||||
delta,
|
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: () =>
|
orders: () =>
|
||||||
request<{
|
request<{
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async function onLogout() {
|
|||||||
<RouterLink to="/analytics">数据</RouterLink>
|
<RouterLink to="/analytics">数据</RouterLink>
|
||||||
<RouterLink to="/content">内容</RouterLink>
|
<RouterLink to="/content">内容</RouterLink>
|
||||||
<RouterLink to="/users">用户</RouterLink>
|
<RouterLink to="/users">用户</RouterLink>
|
||||||
|
<RouterLink to="/plans">套餐</RouterLink>
|
||||||
<RouterLink to="/orders">订单</RouterLink>
|
<RouterLink to="/orders">订单</RouterLink>
|
||||||
<RouterLink to="/audit">审计</RouterLink>
|
<RouterLink to="/audit">审计</RouterLink>
|
||||||
</nav>
|
</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', name: 'users', component: () => import('@/pages/UsersPage.vue') },
|
||||||
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
|
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
|
||||||
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.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') },
|
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
|||||||
h.registerContent(authed)
|
h.registerContent(authed)
|
||||||
h.registerRBAC(authed)
|
h.registerRBAC(authed)
|
||||||
h.registerLifecycle(authed)
|
h.registerLifecycle(authed)
|
||||||
|
h.registerMembershipPlans(authed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) Login(c *gin.Context) {
|
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")
|
var errMissingReport = errString("report_id required for deep_access")
|
||||||
|
|
||||||
type errString string
|
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.
|
// Permission catalog frozen in ECR-013A Spec.
|
||||||
const (
|
const (
|
||||||
PermUsersRead = "admin.users.read"
|
PermUsersRead = "admin.users.read"
|
||||||
PermMembershipGrant = "admin.users.membership.grant"
|
PermMembershipGrant = "admin.users.membership.grant"
|
||||||
PermAskQuotaGrant = "admin.users.ask_quota.grant"
|
PermAskQuotaGrant = "admin.users.ask_quota.grant"
|
||||||
PermOrdersRead = "admin.orders.read"
|
PermOrdersRead = "admin.orders.read"
|
||||||
PermAuditRead = "admin.audit.read"
|
PermAuditRead = "admin.audit.read"
|
||||||
PermAnalyticsRead = "admin.analytics.read"
|
PermAnalyticsRead = "admin.analytics.read"
|
||||||
PermContentWrite = "admin.content.write"
|
PermContentWrite = "admin.content.write"
|
||||||
PermRolesRead = "admin.roles.read"
|
PermRolesRead = "admin.roles.read"
|
||||||
PermRolesWrite = "admin.roles.write"
|
PermRolesWrite = "admin.roles.write"
|
||||||
PermUsersStatusWrite = "admin.users.status.write"
|
PermUsersStatusWrite = "admin.users.status.write"
|
||||||
|
PermMembershipPlansRead = "admin.membership.plans.read"
|
||||||
|
PermMembershipPlansWrite = "admin.membership.plans.write"
|
||||||
)
|
)
|
||||||
|
|
||||||
var knownPermissions = map[string]struct{}{
|
var knownPermissions = map[string]struct{}{
|
||||||
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
|
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
|
||||||
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
|
||||||
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
|
||||||
PermUsersStatusWrite: {},
|
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ type GrantInput struct {
|
|||||||
|
|
||||||
// GrantMembership extends membership and writes audit.
|
// GrantMembership extends membership and writes audit.
|
||||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -230,16 +230,7 @@ func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repos
|
|||||||
}
|
}
|
||||||
|
|
||||||
func planDays(plan string) (int, error) {
|
func planDays(plan string) (int, error) {
|
||||||
switch plan {
|
return planDaysFallback(plan)
|
||||||
case "month":
|
|
||||||
return 31, nil
|
|
||||||
case "quarter":
|
|
||||||
return 92, nil
|
|
||||||
case "year":
|
|
||||||
return 366, nil
|
|
||||||
default:
|
|
||||||
return 0, ErrInvalidPlan
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newToken() (string, error) {
|
func newToken() (string, error) {
|
||||||
|
|||||||
@@ -34,7 +34,17 @@ func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOr
|
|||||||
amount := 990
|
amount := 990
|
||||||
plan := in.Plan
|
plan := in.Plan
|
||||||
if in.Kind == "membership" {
|
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 in.Kind == "ask_pack" {
|
||||||
if plan == "" {
|
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
|
## 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: (无)
|
- 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`
|
- Ops foundation: `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml`
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.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 |
|
| 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 |
|
| 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-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':
|
'401':
|
||||||
description: Unauthorized
|
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:
|
/api/v1/admin/orders:
|
||||||
get:
|
get:
|
||||||
tags: [admin]
|
tags: [admin]
|
||||||
|
|||||||
Reference in New Issue
Block a user