更新订单管理记录更多事情

This commit is contained in:
2026-07-14 12:40:44 +08:00
parent 86658e98a1
commit 4a1b91b759
8 changed files with 83 additions and 18 deletions
+2 -2
View File
@@ -133,8 +133,8 @@ func NewApp(ctx context.Context) (*App, error) {
siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle)
showcaseSvc := service.NewShowcaseService(showcaseRepo) showcaseSvc := service.NewShowcaseService(showcaseRepo)
adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo) adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo)
adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo) adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo)
cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo) cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo) apiKeySvc := service.NewAPIKeyService(apiKeyRepo)
tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient) tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient)
refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient) refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient)
+3 -1
View File
@@ -28,6 +28,8 @@ func orderJSON(o *model.Order) gin.H {
"status": o.Status, "status": o.Status,
"pay_info": o.PayInfo, "pay_info": o.PayInfo,
"pay_info_type": o.PayInfoType, "pay_info_type": o.PayInfoType,
"source": o.Source,
"remark": o.Remark,
"created_at": o.CreatedAt.Unix(), "created_at": o.CreatedAt.Unix(),
"expires_at": o.ExpiresAt.Unix(), "expires_at": o.ExpiresAt.Unix(),
"server_now": time.Now().Unix(), // lets the popup count down on server time "server_now": time.Now().Unix(), // lets the popup count down on server time
@@ -129,7 +131,7 @@ func (h *PaymentHandler) AdminOrders(c *gin.Context) {
status := c.Query("status") status := c.Query("status")
limit := parseInt(c.Query("limit"), 100) limit := parseInt(c.Query("limit"), 100)
offset := parseInt(c.Query("offset"), 0) offset := parseInt(c.Query("offset"), 0)
orders, total, err := h.pay.ListAll(c.Request.Context(), status, strings.TrimSpace(c.Query("q")), limit, offset) orders, total, err := h.pay.ListAll(c.Request.Context(), status, strings.TrimSpace(c.Query("source")), strings.TrimSpace(c.Query("q")), limit, offset)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load orders"}) c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load orders"})
return return
+7 -2
View File
@@ -253,14 +253,19 @@ func AutoMigrateModels() []any {
// Order is a points-recharge order paid via 易支付 (epay). ID is our merchant // Order is a points-recharge order paid via 易支付 (epay). ID is our merchant
// order number (out_trade_no). Status: pending | paid | cancelled. Unpaid orders // order number (out_trade_no). Status: pending | paid | cancelled. Unpaid orders
// auto-cancel 30 min after creation (ExpiresAt). // auto-cancel 30 min after creation (ExpiresAt). Besides epay recharges, the
// table also records credit grants from admin manual adjustments (source=admin)
// and CDK redemptions (source=cdk) as already-paid rows, so 订单管理 shows the
// full credit history in one place.
type Order struct { type Order struct {
ID string `gorm:"primaryKey;size:40"` ID string `gorm:"primaryKey;size:40"`
UserID string `gorm:"size:32;index;not null"` UserID string `gorm:"size:32;index;not null"`
Amount float64 `gorm:"not null"` // 充值金额(元) Amount float64 `gorm:"not null"` // 充值金额(元)
Points int `gorm:"not null"` // 到账积分 Points int `gorm:"not null"` // 到账积分
PayType string `gorm:"size:16"` // wxpay | alipay PayType string `gorm:"size:16"` // wxpay | alipay | admin | cdk
Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled Status string `gorm:"size:16;index;not null"` // pending | paid | cancelled
Source string `gorm:"size:16;index;not null;default:'epay'"` // epay | admin | cdk
Remark string `gorm:"type:text"` // e.g. 兑换码 code / 管理员操作说明
TradeNo string `gorm:"size:64;index"` // 易支付平台订单号 TradeNo string `gorm:"size:64;index"` // 易支付平台订单号
PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url
PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ... PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ...
+9 -5
View File
@@ -34,7 +34,7 @@ func (r *OrderRepository) Update(ctx context.Context, id string, patch map[strin
func (r *OrderRepository) ListByUser(ctx context.Context, userID, status, query string, limit, offset int) ([]model.Order, int64, error) { func (r *OrderRepository) ListByUser(ctx context.Context, userID, status, query string, limit, offset int) ([]model.Order, int64, error) {
var out []model.Order var out []model.Order
var total int64 var total int64
q := r.db.WithContext(ctx).Model(&model.Order{}).Where("user_id = ?", userID) q := r.db.WithContext(ctx).Model(&model.Order{}).Where("user_id = ?", userID).Where("source = ?", "epay")
if status != "" { if status != "" {
q = q.Where("status = ?", status) q = q.Where("status = ?", status)
} }
@@ -52,16 +52,20 @@ func (r *OrderRepository) ListByUser(ctx context.Context, userID, status, query
return out, total, err return out, total, err
} }
// List returns all orders (admin) with optional status filter + pagination. // List returns all orders (admin) with optional status/source filter +
// query — server-side search over 订单号 / 支付方式 / 金额; userIDs — additionally // pagination. query — server-side search over 订单号 / 支付方式 / 金额; userIDs —
// match orders belonging to these users (resolved from a 用户名 search upstream). // additionally match orders belonging to these users (resolved from a 用户名
func (r *OrderRepository) List(ctx context.Context, status, query string, userIDs []string, limit, offset int) ([]model.Order, int64, error) { // search upstream).
func (r *OrderRepository) List(ctx context.Context, status, source, query string, userIDs []string, limit, offset int) ([]model.Order, int64, error) {
var out []model.Order var out []model.Order
var total int64 var total int64
q := r.db.WithContext(ctx).Model(&model.Order{}) q := r.db.WithContext(ctx).Model(&model.Order{})
if status != "" { if status != "" {
q = q.Where("status = ?", status) q = q.Where("status = ?", status)
} }
if source != "" {
q = q.Where("source = ?", source)
}
if term := strings.TrimSpace(query); term != "" { if term := strings.TrimSpace(query); term != "" {
like := "%" + term + "%" like := "%" + term + "%"
if len(userIDs) > 0 { if len(userIDs) > 0 {
+16 -1
View File
@@ -29,9 +29,10 @@ type AdminWriteService struct {
events *repo.EventRepository events *repo.EventRepository
apiKeys *repo.APIKeyRepository apiKeys *repo.APIKeyRepository
tokens *repo.TokenRepository tokens *repo.TokenRepository
orders *repo.OrderRepository
} }
func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository) *AdminWriteService { func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRepository, models *repo.ModelRepository, events *repo.EventRepository, apiKeys *repo.APIKeyRepository, tokens *repo.TokenRepository, orders *repo.OrderRepository) *AdminWriteService {
return &AdminWriteService{ return &AdminWriteService{
users: users, users: users,
showcase: showcase, showcase: showcase,
@@ -39,6 +40,7 @@ func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRep
events: events, events: events,
apiKeys: apiKeys, apiKeys: apiKeys,
tokens: tokens, tokens: tokens,
orders: orders,
} }
} }
@@ -224,6 +226,9 @@ func (s *AdminWriteService) AdjustUserCredits(ctx context.Context, userID string
} }
return nil, err return nil, err
} }
if delta != 0 {
RecordCreditOrder(ctx, s.orders, userID, delta, "admin", "管理员调整余额")
}
return user, nil return user, nil
} }
@@ -234,6 +239,13 @@ func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, v
if value < 0 { if value < 0 {
value = 0 value = 0
} }
before, err := s.users.GetByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
user, err := s.users.SetCredits(ctx, userID, value) user, err := s.users.SetCredits(ctx, userID, value)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -241,6 +253,9 @@ func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, v
} }
return nil, err return nil, err
} }
if delta := user.Credits - before.Credits; delta != 0 {
RecordCreditOrder(ctx, s.orders, userID, delta, "admin", "管理员设置余额")
}
return user, nil return user, nil
} }
+4 -1
View File
@@ -15,13 +15,15 @@ type CDKService struct {
cdks *repo.CDKRepository cdks *repo.CDKRepository
users *repo.UserRepository users *repo.UserRepository
settings *repo.SiteSettingRepository settings *repo.SiteSettingRepository
orders *repo.OrderRepository
} }
func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository) *CDKService { func NewCDKService(cdks *repo.CDKRepository, users *repo.UserRepository, settings *repo.SiteSettingRepository, orders *repo.OrderRepository) *CDKService {
return &CDKService{ return &CDKService{
cdks: cdks, cdks: cdks,
users: users, users: users,
settings: settings, settings: settings,
orders: orders,
} }
} }
@@ -160,6 +162,7 @@ func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[strin
if err != nil { if err != nil {
return nil, err return nil, err
} }
RecordCreditOrder(ctx, s.orders, userID, float64(item.Amount), "cdk", "兑换码 "+item.Code)
return map[string]any{ return map[string]any{
"amount": item.Amount, "amount": item.Amount,
+31 -2
View File
@@ -255,7 +255,7 @@ func (s *PaymentService) ListByUser(ctx context.Context, userID, status, query s
// ListAll — admin order list. A search query also matches 用户名/邮箱: resolve // ListAll — admin order list. A search query also matches 用户名/邮箱: resolve
// the term to user ids first so "张三" finds that user's orders. // the term to user ids first so "张三" finds that user's orders.
func (s *PaymentService) ListAll(ctx context.Context, status, query string, limit, offset int) ([]model.Order, int64, error) { func (s *PaymentService) ListAll(ctx context.Context, status, source, query string, limit, offset int) ([]model.Order, int64, error) {
var userIDs []string var userIDs []string
if term := strings.ToLower(strings.TrimSpace(query)); term != "" { if term := strings.ToLower(strings.TrimSpace(query)); term != "" {
if users, err := s.users.List(ctx); err == nil { if users, err := s.users.List(ctx); err == nil {
@@ -268,7 +268,36 @@ func (s *PaymentService) ListAll(ctx context.Context, status, query string, limi
} }
} }
} }
return s.orders.List(ctx, status, query, userIDs, limit, offset) return s.orders.List(ctx, status, source, query, userIDs, limit, offset)
}
// RecordCreditOrder persists a synthetic already-paid order for a credit grant
// that didn't go through epay — admin manual adjustments (source="admin") and
// CDK redemptions (source="cdk") — so 订单管理 lists them alongside recharges.
// Best-effort: a failed insert must never fail the credit operation itself.
func RecordCreditOrder(ctx context.Context, orders *repo.OrderRepository, userID string, points float64, source, remark string) {
if orders == nil {
return
}
prefix := "X"
switch source {
case "admin":
prefix = "M"
case "cdk":
prefix = "C"
}
now := time.Now()
_ = orders.Create(ctx, &model.Order{
ID: prefix + strconv.FormatInt(now.Unix(), 10) + randomUpper(6),
UserID: userID,
Points: int(math.Round(points)),
PayType: source,
Status: "paid",
Source: source,
Remark: remark,
ExpiresAt: now,
PaidAt: &now,
})
} }
// UserNames maps user id → display name (name, else email, else id) so the admin // UserNames maps user id → display name (name, else email, else id) so the admin
+11 -4
View File
@@ -9,12 +9,13 @@ const items = ref([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const status = ref('') const status = ref('')
const source = ref('')
const search = ref('') const search = ref('')
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
const STATUS = { pending: '待支付', paid: '已支付', cancelled: '已取消' } const STATUS = { pending: '待支付', paid: '已支付', cancelled: '已取消' }
const METHOD = { wxpay: '微信', alipay: '支付宝' } const METHOD = { wxpay: '微信', alipay: '支付宝', admin: '后台充值', cdk: '兑换码' }
const chipClass = (s) => ({ const chipClass = (s) => ({
paid: 'fp-emerald', pending: 'fp-amber', cancelled: '', paid: 'fp-emerald', pending: 'fp-amber', cancelled: '',
}[s] || '') }[s] || '')
@@ -30,6 +31,7 @@ async function load() {
loading.value = true loading.value = true
const qs = new URLSearchParams({ limit: String(pageSize), offset: String((page.value - 1) * pageSize) }) const qs = new URLSearchParams({ limit: String(pageSize), offset: String((page.value - 1) * pageSize) })
if (status.value) qs.set('status', status.value) if (status.value) qs.set('status', status.value)
if (source.value) qs.set('source', source.value)
if (search.value.trim()) qs.set('q', search.value.trim()) if (search.value.trim()) qs.set('q', search.value.trim())
const r = await api('/pay/admin/orders?' + qs.toString()) const r = await api('/pay/admin/orders?' + qs.toString())
loading.value = false loading.value = false
@@ -47,6 +49,7 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize + 1) const pageStart = computed(() => total.value === 0 ? 0 : (page.value - 1) * pageSize + 1)
const pageEnd = computed(() => Math.min(total.value, page.value * pageSize)) const pageEnd = computed(() => Math.min(total.value, page.value * pageSize))
function setStatus(v) { status.value = v; page.value = 1; load() } function setStatus(v) { status.value = v; page.value = 1; load() }
function setSource(v) { source.value = v; page.value = 1; load() }
const pageNumbers = computed(() => { const pageNumbers = computed(() => {
const n = totalPages.value, cur = page.value const n = totalPages.value, cur = page.value
if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1)
@@ -80,6 +83,10 @@ function goPage(n) {
<button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]" <button v-for="s in [['','全部'],['pending','待支付'],['paid','已支付'],['cancelled','已取消']]" :key="s[0]"
@click="setStatus(s[0])" class="fp" :class="status === s[0] && 'fp-on'">{{ s[1] }}</button> @click="setStatus(s[0])" class="fp" :class="status === s[0] && 'fp-on'">{{ s[1] }}</button>
</div> </div>
<div class="flex items-center gap-1.5">
<button v-for="s in [['','全部来源'],['epay','在线充值'],['admin','后台充值'],['cdk','兑换码']]" :key="s[0]"
@click="setSource(s[0])" class="fp" :class="source === s[0] && 'fp-on'">{{ s[1] }}</button>
</div>
<input v-model="search" @keyup.enter="doSearch" @change="doSearch" <input v-model="search" @keyup.enter="doSearch" @change="doSearch"
class="field !py-1.5 text-xs !w-52" placeholder="搜索 订单号 / 用户名 / 金额…" /> class="field !py-1.5 text-xs !w-52" placeholder="搜索 订单号 / 用户名 / 金额…" />
<button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button> <button @click="load" class="btn-soft"><Icon name="refresh" class="w-3.5 h-3.5" /> 刷新</button>
@@ -104,12 +111,12 @@ function goPage(n) {
</thead> </thead>
<tbody> <tbody>
<tr v-for="o in displayed" :key="o.id" class="log-row"> <tr v-for="o in displayed" :key="o.id" class="log-row">
<td class="px-5 py-3.5 align-middle font-mono text-xs text-white/80">{{ o.id }}</td> <td class="px-5 py-3.5 align-middle font-mono text-xs text-white/80" :title="o.remark">{{ o.id }}<span v-if="o.remark" class="ml-2 font-sans text-[10px] text-white/40">{{ o.remark }}</span></td>
<td class="px-3 py-3.5 align-middle text-white/85 truncate max-w-[140px]" :title="o.user_name">{{ o.user_name || '—' }}</td> <td class="px-3 py-3.5 align-middle text-white/85 truncate max-w-[140px]" :title="o.user_name">{{ o.user_name || '—' }}</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.created_at) }}</td> <td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.created_at) }}</td>
<td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.paid_at) }}</td> <td class="px-3 py-3.5 align-middle text-xs text-white/55 whitespace-nowrap">{{ fmt(o.paid_at) }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85">¥{{ o.amount }}</td> <td class="px-3 py-3.5 align-middle text-right tabular-nums text-white/85">{{ o.source && o.source !== 'epay' ? '—' : '¥' + o.amount }}</td>
<td class="px-3 py-3.5 align-middle text-right tabular-nums text-violet-300">{{ o.points }}</td> <td class="px-3 py-3.5 align-middle text-right tabular-nums" :class="o.points < 0 ? 'text-rose-300' : 'text-violet-300'">{{ o.points }}</td>
<td class="px-3 py-3.5 align-middle"> <td class="px-3 py-3.5 align-middle">
<span class="chip" :class="chipClass(o.status)">{{ STATUS[o.status] }}<span class="opacity-50 ml-1">· {{ METHOD[o.pay_type] || o.pay_type }}</span></span> <span class="chip" :class="chipClass(o.status)">{{ STATUS[o.status] }}<span class="opacity-50 ml-1">· {{ METHOD[o.pay_type] || o.pay_type }}</span></span>
</td> </td>