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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user