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
@@ -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