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:
@@ -112,8 +112,25 @@ export const adminApi = {
|
||||
request<{ ok: boolean; ask_paid_quota_left: number }>('POST', `/users/${id}/ask-quota/grant`, {
|
||||
delta,
|
||||
}),
|
||||
orders: () =>
|
||||
request<{
|
||||
orders: (params?: {
|
||||
status?: string
|
||||
kind?: string
|
||||
from?: string
|
||||
to?: string
|
||||
user_id?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.status) q.set('status', params.status)
|
||||
if (params?.kind) q.set('kind', params.kind)
|
||||
if (params?.from) q.set('from', params.from)
|
||||
if (params?.to) q.set('to', params.to)
|
||||
if (params?.user_id) q.set('user_id', params.user_id)
|
||||
if (params?.limit != null) q.set('limit', String(params.limit))
|
||||
if (params?.offset != null) q.set('offset', String(params.offset))
|
||||
const qs = q.toString()
|
||||
return request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
user_id: string
|
||||
@@ -121,9 +138,18 @@ export const adminApi = {
|
||||
plan?: string
|
||||
amount_cents: number
|
||||
status: string
|
||||
refund_status: string
|
||||
created_at: string
|
||||
}>
|
||||
}>('GET', '/orders'),
|
||||
}>('GET', `/orders${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
planPrices: () =>
|
||||
request<{ items: Array<{ plan: string; display_cents: number; updated_at: string }> }>(
|
||||
'GET',
|
||||
'/membership/plan-prices',
|
||||
),
|
||||
savePlanPrices: (items: Array<{ plan: string; display_cents: number }>) =>
|
||||
request<{ ok: boolean }>('PUT', '/membership/plan-prices', { items }),
|
||||
audit: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
|
||||
@@ -29,6 +29,7 @@ async function onLogout() {
|
||||
<RouterLink to="/content">内容</RouterLink>
|
||||
<RouterLink to="/users">用户</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink to="/pricing">定价</RouterLink>
|
||||
<RouterLink to="/audit">审计</RouterLink>
|
||||
</nav>
|
||||
<div class="foot">
|
||||
|
||||
@@ -4,6 +4,11 @@ import { adminApi } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const status = ref('')
|
||||
const kind = ref('')
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
const userId = ref('')
|
||||
const items = ref<
|
||||
Array<{
|
||||
id: string
|
||||
@@ -12,6 +17,7 @@ const items = ref<
|
||||
plan?: string
|
||||
amount_cents: number
|
||||
status: string
|
||||
refund_status: string
|
||||
created_at: string
|
||||
}>
|
||||
>([])
|
||||
@@ -20,7 +26,13 @@ async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.orders()
|
||||
const res = await adminApi.orders({
|
||||
status: status.value || undefined,
|
||||
kind: kind.value || undefined,
|
||||
from: from.value || undefined,
|
||||
to: to.value || undefined,
|
||||
user_id: userId.value || undefined,
|
||||
})
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
@@ -35,13 +47,23 @@ onMounted(load)
|
||||
<template>
|
||||
<section>
|
||||
<h1>订单</h1>
|
||||
<div class="filters card">
|
||||
<label>状态 <input v-model="status" placeholder="paid / pending" /></label>
|
||||
<label>类型 <input v-model="kind" placeholder="membership" /></label>
|
||||
<label>从 <input v-model="from" type="date" /></label>
|
||||
<label>到 <input v-model="to" type="date" /></label>
|
||||
<label>用户 ID <input v-model="userId" class="wide" /></label>
|
||||
<button class="btn" type="button" @click="load">筛选</button>
|
||||
</div>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="card">
|
||||
<p v-if="!items.length" class="muted">暂无订单</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>金额</th><th>时间</th></tr>
|
||||
<tr>
|
||||
<th>ID</th><th>用户</th><th>类型</th><th>状态</th><th>退款</th><th>金额</th><th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in items" :key="o.id">
|
||||
@@ -49,6 +71,7 @@ onMounted(load)
|
||||
<td>{{ o.user_id }}</td>
|
||||
<td>{{ o.kind }} {{ o.plan || '' }}</td>
|
||||
<td>{{ o.status }}</td>
|
||||
<td>{{ o.refund_status }}</td>
|
||||
<td>{{ (o.amount_cents / 100).toFixed(2) }}</td>
|
||||
<td>{{ o.created_at }}</td>
|
||||
</tr>
|
||||
@@ -60,4 +83,8 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 1rem; font-size: 1.35rem; }
|
||||
.filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; margin-bottom: 1rem; }
|
||||
.filters label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
|
||||
.filters input { min-width: 8rem; }
|
||||
.filters input.wide { min-width: 16rem; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const ok = ref('')
|
||||
const items = ref<Array<{ plan: string; display_cents: number; updated_at?: string }>>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.planPrices()
|
||||
items.value = (res.items || []).map((i) => ({ ...i }))
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
ok.value = ''
|
||||
try {
|
||||
await adminApi.savePlanPrices(
|
||||
items.value.map((i) => ({ plan: i.plan, display_cents: Number(i.display_cents) })),
|
||||
)
|
||||
ok.value = '已保存展示价(不影响历史订单金额)'
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>套餐展示价</h1>
|
||||
<p class="muted">仅目录展示价;不改已支付订单的 amount_cents。</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div v-if="!loading && !error" class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>套餐</th><th>展示价(分)</th><th>更新时间</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.plan">
|
||||
<td>{{ row.plan }}</td>
|
||||
<td>
|
||||
<input v-model.number="row.display_cents" type="number" min="0" />
|
||||
</td>
|
||||
<td class="muted">{{ row.updated_at || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn" type="button" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.5rem; font-size: 1.35rem; }
|
||||
.ok { color: #1a7f37; }
|
||||
input[type='number'] { width: 8rem; }
|
||||
.btn { margin-top: 1rem; }
|
||||
</style>
|
||||
@@ -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: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
|
||||
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS membership_plan_prices;
|
||||
DROP INDEX IF EXISTS idx_orders_status_created;
|
||||
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_refund_status_check;
|
||||
ALTER TABLE orders DROP COLUMN IF EXISTS refund_status;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Ops-D: refund_status on orders + membership plan display prices
|
||||
|
||||
ALTER TABLE orders
|
||||
ADD COLUMN IF NOT EXISTS refund_status TEXT NOT NULL DEFAULT 'none';
|
||||
|
||||
ALTER TABLE orders
|
||||
DROP CONSTRAINT IF EXISTS orders_refund_status_check;
|
||||
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT orders_refund_status_check
|
||||
CHECK (refund_status IN ('none', 'pending', 'refunded', 'rejected'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status_created
|
||||
ON orders (status, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS membership_plan_prices (
|
||||
plan TEXT PRIMARY KEY,
|
||||
display_cents INT NOT NULL CHECK (display_cents >= 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO membership_plan_prices(plan, display_cents) VALUES
|
||||
('monthly', 2800),
|
||||
('yearly', 19800)
|
||||
ON CONFLICT (plan) DO NOTHING;
|
||||
Reference in New Issue
Block a user