diff --git a/.claude/skills/project-management/SKILL.md b/.claude/skills/project-management/SKILL.md index ce7ae76..f91e05a 100644 --- a/.claude/skills/project-management/SKILL.md +++ b/.claude/skills/project-management/SKILL.md @@ -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: diff --git a/docs/development/branch-workflow.md b/docs/development/branch-workflow.md index 0e1d31a..ae74818 100644 --- a/docs/development/branch-workflow.md +++ b/docs/development/branch-workflow.md @@ -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/-`。 +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/`。 diff --git a/scripts/git-hooks/post-checkout b/scripts/git-hooks/post-checkout new file mode 100755 index 0000000..14c001f --- /dev/null +++ b/scripts/git-hooks/post-checkout @@ -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 diff --git a/scripts/git-hooks/post-commit b/scripts/git-hooks/post-commit new file mode 100755 index 0000000..24741a8 --- /dev/null +++ b/scripts/git-hooks/post-commit @@ -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 diff --git a/scripts/git-hooks/post-merge b/scripts/git-hooks/post-merge new file mode 100755 index 0000000..24741a8 --- /dev/null +++ b/scripts/git-hooks/post-merge @@ -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 diff --git a/scripts/git-hooks/post-rewrite b/scripts/git-hooks/post-rewrite new file mode 100755 index 0000000..24741a8 --- /dev/null +++ b/scripts/git-hooks/post-rewrite @@ -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 diff --git a/scripts/gitnexus-refresh.sh b/scripts/gitnexus-refresh.sh new file mode 100755 index 0000000..cc7541b --- /dev/null +++ b/scripts/gitnexus-refresh.sh @@ -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 diff --git a/server-api/.env.example b/server-api/.env.example index cea5618..89a4421 100644 --- a/server-api/.env.example +++ b/server-api/.env.example @@ -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= diff --git a/server-api/app/support/ShouqianbaConfigService.php b/server-api/app/support/ShouqianbaConfigService.php index ff675ea..eeb03ac 100644 --- a/server-api/app/support/ShouqianbaConfigService.php +++ b/server-api/app/support/ShouqianbaConfigService.php @@ -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 diff --git a/server-api/app/support/ShouqianbaPaymentService.php b/server-api/app/support/ShouqianbaPaymentService.php index c36c9bf..90432e3 100644 --- a/server-api/app/support/ShouqianbaPaymentService.php +++ b/server-api/app/support/ShouqianbaPaymentService.php @@ -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); diff --git a/server-api/tools/shouqianba_payment_mock_test.php b/server-api/tools/shouqianba_payment_mock_test.php index 2e0184a..c441e33 100644 --- a/server-api/tools/shouqianba_payment_mock_test.php +++ b/server-api/tools/shouqianba_payment_mock_test.php @@ -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', diff --git a/user-app/src/pages/order/index.vue b/user-app/src/pages/order/index.vue index 5c1b3d7..d347440 100644 --- a/user-app/src/pages/order/index.vue +++ b/user-app/src/pages/order/index.vue @@ -10,6 +10,7 @@ const orders = ref([]); 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, "订单加载失败"); diff --git a/user-app/src/utils/auth.ts b/user-app/src/utils/auth.ts index e2c0400..cb487bd 100644 --- a/user-app/src/utils/auth.ts +++ b/user-app/src/utils/auth.ts @@ -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;