接入微信 Native 充值与订单过期处理
调整网页端充值渠道,微信内浏览器在开放平台接入前走 Native 支付。 拆分 Native 支付二维码弹窗,展示金额和充值内容并取消无效 code_url 跳转。 修复微信 Native 下单 time_expire 秒级 RFC3339 格式,避免小数秒参数错误。 新增充值订单 5 分钟过期调度,过期前查询微信真实订单状态。 补充微信授权绑定、路由加载恢复和后台表时间显示等配套逻辑。 更新 SpacetimeDB schema、绑定和相关文档测试。
This commit is contained in:
@@ -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. 后台“充值商品”页维护泥点和会员商品配置,保存后影响新的充值中心快照、下单和支付确认;历史订单保留下单时快照。
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+196
-102
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 不能为空"),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user