fix: improve h5 payment return flow
This commit is contained in:
@@ -11,6 +11,8 @@ Use the outer repository as the single source of truth for all project work. Do
|
||||
|
||||
Do not manage release artifacts in Git. Files under `releases/` and `releases_dev/`, including zip packages, APKs, and checksum files, are local delivery artifacts and must not be committed or pushed. Put production packages in `releases/` and test packages in `releases_dev/`.
|
||||
|
||||
Local development and the test server share the test database connection. Store the real host, username, and password only in ignored `.env` files or server environment variables. The shared test database name is `test_anxinyan`; never commit real database passwords to docs, skills, examples, or Git-tracked templates.
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `master`: stable, release-ready mainline. Do not do daily development directly here.
|
||||
@@ -52,15 +54,42 @@ Before committing, run:
|
||||
```bash
|
||||
git status -sb
|
||||
git diff --check
|
||||
npx gitnexus detect-changes --scope all
|
||||
npx gitnexus detect-changes --scope all --repo anxinyan
|
||||
```
|
||||
|
||||
Before changing code symbols, follow the repository `AGENTS.md` GitNexus rule: run upstream impact analysis for the target symbol and report the blast radius. Before committing any change, run GitNexus detect changes.
|
||||
|
||||
Use GitNexus with the branch workflow:
|
||||
|
||||
- Before changing unfamiliar code, use GitNexus query/context to locate the relevant flow.
|
||||
- Before editing any function, class, or method, run upstream impact analysis for that symbol.
|
||||
- Before committing, run detect-changes against the current diff; this remains a required check even when hooks are enabled.
|
||||
- After commits, merges, branch checkouts, and rewrites, local hooks refresh the index with `npx gitnexus analyze --index-only --name anxinyan .`.
|
||||
|
||||
Enable the versioned lightweight hooks once per clone:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
The hook refresh is best-effort and non-blocking. Disable it temporarily with `GITNEXUS_AUTO_REFRESH=0` if needed. Manual refresh is available with:
|
||||
|
||||
```bash
|
||||
./scripts/gitnexus-refresh.sh
|
||||
```
|
||||
|
||||
For release packaging or deployment preparation, also follow the release checklist and packaging skill. At minimum, run the relevant backend smoke/audit scripts and frontend checks for the surfaces touched.
|
||||
|
||||
Production packages should stay under the ignored local `releases/` directory and use `https://api.anxinjianyan.com`. Test packages should stay under the ignored local `releases_dev/` directory and use `https://test.api.anxinjianyan.com`. Hand off both kinds of artifacts separately from Git branches.
|
||||
|
||||
For backend local/test validation, confirm `server-api/.env` is ignored before using real database credentials:
|
||||
|
||||
```bash
|
||||
git check-ignore -v server-api/.env
|
||||
```
|
||||
|
||||
Before committing environment-related documentation, scan tracked docs and examples to ensure no real `DB_PASSWORD` value is present.
|
||||
|
||||
## Documentation Source
|
||||
|
||||
The human-readable branch workflow is documented in:
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
`releases/` 和 `releases_dev/` 下的 zip、apk、校验文件等发布产物只作为本地交付物管理,不纳入 Git 分支管理范围,也不要提交或推送到远程仓库。正式包放 `releases/`,测试包放 `releases_dev/`。
|
||||
|
||||
本地开发环境和测试服共用测试数据库连接,测试库名为 `test_anxinyan`。真实的数据库 host、用户名和密码只允许放在被忽略的 `server-api/.env` 或服务器环境变量中,不写入 README、规范文档、Skill、`.env.example` 或其他可提交模板。
|
||||
|
||||
## 长期分支
|
||||
|
||||
| 分支 | 用途 | 维护规则 |
|
||||
@@ -44,13 +46,41 @@ git switch -c feature/admin-report-export
|
||||
```bash
|
||||
git status -sb
|
||||
git diff --check
|
||||
npx gitnexus detect-changes --scope all
|
||||
npx gitnexus detect-changes --scope all --repo anxinyan
|
||||
```
|
||||
|
||||
如果修改了 PHP 后端文件,补充运行相关 PHP 语法检查或项目脚本;如果修改了前端,按影响端运行对应的类型检查或构建。正式包发版前按上线检查清单执行 `server-api/tools/release_audit.php`、`server-api/tools/smoke_check.php` 和相关客户端构建;测试包构建前确认各端测试环境配置指向 `https://test.api.anxinjianyan.com`。
|
||||
|
||||
正式包生成后保留在本地 `releases/` 目录,测试包生成后保留在本地 `releases_dev/` 目录,并按需另行交付;Git 提交中不包含这些产物。
|
||||
|
||||
涉及本地或测试服数据库配置时,提交前确认 `server-api/.env` 仍被 Git 忽略,并检查 README、docs、`.claude` 和 `.env.example` 等可提交文件中没有真实数据库密码:
|
||||
|
||||
```bash
|
||||
git check-ignore -v server-api/.env
|
||||
```
|
||||
|
||||
## GitNexus 索引协同
|
||||
|
||||
Git 分支负责协作路径,GitNexus 负责改动影响面。推荐节奏:
|
||||
|
||||
1. 开发前在 `develop` 上创建 `feature/<scope>-<name>`。
|
||||
2. 不熟悉代码时先用 GitNexus query/context 找入口和流程。
|
||||
3. 修改函数、类或方法前先做 upstream impact analysis。
|
||||
4. 提交前运行 `npx gitnexus detect-changes --scope all --repo anxinyan`。
|
||||
5. 提交、合并、切换分支或 rebase 后,由本地 hook 异步刷新 GitNexus 索引。
|
||||
|
||||
团队成员首次 clone 后执行一次:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath scripts/git-hooks
|
||||
```
|
||||
|
||||
自动刷新只执行 `npx gitnexus analyze --index-only --name anxinyan .`,不会更新 `AGENTS.md`、`CLAUDE.md` 或 GitNexus skills。它不替代提交前的 detect-changes 检查。需要临时关闭时,在 Git 命令前加 `GITNEXUS_AUTO_REFRESH=0`;需要手动刷新时运行:
|
||||
|
||||
```bash
|
||||
./scripts/gitnexus-refresh.sh
|
||||
```
|
||||
|
||||
## 发版流程
|
||||
|
||||
1. 从最新 `develop` 创建 `release/<version-or-date>`。
|
||||
|
||||
18
scripts/git-hooks/post-checkout
Executable file
18
scripts/git-hooks/post-checkout
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Third argument is 1 for branch checkout and 0 for file checkout.
|
||||
[ "${3:-0}" = "1" ] || exit 0
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
case "$git_dir" in
|
||||
/*) ;;
|
||||
*) git_dir="$repo_root/$git_dir" ;;
|
||||
esac
|
||||
|
||||
(
|
||||
cd "$repo_root" || exit 0
|
||||
./scripts/gitnexus-refresh.sh
|
||||
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
|
||||
|
||||
exit 0
|
||||
15
scripts/git-hooks/post-commit
Executable file
15
scripts/git-hooks/post-commit
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
case "$git_dir" in
|
||||
/*) ;;
|
||||
*) git_dir="$repo_root/$git_dir" ;;
|
||||
esac
|
||||
|
||||
(
|
||||
cd "$repo_root" || exit 0
|
||||
./scripts/gitnexus-refresh.sh
|
||||
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
|
||||
|
||||
exit 0
|
||||
15
scripts/git-hooks/post-merge
Executable file
15
scripts/git-hooks/post-merge
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
case "$git_dir" in
|
||||
/*) ;;
|
||||
*) git_dir="$repo_root/$git_dir" ;;
|
||||
esac
|
||||
|
||||
(
|
||||
cd "$repo_root" || exit 0
|
||||
./scripts/gitnexus-refresh.sh
|
||||
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
|
||||
|
||||
exit 0
|
||||
15
scripts/git-hooks/post-rewrite
Executable file
15
scripts/git-hooks/post-rewrite
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
||||
case "$git_dir" in
|
||||
/*) ;;
|
||||
*) git_dir="$repo_root/$git_dir" ;;
|
||||
esac
|
||||
|
||||
(
|
||||
cd "$repo_root" || exit 0
|
||||
./scripts/gitnexus-refresh.sh
|
||||
) >>"$git_dir/gitnexus-refresh.log" 2>&1 &
|
||||
|
||||
exit 0
|
||||
54
scripts/gitnexus-refresh.sh
Executable file
54
scripts/gitnexus-refresh.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${GITNEXUS_AUTO_REFRESH:-1}" = "0" ]; then
|
||||
echo "GitNexus auto refresh disabled by GITNEXUS_AUTO_REFRESH=0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
echo "Not inside a git repository"
|
||||
exit 1
|
||||
}
|
||||
cd "$repo_root"
|
||||
|
||||
git_dir=$(git rev-parse --git-dir)
|
||||
case "$git_dir" in
|
||||
/*) ;;
|
||||
*) git_dir="$repo_root/$git_dir" ;;
|
||||
esac
|
||||
|
||||
head_commit=$(git rev-parse HEAD 2>/dev/null || true)
|
||||
if [ -z "$head_commit" ]; then
|
||||
echo "No HEAD commit found; skip GitNexus refresh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
stamp_file="$git_dir/gitnexus-indexed-head"
|
||||
lock_dir="$git_dir/gitnexus-refresh.lock"
|
||||
|
||||
if [ -f "$stamp_file" ] && [ "$(cat "$stamp_file" 2>/dev/null || true)" = "$head_commit" ]; then
|
||||
echo "GitNexus index already refreshed for $head_commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! mkdir "$lock_dir" 2>/dev/null; then
|
||||
echo "GitNexus refresh already running; skip"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rmdir "$lock_dir" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
echo "GitNexus refresh started for $head_commit"
|
||||
|
||||
if npx gitnexus analyze --index-only --name anxinyan .; then
|
||||
printf '%s\n' "$head_commit" > "$stamp_file"
|
||||
echo "GitNexus refresh completed for $head_commit"
|
||||
else
|
||||
status=$?
|
||||
echo "GitNexus refresh failed with exit code $status"
|
||||
exit "$status"
|
||||
fi
|
||||
@@ -8,9 +8,9 @@ PUBLIC_FILE_BASE_URL=
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=anxinyan
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=change_me
|
||||
DB_DATABASE=test_anxinyan
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_PREFIX=
|
||||
|
||||
|
||||
@@ -88,7 +88,11 @@ class ShouqianbaConfigService
|
||||
return '';
|
||||
}
|
||||
|
||||
return $baseUrl . '/#/pages/order/detail?id=' . $orderId;
|
||||
$fallbackQuery = http_build_query([
|
||||
'sqb_return_order_id' => $orderId,
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
return $baseUrl . '/?' . $fallbackQuery . '#/pages/order/detail?id=' . $orderId;
|
||||
}
|
||||
|
||||
public function miniProgramCallbackPath(int $orderId): string
|
||||
|
||||
@@ -54,6 +54,11 @@ class ShouqianbaPaymentService
|
||||
|
||||
$latest = $this->latestPayment($orderId);
|
||||
if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') {
|
||||
if ($this->shouldRefreshH5ReturnUrl($latest, $order)) {
|
||||
$replacement = $this->createPayment($order);
|
||||
$this->markPaymentReplaced($latest);
|
||||
return $replacement;
|
||||
}
|
||||
return $this->buildPaymentLaunchPayload($latest, $order);
|
||||
}
|
||||
|
||||
@@ -281,6 +286,44 @@ class ShouqianbaPaymentService
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function shouldRefreshH5ReturnUrl(array $payment, array $order): bool
|
||||
{
|
||||
if ((string)$order['source_channel'] !== 'h5') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
|
||||
if ($expectedUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requestJson = json_decode((string)($payment['request_json'] ?? ''), true);
|
||||
if (!is_array($requestJson)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$body = $requestJson['body'] ?? ($requestJson['request']['body'] ?? null);
|
||||
if (!is_array($body)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (string)($body['return_url'] ?? '') !== $expectedUrl
|
||||
|| (string)($body['back_url'] ?? '') !== $expectedUrl;
|
||||
}
|
||||
|
||||
private function markPaymentReplaced(array $payment): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
Db::name('shouqianba_payments')
|
||||
->where('id', (int)$payment['id'])
|
||||
->whereIn('status', ['pending', 'created'])
|
||||
->update([
|
||||
'status' => 'replaced',
|
||||
'cancelled_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private function queryRemotePayment(array $payment): array
|
||||
{
|
||||
$config = $this->configService->assertReady(true);
|
||||
|
||||
@@ -320,6 +320,32 @@ try {
|
||||
assertTrue(($launch['status'] ?? '') === 'pending', 'purchase should create pending payment');
|
||||
assertTrue(($launch['cashier_url'] ?? '') !== '', 'purchase cashier_url missing');
|
||||
$payment = latestPayment($notifyOrderId);
|
||||
$requestJson = json_decode((string)$payment['request_json'], true);
|
||||
$purchaseBody = is_array($requestJson) ? ($requestJson['body'] ?? []) : [];
|
||||
assertTrue(
|
||||
($purchaseBody['return_url'] ?? '') === 'https://m.example.com/?sqb_return_order_id=' . $notifyOrderId . '#/pages/order/detail?id=' . $notifyOrderId,
|
||||
'purchase return_url should include H5 order detail fallback'
|
||||
);
|
||||
assertTrue(($purchaseBody['back_url'] ?? '') === ($purchaseBody['return_url'] ?? ''), 'purchase back_url should match return_url');
|
||||
|
||||
$staleReturnOrderId = createMockOrder($userId, 'STALERETURN');
|
||||
$service->createOrReusePayment($staleReturnOrderId);
|
||||
$stalePayment = latestPayment($staleReturnOrderId);
|
||||
$staleRequestJson = json_decode((string)$stalePayment['request_json'], true);
|
||||
if (is_array($staleRequestJson)) {
|
||||
$staleRequestJson['body']['return_url'] = 'https://m.example.com/#/pages/order/detail?id=' . $staleReturnOrderId;
|
||||
$staleRequestJson['body']['back_url'] = $staleRequestJson['body']['return_url'];
|
||||
Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->update([
|
||||
'request_json' => json_encode($staleRequestJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
$replacement = $service->createOrReusePayment($staleReturnOrderId);
|
||||
$stalePaymentAfterReplace = Db::name('shouqianba_payments')->where('id', (int)$stalePayment['id'])->find();
|
||||
$latestReplacement = latestPayment($staleReturnOrderId);
|
||||
assertTrue((string)($stalePaymentAfterReplace['status'] ?? '') === 'replaced', 'stale H5 payment should be marked replaced');
|
||||
assertTrue((int)$latestReplacement['id'] !== (int)$stalePayment['id'], 'stale H5 payment should be replaced with a new payment row');
|
||||
assertTrue(($replacement['check_sn'] ?? '') === (string)$latestReplacement['check_sn'], 'replacement launch payload should use the new payment row');
|
||||
|
||||
$notifyPayload = [
|
||||
'check_sn' => $payment['check_sn'],
|
||||
'order_status' => '4',
|
||||
|
||||
@@ -10,6 +10,7 @@ const orders = ref<OrderListItem[]>([]);
|
||||
const privacyMode = ref(getPrivacyMode());
|
||||
const orderHeroBackground = ref("");
|
||||
const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
|
||||
let orderRefreshTicket = 0;
|
||||
|
||||
const orderHeroStyle = computed(() => ({
|
||||
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
|
||||
@@ -67,17 +68,59 @@ async function fetchPageVisuals() {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSyncPaymentStatus(item: OrderListItem) {
|
||||
return item.order_status === "pending_payment" && item.payment_status !== "paid";
|
||||
}
|
||||
|
||||
async function syncPendingPaymentOrders(list: OrderListItem[]) {
|
||||
const pendingOrders = list.filter(shouldSyncPaymentStatus);
|
||||
if (!pendingOrders.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
pendingOrders.map(async (item) => {
|
||||
try {
|
||||
const data = await appApi.getOrderPaymentStatus(item.order_id);
|
||||
return data.order_status !== item.order_status
|
||||
|| data.payment_status !== item.payment_status
|
||||
|| data.display_status !== item.display_status;
|
||||
} catch (error) {
|
||||
console.warn("order payment status sync skipped", error);
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
async function refreshOrdersWithPaymentSync() {
|
||||
const ticket = ++orderRefreshTicket;
|
||||
const data = await appApi.getOrders();
|
||||
if (ticket !== orderRefreshTicket) return;
|
||||
|
||||
orders.value = data.list;
|
||||
const needsRefresh = await syncPendingPaymentOrders(data.list);
|
||||
if (!needsRefresh || ticket !== orderRefreshTicket) return;
|
||||
|
||||
const refreshed = await appApi.getOrders();
|
||||
if (ticket === orderRefreshTicket) {
|
||||
orders.value = refreshed.list;
|
||||
}
|
||||
}
|
||||
|
||||
onShow(async () => {
|
||||
privacyMode.value = getPrivacyMode();
|
||||
void fetchPageVisuals();
|
||||
if (!isLoggedIn()) {
|
||||
orderRefreshTicket += 1;
|
||||
orders.value = [];
|
||||
redirectToLogin("/pages/order/index");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await appApi.getOrders();
|
||||
orders.value = data.list;
|
||||
await refreshOrdersWithPaymentSync();
|
||||
} catch (error) {
|
||||
orders.value = [];
|
||||
showErrorToast(error, "订单加载失败");
|
||||
|
||||
@@ -192,7 +192,35 @@ export function redirectToLogin(targetUrl?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function consumeShouqianbaH5Return() {
|
||||
// #ifdef H5
|
||||
const url = new URL(window.location.href);
|
||||
const rawOrderId = url.searchParams.get("sqb_return_order_id") || "";
|
||||
const orderId = Number(rawOrderId);
|
||||
if (!Number.isInteger(orderId) || orderId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
url.searchParams.delete("sqb_return_order_id");
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
|
||||
const targetUrl = `/pages/order/detail?id=${orderId}`;
|
||||
if (!isLoggedIn()) {
|
||||
redirectToLogin(targetUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
uni.reLaunch({ url: targetUrl });
|
||||
return true;
|
||||
// #endif
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ensureAuthenticatedPageAccess() {
|
||||
if (consumeShouqianbaH5Return()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = getCurrentPageUrl();
|
||||
if (!currentUrl || !isAuthRequiredPage(currentUrl) || isLoggedIn()) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user