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:
jackyu66git
2026-08-07 18:03:38 +08:00
co-authored by Cursor
parent e25cd94b0c
commit 882c01d81a
31 changed files with 887 additions and 27 deletions
+22
View File
@@ -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<{
+1
View File
@@ -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>
+1
View File
@@ -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') },
],
},
+1
View File
@@ -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
}
}
+13 -11
View File
@@ -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 (
+2 -11
View File
@@ -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;