diff --git a/backend/internal/bootstrap/app.go b/backend/internal/bootstrap/app.go index 5c6256b..a0dbcba 100644 --- a/backend/internal/bootstrap/app.go +++ b/backend/internal/bootstrap/app.go @@ -133,8 +133,8 @@ func NewApp(ctx context.Context) (*App, error) { siteSvc := service.NewSiteService(siteRepo, cfg.AppTitle) showcaseSvc := service.NewShowcaseService(showcaseRepo) adminReadSvc := service.NewAdminReadService(cfg, userRepo, modelRepo, eventRepo, siteRepo, tokenRepo, cdkRepo, rustfsClient, showcaseRepo) - adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo) - cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo) + adminWriteSvc := service.NewAdminWriteService(userRepo, showcaseRepo, modelRepo, eventRepo, apiKeyRepo, tokenRepo, orderRepo) + cdkSvc := service.NewCDKService(cdkRepo, userRepo, siteRepo, orderRepo) apiKeySvc := service.NewAPIKeyService(apiKeyRepo) tokenSvc := service.NewTokenService(tokenRepo, refreshRepo, eventRepo, siteRepo, adobeClient, chatGPTClient, runwayClient, leonardoClient, kreaClient, imagineClient, grokClient) refreshSvc := service.NewRefreshProfileService(refreshRepo, tokenRepo, adobeClient) diff --git a/backend/internal/http/handler/payment.go b/backend/internal/http/handler/payment.go index 08be4a6..16f7b62 100644 --- a/backend/internal/http/handler/payment.go +++ b/backend/internal/http/handler/payment.go @@ -28,6 +28,8 @@ func orderJSON(o *model.Order) gin.H { "status": o.Status, "pay_info": o.PayInfo, "pay_info_type": o.PayInfoType, + "source": o.Source, + "remark": o.Remark, "created_at": o.CreatedAt.Unix(), "expires_at": o.ExpiresAt.Unix(), "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") limit := parseInt(c.Query("limit"), 100) 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 { c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to load orders"}) return diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 8b4e3be..9e94481 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -253,14 +253,19 @@ func AutoMigrateModels() []any { // Order is a points-recharge order paid via 易支付 (epay). ID is our merchant // 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 { ID string `gorm:"primaryKey;size:40"` UserID string `gorm:"size:32;index;not null"` Amount float64 `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 + 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"` // 易支付平台订单号 PayInfo string `gorm:"type:text"` // 二维码 url / 跳转 url PayInfoType string `gorm:"size:16"` // qrcode | jump | html | ... diff --git a/backend/internal/repo/order_repo.go b/backend/internal/repo/order_repo.go index f638e09..9d855e6 100644 --- a/backend/internal/repo/order_repo.go +++ b/backend/internal/repo/order_repo.go @@ -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) { var out []model.Order 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 != "" { q = q.Where("status = ?", status) } @@ -52,16 +52,20 @@ func (r *OrderRepository) ListByUser(ctx context.Context, userID, status, query return out, total, err } -// List returns all orders (admin) with optional status filter + pagination. -// query — server-side search over 订单号 / 支付方式 / 金额; userIDs — additionally -// match orders belonging to these users (resolved from a 用户名 search upstream). -func (r *OrderRepository) List(ctx context.Context, status, query string, userIDs []string, limit, offset int) ([]model.Order, int64, error) { +// List returns all orders (admin) with optional status/source filter + +// pagination. query — server-side search over 订单号 / 支付方式 / 金额; userIDs — +// additionally match orders belonging to these users (resolved from a 用户名 +// 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 total int64 q := r.db.WithContext(ctx).Model(&model.Order{}) if status != "" { q = q.Where("status = ?", status) } + if source != "" { + q = q.Where("source = ?", source) + } if term := strings.TrimSpace(query); term != "" { like := "%" + term + "%" if len(userIDs) > 0 { diff --git a/backend/internal/service/admin_write.go b/backend/internal/service/admin_write.go index 0d7e31a..32a87d6 100644 --- a/backend/internal/service/admin_write.go +++ b/backend/internal/service/admin_write.go @@ -29,9 +29,10 @@ type AdminWriteService struct { events *repo.EventRepository apiKeys *repo.APIKeyRepository 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{ users: users, showcase: showcase, @@ -39,6 +40,7 @@ func NewAdminWriteService(users *repo.UserRepository, showcase *repo.ShowcaseRep events: events, apiKeys: apiKeys, tokens: tokens, + orders: orders, } } @@ -224,6 +226,9 @@ func (s *AdminWriteService) AdjustUserCredits(ctx context.Context, userID string } return nil, err } + if delta != 0 { + RecordCreditOrder(ctx, s.orders, userID, delta, "admin", "管理员调整余额") + } return user, nil } @@ -234,6 +239,13 @@ func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, v if 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) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -241,6 +253,9 @@ func (s *AdminWriteService) SetUserCredits(ctx context.Context, userID string, v } return nil, err } + if delta := user.Credits - before.Credits; delta != 0 { + RecordCreditOrder(ctx, s.orders, userID, delta, "admin", "管理员设置余额") + } return user, nil } diff --git a/backend/internal/service/cdk.go b/backend/internal/service/cdk.go index ba7d18c..7fc94f5 100644 --- a/backend/internal/service/cdk.go +++ b/backend/internal/service/cdk.go @@ -15,13 +15,15 @@ type CDKService struct { cdks *repo.CDKRepository users *repo.UserRepository 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{ cdks: cdks, users: users, settings: settings, + orders: orders, } } @@ -160,6 +162,7 @@ func (s *CDKService) Redeem(ctx context.Context, userID, code string) (map[strin if err != nil { return nil, err } + RecordCreditOrder(ctx, s.orders, userID, float64(item.Amount), "cdk", "兑换码 "+item.Code) return map[string]any{ "amount": item.Amount, diff --git a/backend/internal/service/payment.go b/backend/internal/service/payment.go index 12c0244..739e871 100644 --- a/backend/internal/service/payment.go +++ b/backend/internal/service/payment.go @@ -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 // 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 if term := strings.ToLower(strings.TrimSpace(query)); term != "" { 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 diff --git a/frontend/src/views/AdminOrdersView.vue b/frontend/src/views/AdminOrdersView.vue index 80fef2c..667f32c 100644 --- a/frontend/src/views/AdminOrdersView.vue +++ b/frontend/src/views/AdminOrdersView.vue @@ -9,12 +9,13 @@ const items = ref([]) const total = ref(0) const loading = ref(false) const status = ref('') +const source = ref('') const search = ref('') const page = ref(1) const pageSize = 20 const STATUS = { pending: '待支付', paid: '已支付', cancelled: '已取消' } -const METHOD = { wxpay: '微信', alipay: '支付宝' } +const METHOD = { wxpay: '微信', alipay: '支付宝', admin: '后台充值', cdk: '兑换码' } const chipClass = (s) => ({ paid: 'fp-emerald', pending: 'fp-amber', cancelled: '', }[s] || '') @@ -30,6 +31,7 @@ async function load() { loading.value = true const qs = new URLSearchParams({ limit: String(pageSize), offset: String((page.value - 1) * pageSize) }) 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()) const r = await api('/pay/admin/orders?' + qs.toString()) 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 pageEnd = computed(() => Math.min(total.value, page.value * pageSize)) function setStatus(v) { status.value = v; page.value = 1; load() } +function setSource(v) { source.value = v; page.value = 1; load() } const pageNumbers = computed(() => { const n = totalPages.value, cur = page.value if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1) @@ -80,6 +83,10 @@ function goPage(n) { +
+ +
@@ -104,12 +111,12 @@ function goPage(n) { - {{ o.id }} + {{ o.id }}{{ o.remark }} {{ o.user_name || '—' }} {{ fmt(o.created_at) }} {{ fmt(o.paid_at) }} - ¥{{ o.amount }} - {{ o.points }} + {{ o.source && o.source !== 'epay' ? '—' : '¥' + o.amount }} + {{ o.points }} {{ STATUS[o.status] }}· {{ METHOD[o.pay_type] || o.pay_type }}