feat: add configurable payments and Gitea staging delivery
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / validate (push) Failing after 5m10s
Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) / publish-and-deploy (push) Has been skipped

Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest.
This commit is contained in:
linmeng
2026-07-27 20:19:05 +08:00
parent 9dc115509e
commit 1b8d6fcce6
37 changed files with 1257 additions and 28 deletions
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const root = new URL("../", import.meta.url);
const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8");
const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8");
const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8");
const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8");
test("支付后台接口只允许管理员并查询平台订单", () => {
assert.match(route, /isAdminUser\(user\)/);
assert.match(route, /from\("payment_orders"\)/);
assert.match(route, /auth\.admin\.getUserById/);
assert.match(route, /payment_packages\(name\)/);
assert.match(route, /order_no|orderNo/);
assert.doesNotMatch(route, /SUPABASE_SERVICE_ROLE_KEY/);
assert.doesNotMatch(route, /raw_notify_payload/);
});
test("支付接口包含筛选、统计和分页契约", () => {
for (const field of ["status", "from", "to", "limit", "offset"]) assert.match(route, new RegExp(field));
for (const field of ["totalOrders", "paidOrders", "pendingOrders", "failedExpiredOrders", "paidAmountCents", "grantedCredits"]) assert.match(route, new RegExp(field));
assert.match(route, /max\(100\)/);
assert.match(route, /count: "exact"/);
assert.match(route, /hasMore/);
assert.match(migration, /get_payment_order_stats/);
assert.match(migration, /status = 'paid'/);
});
test("后台支付页面与现有套餐页有入口", () => {
assert.match(page, /平台支付统计/);
assert.match(page, /支付记录/);
assert.match(page, /paidAmountCents/);
assert.match(page, /上一页/);
assert.match(page, /下一页/);
assert.match(packagesPage, /href="\/admin\/payments"/);
});
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const root = new URL("../", import.meta.url);
const adminSource = readFileSync(new URL("src/lib/supabase/admin.ts", root), "utf8");
const layoutSource = readFileSync(new URL("src/app/admin/layout.tsx", root), "utf8");
const codesSource = readFileSync(new URL("src/app/api/admin/codes/route.ts", root), "utf8");
const accountSource = readFileSync(new URL("src/app/api/account/route.ts", root), "utf8");
const usersSource = readFileSync(new URL("src/app/api/admin/users/route.ts", root), "utf8");
const migration = readFileSync(new URL("supabase/migrations/20260727010000_admin_users.sql", root), "utf8");
test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => {
assert.match(adminSource, /configured/);
assert.match(adminSource, /split\(\",\"\)/);
assert.match(adminSource, /toLowerCase/);
assert.match(adminSource, /export function isAdminEmail/);
});
test("admin surfaces await database-backed administrator checks", () => {
assert.match(adminSource, /export async function isAdminUser/);
assert.match(adminSource, /from\("admin_users"\)/);
assert.match(layoutSource, /await isAdminUser\(user\)/);
assert.match(codesSource, /await isAdminUser\(user\)/);
assert.match(accountSource, /isAdmin: await isAdminUser\(user\)/);
});
test("admin_users migration is service-role-only and auditable", () => {
assert.match(migration, /user_id uuid primary key references auth\.users\(id\)/);
assert.match(migration, /created_at timestamptz/);
assert.match(migration, /created_by uuid/);
assert.match(migration, /revoked_at timestamptz/);
assert.match(migration, /revoked_by uuid/);
assert.match(migration, /enable row level security/);
assert.match(migration, /revoke all on table public\.admin_users from anon, authenticated/);
assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/);
});
test("admin users route exposes guarded list, add, and soft revoke contracts", () => {
assert.match(usersSource, /export async function GET/);
assert.match(usersSource, /export async function POST/);
assert.match(usersSource, /export async function DELETE/);
assert.match(usersSource, /auth\.admin\.listUsers/);
assert.match(usersSource, /upsert\(\{ user_id: target\.id, created_by: auth\.user\.id/);
assert.match(usersSource, /revoked_at: new Date\(\)\.toISOString\(\)/);
assert.match(usersSource, /环境配置管理员不可撤销/);
});
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { epayCanonical, epaySign } from "../src/lib/epay/sign";
const migration = readFileSync(new URL("../supabase/migrations/20260727020000_epay_packages_orders.sql", import.meta.url), "utf8");
test("易支付签名过滤空值并按键排序", () => {
const params = { money: "10.00", pid: "10001", name: "套餐", empty: "", sign_type: "MD5" };
assert.equal(epayCanonical(params), "money=10.00&name=套餐&pid=10001");
assert.equal(epaySign(params, "secret"), "79dd3a13f9fd32622fa2197c0a2d7b66");
});
test("支付迁移包含套餐、订单、payment 类型与原子结算", () => {
assert.match(migration, /create table public\.payment_packages/);
assert.match(migration, /create table public\.payment_orders/);
assert.match(migration, /transaction_type in \('redeem', 'reserve', 'refund', 'payment'\)/);
assert.match(migration, /settle_epay_order/);
assert.match(migration, /on conflict \(user_id, transaction_type, request_id\) do nothing/);
});
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
@@ -30,10 +30,13 @@ const syncScript = new URL(
"../../deploy/sync-staging-tree.sh",
import.meta.url,
);
const giteaWorkflowDirectory = new URL("../../.gitea/workflows/", import.meta.url);
const giteaQualityWorkflow = new URL(
"../../.gitea/workflows/backend-quality-gate.yml",
import.meta.url,
"backend-quality-gate.yml",
giteaWorkflowDirectory,
);
const giteaDeployWorkflow = new URL("deploy-staging.yml", giteaWorkflowDirectory);
const giteaMigrationWorkflow = new URL("migrate-staging-database.yml", giteaWorkflowDirectory);
function read(url: URL): string {
return readFileSync(url, "utf8");
@@ -380,21 +383,76 @@ test("production remains manual-only and separate from staging database automati
assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/);
});
test("Gitea staging push uses the xiaoxin Linux runner and immutable ACR images", () => {
test("all Gitea workflows use xiaoxin, native checkout, and safe triggers", () => {
const names = readdirSync(giteaWorkflowDirectory)
.filter((name) => name.endsWith(".yml"));
assert.ok(names.length > 0);
const workflows = new Map(names.map((name) => [name, read(new URL(name, giteaWorkflowDirectory))]));
for (const [name, workflow] of workflows) {
const jobs = workflow.match(/^\s{4}runs-on:\s*(.+)$/gm) ?? [];
assert.ok(jobs.length > 0, `${name} has no jobs`);
assert.ok(jobs.every((line) => line.trim() === "runs-on: xiaoxin"), name);
assert.doesNotMatch(workflow, /ubuntu-latest|github\.com\/actions|actions\/(?:checkout|setup-)|GITEA_OUTPUT/, name);
assert.match(workflow, /git init \./, name);
assert.match(workflow, /git fetch --no-tags origin/, name);
}
const stagingPushOwners = [...workflows]
.filter(([, workflow]) => /push:\n\s+branches:\s*\[staging\]/.test(workflow))
.map(([name]) => name);
assert.deepEqual(stagingPushOwners, ["backend-quality-gate.yml"]);
for (const name of [
"ci.yml",
"deploy-production.yml",
"deploy-staging.yml",
"migrate-staging-database.yml",
"apply-supabase-profile-migrations.yml",
"release-quality-gate.yml",
"test.yml",
"publish-pypi.yml",
]) {
const workflow = workflows.get(name) ?? "";
assert.match(workflow, /^on:\n\s+workflow_dispatch:/m, name);
assert.doesNotMatch(workflow, /\n\s+(?:push|pull_request|workflow_run):/, name);
}
const all = [...workflows.values()].join("\n");
assert.doesNotMatch(all, /GITEA_REGISTRY_USERNAME|GITEA_REGISTRY_TOKEN|git\.copse\.top\/root\/jyotisha-(?:api|web)/);
});
test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => {
const workflow = read(giteaQualityWorkflow);
assert.equal(workflow.match(/runs-on: xiaoxin/g)?.length, 2);
assert.match(workflow, /set -euo pipefail/);
assert.equal(workflow.match(/git fetch --no-tags origin/g)?.length, 2);
assert.doesNotMatch(workflow, /github\.com\/actions/);
assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/);
assert.match(workflow, /gitea\.event_name == 'push'.*refs\/heads\/staging/);
assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/);
assert.match(workflow, /secrets\.REGISTRY_USERNAME/);
assert.match(workflow, /secrets\.REGISTRY_PASSWORD/);
assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/);
assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/);
assert.match(workflow, /api_ref=.*RepoDigests/);
assert.match(workflow, /web_ref=.*RepoDigests/);
assert.match(workflow, /API_IMAGE='\$api_image'.*bash '\$incoming\/deploy\/run-staging-deploy\.sh'/);
assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/);
assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/);
assert.match(workflow, /scp_options=\(-i "\$key_path" -P "\$DEPLOY_PORT"/);
assert.doesNotMatch(workflow, /shell: powershell|17631000304|copse\.ai\.2026/);
});
test("manual Gitea staging deploy and migration use shared ACR digests and live previous SHA", () => {
const deployment = read(giteaDeployWorkflow);
const migration = read(giteaMigrationWorkflow);
for (const workflow of [deployment, migration]) {
assert.match(workflow, /deploy_sha:/);
assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" origin\/main/);
assert.match(workflow, /IMAGE_REPOSITORY: crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/);
assert.match(workflow, /secrets\.REGISTRY_USERNAME/);
assert.match(workflow, /secrets\.REGISTRY_PASSWORD/);
assert.match(workflow, /STAGING_KNOWN_HOSTS/);
assert.match(workflow, /previous_sha="\$\(ssh/);
assert.doesNotMatch(workflow, /EXPECTED_PREVIOUS_SHA='not-deployed'/);
}
assert.match(deployment, /allow_rollback:/);
assert.match(deployment, /default forward-only deployment refused/);
assert.match(deployment, /run-staging-deploy\.sh/);
assert.match(migration, /run-staging-migration\.sh/);
assert.doesNotMatch(migration, /\n\s+push:|workflow_run:/);
assert.match(read(migrationScript), /crpi-d1feco6itet73spp\\\.cn-hongkong\\\.personal\\\.cr\\\.aliyuncs\\\.com\/copse\/jyotisha@sha256/);
});
test("staging scripts pass shell syntax validation", () => {