feat(admin): 积分运营 - 计费订单支持批量确认扣费与退回积分

This commit is contained in:
ddCat
2026-07-29 18:08:37 +08:00
parent 7bcd8d0229
commit d3c5b9aef7
4 changed files with 181 additions and 22 deletions

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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<Array<AdminReferenceData["users"][number] | LocalUser>>(users);
const [searchingUsers, setSearchingUsers] = useState(false);
const [resolvingOrder, setResolvingOrder] = useState<{ order: BillingOrder; action: "settle" | "refund" } | null>(null);
const [selectedOrderIds, setSelectedOrderIds] = useState<string[]>([]);
const [resolutionTarget, setResolutionTarget] = useState<BillingResolutionTarget | null>(null);
const [adjustmentForm] = Form.useForm<AdjustmentFormValues>();
const [resolutionForm] = Form.useForm<ResolutionFormValues>();
const [policyForm] = Form.useForm<PolicyFormValues>();
@@ -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<BillingOrder> = [
{ 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) ? (
<span className="text-xs text-foreground/40">处理完成</span>
) : (
<Space size={6}>
<Button
size="small"
onClick={() => {
setResolvingOrder({ order, action: "settle" });
resolutionForm.resetFields();
}}
onClick={() => openSingleResolution(order, "settle")}
>
确认扣费
</Button>
<Button
size="small"
danger
onClick={() => {
setResolvingOrder({ order, action: "refund" });
resolutionForm.resetFields();
}}
onClick={() => openSingleResolution(order, "refund")}
>
退回积分
</Button>
@@ -347,6 +380,14 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
]}
/>
</ListToolbar>
<AdminBatchBar count={selectedOrderIds.length} onClear={() => setSelectedOrderIds([])}>
<Button size="small" type="primary" icon={<BadgeCheck className="size-3.5" />} onClick={() => openBatchResolution("settle")}>
批量确认扣费
</Button>
<Button size="small" danger icon={<Undo2 className="size-3.5" />} onClick={() => openBatchResolution("refund")}>
批量退回积分
</Button>
</AdminBatchBar>
<TableSurface>
<Table
className="app-data-table"
@@ -355,6 +396,12 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
loading={loading}
columns={columns}
dataSource={orders}
rowSelection={{
selectedRowKeys: selectedOrderIds,
preserveSelectedRowKeys: false,
onChange: (keys) => 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
</TableSurface>
</section>
<Modal title={resolvingOrder?.action === "settle" ? "确认扣除冻结积分" : "确认退回冻结积分"} open={Boolean(resolvingOrder)} onCancel={() => setResolvingOrder(null)} onOk={() => void resolveBilling()} confirmLoading={resolving} okButtonProps={{ danger: resolvingOrder?.action === "refund" }}>
<Modal
title={resolutionTarget?.action === "settle" ? (resolutionTarget.kind === "batch" ? "批量确认扣除冻结积分" : "确认扣除冻结积分") : (resolutionTarget?.kind === "batch" ? "批量确认退回冻结积分" : "确认退回冻结积分")}
open={Boolean(resolutionTarget)}
okText={resolutionTarget?.action === "settle" ? "确认扣费" : "退回积分"}
cancelText="取消"
onCancel={() => {
if (resolving) return;
setResolutionTarget(null);
resolutionForm.resetFields();
}}
onOk={() => void resolveBilling()}
confirmLoading={resolving}
maskClosable={!resolving}
okButtonProps={{ danger: resolutionTarget?.action === "refund" }}
>
{resolutionTarget?.kind === "batch" ? (
<div className="mb-4 rounded-md border border-border bg-muted/25 px-3 py-2.5 text-sm text-foreground/65">
已选择 <span className="font-semibold text-foreground">{resolutionTarget.orderIds.length}</span> 条订单,涉及冻结积分 <span className="font-semibold tabular-nums text-foreground">{formatCredits(resolutionTarget.amountMicrocredits)}</span>。本次核对依据将写入每条订单的审计记录。
</div>
) : null}
<Form form={resolutionForm} layout="vertical" requiredMark={false}>
<Form.Item name="note" label="核对依据" rules={[{ required: true, message: "请填写供应商账单、任务状态或处理依据" }]}>
<Form.Item name="note" label="核对依据" rules={[{ required: true, whitespace: true, message: "请填写供应商账单、任务状态或处理依据" }]}>
<Input.TextArea rows={4} maxLength={500} placeholder="例如:供应商后台确认该请求未产生费用" />
</Form.Item>
</Form>
@@ -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 }) : "--";
}

View File

@@ -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));
}