From d3c5b9aef7cfb364177b2ae3cc0d5127961480d1 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Wed, 29 Jul 2026 18:08:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=A7=AF=E5=88=86=E8=BF=90?= =?UTF-8?q?=E8=90=A5=20-=20=E8=AE=A1=E8=B4=B9=E8=AE=A2=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=89=B9=E9=87=8F=E7=A1=AE=E8=AE=A4=E6=89=A3=E8=B4=B9?= =?UTF-8?q?=E4=B8=8E=E9=80=80=E5=9B=9E=E7=A7=AF=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/finance.go | 19 ++++ backend/internal/service/finance.go | 74 +++++++++++- .../components/credit-operations-panel.tsx | 106 +++++++++++++++--- web/src/services/api/wallet.ts | 4 + 4 files changed, 181 insertions(+), 22 deletions(-) diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index bc0aef265..672a3b23d 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -353,6 +353,25 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, items) }) + r.POST("/admin/billing-orders/batch-resolve", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.ResolveBillingBatchRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ResolveBillingOrders(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) r.POST("/admin/billing-orders/:id/resolve", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 39306ab46..5eee80ac6 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -87,6 +87,22 @@ type ResolveBillingRequest struct { Note string `json:"note"` } +type ResolveBillingBatchRequest struct { + IDs []string `json:"ids"` + Action string `json:"action"` + Note string `json:"note"` +} + +type ResolveBillingBatchFailure struct { + ID string `json:"id"` + Message string `json:"message"` +} + +type ResolveBillingBatchResult struct { + ResolvedCount int `json:"resolvedCount"` + Failed []ResolveBillingBatchFailure `json:"failed"` +} + func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) { if user == nil { return nil, Unauthorized("请先登录") @@ -286,6 +302,58 @@ func (s *Service) ResolveBillingOrder(actor *model.User, id string, req ResolveB if note == "" { return nil, BadAuthRequest("请填写核对依据") } + action := strings.TrimSpace(req.Action) + if action != "settle" && action != "refund" { + return nil, BadAuthRequest("请选择结算或退款") + } + return s.resolveBillingOrder(actor, strings.TrimSpace(id), action, note) +} + +func (s *Service) ResolveBillingOrders(actor *model.User, req ResolveBillingBatchRequest) (*ResolveBillingBatchResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + note := strings.TrimSpace(req.Note) + if note == "" { + return nil, BadAuthRequest("请填写核对依据") + } + action := strings.TrimSpace(req.Action) + if action != "settle" && action != "refund" { + return nil, BadAuthRequest("请选择结算或退款") + } + seen := make(map[string]struct{}, len(req.IDs)) + ids := make([]string, 0, len(req.IDs)) + for _, rawID := range req.IDs { + id := strings.TrimSpace(rawID) + if id == "" { + return nil, BadAuthRequest("计费订单 ID 无效") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, BadAuthRequest("请选择要处理的计费订单") + } + if len(ids) > 100 { + return nil, BadAuthRequest("单次最多处理 100 条计费订单") + } + + // 批量资金操作逐单提交并明确返回失败项,避免部分成功时给出整体成功的错误反馈。 + result := &ResolveBillingBatchResult{Failed: make([]ResolveBillingBatchFailure, 0)} + for _, id := range ids { + if _, err := s.resolveBillingOrder(actor, id, action, note); err != nil { + result.Failed = append(result.Failed, ResolveBillingBatchFailure{ID: id, Message: err.Error()}) + continue + } + result.ResolvedCount++ + } + return result, nil +} + +func (s *Service) resolveBillingOrder(actor *model.User, id string, action string, note string) (*model.BillingOrder, error) { order, err := s.repo.BillingOrder(id) if err != nil { return nil, err @@ -293,13 +361,11 @@ func (s *Service) ResolveBillingOrder(actor *model.User, id string, req ResolveB if order.Status != model.BillingStatusUncertain && order.Status != model.BillingStatusRunning && order.Status != model.BillingStatusReserved { return nil, BadAuthRequest("当前订单不需要人工核对") } - switch strings.TrimSpace(req.Action) { + switch action { case "settle": err = s.SettleBilling(id, order.ProviderRequestID) case "refund": err = s.RefundBilling(id, note) - default: - return nil, BadAuthRequest("请选择结算或退款") } if err != nil { return nil, err @@ -307,7 +373,7 @@ func (s *Service) ResolveBillingOrder(actor *model.User, id string, req ResolveB if err := s.repo.RecordBillingResolution(id, actor.ID, truncateRunes(note, 500)); err != nil { return nil, err } - if err := s.appendAdminAudit(actor, "billing.resolve", "user", order.UserID, "人工核对用户计费订单", map[string]any{"billingOrderId": id, "action": req.Action, "note": truncateRunes(note, 500)}); err != nil { + if err := s.appendAdminAudit(actor, "billing.resolve", "user", order.UserID, "人工核对用户计费订单", map[string]any{"billingOrderId": id, "action": action, "note": truncateRunes(note, 500)}); err != nil { return nil, err } return s.repo.BillingOrder(id) diff --git a/web/src/pages/admin/components/credit-operations-panel.tsx b/web/src/pages/admin/components/credit-operations-panel.tsx index fa67841db..261d899e5 100644 --- a/web/src/pages/admin/components/credit-operations-panel.tsx +++ b/web/src/pages/admin/components/credit-operations-panel.tsx @@ -1,17 +1,23 @@ import { useEffect, useState } from "react"; import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Table, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { CircleAlert, Coins, RefreshCw, Search, Settings2, UserRoundCog } from "lucide-react"; +import { BadgeCheck, CircleAlert, Coins, RefreshCw, Search, Settings2, Undo2, UserRoundCog } from "lucide-react"; import { ListToolbar, TableSurface } from "@/components/layout/workspace-page"; import { formatCredits } from "@/constant/credits"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { listAdminUsers, type AdminReferenceData, type LocalUser } from "@/services/api/auth"; -import { adjustAdminUserCredits, getAdminCreditPolicy, listAdminBillingOrders, resolveAdminBillingOrder, updateAdminCreditPolicy, type BillingOrder } from "@/services/api/wallet"; +import { adjustAdminUserCredits, getAdminCreditPolicy, listAdminBillingOrders, resolveAdminBillingOrder, resolveAdminBillingOrders, updateAdminCreditPolicy, type BillingOrder } from "@/services/api/wallet"; + +import { AdminBatchBar } from "./admin-ui"; type AdjustmentFormValues = { userId: string; amount: number; note: string }; type ResolutionFormValues = { note: string }; type PolicyFormValues = { signupBonus: number; checkinBonus: number; defaultMultiplier: number; modelMultipliers: string }; +type BillingResolutionAction = "settle" | "refund"; +type BillingResolutionTarget = + | { kind: "single"; order: BillingOrder; action: BillingResolutionAction } + | { kind: "batch"; orderIds: string[]; amountMicrocredits: number; action: BillingResolutionAction }; export default function CreditOperationsPanel({ users }: { users: AdminReferenceData["users"] }) { const { message } = App.useApp(); @@ -27,7 +33,8 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference const [total, setTotal] = useState(0); const [adjustmentUsers, setAdjustmentUsers] = useState>(users); const [searchingUsers, setSearchingUsers] = useState(false); - const [resolvingOrder, setResolvingOrder] = useState<{ order: BillingOrder; action: "settle" | "refund" } | null>(null); + const [selectedOrderIds, setSelectedOrderIds] = useState([]); + const [resolutionTarget, setResolutionTarget] = useState(null); const [adjustmentForm] = Form.useForm(); const [resolutionForm] = Form.useForm(); const [policyForm] = Form.useForm(); @@ -39,6 +46,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference const result = await listAdminBillingOrders({ keyword: debouncedKeyword || undefined, status: orderStatus, page: targetPage, limit: targetPageSize }); setOrders(result.orders); setTotal(result.total); + setSelectedOrderIds([]); } catch (error) { message.error(error instanceof Error ? error.message : "读取待核对计费失败"); } finally { @@ -127,15 +135,29 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference }; const resolveBilling = async () => { - if (!resolvingOrder) return; + if (!resolutionTarget) return; const values = await resolutionForm.validateFields(); + const note = values.note.trim(); setResolving(true); try { - await resolveAdminBillingOrder(resolvingOrder.order.id, { action: resolvingOrder.action, note: values.note.trim() }); - setResolvingOrder(null); + if (resolutionTarget.kind === "single") { + await resolveAdminBillingOrder(resolutionTarget.order.id, { action: resolutionTarget.action, note }); + } else { + const result = await resolveAdminBillingOrders({ ids: resolutionTarget.orderIds, action: resolutionTarget.action, note }); + if (result.failed.length > 0) { + const detail = result.failed[0]?.message ? `:${result.failed[0].message}` : ""; + if (result.resolvedCount > 0) message.warning(`已处理 ${result.resolvedCount} 条,${result.failed.length} 条失败${detail}`); + else message.error(`所选 ${result.failed.length} 条订单均处理失败${detail}`); + } else { + message.success(resolutionTarget.action === "settle" ? `已确认扣费 ${result.resolvedCount} 条` : `已退回积分 ${result.resolvedCount} 条`); + } + } + const resolvedAction = resolutionTarget.action; + const wasBatch = resolutionTarget.kind === "batch"; + setResolutionTarget(null); resolutionForm.resetFields(); await reload(page, pageSize); - message.success(resolvingOrder.action === "settle" ? "计费订单已结算" : "冻结积分已退款"); + if (!wasBatch) message.success(resolvedAction === "settle" ? "计费订单已结算" : "冻结积分已退款"); } catch (error) { message.error(error instanceof Error ? error.message : "处理计费订单失败"); } finally { @@ -143,6 +165,23 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference } }; + const openSingleResolution = (order: BillingOrder, action: BillingResolutionAction) => { + setResolutionTarget({ kind: "single", order, action }); + resolutionForm.resetFields(); + }; + + const openBatchResolution = (action: BillingResolutionAction) => { + const selectedOrders = orders.filter((order) => selectedOrderIds.includes(order.id) && canResolveBillingOrder(order)); + if (selectedOrders.length === 0) return; + setResolutionTarget({ + kind: "batch", + orderIds: selectedOrders.map((order) => order.id), + amountMicrocredits: selectedOrders.reduce((sum, order) => sum + order.amountMicrocredits, 0), + action, + }); + resolutionForm.resetFields(); + }; + const columns: ColumnsType = [ { title: "创建时间", dataIndex: "createdAt", width: 170, render: formatTime }, { title: "用户", dataIndex: "userId", width: 150, render: (id) => users.find((user) => user.id === id)?.displayName || id }, @@ -174,26 +213,20 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference width: 180, fixed: "right", render: (_, order) => - order.status === "settled" || order.status === "refunded" ? ( + !canResolveBillingOrder(order) ? ( 处理完成 ) : ( @@ -347,6 +380,14 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference ]} /> + setSelectedOrderIds([])}> + + + setSelectedOrderIds(keys.map(String)), + getCheckboxProps: (order) => ({ disabled: !canResolveBillingOrder(order), name: `${order.model} ${order.scene || order.capability}` }), + }} pagination={{ current: page, pageSize, @@ -372,9 +419,28 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference - setResolvingOrder(null)} onOk={() => void resolveBilling()} confirmLoading={resolving} okButtonProps={{ danger: resolvingOrder?.action === "refund" }}> + { + if (resolving) return; + setResolutionTarget(null); + resolutionForm.resetFields(); + }} + onOk={() => void resolveBilling()} + confirmLoading={resolving} + maskClosable={!resolving} + okButtonProps={{ danger: resolutionTarget?.action === "refund" }} + > + {resolutionTarget?.kind === "batch" ? ( +
+ 已选择 {resolutionTarget.orderIds.length} 条订单,涉及冻结积分 {formatCredits(resolutionTarget.amountMicrocredits)}。本次核对依据将写入每条订单的审计记录。 +
+ ) : null}
- + @@ -383,6 +449,10 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference ); } +function canResolveBillingOrder(order: BillingOrder) { + return order.status === "uncertain" || order.status === "running" || order.status === "reserved"; +} + function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 38bee0a6d..ffe0ed384 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -273,3 +273,7 @@ export function listAdminBillingOrders(params: AdminFinanceListParams = {}) { export function resolveAdminBillingOrder(id: string, input: { action: "settle" | "refund"; note: string }) { return request<{ order: BillingOrder }>(api.post(`/admin/billing-orders/${encodeURIComponent(id)}/resolve`, input)); } + +export function resolveAdminBillingOrders(input: { ids: string[]; action: "settle" | "refund"; note: string }) { + return request<{ resolvedCount: number; failed: Array<{ id: string; message: string }> }>(api.post("/admin/billing-orders/batch-resolve", input)); +}