diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 8b3390e..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,5 @@ - \ No newline at end of file diff --git a/README.en.md b/README.en.md index bf2381d..85d8c85 100644 --- a/README.en.md +++ b/README.en.md @@ -34,15 +34,15 @@ Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](htt ## Tech Stack -| Category | Technology | -|----------|------------| -| Framework | Next.js 16 (App Router) | -| Language | TypeScript 5 + React 19 | -| Styling | TailwindCSS 4 | -| ORM | Prisma 7 (adapter-pg mode) | -| Database | PostgreSQL 16 | -| Container | Docker + Docker Compose | -| Package Manager | pnpm | +| Category | Technology | +| --------------- | -------------------------- | +| Framework | Next.js 16 (App Router) | +| Language | TypeScript 5 + React 19 | +| Styling | TailwindCSS 4 | +| ORM | Prisma 7 (adapter-pg mode) | +| Database | PostgreSQL 16 | +| Container | Docker + Docker Compose | +| Package Manager | pnpm | --- @@ -85,12 +85,12 @@ See [`.env.example`](./.env.example) for the full template. ### Core (Required) -| Variable | Description | -|----------|-------------| -| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` | -| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key | -| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) | -| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` | +| Variable | Description | +| ----------------------- | ---------------------------------------------------------- | +| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` | +| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key | +| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) | +| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` | > `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database. @@ -127,49 +127,50 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such > **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider. -| Variable | Description | -|----------|-------------| -| `EASY_PAY_PID` | EasyPay merchant ID | -| `EASY_PAY_PKEY` | EasyPay merchant secret key | -| `EASY_PAY_API_BASE` | EasyPay API base URL | +| Variable | Description | +| --------------------- | ---------------------------------------------------------------- | +| `EASY_PAY_PID` | EasyPay merchant ID | +| `EASY_PAY_PKEY` | EasyPay merchant secret key | +| `EASY_PAY_API_BASE` | EasyPay API base URL | | `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` | -| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` | -| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) | -| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) | +| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` | +| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) | +| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) | #### Stripe -| Variable | Description | -|----------|-------------| -| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) | -| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) | -| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | +| Variable | Description | +| ------------------------ | ------------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) | +| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | > Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook` > Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed` ### Business Rules -| Variable | Description | Default | -|----------|-------------|---------| -| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` | -| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` | -| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` | -| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` | -| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` | +| Variable | Description | Default | +| --------------------------- | ----------------------------------------------- | -------------------------- | +| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` | +| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` | +| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` | +| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` | +| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` | ### UI Customization (Optional) Display a support contact image and description on the right side of the payment page. -| Variable | Description | -|----------|-------------| -| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) | -| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` | +| Variable | Description | +| -------------------- | ------------------------------------------------------------------------------- | +| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) | +| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` | **Two ways to provide the image:** - **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting). + ```env PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg ``` @@ -188,9 +189,9 @@ Display a support contact image and description on the right side of the payment ### Docker Compose Variables -| Variable | Description | Default | -|----------|-------------|---------| -| `APP_PORT` | Host port mapping | `3001` | +| Variable | Description | Default | +| ------------- | -------------------------------- | ------------------------------------- | +| `APP_PORT` | Host port mapping | `3001` | | `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) | --- @@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy The following page URLs can be configured in the Sub2API admin panel: -| Page | URL | Description | -|------|-----|-------------| -| Payment | `https://pay.example.com/pay` | User recharge entry | -| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history | -| Order Management | `https://pay.example.com/admin` | Sub2API admin only | +| Page | URL | Description | +| ---------------- | ------------------------------------ | ------------------------------------- | +| Payment | `https://pay.example.com/pay` | User recharge entry | +| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history | +| Order Management | `https://pay.example.com/admin` | Sub2API admin only | Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed: -| Parameter | Description | -|-----------|-------------| -| `user_id` | Sub2API user ID | -| `token` | User login token (required to view order history) | -| `theme` | `light` (default) or `dark` | +| Parameter | Description | +| --------- | ------------------------------------------------- | +| `user_id` | Sub2API user ID | +| `token` | User login token (required to view order history) | +| `theme` | `light` (default) or `dark` | | `ui_mode` | `standalone` (default) or `embedded` (for iframe) | --- @@ -287,13 +288,13 @@ Sub2API **v0.1.88** and above will automatically append the following parameters Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN` -| Feature | Description | -|---------|-------------| -| Order List | Filter by status, paginate, choose 20/50/100 per page | -| Order Detail | View all fields and audit log timeline | -| Retry Recharge | Re-trigger recharge for paid-but-failed orders | -| Cancel Order | Force-cancel pending orders | -| Refund | Issue refund and deduct Sub2API balance | +| Feature | Description | +| -------------- | ----------------------------------------------------- | +| Order List | Filter by status, paginate, choose 20/50/100 per page | +| Order Detail | View all fields and audit log timeline | +| Retry Recharge | Re-trigger recharge for paid-but-failed orders | +| Cancel Order | Force-cancel pending orders | +| Refund | Issue refund and deduct Sub2API balance | --- diff --git a/README.md b/README.md index 648a46b..df97151 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,15 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值 ## 技术栈 -| 类别 | 技术 | -|------|------| -| 框架 | Next.js 16 (App Router) | -| 语言 | TypeScript 5 + React 19 | -| 样式 | TailwindCSS 4 | -| ORM | Prisma 7(adapter-pg 模式) | -| 数据库 | PostgreSQL 16 | -| 容器 | Docker + Docker Compose | -| 包管理 | pnpm | +| 类别 | 技术 | +| ------ | --------------------------- | +| 框架 | Next.js 16 (App Router) | +| 语言 | TypeScript 5 + React 19 | +| 样式 | TailwindCSS 4 | +| ORM | Prisma 7(adapter-pg 模式) | +| 数据库 | PostgreSQL 16 | +| 容器 | Docker + Docker Compose | +| 包管理 | pnpm | --- @@ -85,12 +85,12 @@ docker compose up -d --build ### 核心(必填) -| 变量 | 说明 | -|------|------| -| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` | -| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 | -| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) | -| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` | +| 变量 | 说明 | +| ----------------------- | ---------------------------------------------- | +| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` | +| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 | +| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) | +| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` | > `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。 @@ -127,49 +127,50 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay > **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。 -| 变量 | 说明 | -|------|------| -| `EASY_PAY_PID` | EasyPay 商户 ID | -| `EASY_PAY_PKEY` | EasyPay 商户密钥 | -| `EASY_PAY_API_BASE` | EasyPay API 地址 | +| 变量 | 说明 | +| --------------------- | ------------------------------------------------------------- | +| `EASY_PAY_PID` | EasyPay 商户 ID | +| `EASY_PAY_PKEY` | EasyPay 商户密钥 | +| `EASY_PAY_API_BASE` | EasyPay API 地址 | | `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` | -| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` | -| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) | -| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) | +| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` | +| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) | +| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) | #### Stripe -| 变量 | 说明 | -|------|------| -| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) | -| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) | -| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) | +| 变量 | 说明 | +| ------------------------ | -------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) | +| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) | > Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook` > 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed` ### 业务规则 -| 变量 | 说明 | 默认值 | -|------|------|--------| -| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` | -| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` | -| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` | -| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` | -| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` | +| 变量 | 说明 | 默认值 | +| --------------------------- | ---------------------------------- | -------------------------- | +| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` | +| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` | +| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` | +| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` | +| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` | ### UI 定制(可选) 在充值页面右侧可展示客服联系方式、说明图片等帮助内容。 -| 变量 | 说明 | -|------|------| -| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) | -| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` | +| 变量 | 说明 | +| -------------------- | --------------------------------------------------------------- | +| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) | +| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` | **图片地址两种方式:** - **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。 + ```env PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg ``` @@ -188,9 +189,9 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay ### Docker Compose 专用 -| 变量 | 说明 | 默认值 | -|------|------|--------| -| `APP_PORT` | 宿主机映射端口 | `3001` | +| 变量 | 说明 | 默认值 | +| ------------- | ----------------------------------- | ---------------------------- | +| `APP_PORT` | 宿主机映射端口 | `3001` | | `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`(**生产请修改**) | --- @@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy 在 Sub2API 管理后台可配置以下页面链接: -| 页面 | 链接 | 说明 | -|------|------|------| -| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 | -| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 | -| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 | +| 页面 | 链接 | 说明 | +| -------- | ------------------------------------ | ----------------------- | +| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 | +| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 | +| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 | Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加: -| 参数 | 说明 | -|------|------| -| `user_id` | Sub2API 用户 ID | -| `token` | 用户登录 Token(有 token 才能查看订单历史) | -| `theme` | `light`(默认)或 `dark` | +| 参数 | 说明 | +| --------- | ------------------------------------------------ | +| `user_id` | Sub2API 用户 ID | +| `token` | 用户登录 Token(有 token 才能查看订单历史) | +| `theme` | `light`(默认)或 `dark` | | `ui_mode` | `standalone`(默认)或 `embedded`(iframe 嵌入) | --- @@ -287,13 +288,13 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添 访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN` -| 功能 | 说明 | -|------|------| +| 功能 | 说明 | +| -------- | ------------------------------------------- | | 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 | -| 订单详情 | 查看完整字段与操作审计日志 | -| 重试充值 | 对已支付但充值失败的订单重新发起充值 | -| 取消订单 | 强制取消待支付订单 | -| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 | +| 订单详情 | 查看完整字段与操作审计日志 | +| 重试充值 | 对已支付但充值失败的订单重新发起充值 | +| 取消订单 | 强制取消待支付订单 | +| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 | --- diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index af40d44..22e5d96 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -16,7 +16,13 @@ interface DashboardData { avgAmount: number; }; dailySeries: { date: string; amount: number; count: number }[]; - leaderboard: { userId: number; userName: string | null; userEmail: string | null; totalAmount: number; orderCount: number }[]; + leaderboard: { + userId: number; + userName: string | null; + userEmail: string | null; + totalAmount: number; + orderCount: number; + }[]; paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[]; meta: { days: number; generatedAt: string }; } @@ -79,7 +85,9 @@ function DashboardContent() { const btnBase = [ 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', - isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', + isDark + ? 'border-slate-600 text-slate-200 hover:bg-slate-800' + : 'border-slate-300 text-slate-700 hover:bg-slate-100', ].join(' '); const btnActive = [ @@ -97,12 +105,7 @@ function DashboardContent() { actions={ <> {DAYS_OPTIONS.map((d) => ( - ))} @@ -116,7 +119,9 @@ function DashboardContent() { } > {error && ( -
+
{error}
{/* Table */} -
+
{loading ? (
加载中...
) : ( - + )}
@@ -236,7 +255,10 @@ function AdminContent() { pageSize={pageSize} loading={loading} onPageChange={(p) => setPage(p)} - onPageSizeChange={(s) => { setPageSize(s); setPage(1); }} + onPageSizeChange={(s) => { + setPageSize(s); + setPage(1); + }} isDark={isDark} /> diff --git a/src/app/api/admin/dashboard/route.ts b/src/app/api/admin/dashboard/route.ts index e3d04c3..efb0db1 100644 --- a/src/app/api/admin/dashboard/route.ts +++ b/src/app/api/admin/dashboard/route.ts @@ -71,7 +71,13 @@ export async function GET(request: NextRequest) { `, // Leaderboard: GROUP BY user_id only, MAX() for name/email prisma.$queryRaw< - { user_id: number; user_name: string | null; user_email: string | null; total_amount: string; order_count: bigint }[] + { + user_id: number; + user_name: string | null; + user_email: string | null; + total_amount: string; + order_count: bigint; + }[] >` SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email, SUM(amount)::text as total_amount, COUNT(*) as order_count diff --git a/src/app/api/admin/orders/[id]/cancel/route.ts b/src/app/api/admin/orders/[id]/cancel/route.ts index 5c88f3c..1279301 100644 --- a/src/app/api/admin/orders/[id]/cancel/route.ts +++ b/src/app/api/admin/orders/[id]/cancel/route.ts @@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { adminCancelOrder, OrderError } from '@/lib/order/service'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!await verifyAdminToken(request)) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); try { const { id } = await params; diff --git a/src/app/api/admin/orders/[id]/retry/route.ts b/src/app/api/admin/orders/[id]/retry/route.ts index a5764fd..e858892 100644 --- a/src/app/api/admin/orders/[id]/retry/route.ts +++ b/src/app/api/admin/orders/[id]/retry/route.ts @@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { retryRecharge, OrderError } from '@/lib/order/service'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!await verifyAdminToken(request)) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); try { const { id } = await params; diff --git a/src/app/api/admin/orders/[id]/route.ts b/src/app/api/admin/orders/[id]/route.ts index 6fe2af1..c498fbb 100644 --- a/src/app/api/admin/orders/[id]/route.ts +++ b/src/app/api/admin/orders/[id]/route.ts @@ -3,7 +3,7 @@ import { prisma } from '@/lib/db'; import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - if (!await verifyAdminToken(request)) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); const { id } = await params; diff --git a/src/app/api/admin/orders/route.ts b/src/app/api/admin/orders/route.ts index df7e82c..5cd2f3b 100644 --- a/src/app/api/admin/orders/route.ts +++ b/src/app/api/admin/orders/route.ts @@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; import { Prisma, OrderStatus } from '@prisma/client'; export async function GET(request: NextRequest) { - if (!await verifyAdminToken(request)) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); const searchParams = request.nextUrl.searchParams; const page = Math.max(1, Number(searchParams.get('page') || '1')); diff --git a/src/app/api/admin/refund/route.ts b/src/app/api/admin/refund/route.ts index 2a4bd44..f3846f1 100644 --- a/src/app/api/admin/refund/route.ts +++ b/src/app/api/admin/refund/route.ts @@ -10,7 +10,7 @@ const refundSchema = z.object({ }); export async function POST(request: NextRequest) { - if (!await verifyAdminToken(request)) return unauthorizedResponse(); + if (!(await verifyAdminToken(request))) return unauthorizedResponse(); try { const body = await request.json(); diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index cdea249..462fa2c 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -27,9 +27,6 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ received: true }); } catch (error) { console.error('Stripe webhook error:', error); - return NextResponse.json( - { error: 'Webhook processing failed' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Webhook processing failed' }, { status: 400 }); } } diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index a32d41f..6779fef 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -11,10 +11,7 @@ export async function GET(request: NextRequest) { try { const env = getEnv(); - const [user, methodLimits] = await Promise.all([ - getUser(userId), - queryMethodLimits(env.ENABLED_PAYMENT_TYPES), - ]); + const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]); return NextResponse.json({ user: { @@ -29,9 +26,10 @@ export async function GET(request: NextRequest) { methodLimits, helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null, helpText: env.PAY_HELP_TEXT ?? null, - stripePublishableKey: env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY - ? env.STRIPE_PUBLISHABLE_KEY - : null, + stripePublishableKey: + env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY + ? env.STRIPE_PUBLISHABLE_KEY + : null, }, }); } catch (error) { diff --git a/src/app/pay/orders/page.tsx b/src/app/pay/orders/page.tsx index 4dfe829..ee2c35e 100644 --- a/src/app/pay/orders/page.tsx +++ b/src/app/pay/orders/page.tsx @@ -134,17 +134,20 @@ function OrdersContent() { loadOrders(1, newSize); }; - const filteredOrders = - activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter); + const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter); const btnClass = [ 'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors', - isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100', + isDark + ? 'border-slate-600 text-slate-200 hover:bg-slate-800' + : 'border-slate-300 text-slate-700 hover:bg-slate-100', ].join(' '); if (isMobile) { return ( -
+
正在切换到移动端订单 Tab...
); @@ -178,8 +181,14 @@ function OrdersContent() { subtitle={userInfo?.username || `用户 #${effectiveUserId}`} actions={ <> - - {!srcHost && 返回充值} + + {!srcHost && ( + + 返回充值 + + )} } > @@ -208,7 +217,13 @@ function OrdersContent() { export default function OrdersPage() { return ( -
加载中...
}> + +
加载中...
+
+ } + > ); diff --git a/src/app/pay/stripe-popup/page.tsx b/src/app/pay/stripe-popup/page.tsx index 6012307..ecb709d 100644 --- a/src/app/pay/stripe-popup/page.tsx +++ b/src/app/pay/stripe-popup/page.tsx @@ -72,16 +72,18 @@ function StripePopupContent() { if (isAlipay) { // Alipay: confirm directly and redirect, no Payment Element needed - stripe.confirmAlipayPayment(clientSecret, { - return_url: buildReturnUrl(), - }).then((result) => { - if (cancelled) return; - if (result.error) { - setStripeError(result.error.message || '支付失败,请重试'); - setStripeLoaded(true); - } - // If no error, the page has already been redirected - }); + stripe + .confirmAlipayPayment(clientSecret, { + return_url: buildReturnUrl(), + }) + .then((result) => { + if (cancelled) return; + if (result.error) { + setStripeError(result.error.message || '支付失败,请重试'); + setStripeLoaded(true); + } + // If no error, the page has already been redirected + }); return; } @@ -97,7 +99,9 @@ function StripePopupContent() { setStripeLoaded(true); }); }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [credentials, isDark, isAlipay, buildReturnUrl]); // Mount Payment Element (only for non-alipay methods) @@ -151,12 +155,12 @@ function StripePopupContent() { if (!credentials) { return (
-
+
- - 正在初始化... - + 正在初始化...
@@ -167,18 +171,19 @@ function StripePopupContent() { if (isAlipay) { return (
-
+
-
{'\u00A5'}{amount.toFixed(2)}
-

- 订单号: {orderId} -

+
+ {'\u00A5'} + {amount.toFixed(2)} +
+

订单号: {orderId}

{stripeError ? (
-
- {stripeError} -
+
{stripeError}
)} {popupBlocked && ( -
+
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
)} @@ -437,7 +455,12 @@ export default function PaymentQRCode({ ) : ( <> {qrDataUrl && ( -
+
{imageLoading && (
@@ -465,7 +488,12 @@ export default function PaymentQRCode({ {!qrDataUrl && !payUrl && (
-
+

{TEXT_SCAN_PAY}

@@ -484,7 +512,9 @@ export default function PaymentQRCode({ onClick={onBack} className={[ 'flex-1 rounded-lg border py-2 text-sm', - dark ? 'border-slate-700 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50', + dark + ? 'border-slate-700 text-slate-300 hover:bg-slate-800' + : 'border-gray-300 text-gray-600 hover:bg-gray-50', ].join(' ')} > {TEXT_BACK} diff --git a/src/components/admin/DailyChart.tsx b/src/components/admin/DailyChart.tsx index be078a2..692777b 100644 --- a/src/components/admin/DailyChart.tsx +++ b/src/components/admin/DailyChart.tsx @@ -51,7 +51,8 @@ function CustomTooltip({

{label}

{payload.map((p) => (

- {p.dataKey === 'amount' ? '金额' : '笔数'}: {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value} + {p.dataKey === 'amount' ? '金额' : '笔数'}:{' '} + {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}

))}
@@ -63,8 +64,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) { const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0; if (data.length === 0) { return ( -
-

每日充值趋势

+
+

+ 每日充值趋势 +

暂无数据

); @@ -74,8 +82,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) { const gridColor = dark ? '#334155' : '#e2e8f0'; return ( -
-

每日充值趋势

+
+

+ 每日充值趋势 +

diff --git a/src/components/admin/DashboardStats.tsx b/src/components/admin/DashboardStats.tsx index e25b886..6f55c98 100644 --- a/src/components/admin/DashboardStats.tsx +++ b/src/components/admin/DashboardStats.tsx @@ -29,24 +29,14 @@ export default function DashboardStats({ summary, dark }: DashboardStatsProps) { key={card.label} className={[ 'rounded-xl border p-4', - dark - ? 'border-slate-700 bg-slate-800/60' - : 'border-slate-200 bg-white shadow-sm', + dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm', ].join(' ')} > -

- {card.label} -

+

{card.label}

{card.value} diff --git a/src/components/admin/Leaderboard.tsx b/src/components/admin/Leaderboard.tsx index f70c7b8..00a34bc 100644 --- a/src/components/admin/Leaderboard.tsx +++ b/src/components/admin/Leaderboard.tsx @@ -26,15 +26,27 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) { if (data.length === 0) { return ( -

-

充值排行榜 (Top 10)

+
+

+ 充值排行榜 (Top 10) +

暂无数据

); } return ( -
+

充值排行榜 (Top 10)

@@ -56,7 +68,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) { {rankStyle ? ( - + {rank} ) : ( @@ -71,7 +85,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
)} - + ¥{entry.totalAmount.toLocaleString()} {entry.orderCount} diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index ecc1815..43f7946 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -84,7 +84,10 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) >

订单详情

-
@@ -103,16 +106,31 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)

审计日志

{order.auditLogs.map((log) => ( -
+
{log.action} - {new Date(log.createdAt).toLocaleString('zh-CN')} + + {new Date(log.createdAt).toLocaleString('zh-CN')} +
- {log.detail &&
{log.detail}
} - {log.operator &&
操作者: {log.operator}
} + {log.detail && ( +
+ {log.detail} +
+ )} + {log.operator && ( +
+ 操作者: {log.operator} +
+ )}
))} - {order.auditLogs.length === 0 &&
暂无日志
} + {order.auditLogs.length === 0 && ( +
暂无日志
+ )}
diff --git a/src/components/admin/OrderTable.tsx b/src/components/admin/OrderTable.tsx index efff1c1..6a7331a 100644 --- a/src/components/admin/OrderTable.tsx +++ b/src/components/admin/OrderTable.tsx @@ -70,7 +70,10 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da return ( - @@ -79,21 +82,27 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da {order.userEmail || '-'} {order.userNotes || '-'} - ¥{order.amount.toFixed(2)} + + ¥{order.amount.toFixed(2)} + - + {statusInfo.label} - {order.paymentType === 'alipay' ? '支付宝' : order.paymentType === 'wechat' ? '微信支付' : order.paymentType === 'stripe' ? 'Stripe' : order.paymentType} - - - {order.srcHost || '-'} - - - {new Date(order.createdAt).toLocaleString('zh-CN')} + {order.paymentType === 'alipay' + ? '支付宝' + : order.paymentType === 'wechat' + ? '微信支付' + : order.paymentType === 'stripe' + ? 'Stripe' + : order.paymentType} + {order.srcHost || '-'} + {new Date(order.createdAt).toLocaleString('zh-CN')}
{order.rechargeRetryable && ( @@ -119,7 +128,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da })} - {orders.length === 0 &&
暂无订单
} + {orders.length === 0 && ( +
暂无订单
+ )}
); } diff --git a/src/components/admin/PaymentMethodChart.tsx b/src/components/admin/PaymentMethodChart.tsx index 05be717..0fe3bdf 100644 --- a/src/components/admin/PaymentMethodChart.tsx +++ b/src/components/admin/PaymentMethodChart.tsx @@ -21,16 +21,30 @@ const TYPE_CONFIG: Record -

支付方式分布

+
+

+ 支付方式分布 +

暂无数据

); } return ( -
-

支付方式分布

+
+

+ 支付方式分布 +

{data.map((method) => { const config = TYPE_CONFIG[method.paymentType] || { @@ -46,7 +60,11 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro ¥{method.amount.toLocaleString()} · {method.percentage}%
-
+
{ function assertAlipayEnv(env: ReturnType) { if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) { - throw new Error( - 'Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required', - ); + throw new Error('Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required'); } return env as typeof env & { ALIPAY_APP_ID: string; diff --git a/src/lib/alipay/provider.ts b/src/lib/alipay/provider.ts index 7347750..59e5a0e 100644 --- a/src/lib/alipay/provider.ts +++ b/src/lib/alipay/provider.ts @@ -93,9 +93,8 @@ export class AlipayProvider implements PaymentProvider { tradeNo: params.trade_no || '', orderId: params.out_trade_no || '', amount: parseFloat(params.total_amount || '0'), - status: params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' - ? 'success' - : 'failed', + status: + params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed', rawData: params, }; } diff --git a/src/lib/config.ts b/src/lib/config.ts index f64fc56..948c0fc 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -16,7 +16,12 @@ const envSchema = z.object({ PAYMENT_PROVIDERS: z .string() .default('') - .transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)), + .transform((v) => + v + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ), // ── Easy-Pay(PAYMENT_PROVIDERS 含 easypay 时必填) ── EASY_PAY_PID: optionalTrimmedString, @@ -55,9 +60,21 @@ const envSchema = z.object({ // 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。 // 未设置时由各 PaymentProvider.defaultLimits 提供默认值。 - MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()), - MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()), - MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()), + MAX_DAILY_AMOUNT_ALIPAY: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().min(0).optional()), + MAX_DAILY_AMOUNT_WXPAY: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().min(0).optional()), + MAX_DAILY_AMOUNT_STRIPE: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().min(0).optional()), PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'), ADMIN_TOKEN: z.string().min(1), diff --git a/src/lib/easy-pay/provider.ts b/src/lib/easy-pay/provider.ts index df6bd9b..5832795 100644 --- a/src/lib/easy-pay/provider.ts +++ b/src/lib/easy-pay/provider.ts @@ -18,7 +18,7 @@ export class EasyPayProvider implements PaymentProvider { readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay']; readonly defaultLimits = { alipay: { singleMax: 1000, dailyMax: 10000 }, - wxpay: { singleMax: 1000, dailyMax: 10000 }, + wxpay: { singleMax: 1000, dailyMax: 10000 }, }; async createPayment(request: CreatePaymentRequest): Promise { diff --git a/src/lib/order/fee.ts b/src/lib/order/fee.ts index 16fb626..b1733f6 100644 --- a/src/lib/order/fee.ts +++ b/src/lib/order/fee.ts @@ -33,6 +33,6 @@ export function getMethodFeeRate(paymentType: string): number { */ export function calculatePayAmount(rechargeAmount: number, feeRate: number): number { if (feeRate <= 0) return rechargeAmount; - const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100; + const feeAmount = Math.ceil(((rechargeAmount * feeRate) / 100) * 100) / 100; return Math.round((rechargeAmount + feeAmount) * 100) / 100; } diff --git a/src/lib/order/limits.ts b/src/lib/order/limits.ts index cd198ba..71cfc2a 100644 --- a/src/lib/order/limits.ts +++ b/src/lib/order/limits.ts @@ -64,9 +64,7 @@ export interface MethodLimitStatus { * 批量查询多个支付渠道的今日使用情况。 * 一次 DB groupBy 完成,调用方按需传入渠道列表。 */ -export async function queryMethodLimits( - paymentTypes: string[], -): Promise> { +export async function queryMethodLimits(paymentTypes: string[]): Promise> { const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); @@ -80,9 +78,7 @@ export async function queryMethodLimits( _sum: { amount: true }, }); - const usageMap = Object.fromEntries( - usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]), - ); + const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)])); const result: Record = {}; for (const type of paymentTypes) { diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index dcb5008..a6594b8 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -65,11 +65,7 @@ export async function createOrder(input: CreateOrderInput): Promise env.MAX_DAILY_RECHARGE_AMOUNT) { const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid); - throw new OrderError( - 'DAILY_LIMIT_EXCEEDED', - `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, - 429, - ); + throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, 429); } } @@ -605,7 +601,12 @@ export async function processRefund(input: RefundInput): Promise { }); } - await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`); + await subtractBalance( + order.userId, + rechargeAmount, + `sub2apipay refund order:${order.id}`, + `sub2apipay:refund:${order.id}`, + ); await prisma.order.update({ where: { id: input.orderId }, diff --git a/src/lib/payment/index.ts b/src/lib/payment/index.ts index b701d54..16a85a7 100644 --- a/src/lib/payment/index.ts +++ b/src/lib/payment/index.ts @@ -51,7 +51,7 @@ export function initPaymentProviders(): void { if (unsupported.length > 0) { throw new Error( `ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` + - `请检查 PAYMENT_PROVIDERS 配置`, + `请检查 PAYMENT_PROVIDERS 配置`, ); } diff --git a/src/lib/stripe/provider.ts b/src/lib/stripe/provider.ts index 6e5bbdc..618b6ce 100644 --- a/src/lib/stripe/provider.ts +++ b/src/lib/stripe/provider.ts @@ -67,7 +67,10 @@ export class StripeProvider implements PaymentProvider { }; } - async verifyNotification(rawBody: string | Buffer, headers: Record): Promise { + async verifyNotification( + rawBody: string | Buffer, + headers: Record, + ): Promise { const stripe = this.getClient(); const env = getEnv(); if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');