feat(ECR-009): Ops-D order filters, plan display prices, refund status

LOOP-RUN-002 sample: admin order multi-filter, membership display
price catalog, read-only refund_status. No real payment or RBAC.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:45:32 +08:00
co-authored by Cursor
parent 7ab9add5dd
commit ca3b13554c
37 changed files with 2169 additions and 25 deletions
+49 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
@@ -35,6 +36,8 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
authed.POST("/users/:id/membership/grant", h.GrantMembership)
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
authed.GET("/orders", h.ListOrders)
authed.GET("/membership/plan-prices", h.ListPlanPrices)
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
authed.GET("/audit-logs", h.ListAudit)
authed.GET("/analytics/overview", h.AnalyticsOverview)
authed.GET("/analytics/pages", h.AnalyticsPages)
@@ -189,7 +192,16 @@ func (h *AdminHandler) GrantAskQuota(c *gin.Context) {
func (h *AdminHandler) ListOrders(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListOrders(c.Request.Context(), limit, offset)
f := admin.OrderFilterFromQuery(
c.Query("user_id"),
c.Query("status"),
c.Query("kind"),
c.Query("from"),
c.Query("to"),
limit,
offset,
)
items, err := h.Svc.ListOrders(c.Request.Context(), f)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed")
return
@@ -197,6 +209,42 @@ func (h *AdminHandler) ListOrders(c *gin.Context) {
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) ListPlanPrices(c *gin.Context) {
items, err := h.Svc.ListPlanPrices(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50017, "list plan prices failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) PutPlanPrices(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body struct {
Items []struct {
Plan string `json:"plan"`
DisplayCents int `json:"display_cents"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Items) == 0 {
response.Fail(c, http.StatusBadRequest, 40040, "items required")
return
}
prices := make([]repository.PlanPrice, 0, len(body.Items))
for _, it := range body.Items {
prices = append(prices, repository.PlanPrice{Plan: it.Plan, DisplayCents: it.DisplayCents})
}
if err := h.Svc.UpsertPlanPrices(c.Request.Context(), adminID, prices); err != nil {
response.Fail(c, http.StatusBadRequest, 40041, "upsert plan prices failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListAudit(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
@@ -0,0 +1,67 @@
package integration_test
import (
"encoding/json"
"net/http"
"testing"
)
func TestOpsCommercePhaseD(t *testing.T) {
r, _ := setupAPI(t)
loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
"username": "admin", "password": "change-me",
}, "")
if code != http.StatusOK {
t.Fatalf("login http=%d", code)
}
var login struct {
Token string `json:"token"`
}
_ = json.Unmarshal(loginEnv.Data, &login)
pricesEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/membership/plan-prices", nil, login.Token)
if code != http.StatusOK || pricesEnv.Code != 0 {
t.Fatalf("list prices http=%d code=%d msg=%s", code, pricesEnv.Code, pricesEnv.Message)
}
var prices struct {
Items []struct {
Plan string `json:"plan"`
DisplayCents int `json:"display_cents"`
} `json:"items"`
}
_ = json.Unmarshal(pricesEnv.Data, &prices)
if len(prices.Items) == 0 {
t.Fatal("expected seeded plan prices")
}
putEnv, code := doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership/plan-prices", map[string]any{
"items": []map[string]any{
{"plan": "monthly", "display_cents": 2900},
{"plan": "yearly", "display_cents": 19900},
},
}, login.Token)
if code != http.StatusOK || putEnv.Code != 0 {
t.Fatalf("put prices http=%d code=%d msg=%s", code, putEnv.Code, putEnv.Message)
}
ordersEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/orders?status=paid&limit=5", nil, login.Token)
if code != http.StatusOK || ordersEnv.Code != 0 {
t.Fatalf("orders filter http=%d code=%d msg=%s", code, ordersEnv.Code, ordersEnv.Message)
}
var orders struct {
Items []struct {
Status string `json:"status"`
RefundStatus string `json:"refund_status"`
} `json:"items"`
}
_ = json.Unmarshal(ordersEnv.Data, &orders)
for _, o := range orders.Items {
if o.Status != "paid" {
t.Fatalf("filter status=paid returned %q", o.Status)
}
if o.RefundStatus == "" {
t.Fatal("expected refund_status field")
}
}
}
+100 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
@@ -414,17 +415,30 @@ func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID
// OrderListItem for admin order tables.
type OrderListItem struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Plan *string `json:"plan,omitempty"`
AmountCents int `json:"amount_cents"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Plan *string `json:"plan,omitempty"`
AmountCents int `json:"amount_cents"`
Status string `json:"status"`
RefundStatus string `json:"refund_status"`
CreatedAt time.Time `json:"created_at"`
}
// ListOrders lists orders; optional user filter.
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
// OrderListFilter filters admin order lists (Ops-D).
type OrderListFilter struct {
UserID *uuid.UUID
Status string
Kind string
From *time.Time
To *time.Time
Limit int
Offset int
}
// ListOrders lists orders with optional multi-dimensional filters.
func (r *AdminRepo) ListOrders(ctx context.Context, f OrderListFilter) ([]OrderListItem, error) {
limit, offset := f.Limit, f.Offset
if limit <= 0 || limit > 100 {
limit = 20
}
@@ -432,12 +446,18 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
offset = 0
}
rows, err := r.Pool.Query(ctx, `
SELECT id, user_id, kind, plan, amount_cents, status, created_at
SELECT id, user_id, kind, plan, amount_cents, status,
coalesce(refund_status, 'none'), created_at
FROM orders
WHERE deleted_at IS NULL
AND ($1::uuid IS NULL OR user_id = $1)
AND ($2::text = '' OR status = $2)
AND ($3::text = '' OR kind = $3)
AND ($4::timestamptz IS NULL OR created_at >= $4)
AND ($5::timestamptz IS NULL OR created_at < $5)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset)
LIMIT $6 OFFSET $7`,
f.UserID, f.Status, f.Kind, f.From, f.To, limit, offset)
if err != nil {
return nil, err
}
@@ -445,7 +465,10 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
var out []OrderListItem
for rows.Next() {
var o OrderListItem
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
if err := rows.Scan(
&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status,
&o.RefundStatus, &o.CreatedAt,
); err != nil {
return nil, err
}
out = append(out, o)
@@ -453,6 +476,71 @@ func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, of
return out, rows.Err()
}
// PlanPrice is a membership catalog display price (not order amount).
type PlanPrice struct {
Plan string `json:"plan"`
DisplayCents int `json:"display_cents"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListPlanPrices returns all membership display prices.
func (r *AdminRepo) ListPlanPrices(ctx context.Context) ([]PlanPrice, error) {
rows, err := r.Pool.Query(ctx, `
SELECT plan, display_cents, updated_at
FROM membership_plan_prices
ORDER BY plan`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []PlanPrice
for rows.Next() {
var p PlanPrice
if err := rows.Scan(&p.Plan, &p.DisplayCents, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// UpsertPlanPricesWithAudit replaces display prices and writes audit.
func (r *AdminRepo) UpsertPlanPricesWithAudit(
ctx context.Context,
adminID uuid.UUID,
items []PlanPrice,
meta json.RawMessage,
) error {
tx, err := r.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, it := range items {
if it.Plan == "" || it.DisplayCents < 0 {
return fmt.Errorf("invalid plan price")
}
if _, err := tx.Exec(ctx, `
INSERT INTO membership_plan_prices(plan, display_cents, updated_at)
VALUES ($1,$2,now())
ON CONFLICT (plan) DO UPDATE SET
display_cents=EXCLUDED.display_cents,
updated_at=now()`, it.Plan, it.DisplayCents); err != nil {
return err
}
}
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,'plan_price.upsert','membership_plan_prices','catalog',$2)`,
adminID, meta); err != nil {
return err
}
return tx.Commit(ctx)
}
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
func (r *AdminRepo) GrantMembershipWithAudit(
ctx context.Context,
+49 -4
View File
@@ -168,7 +168,11 @@ func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, e
if err != nil {
return nil, err
}
orders, err := s.Repo.ListOrders(ctx, &userID, 10, 0)
orders, err := s.Repo.ListOrders(ctx, repository.OrderListFilter{
UserID: &userID,
Limit: 10,
Offset: 0,
})
if err != nil {
return nil, err
}
@@ -231,9 +235,23 @@ func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID,
return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, meta)
}
// ListOrders lists commerce orders.
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, nil, limit, offset)
// ListOrders lists commerce orders with Ops-D filters.
func (s *Service) ListOrders(ctx context.Context, f repository.OrderListFilter) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, f)
}
// ListPlanPrices returns membership catalog display prices.
func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, error) {
return s.Repo.ListPlanPrices(ctx)
}
// UpsertPlanPrices updates display prices (not historical order amounts).
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
if len(items) == 0 {
return ErrInvalidPlan
}
meta, _ := json.Marshal(map[string]any{"count": len(items)})
return s.Repo.UpsertPlanPricesWithAudit(ctx, adminID, items, meta)
}
// ListAuditLogs lists audit entries.
@@ -261,3 +279,30 @@ func newToken() (string, error) {
}
return "adm_" + hex.EncodeToString(b), nil
}
// OrderFilterFromQuery builds repository filter from HTTP query strings.
func OrderFilterFromQuery(userID, status, kind, from, to string, limit, offset int) repository.OrderListFilter {
f := repository.OrderListFilter{
Status: status,
Kind: kind,
Limit: limit,
Offset: offset,
}
if userID != "" {
if id, err := uuid.Parse(userID); err == nil {
f.UserID = &id
}
}
if from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
f.From = &t
}
}
if to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
end := t.Add(24 * time.Hour)
f.To = &end
}
}
return f
}