接入微信 Native 充值与订单过期处理

调整网页端充值渠道,微信内浏览器在开放平台接入前走 Native 支付。

拆分 Native 支付二维码弹窗,展示金额和充值内容并取消无效 code_url 跳转。

修复微信 Native 下单 time_expire 秒级 RFC3339 格式,避免小数秒参数错误。

新增充值订单 5 分钟过期调度,过期前查询微信真实订单状态。

补充微信授权绑定、路由加载恢复和后台表时间显示等配套逻辑。

更新 SpacetimeDB schema、绑定和相关文档测试。
This commit is contained in:
kdletters
2026-07-08 21:32:51 +08:00
parent 2721fec51c
commit 542fb09365
385 changed files with 6811 additions and 3169 deletions
@@ -1,14 +1,14 @@
/* @vitest-environment jsdom */
import {render, screen, waitFor} from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage';
import { AdminDatabaseTablesPage } from './AdminDatabaseTablesPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -36,12 +36,7 @@ beforeEach(() => {
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: [
'u-b',
'u-a',
'INV-1001',
'2026-05-02T00:00:00Z',
],
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
@@ -69,32 +64,37 @@ beforeEach(() => {
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
const user = userEvent.setup();
const {container} = render(
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await screen.findByText('2026-05-02 08:00:00');
const tableWrap = container.querySelector('.admin-table-wrap');
expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull();
expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation'}).getAttribute('title')).toBe(
'原始表名:profile_referral_relation。邀请关系记录表。',
);
expect(screen.getByText('已选表:邀请关系(profile_referral_relation')).toBeTruthy();
expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe(
'原始表名:profile_referral_relation。邀请关系记录表。',
);
expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。',
);
expect(
screen
.getByRole('option', { name: '邀请关系(profile_referral_relation' })
.getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByText('已选表:邀请关系(profile_referral_relation'),
).toBeTruthy();
expect(
screen.getByRole('heading', { name: '邀请关系' }).getAttribute('title'),
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
});
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
@@ -7,7 +7,7 @@ import {
Search,
X,
} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import {
getAdminDatabaseTableRows,
@@ -17,7 +17,7 @@ import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminDatabaseTablesPageProps {
token: string;
@@ -35,8 +35,11 @@ export function AdminDatabaseTablesPage({
const [search, setSearch] = useState('');
const [filters, setFilters] = useState('');
const [limit, setLimit] = useState('100');
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(null);
const [detailRow, setDetailRow] = useState<AdminDatabaseTableRowPayload | null>(null);
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(
null,
);
const [detailRow, setDetailRow] =
useState<AdminDatabaseTableRowPayload | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [copyMessage, setCopyMessage] = useState('');
const [sortColumn, setSortColumn] = useState('');
@@ -90,7 +93,9 @@ export function AdminDatabaseTablesPage({
const tableOptions = useMemo(() => {
const optionNames =
tableName && !tables.includes(tableName) ? [tableName, ...tables] : tables;
tableName && !tables.includes(tableName)
? [tableName, ...tables]
: tables;
return optionNames.map(getDatabaseTableHeader);
}, [tableName, tables]);
@@ -119,7 +124,7 @@ export function AdminDatabaseTablesPage({
}
return [...rows]
.map((row, index) => ({index, row}))
.map((row, index) => ({ index, row }))
.sort((left, right) => {
const comparison = compareTableCellValues(
left.row.cells[sortColumn],
@@ -131,7 +136,7 @@ export function AdminDatabaseTablesPage({
}
return left.index - right.index;
})
.map(({row}) => row);
.map(({ row }) => row);
}, [result, sortColumn, sortDirection, visibleColumns]);
async function loadTables() {
@@ -168,11 +173,15 @@ export function AdminDatabaseTablesPage({
setIsLoadingRows(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTableRows(token, normalizedTableName, {
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
});
const response = await getAdminDatabaseTableRows(
token,
normalizedTableName,
{
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
},
);
setResult(response);
setCopyMessage('');
} catch (error: unknown) {
@@ -199,7 +208,7 @@ export function AdminDatabaseTablesPage({
setSearch('');
setFilters('');
setLimit('100');
void refreshRows(tableName, {search: '', filters: '', limit: '100'});
void refreshRows(tableName, { search: '', filters: '', limit: '100' });
}
function handleSortColumn(column: string) {
@@ -219,7 +228,11 @@ export function AdminDatabaseTablesPage({
return;
}
const copiedText = JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2);
const copiedText = JSON.stringify(
detailRow.raw ?? detailRow.cells,
null,
2,
);
try {
await navigator.clipboard.writeText(copiedText);
setCopyMessage('已复制 JSON');
@@ -265,7 +278,7 @@ export function AdminDatabaseTablesPage({
value={tableName}
onChange={(event) => handleTableChange(event.target.value)}
>
{tableOptions.map(({name, optionLabel, description}) => (
{tableOptions.map(({ name, optionLabel, description }) => (
<option key={name} title={description} value={name}>
{optionLabel}
</option>
@@ -296,7 +309,11 @@ export function AdminDatabaseTablesPage({
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button className="admin-secondary-button" disabled={isLoadingRows} type="submit">
<button
className="admin-secondary-button"
disabled={isLoadingRows}
type="submit"
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
@@ -327,14 +344,16 @@ export function AdminDatabaseTablesPage({
<section className="admin-panel">
<div className="admin-panel-heading">
<h3 title={resultTableHeader.description}>{resultTableHeader.label}</h3>
<h3 title={resultTableHeader.description}>
{resultTableHeader.label}
</h3>
<span>{result?.totalReturned ?? 0} </span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide admin-database-table">
<thead>
<tr>
{columnHeaders.map(({column, label, description}) => {
{columnHeaders.map(({ column, label, description }) => {
const isSorted = sortColumn === column;
return (
<th
@@ -380,7 +399,10 @@ export function AdminDatabaseTablesPage({
onClick={() => setDetailRow(row)}
>
{visibleColumns.map((column) => {
const cellValue = formatCellValue(row.cells[column]);
const cellValue = formatCellValue(
row.cells[column],
column,
);
return (
<td key={column}>
<span
@@ -409,7 +431,9 @@ export function AdminDatabaseTablesPage({
))
) : (
<tr>
<td colSpan={Math.max(visibleColumns.length + 1, 1)}></td>
<td colSpan={Math.max(visibleColumns.length + 1, 1)}>
</td>
</tr>
)}
</tbody>
@@ -419,7 +443,11 @@ export function AdminDatabaseTablesPage({
{detailRow ? (
<div className="admin-confirm-backdrop" role="presentation">
<section className="admin-detail-panel" role="dialog" aria-modal="true">
<section
className="admin-detail-panel"
role="dialog"
aria-modal="true"
>
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-detail-actions">
@@ -440,7 +468,9 @@ export function AdminDatabaseTablesPage({
</button>
</div>
</div>
{copyMessage ? <div className="admin-status admin-status-ok">{copyMessage}</div> : null}
{copyMessage ? (
<div className="admin-status admin-status-ok">{copyMessage}</div>
) : null}
<pre className="admin-code-block">
{JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
</pre>
@@ -457,7 +487,9 @@ function readHashTableName() {
if (queryIndex < 0) {
return '';
}
return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? '';
return (
new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? ''
);
}
function parseLimit(value: string) {
@@ -501,7 +533,10 @@ function getDatabaseTableLabel(tableName: string) {
}
function getDatabaseTableDescription(tableName: string, label: string) {
return databaseTableDescriptionMap[tableName] ?? `当前 SpacetimeDB 中的 ${label}`;
return (
databaseTableDescriptionMap[tableName] ??
`当前 SpacetimeDB 中的 ${label}`
);
}
function getDatabaseTableColumnHeader(tableName: string, column: string) {
@@ -512,7 +547,7 @@ function getDatabaseTableColumnHeader(tableName: string, column: string) {
normalizedColumn,
label,
);
return {column: normalizedColumn, label, description};
return { column: normalizedColumn, label, description };
}
function getDatabaseTableColumnLabel(column: string) {
@@ -541,8 +576,7 @@ function getDatabaseTableColumnDescription(
) {
const exactDescription = databaseTableColumnDescriptionMap[column];
const description =
exactDescription ??
`当前表 ${tableName || '未知'} 中的 ${label} 字段`;
exactDescription ?? `当前表 ${tableName || '未知'} 中的 ${label} 字段`;
return `原始字段名:${column}${description}。点击可按此列排序。`;
}
@@ -566,7 +600,9 @@ function compareTableCellValues(
}
if (left.kind !== right.kind) {
return direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind));
return (
direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind))
);
}
let comparison = 0;
@@ -578,7 +614,10 @@ function compareTableCellValues(
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
break;
case 'text':
comparison = tableSortCollator.compare(left.value, getSortableTextValue(right));
comparison = tableSortCollator.compare(
left.value,
getSortableTextValue(right),
);
break;
}
@@ -587,26 +626,26 @@ function compareTableCellValues(
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return {kind: 'empty'};
return { kind: 'empty' };
}
if (typeof value === 'number' && Number.isFinite(value)) {
return {kind: 'number', value};
return { kind: 'number', value };
}
if (typeof value === 'boolean') {
return {kind: 'boolean', value};
return { kind: 'boolean', value };
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return {kind: 'empty'};
return { kind: 'empty' };
}
return {kind: 'text', value: trimmed};
return { kind: 'text', value: trimmed };
}
return {kind: 'text', value: stringifyUnknownValue(value)};
return { kind: 'text', value: stringifyUnknownValue(value) };
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
@@ -614,13 +653,24 @@ function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
return `${rowIndex}-${String(firstValue ?? '')}`;
}
function formatCellValue(value: unknown): FormattedTableCellValue {
function formatCellValue(value: unknown, column = ''): FormattedTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return {content: '-', fullText: '-'};
return { content: '-', fullText: '-' };
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
const text = String(value);
return {content: text, fullText: text};
const readableTimestamp = formatReadableTimestampValue(value, column);
if (readableTimestamp) {
return {
content: readableTimestamp,
fullText: `${readableTimestamp}(原始值:${text}`,
};
}
return { content: text, fullText: text };
}
return {
content: stringifyUnknownValue(value),
@@ -628,6 +678,107 @@ function formatCellValue(value: unknown): FormattedTableCellValue {
};
}
function formatReadableTimestampValue(
value: string | number | boolean,
column: string,
) {
if (typeof value === 'boolean' || !isTimestampColumn(column)) {
return '';
}
const timestampMs = parseTimestampMillis(value, column);
if (timestampMs === null) {
return '';
}
const date = new Date(timestampMs);
if (Number.isNaN(date.getTime())) {
return '';
}
return formatBeijingDateTime(date);
}
function isTimestampColumn(column: string) {
const normalizedColumn = column.trim().toLowerCase();
return (
normalizedColumn.endsWith('_at') ||
normalizedColumn.endsWith('_at_ms') ||
normalizedColumn.endsWith('_at_micros') ||
normalizedColumn.endsWith('_timestamp') ||
normalizedColumn.endsWith('_timestamp_ms') ||
normalizedColumn.endsWith('_timestamp_micros')
);
}
function parseTimestampMillis(value: string | number, column: string) {
if (typeof value === 'number') {
return parseNumericTimestampMillis(value, column);
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const numericValue = Number(trimmed);
if (Number.isFinite(numericValue) && /^-?\d+(\.\d+)?$/.test(trimmed)) {
return parseNumericTimestampMillis(numericValue, column);
}
const parsed = Date.parse(trimmed);
return Number.isNaN(parsed) ? null : parsed;
}
function parseNumericTimestampMillis(value: number, column: string) {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
const normalizedColumn = column.trim().toLowerCase();
if (
normalizedColumn.endsWith('_at_ms') ||
normalizedColumn.endsWith('_timestamp_ms')
) {
return value;
}
if (
normalizedColumn.endsWith('_at_micros') ||
normalizedColumn.endsWith('_timestamp_micros')
) {
return Math.floor(value / 1_000);
}
if (value >= 1_000_000_000_000_000) {
return Math.floor(value / 1_000);
}
if (value >= 1_000_000_000_000) {
return value;
}
if (value >= 1_000_000_000) {
return value * 1_000;
}
return null;
}
function formatBeijingDateTime(date: Date) {
const parts = new Intl.DateTimeFormat('zh-CN', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
}).formatToParts(date);
const partMap = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`;
}
function stringifyPrettyUnknownValue(value: unknown) {
try {
const serialized = JSON.stringify(value, null, 2);
@@ -662,10 +813,10 @@ function getSortKindOrder(kind: SortableTableCellValue['kind']): number {
}
type SortableTableCellValue =
| {kind: 'empty'}
| {kind: 'number'; value: number}
| {kind: 'boolean'; value: boolean}
| {kind: 'text'; value: string};
| { kind: 'empty' }
| { kind: 'number'; value: number }
| { kind: 'boolean'; value: boolean }
| { kind: 'text'; value: string };
interface DatabaseTableHeader {
name: string;
@@ -1257,7 +1408,8 @@ const databaseTableLabelMap: Record<string, string> = {
};
const databaseTableDescriptionMap: Record<string, string> = {
database_migration_operator: '管理数据库迁移导出、导入和增量导入权限的操作员表',
database_migration_operator:
'管理数据库迁移导出、导入和增量导入权限的操作员表',
database_migration_import_chunk: '大迁移 JSON 分片导入的临时表',
auth_store_snapshot: '旧认证仓储的整份 JSON 快照表',
user_account: '用户账号主表',
@@ -1347,23 +1499,25 @@ function getSortableBooleanValue(value: SortableTableCellValue) {
}
function getSortableTextValue(value: SortableTableCellValue) {
return isSortableTextValue(value) ? value.value : stringifyUnknownValue(value);
return isSortableTextValue(value)
? value.value
: stringifyUnknownValue(value);
}
function isSortableNumberValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'number'}> {
): value is Extract<SortableTableCellValue, { kind: 'number' }> {
return value.kind === 'number';
}
function isSortableBooleanValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'boolean'}> {
): value is Extract<SortableTableCellValue, { kind: 'boolean' }> {
return value.kind === 'boolean';
}
function isSortableTextValue(
value: SortableTableCellValue,
): value is Extract<SortableTableCellValue, {kind: 'text'}> {
): value is Extract<SortableTableCellValue, { kind: 'text' }> {
return value.kind === 'text';
}
@@ -192,9 +192,11 @@ npm run check:server-rs-ddd
3. 充值中心、下单校验和支付确认入账都读取 `profile_recharge_product_config`。历史订单保留下单时写入的商品标题、金额、渠道、状态和 provider transaction id,不随配置改动回写。
4. 泥点首充资格按 `user_id + product_id` 的历史 `paid` 订单独立判断。某个档位已支付后,只隐藏该档位的首充赠送;其它未购买档位仍展示和结算首充赠送。
5. `hasPointsRecharged` 只保留为账号是否发生过任一泥点充值的兼容字段,不得驱动所有商品展示隐藏或结算金额计算。前端只渲染后端返回的商品快照。
6. `paymentChannel` 缺失、未知或和设备不匹配时必须拒绝;真实微信渠道只允许 `wechat_mp``wechat_h5``wechat_native`,生产配置不得把真实支付静默降级为 `mock`
7. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截渠道:小程序只允许 `wechat_mp`,手机微信内网页只允许 `wechat_h5`,桌面微信内网页只允许 `wechat_native`
6. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp``wechat_mp_virtual``wechat_jsapi``wechat_h5``wechat_native`,生产配置不得把真实支付静默降级为 `mock`
7. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`;微信内浏览器使用 `wechat_jsapi`;普通 Web 使用 `wechat_native`,历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付
8. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
9. 微信 Native 下单显式传 `time_expire`,当前有效期为 5 分钟,并通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
10. 普通微信支付渠道的新建 pending 充值订单会写入 `profile_recharge_order_expiration_schedule`。到期处理由 `api-server` 后台 worker claim 调度行后调用微信查单;只有微信返回 `SUCCESS` 才补确认入账,返回 `NOTPAY` / `CLOSED` / `REVOKED` / `PAYERROR` 才关闭本地订单,查询失败或 `USERPAYING` 保留租约等待重试。SpacetimeDB module 不直接发起微信 HTTP 请求。
## 创作入口泥点扣费契约
@@ -695,6 +697,12 @@ npm run check:server-rs-ddd
- Rust 结构体:`ProfileRechargeOrder`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
### `profile_recharge_order_expiration_schedule`
- Rust 结构体:`ProfileRechargeOrderExpirationSchedule`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:普通微信充值订单的到期查单调度表。表内只保存待检查订单、计划检查时间和 worker 短租约;支付成功或本地关闭后删除对应行。
### `profile_redeem_code`
- Rust 结构体:`ProfileRedeemCode`
@@ -6,13 +6,14 @@
- 泥点充值在微信小程序 WebView 内走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_coin` 模式。
- 会员商品在微信小程序 WebView 内同样走 `wechat_mp_virtual`,由小程序页调用 `wx.requestVirtualPayment``short_series_goods` 模式,并在 `signData` 内带 `productId``goodsPrice`
- H5 与桌面微信环境仍分别走 `wechat_h5` / `wechat_native`,不进入虚拟支付链路。
- 微信内浏览器走 `wechat_jsapi`,复用微信支付 V3 JSAPI 下单返回的预支付参数并通过 `WeixinJSBridge.invoke('getBrandWCPayRequest')` 调起支付;普通 Web 统一走 `wechat_native` 二维码支付,不进入虚拟支付链路,也不依赖 H5 产品权限。`wechat_h5` 仅作为未来 H5 产品权限明确开通后的保留渠道
- `session_key` 只保存在后端认证仓储内,用于计算虚拟支付用户态签名,不下发给前端。
- 客户端支付成功回调只代表已拉起支付并返回成功;最终到账仍以后端虚拟支付消息推送写入订单为准,普通微信支付订单则继续走微信支付 V3 notify / query。虚拟支付订单的确认接口只读取本地订单真相,不再用普通微信支付 V3 查单。
- 小程序 WebView 普通进入不预登录;H5 触发受保护入口或支付前必须保留 `clientRuntime=wechat_mini_program` 等宿主上下文,并用 `MicroMessenger + miniProgram` User-Agent 兜底识别首点 bridge 未就绪场景,再跳转小程序原生授权态,确保后端拿到带 `session_key` 的微信登录态。
## 关键文件
- JSAPI 支付缺少当前用户 openid 时,前端调用 `GET /api/auth/wechat/bind-start` 发起 OAuth;后端把当前 `user_id` 写入 OAuth state,微信回调仍走 `/api/auth/wechat/callback`,但只把获得的微信身份绑定到当前账号,不走普通 `/api/auth/wechat/start` 的登录/切号流程。
- 前端渠道选择:`src/services/payment/paymentPlatform.ts`
- 充值入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
- 小程序支付承接页:`miniprogram/pages/wechat-pay/index.shared.js`
@@ -60,8 +60,8 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
2. 泥点默认档位为 `60 / 180 / 300 / 680 / 1280 / 3280`,会员默认档位为月卡、季卡、年卡;实际展示、下单校验和支付确认都以后端返回的充值商品配置为准。
3. 首充双倍按泥点商品档位独立计算。用户买过 `points_60` 后,只影响 `points_60` 的首充展示和结算,其它未购买档位仍保留各自首充权益。
4. 前端不得用 `hasPointsRecharged` 统一隐藏所有泥点档位首充权益;该字段只表示账号是否发生过任一泥点充值。
5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp``wechat_h5``wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
6. 小程序 WebView 充值使用 `wechat_mp` 渠道时,H5 只跳转 native 支付页并在返回后请求服务端查单确认;手机微信内网页使用 `wechat_h5` 跳转微信 H5 支付;桌面微信内网页使用 `wechat_native` 二维码。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。
5. 充值支付渠道只允许由设备平台隔离层解析为 `wechat_mp``wechat_mp_virtual``wechat_jsapi``wechat_h5``wechat_native`;生产真实支付不得默认落到 `mock`,缺失或未知 `paymentChannel` 必须拒绝。
6. 小程序 WebView 充值使用 `wechat_mp_virtual` 调起小程序虚拟支付;微信内浏览器使用 `wechat_jsapi` 调起微信支付 JSAPI;普通 Web 使用 `wechat_native` 二维码支付,避免因移动 UA、触控能力或窄屏误入 `wechat_h5`。只有微信通知或查单确认 `SUCCESS` 后才刷新余额或会员状态。
7. 后端必须按 access JWT 中的最小设备快照拦截真实微信充值路径,不能只依赖前端隐藏入口或请求体传入的 `paymentChannel`
8. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。
+2
View File
@@ -164,6 +164,7 @@ export type ProfileRechargeCenterResponse = {
};
export type WechatMiniProgramPayParams = {
appId?: string;
timeStamp: string;
nonceStr: string;
package: string;
@@ -184,6 +185,7 @@ export type WechatH5Payment = {
export type WechatNativePayment = {
codeUrl: string;
expiresAt: string;
};
export type CreateProfileRechargeOrderRequest = {
+307 -138
View File
File diff suppressed because it is too large Load Diff
+196 -102
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -3388,6 +3388,170 @@ mod tests {
);
}
#[tokio::test]
async fn wechat_bind_start_binds_oauth_identity_to_current_phone_user() {
let config = AppConfig {
sms_auth_enabled: true,
wechat_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let phone_send_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"scene": "login"
})
.to_string(),
))
.expect("phone send request should build"),
)
.await
.expect("phone send request should succeed");
assert_eq!(phone_send_response.status(), StatusCode::OK);
let phone_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138000",
"code": "123456"
})
.to_string(),
))
.expect("phone login request should build"),
)
.await
.expect("phone login request should succeed");
let phone_login_body = phone_login_response
.into_body()
.collect()
.await
.expect("phone login body should collect")
.to_bytes();
let phone_login_payload: Value =
serde_json::from_slice(&phone_login_body).expect("phone login payload should be json");
let phone_user_id = phone_login_payload["user"]["id"].clone();
let phone_token = phone_login_payload["token"]
.as_str()
.expect("phone token should exist");
let bind_start_response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/auth/wechat/bind-start?redirectPath=%2Fplay%3FclientRuntime%3Dwechat_h5")
.header("authorization", format!("Bearer {phone_token}"))
.header(
"user-agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.50",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat bind start request should build"),
)
.await
.expect("wechat bind start should succeed");
assert_eq!(bind_start_response.status(), StatusCode::OK);
let bind_start_body = bind_start_response
.into_body()
.collect()
.await
.expect("wechat bind start body should collect")
.to_bytes();
let bind_start_payload: Value = serde_json::from_slice(&bind_start_body)
.expect("wechat bind start payload should be json");
let authorization_url = bind_start_payload["authorizationUrl"]
.as_str()
.expect("wechat bind authorization url should exist");
let callback_state = url::Url::parse(authorization_url)
.expect("authorization url should be valid")
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.expect("state should exist");
let callback_response = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/api/auth/wechat/callback?state={callback_state}&mock_code=wx-bind-code"
))
.header(
"user-agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0.50",
)
.header("host", "localhost:3000")
.body(Body::empty())
.expect("wechat callback request should build"),
)
.await
.expect("wechat callback request should succeed");
assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
let location = callback_response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("redirect location should exist");
assert!(location.starts_with("/play?clientRuntime=wechat_h5#"));
assert!(location.contains("auth_provider=wechat"));
assert!(location.contains("auth_binding_status=active"));
let auth_hash = location
.split('#')
.nth(1)
.expect("hash fragment should exist");
let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes())
.into_owned()
.collect::<std::collections::HashMap<String, String>>();
let bound_token = auth_params
.get("auth_token")
.expect("bound auth token should exist");
let me_response = app
.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {bound_token}"))
.body(Body::empty())
.expect("auth me request should build"),
)
.await
.expect("auth me request should succeed");
assert_eq!(me_response.status(), StatusCode::OK);
let me_body = me_response
.into_body()
.collect()
.await
.expect("auth me body should collect")
.to_bytes();
let me_payload: Value =
serde_json::from_slice(&me_body).expect("auth me payload should be json");
assert_eq!(me_payload["user"]["id"], phone_user_id);
assert_eq!(
me_payload["user"]["loginMethod"],
Value::String("phone".to_string())
);
assert_eq!(me_payload["user"]["wechatBound"], Value::Bool(true));
assert_eq!(
me_payload["user"]["wechatAccount"],
Value::String("wx-bind-code".to_string())
);
}
#[tokio::test]
async fn auth_sessions_returns_multi_device_session_summaries() {
let state = AppState::new(AppConfig::default()).expect("state should build");
+3
View File
@@ -75,6 +75,7 @@ mod phone_auth;
mod platform_errors;
mod process_metrics;
mod profile_identity;
mod profile_recharge_expiration_worker;
mod prompt;
mod public_work;
mod puzzle;
@@ -131,6 +132,7 @@ use crate::{
config::{AppConfig, ProcessRole},
external_generation_worker::run_external_generation_worker,
external_generation_worker_controller::run_external_generation_worker_controller,
profile_recharge_expiration_worker::spawn_profile_recharge_expiration_worker,
state::{AppState, AppStateInitError},
tracking_outbox::TrackingOutbox,
wallet_refund_outbox::WalletRefundOutbox,
@@ -408,6 +410,7 @@ async fn finalize_shutdown(context: ShutdownContext) {
fn spawn_app_state_background_workers(state: &AppState) {
state.puzzle_gallery_cache().spawn_cleanup_task();
spawn_profile_recharge_expiration_worker(state.clone());
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
@@ -17,7 +17,8 @@ use crate::{
refresh_session::refresh_session,
state::AppState,
wechat::auth::{
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_bind,
start_wechat_login,
},
};
@@ -72,6 +73,13 @@ pub fn router(state: AppState) -> Router<AppState> {
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))
.route(
"/api/auth/wechat/bind-start",
get(start_wechat_bind).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
.route(
"/api/auth/wechat/miniprogram-login",
@@ -0,0 +1,207 @@
use std::{process, time::Duration};
use module_runtime::RuntimeProfileRechargeOrderExpirationScheduleSnapshot;
use platform_wechat::pay::WechatPayError;
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tokio::time::MissedTickBehavior;
use tracing::{debug, info, warn};
use crate::state::AppState;
const PROFILE_RECHARGE_EXPIRATION_WORKER_INTERVAL: Duration = Duration::from_secs(15);
const PROFILE_RECHARGE_EXPIRATION_WORKER_LEASE: Duration = Duration::from_secs(90);
const PROFILE_RECHARGE_EXPIRATION_WORKER_CLAIM_LIMIT: u32 = 20;
pub fn spawn_profile_recharge_expiration_worker(state: AppState) {
tokio::spawn(async move {
run_profile_recharge_expiration_worker(state).await;
});
}
async fn run_profile_recharge_expiration_worker(state: AppState) {
let worker_id = format!("api:{}:profile-recharge-expiration", process::id());
let mut interval = tokio::time::interval(PROFILE_RECHARGE_EXPIRATION_WORKER_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
interval.tick().await;
let now_micros = current_unix_micros();
let lease_expires_at_micros = now_micros
+ i64::try_from(PROFILE_RECHARGE_EXPIRATION_WORKER_LEASE.as_micros())
.unwrap_or(90_000_000);
let schedules = match state
.spacetime_client()
.claim_profile_recharge_order_expiration_schedules(
worker_id.clone(),
now_micros,
lease_expires_at_micros,
PROFILE_RECHARGE_EXPIRATION_WORKER_CLAIM_LIMIT,
)
.await
{
Ok(schedules) => schedules,
Err(error) => {
warn!("充值订单过期检查 claim 失败:{error}");
continue;
}
};
if schedules.is_empty() {
debug!("充值订单过期检查暂无到期任务");
continue;
}
for schedule in schedules {
process_profile_recharge_expiration_schedule(&state, schedule).await;
}
}
}
async fn process_profile_recharge_expiration_schedule(
state: &AppState,
schedule: RuntimeProfileRechargeOrderExpirationScheduleSnapshot,
) {
let order_id = schedule.order_id.clone();
let wechat_order = match state
.wechat_pay_client()
.query_order_by_out_trade_no(&order_id)
.await
{
Ok(order) => order,
Err(WechatPayError::OrderNotExist(message)) => {
warn!(
order_id = order_id.as_str(),
error = message.as_str(),
"过期前微信查单确认订单不存在,关闭本地 pending 订单"
);
close_order_not_found_profile_recharge_order(state, &order_id).await;
return;
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"过期前微信查单失败,将等待租约过期后重试:{error}"
);
return;
}
};
if wechat_order.out_trade_no != order_id {
warn!(
order_id = order_id.as_str(),
provider_order_id = wechat_order.out_trade_no.as_str(),
"过期前微信查单返回的商户订单号不匹配,将等待租约过期后重试"
);
return;
}
match wechat_order.trade_state.as_str() {
"SUCCESS" => {
let paid_at_micros = wechat_order
.success_time
.as_deref()
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
match state
.spacetime_client()
.mark_profile_recharge_order_paid(
order_id.clone(),
paid_at_micros,
wechat_order.transaction_id,
)
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, &order_id).await;
state.publish_profile_recharge_order_update(order_id.clone());
info!(
order_id = order_id.as_str(),
"过期检查发现微信已支付,已补确认入账"
);
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"过期检查确认已支付订单失败:{error}"
);
}
}
}
"NOTPAY" | "CLOSED" | "REVOKED" | "PAYERROR" => {
match state
.spacetime_client()
.close_profile_recharge_order(order_id.clone(), current_unix_micros())
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, &order_id).await;
state.publish_profile_recharge_order_update(order_id.clone());
info!(
order_id = order_id.as_str(),
trade_state = wechat_order.trade_state.as_str(),
"充值订单到期且微信确认未支付,已关闭本地订单"
);
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"关闭过期充值订单失败:{error}"
);
}
}
}
"USERPAYING" => {
info!(
order_id = order_id.as_str(),
"微信订单仍在支付中,将等待租约过期后重试"
);
}
trade_state => {
warn!(
order_id = order_id.as_str(),
trade_state, "微信订单状态暂不适合本地关闭,将等待租约过期后重试"
);
}
}
}
async fn close_order_not_found_profile_recharge_order(state: &AppState, order_id: &str) {
match state
.spacetime_client()
.close_profile_recharge_order(order_id.to_string(), current_unix_micros())
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, order_id).await;
state.publish_profile_recharge_order_update(order_id.to_string());
info!(
order_id,
reason = "ORDER_NOT_EXIST",
"过期检查发现微信订单不存在,已关闭本地充值订单"
);
}
Err(error) => {
warn!(
order_id,
reason = "ORDER_NOT_EXIST",
"关闭微信不存在的过期充值订单失败:{error}"
);
}
}
}
async fn complete_profile_recharge_expiration_schedule(state: &AppState, order_id: &str) {
if let Err(error) = state
.spacetime_client()
.complete_profile_recharge_order_expiration_schedule(order_id.to_string())
.await
{
warn!(order_id, "删除充值订单过期检查任务失败:{error}");
}
}
fn current_unix_micros() -> i64 {
offset_datetime_to_unix_micros(OffsetDateTime::now_utc())
}
@@ -10,7 +10,7 @@ use axum::{
use hmac::{Hmac, Mac};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord,
@@ -240,6 +240,7 @@ pub async fn create_profile_recharge_order(
let wechat_mini_program_pay_params = if payment_channel
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
|| payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
{
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
@@ -407,6 +408,7 @@ pub async fn confirm_wechat_profile_recharge_order(
map_runtime_profile_client_error(error),
)
})?;
state.publish_profile_recharge_order_update(order.order_id.clone());
Ok(json_success_body(
Some(&request_context),
@@ -1305,18 +1307,43 @@ fn validate_recharge_device_for_payment_channel(
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI => is_web_recharge_device(claims),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => is_web_recharge_device(claims),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => is_web_recharge_device(claims),
_ => false,
};
if is_supported_device {
return Ok(());
}
tracing::warn!(
payment_channel = payment_channel,
client_type = claims.client_type().unwrap_or("<missing>"),
client_platform = claims.client_platform().unwrap_or("<missing>"),
is_mobile_device = claims.is_mobile_device(),
is_desktop_device = claims.is_desktop_device(),
is_wechat_mini_program_device = claims.is_wechat_mini_program_device(),
"profile recharge payment channel rejected for current login device"
);
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("当前登录设备不支持充值,请在微信环境内登录后重试"))
}
fn is_web_recharge_device(claims: &platform_auth::AccessTokenClaims) -> bool {
is_web_recharge_client_type(claims) || is_legacy_web_recharge_device(claims)
}
fn is_web_recharge_client_type(claims: &platform_auth::AccessTokenClaims) -> bool {
matches!(claims.client_type(), Some("wechat_h5" | "web_browser"))
}
fn is_legacy_web_recharge_device(claims: &platform_auth::AccessTokenClaims) -> bool {
let legacy_or_unknown_platform =
matches!(claims.client_platform(), None | Some("unknown" | "test"));
legacy_or_unknown_platform && matches!(claims.client_type(), None | Some("web_browser"))
}
fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> {
if !state.config.wechat_pay_enabled {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
@@ -1353,6 +1380,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
@@ -2352,7 +2380,7 @@ mod tests {
}
#[tokio::test]
async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() {
async fn profile_recharge_order_rejects_non_web_device_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
@@ -2360,7 +2388,13 @@ mod tests {
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let token = issue_device_access_token(
&state,
"native_app",
"native_app",
"windows",
"sess_runtime_profile_native_app",
);
let app = build_router(state);
let response = app
@@ -2394,7 +2428,93 @@ mod tests {
}
#[tokio::test]
async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() {
async fn profile_recharge_order_allows_legacy_web_session_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_legacy_web_unknown_platform_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_web_browser_access_token(&state, "test", "sess_runtime_profile_legacy_web");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_web_device_using_mini_program_channel_before_spacetime()
{
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
@@ -2413,7 +2533,7 @@ mod tests {
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
r#"{"productId":"points_60","paymentChannel":"wechat_mp_virtual"}"#,
))
.expect("request should build"),
)
@@ -2478,6 +2598,181 @@ mod tests {
);
}
#[tokio::test]
async fn profile_recharge_order_allows_mobile_web_h5_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_web_browser_access_token(&state, "ios", "sess_runtime_profile_mobile_web");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_desktop_web_native_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_web_browser_access_token(&state, "windows", "sess_runtime_profile_desktop_web");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_mobile_web_native_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_web_browser_access_token(&state, "ios", "sess_runtime_profile_mobile_web_native");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_desktop_web_h5_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_web_browser_access_token(
&state,
"windows",
"sess_runtime_profile_desktop_web_h5",
);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
let state = seed_authenticated_state_with_config(AppConfig {
@@ -3203,6 +3498,68 @@ mod tests {
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn issue_device_access_token(
state: &AppState,
client_type: &str,
client_runtime: &str,
client_platform: &str,
session_id: &str,
) -> String {
let user_id = test_authenticated_user_id(state);
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: user_id.clone(),
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("设备资料页用户".to_string()),
},
Some(platform_auth::AccessTokenDeviceInfo {
client_type: client_type.to_string(),
client_runtime: client_runtime.to_string(),
client_platform: client_platform.to_string(),
}),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn issue_web_browser_access_token(
state: &AppState,
client_platform: &str,
session_id: &str,
) -> String {
let user_id = test_authenticated_user_id(state);
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: user_id.clone(),
session_id: state.seed_test_refresh_session_for_user_id(&user_id, session_id),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("网页资料页用户".to_string()),
},
Some(platform_auth::AccessTokenDeviceInfo {
client_type: "web_browser".to_string(),
client_runtime: "web".to_string(),
client_platform: client_platform.to_string(),
}),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn test_authenticated_user_id(state: &AppState) -> String {
state
.auth_user_service()
+74 -11
View File
@@ -5,7 +5,7 @@
response::{IntoResponse, Redirect, Response},
};
use module_auth::{
AuthLoginMethod, BindWechatPhoneInput, BindWechatVerifiedPhoneInput,
AuthLoginMethod, BindWechatIdentityInput, BindWechatPhoneInput, BindWechatVerifiedPhoneInput,
CreateWechatAuthStateInput, WechatAuthError,
};
use platform_auth::WechatAuthScene;
@@ -57,6 +57,52 @@ pub async fn start_wechat_login(
),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
bind_user_id: None,
},
OffsetDateTime::now_utc(),
)
.map_err(map_wechat_auth_error)?;
let authorization_url = state
.wechat_provider()
.build_authorization_url(
&resolve_wechat_callback_url(&state, &headers)?,
&state_record.state.state_token,
&scene,
)
.map_err(map_wechat_provider_error)?;
Ok(json_success_body(
Some(&request_context),
WechatStartResponse { authorization_url },
))
}
pub async fn start_wechat_bind(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Query(query): Query<WechatStartQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
if !state.config.wechat_auth_enabled {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
}
let user_agent = headers
.get("user-agent")
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let scene = resolve_wechat_scene(user_agent.as_deref())?;
let state_record = state
.wechat_auth_state_service()
.create_state(
CreateWechatAuthStateInput {
redirect_path: normalize_redirect_path(
query.redirect_path.as_deref(),
&state.config.wechat_redirect_path,
),
scene: map_wechat_scene_to_domain(&scene),
request_user_agent: user_agent.clone(),
bind_user_id: Some(authenticated.claims().user_id().to_string()),
},
OffsetDateTime::now_utc(),
)
@@ -128,23 +174,39 @@ pub async fn handle_wechat_callback(
.resolve_callback_profile(query.code.as_deref(), query.mock_code.as_deref())
.await
{
Ok(profile) => state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput {
profile: map_wechat_profile_to_domain(profile),
})
.await
.map_err(map_wechat_auth_error),
Ok(profile) => {
let profile = map_wechat_profile_to_domain(profile);
if let Some(bind_user_id) = consumed.state.bind_user_id.as_deref() {
state
.wechat_auth_service()
.bind_identity_to_user(BindWechatIdentityInput {
user_id: bind_user_id.to_string(),
profile,
})
.map(|user| module_auth::ResolveWechatLoginResult {
user,
created: false,
})
.map_err(map_wechat_auth_error)
} else {
state
.wechat_auth_service()
.resolve_login(module_auth::ResolveWechatLoginInput { profile })
.await
.map_err(map_wechat_auth_error)
}
}
Err(error) => Err(map_wechat_provider_error(error)),
};
match result {
Ok(result) => {
let session_provider = result.user.login_method.clone();
let signed_session = create_auth_session(
&state,
&result.user,
&session_client,
AuthLoginMethod::Wechat,
session_provider.clone(),
)?;
state
.sync_auth_store_tables_to_spacetime()
@@ -157,7 +219,7 @@ pub async fn handle_wechat_callback(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
session_provider,
)
.await;
let mut response = Redirect::to(&build_auth_result_redirect_url(
@@ -478,7 +540,8 @@ fn map_wechat_auth_error(error: WechatAuthError) -> AppError {
| WechatAuthError::StateNotFound
| WechatAuthError::StateExpired
| WechatAuthError::StateConsumed
| WechatAuthError::MissingWechatIdentity => {
| WechatAuthError::MissingWechatIdentity
| WechatAuthError::WechatIdentityAlreadyBound => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string())
}
WechatAuthError::UserNotFound => {
@@ -70,6 +70,7 @@ pub async fn handle_wechat_pay_notify(
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("确认微信支付订单失败:{error}"))
})?;
state.publish_profile_recharge_order_update(notify.out_trade_no.clone());
info!(
order_id = notify.out_trade_no.as_str(),
"微信支付通知已确认订单入账"
@@ -260,6 +261,9 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
WechatPayError::InvalidRequest(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay" })),
WechatPayError::OrderNotExist(message) => AppError::from_status(StatusCode::NOT_FOUND)
.with_message(message)
.with_details(json!({ "provider": "wechat_pay", "code": "ORDER_NOT_EXIST" })),
WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
@@ -331,6 +335,7 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res
WechatPayError::Disabled => "微信消息推送暂未启用".to_string(),
WechatPayError::InvalidConfig(message)
| WechatPayError::InvalidRequest(message)
| WechatPayError::OrderNotExist(message)
| WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
@@ -349,6 +354,7 @@ fn build_virtual_payment_notify_error_response(
WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(),
WechatPayError::InvalidConfig(message)
| WechatPayError::InvalidRequest(message)
| WechatPayError::OrderNotExist(message)
| WechatPayError::RequestFailed(message)
| WechatPayError::Upstream(message)
| WechatPayError::Deserialize(message)
@@ -51,11 +51,18 @@ pub struct ResolveWechatLoginInput {
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BindWechatIdentityInput {
pub user_id: String,
pub profile: WechatIdentityProfile,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateWechatAuthStateInput {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub bind_user_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -142,6 +142,7 @@ pub struct WechatAuthStateRecord {
pub redirect_path: String,
pub scene: WechatAuthScene,
pub request_user_agent: Option<String>,
pub bind_user_id: Option<String>,
pub expires_at: String,
pub consumed_at: Option<String>,
pub created_at: String,
@@ -42,6 +42,7 @@ pub enum WechatAuthError {
StateConsumed,
UserNotFound,
MissingWechatIdentity,
WechatIdentityAlreadyBound,
Store(String),
PasswordHash(String),
}
@@ -109,6 +110,7 @@ impl fmt::Display for WechatAuthError {
Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"),
Self::UserNotFound => f.write_str("用户不存在"),
Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"),
Self::WechatIdentityAlreadyBound => f.write_str("该微信身份已绑定其他账号"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
}
+109
View File
@@ -793,6 +793,7 @@ impl WechatAuthStateService {
redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(),
scene: input.scene,
request_user_agent: normalize_optional_string(input.request_user_agent),
bind_user_id: normalize_optional_string(input.bind_user_id),
expires_at,
consumed_at: None,
created_at: created_at.clone(),
@@ -858,6 +859,17 @@ impl WechatAuthService {
})
}
pub fn bind_identity_to_user(
&self,
input: BindWechatIdentityInput,
) -> Result<AuthUser, WechatAuthError> {
if input.profile.provider_uid.trim().is_empty() {
return Err(WechatAuthError::MissingProfile);
}
self.store
.bind_wechat_identity_to_user(&input.user_id, input.profile)
}
pub fn get_identity_by_user_id(
&self,
user_id: &str,
@@ -1691,6 +1703,103 @@ impl InMemoryAuthStore {
}))
}
fn bind_wechat_identity_to_user(
&self,
user_id: &str,
profile: WechatIdentityProfile,
) -> Result<AuthUser, WechatAuthError> {
let user_id = normalize_required_string(user_id).ok_or(WechatAuthError::UserNotFound)?;
let provider_uid = normalize_required_string(&profile.provider_uid)
.ok_or(WechatAuthError::MissingProfile)?;
let provider_union_id = normalize_optional_string(profile.provider_union_id);
let display_name = normalize_optional_string(profile.display_name);
let avatar_url = normalize_optional_string(profile.avatar_url);
let session_key = normalize_optional_string(profile.session_key);
let mut state = self
.inner
.lock()
.map_err(|_| WechatAuthError::Store("鐢ㄦ埛浠撳偍閿佸凡涓瘨".to_string()))?;
if !state
.users_by_username
.values()
.any(|stored_user| stored_user.user.id == user_id)
{
return Err(WechatAuthError::UserNotFound);
}
if let Some(existing) = state.wechat_identity_by_provider_uid.get(&provider_uid)
&& existing.user_id != user_id
{
return Err(WechatAuthError::WechatIdentityAlreadyBound);
}
if let Some(provider_union_id) = provider_union_id.as_deref()
&& let Some(existing_user_id) =
state.user_id_by_provider_union_id.get(provider_union_id)
&& existing_user_id != &user_id
{
return Err(WechatAuthError::WechatIdentityAlreadyBound);
}
if let Some(existing_identity) = state
.wechat_identity_by_provider_uid
.values()
.find(|identity| identity.user_id == user_id)
.cloned()
{
state
.wechat_identity_by_provider_uid
.remove(&existing_identity.provider_uid);
if let Some(existing_union_id) = existing_identity.provider_union_id
&& provider_union_id.as_deref() != Some(existing_union_id.as_str())
{
state
.user_id_by_provider_union_id
.remove(&existing_union_id);
}
}
state.wechat_identity_by_provider_uid.insert(
provider_uid.clone(),
StoredWechatIdentity {
user_id: user_id.clone(),
provider_uid: provider_uid.clone(),
provider_union_id: provider_union_id.clone(),
display_name: display_name.clone(),
avatar_url: avatar_url.clone(),
session_key,
},
);
if let Some(provider_union_id) = provider_union_id {
state
.user_id_by_provider_union_id
.insert(provider_union_id, user_id.clone());
}
let next_user = {
let stored_user = state
.users_by_username
.values_mut()
.find(|stored_user| stored_user.user.id == user_id)
.ok_or(WechatAuthError::UserNotFound)?;
stored_user.user.wechat_bound = true;
stored_user.user.wechat_account = Some(provider_uid);
if let Some(display_name) = display_name {
stored_user.user.wechat_display_name = Some(display_name);
}
if stored_user.user.avatar_url.is_none()
&& let Some(avatar_url) = avatar_url
{
stored_user.user.avatar_url = Some(avatar_url);
}
stored_user.user.clone()
};
self.persist_wechat_state(&state)?;
Ok(next_user)
}
fn refresh_wechat_identity_profile(
&self,
user_id: &str,
@@ -397,6 +397,42 @@ pub fn build_runtime_profile_recharge_order_paid_input(
})
}
pub fn build_runtime_profile_recharge_order_close_input(
order_id: String,
closed_at_micros: i64,
) -> Result<RuntimeProfileRechargeOrderCloseInput, RuntimeProfileFieldError> {
let order_id =
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
Ok(RuntimeProfileRechargeOrderCloseInput {
order_id,
closed_at_micros,
})
}
pub fn build_runtime_profile_recharge_order_expiration_claim_input(
worker_id: String,
now_micros: i64,
lease_expires_at_micros: i64,
limit: u32,
) -> Result<RuntimeProfileRechargeOrderExpirationClaimInput, RuntimeProfileFieldError> {
let worker_id =
normalize_required_string(worker_id).ok_or(RuntimeProfileFieldError::MissingWorkerId)?;
Ok(RuntimeProfileRechargeOrderExpirationClaimInput {
worker_id,
now_micros,
lease_expires_at_micros,
limit,
})
}
pub fn build_runtime_profile_recharge_order_expiration_complete_input(
order_id: String,
) -> Result<RuntimeProfileRechargeOrderExpirationCompleteInput, RuntimeProfileFieldError> {
let order_id =
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
Ok(RuntimeProfileRechargeOrderExpirationCompleteInput { order_id })
}
pub fn build_runtime_profile_feedback_submission_input(
user_id: String,
description: String,
@@ -36,8 +36,10 @@ pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM: &str = "wechat_mp";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL: &str = "wechat_mp_virtual";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI: &str = "wechat_jsapi";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5: &str = "wechat_h5";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE: &str = "wechat_native";
pub const PROFILE_RECHARGE_ORDER_EXPIRATION_SECONDS: i64 = 5 * 60;
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;
@@ -1358,6 +1360,55 @@ pub struct RuntimeProfileRechargeOrderPaidInput {
pub provider_transaction_id: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderCloseInput {
pub order_id: String,
pub closed_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationClaimInput {
pub worker_id: String,
pub now_micros: i64,
pub lease_expires_at_micros: i64,
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCompleteInput {
pub order_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationScheduleSnapshot {
pub order_id: String,
pub user_id: String,
pub scheduled_at_micros: i64,
pub lease_owner: Option<String>,
pub lease_expires_at_micros: Option<i64>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationClaimProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRechargeOrderExpirationScheduleSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCompleteProcedureResult {
pub ok: bool,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
@@ -74,6 +74,7 @@ pub enum RuntimeProfileFieldError {
TaskDisabled,
TaskNotClaimable,
TaskAlreadyClaimed,
MissingWorkerId,
MissingOrderId,
MissingProductId,
MissingProductTitle,
@@ -149,6 +150,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::TaskDisabled => f.write_str("任务已停用"),
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingWorkerId => f.write_str("worker_id 不能为空"),
Self::MissingOrderId => f.write_str("recharge.order_id 不能为空"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingProductTitle => f.write_str("recharge.product_title 不能为空"),
+1 -1
View File
@@ -16,7 +16,7 @@ serde_json = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
shared-contracts = { workspace = true }
time = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
+67 -2
View File
@@ -24,7 +24,7 @@ use shared_contracts::runtime::{
WechatH5PaymentResponse, WechatMiniProgramPayParamsResponse, WechatNativePaymentResponse,
};
use std::convert::TryInto;
use time::OffsetDateTime;
use time::{Duration as TimeDuration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::warn;
use url::Url;
@@ -47,6 +47,7 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
const WECHAT_NATIVE_PAY_EXPIRE_SECONDS: i64 = 5 * 60;
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
@@ -134,6 +135,7 @@ pub enum WechatPayError {
Disabled,
InvalidConfig(String),
InvalidRequest(String),
OrderNotExist(String),
RequestFailed(String),
Upstream(String),
Deserialize(String),
@@ -192,6 +194,7 @@ struct WechatNativeOrderRequest<'a> {
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatNativeSceneInfo<'a>,
@@ -257,6 +260,14 @@ struct WechatPayQueryOrderResponse {
success_time: Option<String>,
}
#[derive(Deserialize)]
struct WechatPayErrorResponse {
#[serde(default)]
code: Option<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Deserialize)]
struct WechatVirtualPaymentNotifyBody {
#[serde(rename = "Event", alias = "event")]
@@ -569,11 +580,16 @@ impl RealWechatPayClient {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_NATIVE_PAY_EXPIRE_SECONDS);
let expires_at_text =
format_wechat_pay_rfc3339_seconds(expires_at, "微信支付 Native 过期时间")?;
let body = serde_json::to_string(&WechatNativeOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
@@ -611,7 +627,10 @@ impl RealWechatPayClient {
)
})?;
Ok(WechatNativePaymentResponse { code_url })
Ok(WechatNativePaymentResponse {
code_url,
expires_at: expires_at_text,
})
}
async fn post_wechat_json(
@@ -680,6 +699,7 @@ impl RealWechatPayClient {
let pay_sign = self.sign_message(&message)?;
Ok(WechatMiniProgramPayParamsResponse {
app_id: Some(self.app_id.clone()),
time_stamp,
nonce_str,
package,
@@ -755,6 +775,17 @@ impl RealWechatPayClient {
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
})?;
if !status.is_success() {
if let Ok(payload) = serde_json::from_str::<WechatPayErrorResponse>(&response_text)
&& payload.code.as_deref() == Some("ORDER_NOT_EXIST")
{
return Err(WechatPayError::OrderNotExist(
payload
.message
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| "微信支付订单不存在".to_string()),
));
}
return Err(WechatPayError::Upstream(format!(
"微信支付查单失败:HTTP {status}{response_text}"
)));
@@ -856,6 +887,7 @@ fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
let pay_sign = hex_sha256(format!("{time_stamp}\n{nonce_str}\n{package}\n").as_bytes());
WechatMiniProgramPayParamsResponse {
app_id: Some("wx-mock-app".to_string()),
time_stamp,
nonce_str,
package,
@@ -873,12 +905,28 @@ fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
}
}
fn format_wechat_pay_rfc3339_seconds(
value: OffsetDateTime,
context: &str,
) -> Result<String, WechatPayError> {
let value = value.replace_nanosecond(0).map_err(|error| {
WechatPayError::InvalidRequest(format!("{context} 秒级时间规整失败:{error}"))
})?;
value
.format(&Rfc3339)
.map_err(|error| WechatPayError::InvalidRequest(format!("{context} 格式化失败:{error}")))
}
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_NATIVE_PAY_EXPIRE_SECONDS);
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
hex_sha256(order_id.as_bytes())
),
expires_at: format_wechat_pay_rfc3339_seconds(expires_at, "mock 微信支付 Native 过期时间")
.unwrap_or_else(|_| expires_at.to_string()),
}
}
@@ -1552,6 +1600,7 @@ impl std::fmt::Display for WechatPayError {
Self::Disabled => formatter.write_str("微信支付暂未启用"),
Self::InvalidConfig(message)
| Self::InvalidRequest(message)
| Self::OrderNotExist(message)
| Self::RequestFailed(message)
| Self::Upstream(message)
| Self::Deserialize(message)
@@ -1639,6 +1688,7 @@ mod tests {
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
@@ -1655,12 +1705,27 @@ mod tests {
.expect("Native order response should deserialize");
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(
response.code_url.as_deref(),
Some("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
);
}
#[test]
fn wechat_pay_expire_time_uses_rfc3339_without_fractional_seconds() {
let value = OffsetDateTime::from_unix_timestamp(1_768_398_476)
.expect("timestamp should be valid")
.replace_nanosecond(395_297_400)
.expect("nanosecond should be valid");
let formatted =
format_wechat_pay_rfc3339_seconds(value, "测试时间").expect("time should format");
assert_eq!(formatted, "2026-01-14T13:47:56Z");
assert!(!formatted.contains('.'));
}
#[test]
fn transaction_endpoints_reuse_configured_wechat_pay_origin() {
let h5_endpoint = resolve_wechat_pay_transaction_endpoint(
@@ -238,6 +238,8 @@ pub struct ProfileRechargeOrderResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WechatMiniProgramPayParamsResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_id: Option<String>,
pub time_stamp: String,
pub nonce_str: String,
pub package: String,
@@ -271,6 +273,7 @@ pub struct WechatH5PaymentResponse {
#[serde(rename_all = "camelCase")]
pub struct WechatNativePaymentResponse {
pub code_url: String,
pub expires_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -1496,6 +1499,7 @@ mod tests {
}),
wechat_native_payment: Some(WechatNativePaymentResponse {
code_url: "weixin://pay.weixin.qq.com/bizpayurl/up?pr=test".to_string(),
expires_at: "2026-05-15T10:05:00Z".to_string(),
}),
})
.expect("payload should serialize");
@@ -1508,6 +1512,10 @@ mod tests {
payload["wechatNativePayment"]["codeUrl"],
json!("weixin://pay.weixin.qq.com/bizpayurl/up?pr=test")
);
assert_eq!(
payload["wechatNativePayment"]["expiresAt"],
json!("2026-05-15T10:05:00Z")
);
}
#[test]

Some files were not shown because too many files have changed in this diff Show More