feat(admin-payment): 支付订单 - 补充用户身份与详情

This commit is contained in:
MasterChiefCN
2026-09-11 16:13:44 +08:00
committed by ddCat
parent 15e5b4a8bf
commit abc7481ac1
9 changed files with 201 additions and 39 deletions

View File

@@ -101,10 +101,22 @@ type PaymentOrderView struct {
}
type AdminPaymentOrderPage struct {
Orders []PaymentOrderView `json:"orders"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
Orders []AdminPaymentOrderView `json:"orders"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type AdminPaymentOrderUser struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
}
type AdminPaymentOrderView struct {
PaymentOrderView
User *AdminPaymentOrderUser `json:"user"`
}
func (s *Service) PaymentNotificationResponse(providerID string, success bool) (int, string, string) {
@@ -876,9 +888,21 @@ func (s *Service) AdminPaymentOrderPage(actor *model.User, status, keyword strin
if err != nil {
return nil, err
}
views := make([]PaymentOrderView, 0, len(orders))
userIDs := make([]string, 0, len(orders))
for _, order := range orders {
views = append(views, paymentOrderView(order))
userIDs = append(userIDs, order.UserID)
}
users, err := s.repo.UsersByIDs(userIDs)
if err != nil {
return nil, err
}
views := make([]AdminPaymentOrderView, 0, len(orders))
for _, order := range orders {
view := AdminPaymentOrderView{PaymentOrderView: paymentOrderView(order)}
if user, ok := users[order.UserID]; ok {
view.User = &AdminPaymentOrderUser{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName, Email: user.Email}
}
views = append(views, view)
}
return &AdminPaymentOrderPage{Orders: views, Total: total, Page: page, Limit: limit}, nil
}

View File

@@ -0,0 +1,39 @@
package app
import (
"encoding/json"
"strings"
"testing"
)
func TestPaymentOrderIdentityOnlyInAdminList(t *testing.T) {
base := PaymentOrderView{ID: "order", UserID: "user", MerchantOrderNo: "merchant"}
ordinary, err := json.Marshal(base)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(ordinary), `"user":`) || strings.Contains(string(ordinary), `"email":`) {
t.Fatalf("ordinary response includes identity: %s", ordinary)
}
admin, err := json.Marshal(AdminPaymentOrderView{PaymentOrderView: base, User: &AdminPaymentOrderUser{ID: "user", Username: "alice", DisplayName: "小林", Email: "alice@example.com"}})
if err != nil {
t.Fatal(err)
}
var result struct {
ID string `json:"id"`
User *AdminPaymentOrderUser `json:"user"`
}
if err := json.Unmarshal(admin, &result); err != nil {
t.Fatal(err)
}
if result.ID != "order" || result.User == nil || result.User.Email != "alice@example.com" {
t.Fatalf("invalid admin response: %s", admin)
}
missing, err := json.Marshal(AdminPaymentOrderView{PaymentOrderView: base})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(missing), `"user":null`) || !strings.Contains(string(missing), `"userId":"user"`) {
t.Fatalf("missing user loses original identity: %s", missing)
}
}

View File

@@ -73,7 +73,7 @@ func (r *Repository) UsersByIDs(ids []string) (map[string]model.User, error) {
return map[string]model.User{}, nil
}
var users []model.User
if err := r.db.Select("id", "username", "display_name").Where("id IN ?", ids).Find(&users).Error; err != nil {
if err := r.db.Select("id", "username", "display_name", "email").Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
result := make(map[string]model.User, len(users))

View File

@@ -391,7 +391,8 @@ func (r *Repository) AdminPaymentOrders(status, keyword string, limit, offset in
}
if normalized := strings.TrimSpace(keyword); normalized != "" {
like := "%" + normalized + "%"
query = query.Where("merchant_order_no LIKE ? OR provider_trade_no LIKE ? OR user_id LIKE ?", like, like, like)
users := r.db.Model(&model.User{}).Select("id").Where("LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(email) LIKE ?", strings.ToLower(like), strings.ToLower(like), strings.ToLower(like))
query = query.Where("merchant_order_no LIKE ? OR provider_trade_no LIKE ? OR user_id = ? OR user_id IN (?)", like, like, normalized, users)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err

View File

@@ -363,6 +363,50 @@ func TestUnresolvedPaymentOrderCandidateCountOverlapping(t *testing.T) {
}
}
func TestAdminPaymentOrdersSearchUserIdentity(t *testing.T) {
db := openPaymentTestDB(t)
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
users := []model.User{
{ID: "user-alice", Username: "alice", DisplayName: "小林", Email: "alice@example.com"},
{ID: "user-bob", Username: "bob", DisplayName: "小林", Email: "bob@example.com"},
}
if err := db.Create(&users).Error; err != nil {
t.Fatal(err)
}
orders := []model.PaymentOrder{
{ID: "a", UserID: "user-alice", MerchantOrderNo: "merchant-a", IdempotencyKey: "a", Status: model.PaymentOrderPending},
{ID: "b", UserID: "user-bob", MerchantOrderNo: "merchant-b", IdempotencyKey: "b", Status: model.PaymentOrderClosed},
{ID: "c", UserID: "missing-user", MerchantOrderNo: "merchant-c", IdempotencyKey: "c", Status: model.PaymentOrderClosed},
}
tradeNo := "trade-c"
orders[2].ProviderTradeNo = &tradeNo
if err := db.Create(&orders).Error; err != nil {
t.Fatal(err)
}
repo := New(db)
for _, tc := range []struct {
keyword, status string
total int64
}{
{"ALICE@EXAMPLE.COM", "", 1}, {"alice", "", 1}, {"小林", "", 2},
{"小林", "pending", 1}, {"alice", "closed", 0}, {"user-bob", "", 1},
{"merchant-c", "", 1}, {"trade-c", "", 1}, {"missing-user", "", 1}, {"", "", 3},
} {
t.Run(tc.keyword+"/"+tc.status, func(t *testing.T) {
items, total, err := repo.AdminPaymentOrders(tc.status, tc.keyword, 1, 0)
if err != nil || total != tc.total || len(items) > 1 {
t.Fatalf("got items=%d total=%d err=%v; want total=%d", len(items), total, err, tc.total)
}
})
}
identities, err := repo.UsersByIDs([]string{"user-alice", "missing-user"})
if err != nil || len(identities) != 1 || identities["user-alice"].Email != "alice@example.com" {
t.Fatalf("identities=%v err=%v", identities, err)
}
}
func openPaymentTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared&_busy_timeout=5000"), &gorm.Config{})

View File

@@ -9,6 +9,14 @@ description: 信封、错误码与 OpenAPI
机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。
## 管理员支付订单
`GET /api/admin/payments/orders` 需要管理员权限,支持 `status`、`keyword`、`page`、`pageSize`。关键词匹配订单号、渠道交易号、用户名称、用户名或邮箱,也支持完整用户 ID 精确匹配。用户名和邮箱搜索忽略大小写;状态过滤同时生效,返回匹配总数和当前页订单。
每条管理员列表订单在原有字段之外包含 `user: { id, username, displayName, email }`,由当前页用户 ID 批量读取;用户不存在时返回 `user: null`,保留原 `userId` 与历史订单。此身份摘要仅用于管理员订单列表,不添加到普通用户支付接口或支付状态同步响应。
后台支付订单列表展示用户身份,点击名称打开用户详情;订单详情提供完整订单号、渠道交易号、用户 ID 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。
## 信封
成功:

View File

@@ -1636,3 +1636,11 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme
- [ ] 套餐未开通等上游 403 场景,上传失败提示包含方舟原始错误码(如 SubscriptionRequired),不再是「模型服务鉴权失败」。
- [ ] 素材库编辑图片素材:可填写 asset- 开头的方舟素材 ID、勾选人像认证,保存后刷新页面两字段仍在;卡片封面出现「人像认证」徽章,详情抽屉显示方舟素材 ID 标签;非法 ID(不以 asset- 开头)被表单拦截。
- [ ] 画布图片节点绑定已录入方舟素材 ID 的素材后连接视频节点,用方舟渠道生成:任务参考图为 asset:// 引用、跳过「同步方舟可信素材」;非方舟渠道生成不受影响,仍走原参考图路径。
## 支付订单用户识别(待验收)
已通过支付页与 API 的 ESLint、15 项后台 UI 回归检查,以及后端身份响应隔离测试和相关包编译。全量前端类型检查仍在未修改的动画、Tooltip 和画布模块报错;数据库搜索专项测试因本机 `CGO_ENABLED=0` 无法执行。尚未进行浏览器视觉验收。
- 核对名称、用户名和邮箱与用户管理一致;同名用户可通过用户名和邮箱区分。
- 验收用户身份、订单号、渠道交易号和完整用户 ID 查询,与状态过滤、分页组合使用。
- 验收用户详情、订单详情、订单号复制和用户不存在的历史订单;关闭侧栏保留列表状态。
- 检查同步状态、关单确认、明暗主题、长文本和窄屏固定操作列。

View File

@@ -1,6 +1,7 @@
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
import { Callout } from "@/components/ui/product/callout";
import { App, Button, DatePicker, Drawer, Form, Input, InputNumber, Select, Tabs } from "antd";
import { App, Button, DatePicker, Descriptions, Drawer, Form, Input, InputNumber, Select, Tabs, Typography } from "antd";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { Switch } from "@/components/ui/base/switch";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
@@ -22,6 +23,7 @@ import {
updateAdminPaymentProvider,
updateAdminTopupProduct,
type AdminPaymentProvider,
type AdminPaymentOrder,
type PaymentOrder,
type PaymentReconciliationItem,
type PaymentReconciliationRun,
@@ -29,7 +31,8 @@ import {
} from "@/services/api/payments";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui";
import { AdminUserDetailDrawer } from "../components/admin-user-detail-drawer";
import "./payments-page.css";
type ProviderFormValues = {
@@ -81,7 +84,9 @@ export default function AdminPaymentsPage() {
const [productSaving, setProductSaving] = useState(false);
const [productForm] = Form.useForm<ProductFormValues>();
const [orders, setOrders] = useState<PaymentOrder[]>([]);
const [orders, setOrders] = useState<AdminPaymentOrder[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedOrder, setSelectedOrder] = useState<AdminPaymentOrder | null>(null);
const [orderTotal, setOrderTotal] = useState(0);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(30);
@@ -378,16 +383,35 @@ export default function AdminPaymentsPage() {
},
];
const orderColumns: ColumnsType<PaymentOrder> = [
const orderColumns: ColumnsType<AdminPaymentOrder> = [
{
title: "订单",
title: "用户",
key: "user",
width: 230,
render: (_, order) => (
<div className="min-w-0">
{order.user ? <>
<div className="flex min-w-0 items-baseline gap-2">
<button type="button" className="admin-table-primary-link truncate font-medium" title={order.user.displayName || order.user.username} onClick={() => setSelectedUserId(order.user!.id)}>{order.user.displayName || order.user.username}</button>
<span className="truncate text-xs text-foreground/45" title={`@${order.user.username}`}>@{order.user.username}</span>
</div>
<div className="mt-1 truncate text-xs text-foreground/60" title={order.user.email}>{order.user.email || "未填写邮箱"}</div>
</> : <>
<div className="text-foreground/60">用户不存在</div>
<Typography.Text className="text-xs" copyable={order.userId ? { text: order.userId } : false}>{order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"}</Typography.Text>
</>}
</div>
),
},
{
title: "订单 / 商品",
key: "order",
width: 280,
width: 210,
render: (_, order) => (
<div>
<div className="font-mono text-xs">{order.merchantOrderNo}</div>
<div className="mt-1 truncate text-xs text-foreground/45">
用户 {order.userId || "--"} · {order.productName}
<Typography.Text className="font-mono text-xs" title={order.merchantOrderNo} copyable={{ text: order.merchantOrderNo }}>{order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo}</Typography.Text>
<div className="mt-1 truncate text-xs text-foreground/45" title={order.productName}>
{order.productName}
</div>
</div>
),
@@ -395,7 +419,7 @@ export default function AdminPaymentsPage() {
{
title: "渠道",
dataIndex: "providerId",
width: 160,
width: 150,
render: (value) => (
<span className="inline-flex items-center gap-2">
<PaymentBrandIcon providerId={value} compact />
@@ -406,7 +430,7 @@ export default function AdminPaymentsPage() {
{
title: "金额 / 积分",
key: "amount",
width: 150,
width: 130,
align: "right",
render: (_, order) => (
<div>
@@ -416,29 +440,22 @@ export default function AdminPaymentsPage() {
),
},
{ title: "状态", dataIndex: "status", width: 110, align: "center", render: (value) => <AdminStatusBadge {...(paymentOrderStatus[value] || { label: value, tone: "neutral" as const })} /> },
{ title: "创建时间", dataIndex: "createdAt", width: 170, render: (value) => formatDateTime(value) },
{ title: "创建时间", dataIndex: "createdAt", width: 130, render: (value) => <span title={formatDateTime(value)}>{dayjs(value).format("MM-DD HH:mm")}</span> },
{
title: "操作",
key: "actions",
width: 150,
fixed: "right",
align: "center",
render: (_, order) => (
<div className="flex justify-center gap-1">
<Button type="text" size="small" loading={orderActionId === order.id} disabled={order.status === "credited" || order.status === "closed"} onClick={() => void queryOrder(order)}>
查单
</Button>
<Button
danger
type="text"
size="small"
icon={<XCircle className="size-3.5" />}
loading={orderActionId === order.id}
disabled={!["created", "pending", "create_failed", "closing"].includes(order.status)}
onClick={() => closeOrder(order)}
>
关单
</Button>
</div>
<AdminRowActions
primary={{ label: "详情", icon: <Eye className="size-3.5" />, onClick: () => setSelectedOrder(order) }}
visibleActionCount={0}
actions={[
{ key: "sync", label: "同步支付状态", icon: <RefreshCw className="size-3.5" />, disabled: Boolean(orderActionId) || ["credited", "closed"].includes(order.status), onClick: () => queryOrder(order) },
{ key: "close", label: "关闭订单", icon: <XCircle className="size-3.5" />, danger: true, disabled: Boolean(orderActionId) || !["created", "pending", "create_failed", "closing"].includes(order.status), onClick: () => closeOrder(order) },
]}
/>
),
},
];
@@ -538,7 +555,8 @@ export default function AdminPaymentsPage() {
allowClear
prefix={<Search className="size-4 text-foreground/40" />}
value={orderKeyword}
placeholder="订单号、渠道交易号或用户 ID"
placeholder="搜索名称、用户名、邮箱、订单号"
title="支持名称、用户名、邮箱、订单号、渠道交易号和完整用户 ID"
onChange={(event) => setOrderKeyword(event.target.value)}
onPressEnter={() => void loadOrders(1)}
/>
@@ -552,7 +570,7 @@ export default function AdminPaymentsPage() {
/>
}
trailing={<Button onClick={() => void loadOrders(1)}>查询</Button>}
table={{ rowKey: "id", loading: ordersLoading, columns: orderColumns, dataSource: orders, pagination: false, scroll: { x: 1180 } }}
table={{ rowKey: "id", loading: ordersLoading, columns: orderColumns, dataSource: orders, pagination: false, tableLayout: "fixed", scroll: { x: 1110 } }}
empty={<AdminTableEmpty filtered={Boolean(orderKeyword || orderStatusFilter !== "all")} title="没有支付订单" />}
footer={<PaginationBar alwaysShow current={orderPage} pageSize={orderPageSize} total={orderTotal} onChange={(page, size) => void loadOrders(size !== orderPageSize ? 1 : page, size)} />}
/>
@@ -600,6 +618,22 @@ export default function AdminPaymentsPage() {
]}
/>
<AppDrawer title="支付订单详情" size="min(680px, 100vw)" open={Boolean(selectedOrder)} onClose={() => setSelectedOrder(null)}>
{selectedOrder && <Descriptions column={1} bordered size="small" items={[
{ key: "user", label: "用户", children: selectedOrder.user ? <button type="button" className="admin-table-primary-link" onClick={() => { setSelectedUserId(selectedOrder.user!.id); setSelectedOrder(null); }}>{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username}</button> : "用户不存在" },
{ key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" },
{ key: "userId", label: "用户 ID", children: <Typography.Text copyable className="break-all">{selectedOrder.userId || "--"}</Typography.Text> },
{ key: "order", label: "订单号", children: <Typography.Text copyable className="break-all">{selectedOrder.merchantOrderNo}</Typography.Text> },
{ key: "trade", label: "渠道交易号", children: selectedOrder.providerTradeNo ? <Typography.Text copyable className="break-all">{selectedOrder.providerTradeNo}</Typography.Text> : "--" },
{ key: "product", label: "商品", children: selectedOrder.productName },
{ key: "channel", label: "支付渠道", children: providerNames[selectedOrder.providerId] || selectedOrder.providerId },
{ key: "amount", label: "金额 / 积分", children: `¥ ${(selectedOrder.amountFen / 100).toFixed(2)} / ${formatCredits(selectedOrder.creditsMicrocredits)} 积分` },
{ key: "status", label: "状态", children: paymentOrderStatus[selectedOrder.status]?.label || selectedOrder.status },
...([{ key: "createdAt", label: "创建时间" }, { key: "expiresAt", label: "过期时间" }, { key: "providerPaidAt", label: "支付时间" }, { key: "creditedAt", label: "入账时间" }, { key: "closedAt", label: "关闭时间" }] as const).map(({ key, label }) => ({ key, label, children: selectedOrder[key] ? formatDateTime(selectedOrder[key]!) : "--" })),
]} />}
</AppDrawer>
<AdminUserDetailDrawer userId={selectedUserId} onClose={() => setSelectedUserId(null)} />
<Drawer
title={providerDrawer ? `配置 ${providerDrawer.name}` : "配置支付渠道"}
width={620}

View File

@@ -125,8 +125,12 @@ export function updateAdminTopupProduct(id: string, input: TopupProductInput) {
return http.put<{ product: TopupProduct }>(`/admin/payments/products/${encodeURIComponent(id)}`, input);
}
export type AdminPaymentOrder = PaymentOrder & {
user: { id: string; username: string; displayName: string; email: string } | null;
};
export function listAdminPaymentOrders(params: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) {
return http.get<{ orders: PaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params });
return http.get<{ orders: AdminPaymentOrder[]; total: number; page: number; pageSize: number }>("/admin/payments/orders", { params });
}
export function queryAdminPaymentOrder(id: string) {