feat(admin): 积分运营 - 计费订单支持批量确认扣费与退回积分
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) : "--";
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user