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
+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;