diff --git a/backend/internal/app/payment.go b/backend/internal/app/payment.go index fd193963a..d7260b7d4 100644 --- a/backend/internal/app/payment.go +++ b/backend/internal/app/payment.go @@ -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 } diff --git a/backend/internal/app/payment_order_identity_test.go b/backend/internal/app/payment_order_identity_test.go new file mode 100644 index 000000000..287072852 --- /dev/null +++ b/backend/internal/app/payment_order_identity_test.go @@ -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) + } +} diff --git a/backend/internal/repository/admin_storage.go b/backend/internal/repository/admin_storage.go index d2f9d094d..1483d2fdf 100644 --- a/backend/internal/repository/admin_storage.go +++ b/backend/internal/repository/admin_storage.go @@ -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)) diff --git a/backend/internal/repository/payment.go b/backend/internal/repository/payment.go index 7fc156dbc..e9028afbd 100644 --- a/backend/internal/repository/payment.go +++ b/backend/internal/repository/payment.go @@ -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 diff --git a/backend/internal/repository/payment_test.go b/backend/internal/repository/payment_test.go index 158461580..27ce39719 100644 --- a/backend/internal/repository/payment_test.go +++ b/backend/internal/repository/payment_test.go @@ -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{}) diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index b2fc5f99f..41cd39d9e 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -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 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。 + ## 信封 成功: diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 8dd1e3653..2c28ab18a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 查询,与状态过滤、分页组合使用。 +- 验收用户详情、订单详情、订单号复制和用户不存在的历史订单;关闭侧栏保留列表状态。 +- 检查同步状态、关单确认、明暗主题、长文本和窄屏固定操作列。 diff --git a/web/src/pages/admin/payments/payments-page.tsx b/web/src/pages/admin/payments/payments-page.tsx index 266ec9c1f..463c5c033 100644 --- a/web/src/pages/admin/payments/payments-page.tsx +++ b/web/src/pages/admin/payments/payments-page.tsx @@ -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(); - const [orders, setOrders] = useState([]); + const [orders, setOrders] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedOrder, setSelectedOrder] = useState(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 = [ + const orderColumns: ColumnsType = [ { - title: "订单", + title: "用户", + key: "user", + width: 230, + render: (_, order) => ( +
+ {order.user ? <> +
+ + @{order.user.username} +
+
{order.user.email || "未填写邮箱"}
+ : <> +
用户不存在
+ {order.userId ? `${order.userId.slice(0, 8)}…${order.userId.slice(-6)}` : "--"} + } +
+ ), + }, + { + title: "订单 / 商品", key: "order", - width: 280, + width: 210, render: (_, order) => (
-
{order.merchantOrderNo}
-
- 用户 {order.userId || "--"} · {order.productName} + {order.merchantOrderNo.length > 20 ? `${order.merchantOrderNo.slice(0, 10)}…${order.merchantOrderNo.slice(-6)}` : order.merchantOrderNo} +
+ {order.productName}
), @@ -395,7 +419,7 @@ export default function AdminPaymentsPage() { { title: "渠道", dataIndex: "providerId", - width: 160, + width: 150, render: (value) => ( @@ -406,7 +430,7 @@ export default function AdminPaymentsPage() { { title: "金额 / 积分", key: "amount", - width: 150, + width: 130, align: "right", render: (_, order) => (
@@ -416,29 +440,22 @@ export default function AdminPaymentsPage() { ), }, { title: "状态", dataIndex: "status", width: 110, align: "center", render: (value) => }, - { title: "创建时间", dataIndex: "createdAt", width: 170, render: (value) => formatDateTime(value) }, + { title: "创建时间", dataIndex: "createdAt", width: 130, render: (value) => {dayjs(value).format("MM-DD HH:mm")} }, { title: "操作", key: "actions", width: 150, + fixed: "right", align: "center", render: (_, order) => ( -
- - -
+ , onClick: () => setSelectedOrder(order) }} + visibleActionCount={0} + actions={[ + { key: "sync", label: "同步支付状态", icon: , disabled: Boolean(orderActionId) || ["credited", "closed"].includes(order.status), onClick: () => queryOrder(order) }, + { key: "close", label: "关闭订单", icon: , 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={} 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={} - 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={} footer={ void loadOrders(size !== orderPageSize ? 1 : page, size)} />} /> @@ -600,6 +618,22 @@ export default function AdminPaymentsPage() { ]} /> + setSelectedOrder(null)}> + {selectedOrder && { setSelectedUserId(selectedOrder.user!.id); setSelectedOrder(null); }}>{selectedOrder.user.displayName || selectedOrder.user.username} · @{selectedOrder.user.username} : "用户不存在" }, + { key: "email", label: "邮箱", children: selectedOrder.user?.email || "未填写邮箱" }, + { key: "userId", label: "用户 ID", children: {selectedOrder.userId || "--"} }, + { key: "order", label: "订单号", children: {selectedOrder.merchantOrderNo} }, + { key: "trade", label: "渠道交易号", children: selectedOrder.providerTradeNo ? {selectedOrder.providerTradeNo} : "--" }, + { 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]!) : "--" })), + ]} />} + + setSelectedUserId(null)} /> + (`/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) {