feat: migrate payment provider to easy-pay, add order history and refund support
- Replace zpay with easy-pay payment provider (new lib/easy-pay/ module) - Add order history page for users (pay/orders) - Add GET /api/orders/my endpoint to list user's own orders - Add GET /api/users/[id] endpoint for sub2api user lookup - Add order status tracking module (lib/order/status.ts) - Update config to support easy-pay credentials (merchant ID, key, gateway) - Update PaymentForm and PaymentQRCode components for easy-pay flow - Update pay page and admin page with new order management UI - Update order service to support easy-pay, cancellation, and refund
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
third-party
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.md
|
||||||
|
.claude
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# third-party source code (local reference only)
|
||||||
|
/third-party
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm prisma generate
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY --from=builder /app/node_modules/.pnpm ./node_modules/.pnpm
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=builder /app/start.sh ./start.sh
|
||||||
|
RUN chmod +x start.sh && \
|
||||||
|
PRISMA_PKG=$(find node_modules/.pnpm -path '*/prisma/build/index.js' -type f | head -1 | sed 's|/build/index.js||') && \
|
||||||
|
ln -s /app/$PRISMA_PKG node_modules/prisma
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
CMD ["./start.sh"]
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
15
docker-compose.prod.yml
Normal file
15
docker-compose.prod.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: sub2apipay:latest
|
||||||
|
container_name: sub2apipay
|
||||||
|
ports:
|
||||||
|
- "8087:3000"
|
||||||
|
env_file: .env
|
||||||
|
networks:
|
||||||
|
- sub2api-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sub2api-network:
|
||||||
|
external: true
|
||||||
|
name: sub2api-star_sub2api-network
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3001}:3000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sub2apipay
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
|
POSTGRES_DB: sub2apipay
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U sub2apipay"]
|
||||||
|
interval: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "sub2apipay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "7.4.1",
|
||||||
|
"@prisma/client": "^7.4.2",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"pg": "^8.19.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"prisma",
|
||||||
|
"@prisma/engines",
|
||||||
|
"esbuild"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"prisma": "7.4.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
5744
pnpm-lock.yaml
generated
Normal file
5744
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- '@prisma/engines'
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
8
prisma.config.ts
Normal file
8
prisma.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay',
|
||||||
|
},
|
||||||
|
});
|
||||||
71
prisma/migrations/20260228000000_init/migration.sql
Normal file
71
prisma/migrations/20260228000000_init/migration.sql
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "orders" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"user_email" TEXT,
|
||||||
|
"user_name" TEXT,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"recharge_code" TEXT NOT NULL,
|
||||||
|
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"payment_type" TEXT NOT NULL,
|
||||||
|
"zpay_trade_no" TEXT,
|
||||||
|
"pay_url" TEXT,
|
||||||
|
"qr_code" TEXT,
|
||||||
|
"qr_code_img" TEXT,
|
||||||
|
"refund_amount" DECIMAL(10,2),
|
||||||
|
"refund_reason" TEXT,
|
||||||
|
"refund_at" TIMESTAMP(3),
|
||||||
|
"force_refund" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"paid_at" TIMESTAMP(3),
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
"failed_at" TIMESTAMP(3),
|
||||||
|
"failed_reason" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"client_ip" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"order_id" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"detail" TEXT,
|
||||||
|
"operator" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "orders_recharge_code_key" ON "orders"("recharge_code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "orders_user_id_idx" ON "orders"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "orders_status_idx" ON "orders"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "orders_expires_at_idx" ON "orders"("expires_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "orders_created_at_idx" ON "orders"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_logs_order_id_idx" ON "audit_logs"("order_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
72
prisma/schema.prisma
Normal file
72
prisma/schema.prisma
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId Int @map("user_id")
|
||||||
|
userEmail String? @map("user_email")
|
||||||
|
userName String? @map("user_name")
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
rechargeCode String @unique @map("recharge_code")
|
||||||
|
status OrderStatus @default(PENDING)
|
||||||
|
paymentType String @map("payment_type")
|
||||||
|
|
||||||
|
zpayTradeNo String? @map("zpay_trade_no")
|
||||||
|
payUrl String? @map("pay_url")
|
||||||
|
qrCode String? @map("qr_code")
|
||||||
|
qrCodeImg String? @map("qr_code_img")
|
||||||
|
|
||||||
|
refundAmount Decimal? @db.Decimal(10, 2) @map("refund_amount")
|
||||||
|
refundReason String? @map("refund_reason")
|
||||||
|
refundAt DateTime? @map("refund_at")
|
||||||
|
forceRefund Boolean @default(false) @map("force_refund")
|
||||||
|
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
paidAt DateTime? @map("paid_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
failedAt DateTime? @map("failed_at")
|
||||||
|
failedReason String? @map("failed_reason")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
clientIp String? @map("client_ip")
|
||||||
|
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("orders")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderStatus {
|
||||||
|
PENDING
|
||||||
|
PAID
|
||||||
|
RECHARGING
|
||||||
|
COMPLETED
|
||||||
|
EXPIRED
|
||||||
|
CANCELLED
|
||||||
|
FAILED
|
||||||
|
REFUNDING
|
||||||
|
REFUNDED
|
||||||
|
REFUND_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
orderId String @map("order_id")
|
||||||
|
order Order @relation(fields: [orderId], references: [id])
|
||||||
|
action String
|
||||||
|
detail String? @db.Text
|
||||||
|
operator String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
21
src/__tests__/lib/order/code-gen.test.ts
Normal file
21
src/__tests__/lib/order/code-gen.test.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateRechargeCode } from '@/lib/order/code-gen';
|
||||||
|
|
||||||
|
describe('generateRechargeCode', () => {
|
||||||
|
it('should generate code with s2p_ prefix', () => {
|
||||||
|
const code = generateRechargeCode('cm1234567890');
|
||||||
|
expect(code).toBe('s2p_cm1234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long order IDs to fit 32 chars', () => {
|
||||||
|
const longId = 'a'.repeat(50);
|
||||||
|
const code = generateRechargeCode(longId);
|
||||||
|
expect(code.length).toBeLessThanOrEqual(32);
|
||||||
|
expect(code.startsWith('s2p_')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const code = generateRechargeCode('');
|
||||||
|
expect(code).toBe('s2p_');
|
||||||
|
});
|
||||||
|
});
|
||||||
80
src/__tests__/lib/sub2api/client.test.ts
Normal file
80
src/__tests__/lib/sub2api/client.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getEnv: () => ({
|
||||||
|
SUB2API_BASE_URL: 'https://test.sub2api.com',
|
||||||
|
SUB2API_ADMIN_API_KEY: 'admin-testkey123',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
|
describe('Sub2API Client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUser should return user data', async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'test@example.com',
|
||||||
|
status: 'active',
|
||||||
|
balance: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ data: mockUser }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await getUser(1);
|
||||||
|
expect(user.username).toBe('testuser');
|
||||||
|
expect(user.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getUser should throw USER_NOT_FOUND for 404', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createAndRedeem should send correct request', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
code: 1,
|
||||||
|
redeem_code: {
|
||||||
|
id: 1,
|
||||||
|
code: 's2p_test123',
|
||||||
|
type: 'balance',
|
||||||
|
value: 100,
|
||||||
|
status: 'used',
|
||||||
|
used_by: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||||
|
expect(result.code).toBe('s2p_test123');
|
||||||
|
|
||||||
|
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
||||||
|
const headers = fetchCall[1].headers;
|
||||||
|
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subtractBalance should send subtract request', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
|
||||||
|
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||||
|
|
||||||
|
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
const body = JSON.parse(fetchCall[1].body);
|
||||||
|
expect(body.operation).toBe('subtract');
|
||||||
|
expect(body.amount).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/__tests__/lib/zpay/client.test.ts
Normal file
90
src/__tests__/lib/zpay/client.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getEnv: () => ({
|
||||||
|
ZPAY_PID: 'test_pid',
|
||||||
|
ZPAY_PKEY: 'test_pkey',
|
||||||
|
ZPAY_API_BASE: 'https://test.zpay.com',
|
||||||
|
ZPAY_NOTIFY_URL: 'https://test.com/api/zpay/notify',
|
||||||
|
ZPAY_RETURN_URL: 'https://test.com/pay/result',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createPayment, queryOrder, refund } from '@/lib/zpay/client';
|
||||||
|
|
||||||
|
describe('ZPAY Client', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPayment should post to mapi.php and return result', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
code: 1,
|
||||||
|
trade_no: 'zpay_123',
|
||||||
|
payurl: 'https://pay.example.com',
|
||||||
|
qrcode: 'https://qr.example.com',
|
||||||
|
img: 'https://img.example.com/qr.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createPayment({
|
||||||
|
outTradeNo: 'test_order_1',
|
||||||
|
amount: '10.00',
|
||||||
|
paymentType: 'alipay',
|
||||||
|
clientIp: '127.0.0.1',
|
||||||
|
productName: 'Test Product',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.trade_no).toBe('zpay_123');
|
||||||
|
expect(result.payurl).toBe('https://pay.example.com');
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'https://test.zpay.com/mapi.php',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPayment should throw on error response', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ code: 0, msg: 'insufficient balance' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPayment({
|
||||||
|
outTradeNo: 'test_order_2',
|
||||||
|
amount: '10.00',
|
||||||
|
paymentType: 'alipay',
|
||||||
|
clientIp: '127.0.0.1',
|
||||||
|
productName: 'Test Product',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('ZPAY create payment failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryOrder should fetch order status', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
code: 1,
|
||||||
|
trade_no: 'zpay_123',
|
||||||
|
out_trade_no: 'test_order_1',
|
||||||
|
status: 1,
|
||||||
|
money: '10.00',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await queryOrder('test_order_1');
|
||||||
|
expect(result.status).toBe(1);
|
||||||
|
expect(result.money).toBe('10.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refund should post refund request', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ code: 1, msg: '退款成功' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await refund('zpay_123', 'test_order_1', '10.00');
|
||||||
|
expect(result.code).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/__tests__/lib/zpay/sign.test.ts
Normal file
56
src/__tests__/lib/zpay/sign.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateSign, verifySign } from '@/lib/zpay/sign';
|
||||||
|
|
||||||
|
describe('ZPAY Sign', () => {
|
||||||
|
const pkey = 'YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k';
|
||||||
|
|
||||||
|
it('should generate correct sign with sorted params', () => {
|
||||||
|
const params = {
|
||||||
|
pid: '2026022720004756',
|
||||||
|
type: 'alipay',
|
||||||
|
out_trade_no: '20160806151343349',
|
||||||
|
notify_url: 'http://www.aaa.com/notify_url.php',
|
||||||
|
name: 'test product',
|
||||||
|
money: '1.00',
|
||||||
|
return_url: 'http://www.aaa.com/return_url.php',
|
||||||
|
};
|
||||||
|
const sign = generateSign(params, pkey);
|
||||||
|
expect(sign).toMatch(/^[a-f0-9]{32}$/); // md5 lowercase hex
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out empty values, sign and sign_type', () => {
|
||||||
|
const params = {
|
||||||
|
a: '1',
|
||||||
|
b: '',
|
||||||
|
sign: 'xxx',
|
||||||
|
sign_type: 'MD5',
|
||||||
|
c: '3',
|
||||||
|
};
|
||||||
|
const sign = generateSign(params, pkey);
|
||||||
|
// Should only use a=1&c=3 + pkey
|
||||||
|
const expected = generateSign({ a: '1', c: '3' }, pkey);
|
||||||
|
expect(sign).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort params by ASCII order', () => {
|
||||||
|
const params1 = { z: '1', a: '2', m: '3' };
|
||||||
|
const params2 = { a: '2', m: '3', z: '1' };
|
||||||
|
expect(generateSign(params1, pkey)).toBe(generateSign(params2, pkey));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify valid signature', () => {
|
||||||
|
const params = { a: '1', b: '2' };
|
||||||
|
const sign = generateSign(params, pkey);
|
||||||
|
expect(verifySign(params, pkey, sign)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid signature', () => {
|
||||||
|
const params = { a: '1', b: '2' };
|
||||||
|
expect(verifySign(params, pkey, 'invalidsignature1234567890123456')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject signature with wrong length', () => {
|
||||||
|
const params = { a: '1', b: '2' };
|
||||||
|
expect(verifySign(params, pkey, 'short')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
src/app/admin/page.tsx
Normal file
208
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||||
|
import OrderTable from '@/components/admin/OrderTable';
|
||||||
|
import OrderDetail from '@/components/admin/OrderDetail';
|
||||||
|
|
||||||
|
function AdminContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Dialogs
|
||||||
|
const [detailOrder, setDetailOrder] = useState<any>(null);
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ token, page: String(page), page_size: '20' });
|
||||||
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/orders?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError('管理员凭证无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setOrders(data.orders);
|
||||||
|
setTotal(data.total);
|
||||||
|
setTotalPages(data.total_pages);
|
||||||
|
} catch (e) {
|
||||||
|
setError('加载订单列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, page, statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, [fetchOrders]);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-red-500">缺少管理员凭证</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetry = async (orderId: string) => {
|
||||||
|
if (!confirm('确认重试充值?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || '重试失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('重试请求失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (orderId: string) => {
|
||||||
|
if (!confirm('确认取消该订单?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchOrders();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || '取消失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('取消请求失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = async (orderId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/orders/${orderId}?token=${token}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setDetailOrder(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('加载订单详情失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
'': '全部',
|
||||||
|
PENDING: '待支付',
|
||||||
|
PAID: '已支付',
|
||||||
|
RECHARGING: '充值中',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
EXPIRED: '已超时',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
FAILED: '充值失败',
|
||||||
|
REFUNDED: '已退款',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto min-h-screen max-w-6xl p-4">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold text-gray-900">Sub2ApiPay 订单管理</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
<button onClick={() => setError('')} className="ml-2 text-red-400 hover:text-red-600">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => { setStatusFilter(s); setPage(1); }}
|
||||||
|
className={`rounded-full px-3 py-1 text-sm transition-colors ${
|
||||||
|
statusFilter === s
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabels[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-12 text-center text-gray-500">加载中...</div>
|
||||||
|
) : (
|
||||||
|
<OrderTable
|
||||||
|
orders={orders}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onViewDetail={handleViewDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>共 {total} 条记录</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1">{page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="rounded border px-3 py-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Order Detail */}
|
||||||
|
{detailOrder && (
|
||||||
|
<OrderDetail
|
||||||
|
order={detailOrder}
|
||||||
|
onClose={() => setDetailOrder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AdminContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/api/admin/orders/[id]/cancel/route.ts
Normal file
25
src/app/api/admin/orders/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
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 (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await adminCancelOrder(id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Admin cancel order error:', error);
|
||||||
|
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/api/admin/orders/[id]/retry/route.ts
Normal file
25
src/app/api/admin/orders/[id]/retry/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
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 (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await retryRecharge(id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Retry recharge error:', error);
|
||||||
|
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/admin/orders/[id]/route.ts
Normal file
31
src/app/api/admin/orders/[id]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
auditLogs: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...order,
|
||||||
|
amount: Number(order.amount),
|
||||||
|
refundAmount: order.refundAmount ? Number(order.refundAmount) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
60
src/app/api/admin/orders/route.ts
Normal file
60
src/app/api/admin/orders/route.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = Math.max(1, Number(searchParams.get('page') || '1'));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('page_size') || '20')));
|
||||||
|
const status = searchParams.get('status');
|
||||||
|
const userId = searchParams.get('user_id');
|
||||||
|
const dateFrom = searchParams.get('date_from');
|
||||||
|
const dateTo = searchParams.get('date_to');
|
||||||
|
|
||||||
|
const where: Prisma.OrderWhereInput = {};
|
||||||
|
if (status) where.status = status as any;
|
||||||
|
if (userId) where.userId = Number(userId);
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
|
||||||
|
if (dateTo) where.createdAt.lte = new Date(dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
userName: true,
|
||||||
|
userEmail: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
paymentType: true,
|
||||||
|
createdAt: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
failedReason: true,
|
||||||
|
expiresAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.order.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
orders: orders.map(o => ({
|
||||||
|
...o,
|
||||||
|
amount: Number(o.amount),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: Math.ceil(total / pageSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
43
src/app/api/admin/refund/route.ts
Normal file
43
src/app/api/admin/refund/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
|
import { processRefund, OrderError } from '@/lib/order/service';
|
||||||
|
|
||||||
|
const refundSchema = z.object({
|
||||||
|
order_id: z.string().min(1),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
force: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = refundSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await processRefund({
|
||||||
|
orderId: parsed.data.order_id,
|
||||||
|
reason: parsed.data.reason,
|
||||||
|
force: parsed.data.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Refund error:', error);
|
||||||
|
return NextResponse.json({ error: '退款失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/easy-pay/notify/route.ts
Normal file
32
src/app/api/easy-pay/notify/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handlePaymentNotify } from '@/lib/order/service';
|
||||||
|
import type { EasyPayNotifyParams } from '@/lib/easy-pay/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
const params: EasyPayNotifyParams = {
|
||||||
|
pid: searchParams.get('pid') || '',
|
||||||
|
name: searchParams.get('name') || '',
|
||||||
|
money: searchParams.get('money') || '',
|
||||||
|
out_trade_no: searchParams.get('out_trade_no') || '',
|
||||||
|
trade_no: searchParams.get('trade_no') || '',
|
||||||
|
param: searchParams.get('param') || '',
|
||||||
|
trade_status: searchParams.get('trade_status') || '',
|
||||||
|
type: searchParams.get('type') || '',
|
||||||
|
sign: searchParams.get('sign') || '',
|
||||||
|
sign_type: searchParams.get('sign_type') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await handlePaymentNotify(params);
|
||||||
|
return new Response(success ? 'success' : 'fail', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EasyPay notify error:', error);
|
||||||
|
return new Response('fail', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/orders/[id]/cancel/route.ts
Normal file
37
src/app/api/orders/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { cancelOrder, OrderError } from '@/lib/order/service';
|
||||||
|
|
||||||
|
const cancelSchema = z.object({
|
||||||
|
user_id: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = cancelSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await cancelOrder(id, parsed.data.user_id);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Cancel order error:', error);
|
||||||
|
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/orders/[id]/route.ts
Normal file
50
src/app/api/orders/[id]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
userName: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
paymentType: true,
|
||||||
|
payUrl: true,
|
||||||
|
qrCode: true,
|
||||||
|
qrCodeImg: true,
|
||||||
|
expiresAt: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
failedReason: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
order_id: order.id,
|
||||||
|
user_id: order.userId,
|
||||||
|
user_name: order.userName,
|
||||||
|
amount: Number(order.amount),
|
||||||
|
status: order.status,
|
||||||
|
payment_type: order.paymentType,
|
||||||
|
pay_url: order.payUrl,
|
||||||
|
qr_code: order.qrCode,
|
||||||
|
qr_code_img: order.qrCodeImg,
|
||||||
|
expires_at: order.expiresAt,
|
||||||
|
paid_at: order.paidAt,
|
||||||
|
completed_at: order.completedAt,
|
||||||
|
failed_reason: order.failedReason,
|
||||||
|
created_at: order.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
56
src/app/api/orders/my/route.ts
Normal file
56
src/app/api/orders/my/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||||
|
import { deriveOrderState, isRechargeRetryable } from '@/lib/order/status';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUserByToken(token);
|
||||||
|
const orders = await prisma.order.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
amount: true,
|
||||||
|
status: true,
|
||||||
|
paymentType: true,
|
||||||
|
createdAt: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.username || user.email || `User #${user.id}`,
|
||||||
|
balance: user.balance,
|
||||||
|
},
|
||||||
|
orders: orders.map((item) => {
|
||||||
|
const derived = deriveOrderState(item);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
amount: Number(item.amount),
|
||||||
|
status: item.status,
|
||||||
|
paymentType: item.paymentType,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
paymentSuccess: derived.paymentSuccess,
|
||||||
|
rechargeSuccess: derived.rechargeSuccess,
|
||||||
|
rechargeStatus: derived.rechargeStatus,
|
||||||
|
rechargeRetryable: isRechargeRetryable(item),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get my orders error:', error);
|
||||||
|
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/api/orders/route.ts
Normal file
68
src/app/api/orders/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createOrder, OrderError } from '@/lib/order/service';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
|
||||||
|
const createOrderSchema = z.object({
|
||||||
|
user_id: z.number().int().positive(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
payment_type: z.enum(['alipay', 'wxpay']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const env = getEnv();
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = createOrderSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user_id, amount, payment_type } = parsed.data;
|
||||||
|
|
||||||
|
// Validate amount range
|
||||||
|
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate payment type is enabled
|
||||||
|
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `不支持的支付方式: ${payment_type}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
|
|| request.headers.get('x-real-ip')
|
||||||
|
|| '127.0.0.1';
|
||||||
|
|
||||||
|
const result = await createOrder({
|
||||||
|
userId: user_id,
|
||||||
|
amount,
|
||||||
|
paymentType: payment_type,
|
||||||
|
clientIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.statusCode },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('Create order error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '创建订单失败,请稍后重试' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/user/route.ts
Normal file
37
src/app/api/user/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getUser } from '@/lib/sub2api/client';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const userId = Number(request.nextUrl.searchParams.get('user_id'));
|
||||||
|
if (!userId || isNaN(userId) || userId <= 0) {
|
||||||
|
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = getEnv();
|
||||||
|
const user = await getUser(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
status: user.status,
|
||||||
|
balance: user.balance,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
||||||
|
minAmount: env.MIN_RECHARGE_AMOUNT,
|
||||||
|
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message === 'USER_NOT_FOUND') {
|
||||||
|
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
console.error('Get user error:', error);
|
||||||
|
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/users/[id]/route.ts
Normal file
34
src/app/api/users/[id]/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getUser } from '@/lib/sub2api/client';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
const userId = Number(id);
|
||||||
|
|
||||||
|
if (!Number.isInteger(userId) || userId <= 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid user id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await getUser(userId);
|
||||||
|
const displayName = user.username || user.email || `User #${user.id}`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
displayName,
|
||||||
|
balance: user.balance,
|
||||||
|
status: user.status,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'USER_NOT_FOUND') {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
console.error('Get user info error:', error);
|
||||||
|
return NextResponse.json({ error: 'Get user info failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/zpay/notify/route.ts
Normal file
34
src/app/api/zpay/notify/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { handlePaymentNotify } from '@/lib/order/service';
|
||||||
|
import type { ZPayNotifyParams } from '@/lib/zpay/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
const params: ZPayNotifyParams = {
|
||||||
|
pid: searchParams.get('pid') || '',
|
||||||
|
name: searchParams.get('name') || '',
|
||||||
|
money: searchParams.get('money') || '',
|
||||||
|
out_trade_no: searchParams.get('out_trade_no') || '',
|
||||||
|
trade_no: searchParams.get('trade_no') || '',
|
||||||
|
param: searchParams.get('param') || '',
|
||||||
|
trade_status: searchParams.get('trade_status') || '',
|
||||||
|
type: searchParams.get('type') || '',
|
||||||
|
sign: searchParams.get('sign') || '',
|
||||||
|
sign_type: searchParams.get('sign_type') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await handlePaymentNotify(params);
|
||||||
|
|
||||||
|
// ZPAY requires plain text response
|
||||||
|
return new Response(success ? 'success' : 'fail', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ZPAY notify error:', error);
|
||||||
|
return new Response('fail', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
11
src/app/globals.css
Normal file
11
src/app/globals.css
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #f9fafb;
|
||||||
|
--foreground: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
21
src/app/layout.tsx
Normal file
21
src/app/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sub2API 充值",
|
||||||
|
description: "Sub2API 余额充值平台",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body className="bg-gray-50 text-gray-900 antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/page.tsx
Normal file
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/pay');
|
||||||
|
}
|
||||||
402
src/app/pay/orders/page.tsx
Normal file
402
src/app/pay/orders/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id?: number;
|
||||||
|
username: string;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyOrder {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
|
||||||
|
|
||||||
|
const STATUS_TEXT_MAP: Record<string, string> = {
|
||||||
|
PENDING: '待支付',
|
||||||
|
PAID: '已支付',
|
||||||
|
RECHARGING: '充值中',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
EXPIRED: '已超时',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
FAILED: '失败',
|
||||||
|
REFUNDING: '退款中',
|
||||||
|
REFUNDED: '已退款',
|
||||||
|
REFUND_FAILED: '退款失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||||
|
{ key: 'ALL', label: '全部' },
|
||||||
|
{ key: 'PENDING', label: '待支付' },
|
||||||
|
{ key: 'COMPLETED', label: '已完成' },
|
||||||
|
{ key: 'CANCELLED', label: '已取消' },
|
||||||
|
{ key: 'EXPIRED', label: '已超时' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function detectDeviceIsMobile(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
|
||||||
|
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
|
||||||
|
const touchCapable = navigator.maxTouchPoints > 1;
|
||||||
|
|
||||||
|
return mobileUA || (touchCapable && smallPhysicalScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrdersContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const userId = Number(searchParams.get('user_id'));
|
||||||
|
const token = (searchParams.get('token') || '').trim();
|
||||||
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
|
const [orders, setOrders] = useState<MyOrder[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||||
|
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||||
|
const hasToken = token.length > 0;
|
||||||
|
const effectiveUserId = resolvedUserId || userId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
setIsIframeContext(window.self !== window.top);
|
||||||
|
setIsMobile(detectDeviceIsMobile());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildMobilePayOrdersTabUrl = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (userId && !Number.isNaN(userId)) params.set('user_id', String(userId));
|
||||||
|
if (token) params.set('token', token);
|
||||||
|
params.set('theme', theme);
|
||||||
|
params.set('ui_mode', uiMode);
|
||||||
|
params.set('tab', 'orders');
|
||||||
|
return `/pay?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || isEmbedded || typeof window === 'undefined') return;
|
||||||
|
window.location.replace(buildMobilePayOrdersTabUrl());
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isMobile, isEmbedded, userId, token, theme, uiMode]);
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!userId || Number.isNaN(userId) || userId <= 0) {
|
||||||
|
setError('无效的用户 ID');
|
||||||
|
setOrders([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasToken) {
|
||||||
|
const res = await fetch(`/api/users/${userId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUserInfo({
|
||||||
|
id: userId,
|
||||||
|
username:
|
||||||
|
(typeof data.displayName === 'string' && data.displayName.trim()) ||
|
||||||
|
(typeof data.username === 'string' && data.username.trim()) ||
|
||||||
|
(typeof data.email === 'string' && data.email.trim()) ||
|
||||||
|
`用户 #${userId}`,
|
||||||
|
balance: typeof data.balance === 'number' ? data.balance : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOrders([]);
|
||||||
|
setError('当前链接未携带登录 token,无法查询“我的订单”。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||||
|
if (!meRes.ok) {
|
||||||
|
if (meRes.status === 401) {
|
||||||
|
setError('登录态已失效,请从 Sub2API 重新进入支付页。');
|
||||||
|
} else {
|
||||||
|
setError('订单加载失败,请稍后重试。');
|
||||||
|
}
|
||||||
|
setOrders([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meData = await meRes.json();
|
||||||
|
const meUser = meData.user || {};
|
||||||
|
const meId = Number(meUser.id);
|
||||||
|
if (Number.isInteger(meId) && meId > 0) {
|
||||||
|
setResolvedUserId(meId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserInfo({
|
||||||
|
id: Number.isInteger(meId) && meId > 0 ? meId : userId,
|
||||||
|
username:
|
||||||
|
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||||
|
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||||
|
`用户 #${userId}`,
|
||||||
|
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(meData.orders)) {
|
||||||
|
setOrders(meData.orders);
|
||||||
|
} else {
|
||||||
|
setOrders([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOrders([]);
|
||||||
|
setError('网络错误,请稍后重试。');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && !isEmbedded) return;
|
||||||
|
loadOrders();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId, token, isMobile, isEmbedded]);
|
||||||
|
|
||||||
|
const filteredOrders = useMemo(() => {
|
||||||
|
if (activeFilter === 'ALL') return orders;
|
||||||
|
return orders.filter((item) => item.status === activeFilter);
|
||||||
|
}, [orders, activeFilter]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const total = orders.length;
|
||||||
|
const pending = orders.filter((item) => item.status === 'PENDING').length;
|
||||||
|
const completed = orders.filter((item) => item.status === 'COMPLETED' || item.status === 'PAID').length;
|
||||||
|
const failed = orders.filter((item) => ['FAILED', 'CANCELLED', 'EXPIRED'].includes(item.status)).length;
|
||||||
|
return { total, pending, completed, failed };
|
||||||
|
}, [orders]);
|
||||||
|
|
||||||
|
const formatStatus = (status: string) => STATUS_TEXT_MAP[status] || status;
|
||||||
|
|
||||||
|
const formatCreatedAt = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeClass = (status: string) => {
|
||||||
|
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||||
|
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||||
|
}
|
||||||
|
if (status === 'PENDING') {
|
||||||
|
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
|
||||||
|
}
|
||||||
|
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
|
||||||
|
return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||||
|
}
|
||||||
|
return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildScopedUrl = (path: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||||
|
if (token) params.set('token', token);
|
||||||
|
params.set('theme', theme);
|
||||||
|
params.set('ui_mode', uiMode);
|
||||||
|
return `${path}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const payUrl = buildScopedUrl('/pay');
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
|
||||||
|
正在切换到移动端订单 Tab...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p className="text-lg font-medium">无效的用户 ID</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问订单页面</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative min-h-screen w-full overflow-hidden p-3 sm:p-4',
|
||||||
|
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl',
|
||||||
|
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl',
|
||||||
|
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative mx-auto w-full max-w-6xl rounded-3xl border p-4 sm:p-6',
|
||||||
|
isDark
|
||||||
|
? 'border-slate-700/70 bg-slate-900/85 shadow-2xl shadow-black/35'
|
||||||
|
: 'border-slate-200/90 bg-white/95 shadow-2xl shadow-slate-300/45',
|
||||||
|
isEmbedded ? '' : 'mt-6',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'mb-2 inline-flex items-center rounded-full px-3 py-1 text-[11px] font-medium',
|
||||||
|
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Sub2API Secure Pay
|
||||||
|
</div>
|
||||||
|
<h1 className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||||
|
我的订单
|
||||||
|
</h1>
|
||||||
|
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{userInfo?.username || `用户 #${effectiveUserId}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadOrders}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border px-3 py-2 text-xs font-medium',
|
||||||
|
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={payUrl}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border px-3 py-2 text-xs font-medium',
|
||||||
|
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
返回充值
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>总订单</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>待支付</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
|
||||||
|
</div>
|
||||||
|
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>已完成</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
|
||||||
|
</div>
|
||||||
|
<div className={['rounded-xl border p-3', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>异常/关闭</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{FILTER_OPTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveFilter(item.key)}
|
||||||
|
className={[
|
||||||
|
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
activeFilter === item.key
|
||||||
|
? (isDark ? 'border-slate-500 bg-slate-700 text-slate-100' : 'border-slate-400 bg-slate-900 text-white')
|
||||||
|
: (isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-slate-300 text-slate-600 hover:bg-slate-100'),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={['rounded-2xl border p-3 sm:p-4', isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80'].join(' ')}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<div className={['h-6 w-6 animate-spin rounded-full border-2 border-t-transparent', isDark ? 'border-slate-400' : 'border-slate-500'].join(' ')} />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700'].join(' ')}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div className={['rounded-xl border border-dashed px-4 py-10 text-center text-sm', isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500'].join(' ')}>
|
||||||
|
暂无符合条件的订单记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={['hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr]', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
|
<span>订单号</span>
|
||||||
|
<span>金额</span>
|
||||||
|
<span>支付方式</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>创建时间</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:space-y-0">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<div key={order.id} className={['border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr] md:items-center', isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700'].join(' ')}>
|
||||||
|
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||||
|
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||||
|
<div>{order.paymentType}</div>
|
||||||
|
<div>
|
||||||
|
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status)].join(' ')}>
|
||||||
|
{formatStatus(order.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OrdersContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
615
src/app/pay/page.tsx
Normal file
615
src/app/pay/page.tsx
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useState, useEffect, Suspense, useMemo } from 'react';
|
||||||
|
import PaymentForm from '@/components/PaymentForm';
|
||||||
|
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||||
|
import OrderStatus from '@/components/OrderStatus';
|
||||||
|
|
||||||
|
interface OrderResult {
|
||||||
|
orderId: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: 'alipay' | 'wxpay';
|
||||||
|
payUrl?: string | null;
|
||||||
|
qrCode?: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id?: number;
|
||||||
|
username: string;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyOrder {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppConfig {
|
||||||
|
enabledPaymentTypes: string[];
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderStatusFilter = 'ALL' | 'PENDING' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED';
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||||
|
{ key: 'ALL', label: '全部' },
|
||||||
|
{ key: 'PENDING', label: '待支付' },
|
||||||
|
{ key: 'COMPLETED', label: '已完成' },
|
||||||
|
{ key: 'CANCELLED', label: '已取消' },
|
||||||
|
{ key: 'EXPIRED', label: '已超时' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_TEXT_MAP: Record<string, string> = {
|
||||||
|
PENDING: '待支付',
|
||||||
|
PAID: '已支付',
|
||||||
|
RECHARGING: '充值中',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
EXPIRED: '已超时',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
FAILED: '失败',
|
||||||
|
REFUNDING: '退款中',
|
||||||
|
REFUNDED: '已退款',
|
||||||
|
REFUND_FAILED: '退款失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectDeviceIsMobile(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
|
||||||
|
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
|
||||||
|
const touchCapable = navigator.maxTouchPoints > 1;
|
||||||
|
|
||||||
|
return mobileUA || (touchCapable && smallPhysicalScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const userId = Number(searchParams.get('user_id'));
|
||||||
|
const token = (searchParams.get('token') || '').trim();
|
||||||
|
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||||
|
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||||
|
const tab = searchParams.get('tab');
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
||||||
|
const [finalStatus, setFinalStatus] = useState('');
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||||
|
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||||
|
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||||
|
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||||
|
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||||
|
|
||||||
|
const [config] = useState<AppConfig>({
|
||||||
|
enabledPaymentTypes: ['alipay', 'wxpay'],
|
||||||
|
minAmount: 1,
|
||||||
|
maxAmount: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveUserId = resolvedUserId || userId;
|
||||||
|
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||||
|
const hasToken = token.length > 0;
|
||||||
|
const helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim();
|
||||||
|
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim();
|
||||||
|
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
setIsIframeContext(window.self !== window.top);
|
||||||
|
setIsMobile(detectDeviceIsMobile());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || step !== 'form') return;
|
||||||
|
if (tab === 'orders') {
|
||||||
|
setActiveMobileTab('orders');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveMobileTab('pay');
|
||||||
|
}, [isMobile, step, tab]);
|
||||||
|
|
||||||
|
const loadUserAndOrders = async () => {
|
||||||
|
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (token) {
|
||||||
|
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||||
|
if (meRes.ok) {
|
||||||
|
const meData = await meRes.json();
|
||||||
|
const meUser = meData.user || {};
|
||||||
|
const meId = Number(meUser.id);
|
||||||
|
if (Number.isInteger(meId) && meId > 0) {
|
||||||
|
setResolvedUserId(meId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserInfo({
|
||||||
|
id: Number.isInteger(meId) && meId > 0 ? meId : userId,
|
||||||
|
username:
|
||||||
|
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||||
|
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||||
|
`用户 #${userId}`,
|
||||||
|
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(meData.orders)) {
|
||||||
|
setMyOrders(meData.orders);
|
||||||
|
} else {
|
||||||
|
setMyOrders([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/users/${userId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setUserInfo({
|
||||||
|
id: userId,
|
||||||
|
username:
|
||||||
|
(typeof data.displayName === 'string' && data.displayName.trim()) ||
|
||||||
|
(typeof data.username === 'string' && data.username.trim()) ||
|
||||||
|
(typeof data.email === 'string' && data.email.trim()) ||
|
||||||
|
`用户 #${userId}`,
|
||||||
|
balance: typeof data.balance === 'number' ? data.balance : 0,
|
||||||
|
});
|
||||||
|
setMyOrders([]);
|
||||||
|
} catch {
|
||||||
|
// ignore and keep page usable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUserAndOrders();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId, token]);
|
||||||
|
|
||||||
|
const filteredOrders = useMemo(() => {
|
||||||
|
if (activeFilter === 'ALL') return myOrders;
|
||||||
|
return myOrders.filter((item) => item.status === activeFilter);
|
||||||
|
}, [myOrders, activeFilter]);
|
||||||
|
|
||||||
|
const formatStatus = (status: string) => STATUS_TEXT_MAP[status] || status;
|
||||||
|
|
||||||
|
const formatCreatedAt = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeClass = (status: string) => {
|
||||||
|
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||||
|
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||||
|
}
|
||||||
|
if (status === 'PENDING') {
|
||||||
|
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
|
||||||
|
}
|
||||||
|
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
|
||||||
|
return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||||
|
}
|
||||||
|
return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||||
|
return (
|
||||||
|
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p className="text-lg font-medium">无效的用户 ID</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问充值页面</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||||
|
if (token) params.set('token', token);
|
||||||
|
params.set('theme', theme);
|
||||||
|
params.set('ui_mode', uiMode);
|
||||||
|
if (forceOrdersTab) params.set('tab', 'orders');
|
||||||
|
return `${path}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pcOrdersUrl = buildScopedUrl('/pay/orders');
|
||||||
|
const mobileOrdersUrl = buildScopedUrl('/pay', true);
|
||||||
|
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
||||||
|
|
||||||
|
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: effectiveUserId,
|
||||||
|
amount,
|
||||||
|
payment_type: paymentType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || '创建订单失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderResult({
|
||||||
|
orderId: data.orderId,
|
||||||
|
amount: data.amount,
|
||||||
|
status: data.status,
|
||||||
|
paymentType: data.paymentType || paymentType,
|
||||||
|
payUrl: data.payUrl,
|
||||||
|
qrCode: data.qrCode,
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.userName || typeof data.userBalance === 'number') {
|
||||||
|
setUserInfo((prev) => ({
|
||||||
|
username:
|
||||||
|
(typeof data.userName === 'string' && data.userName.trim()) ||
|
||||||
|
prev?.username ||
|
||||||
|
`用户 #${effectiveUserId}`,
|
||||||
|
balance: typeof data.userBalance === 'number' ? data.userBalance : (prev?.balance ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep('paying');
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (status: string) => {
|
||||||
|
setFinalStatus(status);
|
||||||
|
setStep('result');
|
||||||
|
if (isMobile) {
|
||||||
|
setActiveMobileTab('orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep('form');
|
||||||
|
setOrderResult(null);
|
||||||
|
setFinalStatus('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setStep('form');
|
||||||
|
setOrderResult(null);
|
||||||
|
setFinalStatus('');
|
||||||
|
setError('');
|
||||||
|
loadUserAndOrders();
|
||||||
|
}, 2200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [step, finalStatus]);
|
||||||
|
|
||||||
|
const renderMobileOrders = () => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>我的订单</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadUserAndOrders}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border px-2.5 py-1 text-xs font-medium',
|
||||||
|
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FILTER_OPTIONS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveFilter(item.key)}
|
||||||
|
className={[
|
||||||
|
'rounded-full border px-3 py-1 text-xs font-medium',
|
||||||
|
activeFilter === item.key
|
||||||
|
? (isDark ? 'border-slate-500 bg-slate-700 text-slate-100' : 'border-slate-400 bg-slate-900 text-white')
|
||||||
|
: (isDark ? 'border-slate-600 text-slate-300' : 'border-slate-300 text-slate-600'),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasToken ? (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border border-dashed px-4 py-8 text-center text-sm',
|
||||||
|
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
当前链接未携带登录 token,无法查询“我的订单”。
|
||||||
|
</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-xl border border-dashed px-4 py-8 text-center text-sm',
|
||||||
|
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
暂无符合条件的订单记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
className={[
|
||||||
|
'rounded-xl border px-3 py-3',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-900/70' : 'border-slate-200 bg-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-2xl font-semibold">¥{order.amount.toFixed(2)}</span>
|
||||||
|
<span className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status)].join(' ')}>
|
||||||
|
{formatStatus(order.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
|
{order.paymentType}
|
||||||
|
</div>
|
||||||
|
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{formatCreatedAt(order.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative min-h-screen w-full overflow-hidden p-3 sm:p-4',
|
||||||
|
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl',
|
||||||
|
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl',
|
||||||
|
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative mx-auto w-full rounded-3xl border p-4 sm:p-5',
|
||||||
|
isMobile ? 'max-w-lg' : 'max-w-6xl',
|
||||||
|
isDark
|
||||||
|
? 'border-slate-700/70 bg-slate-900/85 shadow-2xl shadow-black/35'
|
||||||
|
: 'border-slate-200/90 bg-white/95 shadow-2xl shadow-slate-300/45',
|
||||||
|
isEmbedded ? '' : 'mt-6',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-start justify-between gap-3">
|
||||||
|
<div className="text-left">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'mb-2 inline-flex items-center rounded-full px-3 py-1 text-[11px] font-medium',
|
||||||
|
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
Sub2API Secure Pay
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className={[
|
||||||
|
'text-2xl font-semibold tracking-tight',
|
||||||
|
isDark ? 'text-slate-100' : 'text-slate-900',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{'Sub2API '}{'\u4F59\u989D\u5145\u503C'}
|
||||||
|
</h1>
|
||||||
|
<p className={['mt-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
{'\u5B89\u5168\u652F\u4ED8\uFF0C\u81EA\u52A8\u5230\u8D26'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isMobile && (
|
||||||
|
<a
|
||||||
|
href={ordersUrl}
|
||||||
|
className={[
|
||||||
|
'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',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{'\u6211\u7684\u8BA2\u5355'}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'form' && isMobile && (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||||
|
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-300 bg-slate-100/90',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveMobileTab('pay')}
|
||||||
|
className={[
|
||||||
|
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||||
|
activeMobileTab === 'pay'
|
||||||
|
? (isDark
|
||||||
|
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||||
|
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||||
|
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
充值
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveMobileTab('orders')}
|
||||||
|
className={[
|
||||||
|
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||||
|
activeMobileTab === 'orders'
|
||||||
|
? (isDark
|
||||||
|
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||||
|
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||||
|
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
我的订单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'form' && (
|
||||||
|
<>
|
||||||
|
{isMobile ? (
|
||||||
|
activeMobileTab === 'pay' ? (
|
||||||
|
<PaymentForm
|
||||||
|
userId={effectiveUserId}
|
||||||
|
userName={userInfo?.username}
|
||||||
|
userBalance={userInfo?.balance}
|
||||||
|
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||||
|
minAmount={config.minAmount}
|
||||||
|
maxAmount={config.maxAmount}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
dark={isDark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderMobileOrders()
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<PaymentForm
|
||||||
|
userId={effectiveUserId}
|
||||||
|
userName={userInfo?.username}
|
||||||
|
userBalance={userInfo?.balance}
|
||||||
|
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||||
|
minAmount={config.minAmount}
|
||||||
|
maxAmount={config.maxAmount}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
dark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>订单中心</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold">最近订单:{myOrders.length} 条</div>
|
||||||
|
<a
|
||||||
|
href={pcOrdersUrl}
|
||||||
|
className={[
|
||||||
|
'mt-3 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',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
打开完整订单页
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>支付说明</div>
|
||||||
|
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
|
<li>订单完成后会自动到账</li>
|
||||||
|
<li>如需历史记录请查看“我的订单”</li>
|
||||||
|
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasHelpContent && (
|
||||||
|
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
|
||||||
|
{helpImageUrl && (
|
||||||
|
<img
|
||||||
|
src={helpImageUrl}
|
||||||
|
alt='help'
|
||||||
|
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{helpText && (
|
||||||
|
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||||
|
{helpText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'paying' && orderResult && (
|
||||||
|
<PaymentQRCode
|
||||||
|
orderId={orderResult.orderId}
|
||||||
|
payUrl={orderResult.payUrl}
|
||||||
|
qrCode={orderResult.qrCode}
|
||||||
|
paymentType={orderResult.paymentType}
|
||||||
|
amount={orderResult.amount}
|
||||||
|
expiresAt={orderResult.expiresAt}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onBack={handleBack}
|
||||||
|
dark={isDark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'result' && (
|
||||||
|
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PayContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/app/pay/result/page.tsx
Normal file
110
src/app/pay/result/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
|
||||||
|
function ResultContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const outTradeNo = searchParams.get('out_trade_no');
|
||||||
|
const tradeStatus = searchParams.get('trade_status');
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!outTradeNo) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkOrder = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${outTradeNo}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setStatus(data.status);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkOrder();
|
||||||
|
// Poll a few times in case status hasn't updated yet
|
||||||
|
const timer = setInterval(checkOrder, 3000);
|
||||||
|
const timeout = setTimeout(() => clearInterval(timer), 30000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [outTradeNo]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">查询支付结果中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
|
||||||
|
const isPending = status === 'PENDING';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||||
|
{isSuccess ? (
|
||||||
|
<>
|
||||||
|
<div className="text-6xl text-green-500">✓</div>
|
||||||
|
<h1 className="mt-4 text-xl font-bold text-green-600">
|
||||||
|
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
{status === 'COMPLETED'
|
||||||
|
? '余额已成功到账!'
|
||||||
|
: '支付成功,余额正在充值中...'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="text-6xl text-yellow-500">⏳</div>
|
||||||
|
<h1 className="mt-4 text-xl font-bold text-yellow-600">等待支付</h1>
|
||||||
|
<p className="mt-2 text-gray-500">订单尚未完成支付</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-6xl text-red-500">✗</div>
|
||||||
|
<h1 className="mt-4 text-xl font-bold text-red-600">
|
||||||
|
{status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
{status === 'EXPIRED'
|
||||||
|
? '订单已超时,请重新充值'
|
||||||
|
: status === 'CANCELLED'
|
||||||
|
? '订单已被取消'
|
||||||
|
: '请联系管理员处理'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-gray-400">
|
||||||
|
订单号: {outTradeNo || '未知'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PayResultPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-gray-500">加载中...</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ResultContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/OrderStatus.tsx
Normal file
68
src/components/OrderStatus.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface OrderStatusProps {
|
||||||
|
status: string;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
|
||||||
|
COMPLETED: {
|
||||||
|
label: '充值成功',
|
||||||
|
color: 'text-green-600',
|
||||||
|
icon: '✓',
|
||||||
|
message: '余额已到账,感谢您的充值!',
|
||||||
|
},
|
||||||
|
PAID: {
|
||||||
|
label: '充值中',
|
||||||
|
color: 'text-blue-600',
|
||||||
|
icon: '⟳',
|
||||||
|
message: '支付成功,正在充值余额中...',
|
||||||
|
},
|
||||||
|
RECHARGING: {
|
||||||
|
label: '充值中',
|
||||||
|
color: 'text-blue-600',
|
||||||
|
icon: '⟳',
|
||||||
|
message: '正在充值余额中,请稍候...',
|
||||||
|
},
|
||||||
|
FAILED: {
|
||||||
|
label: '充值失败',
|
||||||
|
color: 'text-red-600',
|
||||||
|
icon: '✗',
|
||||||
|
message: '充值失败,请联系管理员处理。',
|
||||||
|
},
|
||||||
|
EXPIRED: {
|
||||||
|
label: '订单超时',
|
||||||
|
color: 'text-gray-500',
|
||||||
|
icon: '⏰',
|
||||||
|
message: '订单已超时,请重新创建订单。',
|
||||||
|
},
|
||||||
|
CANCELLED: {
|
||||||
|
label: '已取消',
|
||||||
|
color: 'text-gray-500',
|
||||||
|
icon: '✗',
|
||||||
|
message: '订单已取消。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderStatus({ status, onBack }: OrderStatusProps) {
|
||||||
|
const config = STATUS_CONFIG[status] || {
|
||||||
|
label: status,
|
||||||
|
color: 'text-gray-600',
|
||||||
|
icon: '?',
|
||||||
|
message: '未知状态',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-8">
|
||||||
|
<div className={`text-6xl ${config.color}`}>{config.icon}</div>
|
||||||
|
<h2 className={`text-xl font-bold ${config.color}`}>{config.label}</h2>
|
||||||
|
<p className="text-center text-gray-500">{config.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{status === 'COMPLETED' ? '完成' : '返回充值'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/components/PaymentForm.tsx
Normal file
203
src/components/PaymentForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface PaymentFormProps {
|
||||||
|
userId: number;
|
||||||
|
userName?: string;
|
||||||
|
userBalance?: number;
|
||||||
|
enabledPaymentTypes: string[];
|
||||||
|
minAmount: number;
|
||||||
|
maxAmount: number;
|
||||||
|
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
dark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500];
|
||||||
|
const AMOUNT_TEXT_PATTERN = /^\d*(\.\d{0,2})?$/;
|
||||||
|
|
||||||
|
function hasValidCentPrecision(num: number): boolean {
|
||||||
|
return Math.abs(Math.round(num * 100) - num * 100) < 1e-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentForm({
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
userBalance,
|
||||||
|
enabledPaymentTypes,
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
onSubmit,
|
||||||
|
loading,
|
||||||
|
dark = false,
|
||||||
|
}: PaymentFormProps) {
|
||||||
|
const [amount, setAmount] = useState<number | ''>('');
|
||||||
|
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||||
|
const [customAmount, setCustomAmount] = useState('');
|
||||||
|
|
||||||
|
const handleQuickAmount = (val: number) => {
|
||||||
|
setAmount(val);
|
||||||
|
setCustomAmount(String(val));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomAmountChange = (val: string) => {
|
||||||
|
if (!AMOUNT_TEXT_PATTERN.test(val)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomAmount(val);
|
||||||
|
|
||||||
|
if (val === '') {
|
||||||
|
setAmount('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (!isNaN(num) && num > 0 && hasValidCentPrecision(num)) {
|
||||||
|
setAmount(num);
|
||||||
|
} else {
|
||||||
|
setAmount('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedAmount = amount || 0;
|
||||||
|
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isValid || loading) return;
|
||||||
|
await onSubmit(selectedAmount, paymentType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAlipay = (type: string) => type === 'alipay';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* User Info */}
|
||||||
|
<div className={['rounded-xl border p-4', dark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||||
|
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>充值账户</div>
|
||||||
|
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||||
|
{userName || `用户 #${userId}`}
|
||||||
|
</div>
|
||||||
|
{userBalance !== undefined && (
|
||||||
|
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||||
|
当前余额: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Amount Selection */}
|
||||||
|
<div>
|
||||||
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||||
|
充值金额
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{QUICK_AMOUNTS.map((val) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleQuickAmount(val)}
|
||||||
|
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
||||||
|
amount === val
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||||
|
: dark
|
||||||
|
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||||
|
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
¥{val}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Amount */}
|
||||||
|
<div>
|
||||||
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||||
|
自定义金额
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>¥</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
min={minAmount}
|
||||||
|
max={maxAmount}
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||||
|
placeholder={`${minAmount} - ${maxAmount}`}
|
||||||
|
className={[
|
||||||
|
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
|
||||||
|
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customAmount !== '' && !isValid && (
|
||||||
|
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||||
|
{'\u91D1\u989D\u9700\u5728\u8303\u56F4\u5185\uFF0C\u4E14\u6700\u591A\u652F\u6301 2 \u4F4D\u5C0F\u6570\uFF08\u7CBE\u786E\u5230\u5206\uFF09'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Type */}
|
||||||
|
<div>
|
||||||
|
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>支付方式</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{enabledPaymentTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentType(type)}
|
||||||
|
className={`flex h-[58px] flex-1 items-center justify-center rounded-lg border px-3 transition-all ${
|
||||||
|
paymentType === type
|
||||||
|
? isAlipay(type)
|
||||||
|
? 'border-cyan-400 bg-cyan-50 text-slate-900 shadow-sm'
|
||||||
|
: 'border-green-500 bg-green-50 text-slate-900 shadow-sm'
|
||||||
|
: dark
|
||||||
|
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||||
|
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isAlipay(type) ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||||
|
支
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-col items-start leading-none">
|
||||||
|
<span className="text-xl font-semibold tracking-tight">支付宝</span>
|
||||||
|
<span className="text-[10px] tracking-[0.25em] text-slate-600">ALIPAY</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
|
||||||
|
<path d="M5 12.5 10.2 17 19 8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold tracking-tight">微信支付</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || loading}
|
||||||
|
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||||
|
isValid && !loading
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||||
|
: dark ? 'cursor-not-allowed bg-slate-700 text-slate-300' : 'cursor-not-allowed bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/PaymentQRCode.tsx
Normal file
219
src/components/PaymentQRCode.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
interface PaymentQRCodeProps {
|
||||||
|
orderId: string;
|
||||||
|
payUrl?: string | null;
|
||||||
|
qrCode?: string | null;
|
||||||
|
paymentType?: 'alipay' | 'wxpay';
|
||||||
|
amount: number;
|
||||||
|
expiresAt: string;
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
dark?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
|
||||||
|
const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4';
|
||||||
|
const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
|
||||||
|
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
|
||||||
|
const TEXT_BACK = '\u8FD4\u56DE';
|
||||||
|
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||||
|
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||||
|
|
||||||
|
export default function PaymentQRCode({
|
||||||
|
orderId,
|
||||||
|
payUrl,
|
||||||
|
qrCode,
|
||||||
|
paymentType,
|
||||||
|
amount,
|
||||||
|
expiresAt,
|
||||||
|
onStatusChange,
|
||||||
|
onBack,
|
||||||
|
dark = false,
|
||||||
|
}: PaymentQRCodeProps) {
|
||||||
|
const [timeLeft, setTimeLeft] = useState('');
|
||||||
|
const [expired, setExpired] = useState(false);
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [imageLoading, setImageLoading] = useState(false);
|
||||||
|
|
||||||
|
const qrPayload = useMemo(() => {
|
||||||
|
const value = (qrCode || payUrl || '').trim();
|
||||||
|
return value;
|
||||||
|
}, [qrCode, payUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!qrPayload) {
|
||||||
|
setQrDataUrl('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageLoading(true);
|
||||||
|
QRCode.toDataURL(qrPayload, {
|
||||||
|
width: 224,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setQrDataUrl(url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setQrDataUrl('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setImageLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [qrPayload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTimer = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
const diff = expiry - now;
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
setTimeLeft(TEXT_EXPIRED);
|
||||||
|
setExpired(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const seconds = Math.floor((diff % 60000) / 1000);
|
||||||
|
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTimer();
|
||||||
|
const timer = setInterval(updateTimer, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [expiresAt]);
|
||||||
|
|
||||||
|
const pollStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${orderId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (TERMINAL_STATUSES.has(data.status)) {
|
||||||
|
onStatusChange(data.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore polling errors
|
||||||
|
}
|
||||||
|
}, [orderId, onStatusChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expired) return;
|
||||||
|
pollStatus();
|
||||||
|
const timer = setInterval(pollStatus, 2000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [pollStatus, expired]);
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/orders/${orderId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
await fetch(`/api/orders/${orderId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: data.user_id }),
|
||||||
|
});
|
||||||
|
onStatusChange('CANCELLED');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWx = paymentType === 'wxpay';
|
||||||
|
const iconSrc = isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||||
|
const channelLabel = isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||||
|
const iconBgClass = isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
|
||||||
|
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||||
|
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!expired && (
|
||||||
|
<>
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
|
||||||
|
{imageLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<img src={qrDataUrl} alt="payment qrcode" className="h-56 w-56 rounded" />
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className={`rounded-full p-2 shadow ring-2 ring-white ${iconBgClass}`}>
|
||||||
|
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!qrDataUrl && payUrl && (
|
||||||
|
<a
|
||||||
|
href={payUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{TEXT_GO_PAY}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!qrDataUrl && !payUrl && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
||||||
|
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
|
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex w-full gap-3">
|
||||||
|
<button
|
||||||
|
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',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{TEXT_BACK}
|
||||||
|
</button>
|
||||||
|
{!expired && (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
{TEXT_CANCEL_ORDER}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/admin/OrderDetail.tsx
Normal file
129
src/components/admin/OrderDetail.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
detail: string | null;
|
||||||
|
operator: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderDetailProps {
|
||||||
|
order: {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
userName: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: string;
|
||||||
|
rechargeCode: string;
|
||||||
|
zpayTradeNo: string | null;
|
||||||
|
refundAmount: number | null;
|
||||||
|
refundReason: string | null;
|
||||||
|
refundAt: string | null;
|
||||||
|
forceRefund: boolean;
|
||||||
|
expiresAt: string;
|
||||||
|
paidAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
failedAt: string | null;
|
||||||
|
failedReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
clientIp: string | null;
|
||||||
|
paymentSuccess?: boolean;
|
||||||
|
rechargeSuccess?: boolean;
|
||||||
|
rechargeStatus?: string;
|
||||||
|
auditLogs: AuditLog[];
|
||||||
|
};
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetail({ order, onClose }: OrderDetailProps) {
|
||||||
|
const fields = [
|
||||||
|
{ label: '订单号', value: order.id },
|
||||||
|
{ label: '用户ID', value: order.userId },
|
||||||
|
{ label: '用户名', value: order.userName || '-' },
|
||||||
|
{ label: '邮箱', value: order.userEmail || '-' },
|
||||||
|
{ label: '金额', value: `¥${order.amount.toFixed(2)}` },
|
||||||
|
{ label: '状态', value: order.status },
|
||||||
|
{ label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' },
|
||||||
|
{ label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' },
|
||||||
|
{ label: 'Recharge Status', value: order.rechargeStatus || '-' },
|
||||||
|
{ label: '支付方式', value: order.paymentType === 'alipay' ? '支付宝' : '微信支付' },
|
||||||
|
{ label: '充值码', value: order.rechargeCode },
|
||||||
|
{ label: 'ZPAY订单号', value: order.zpayTradeNo || '-' },
|
||||||
|
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||||
|
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||||
|
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
||||||
|
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
|
||||||
|
{ label: '完成时间', value: order.completedAt ? new Date(order.completedAt).toLocaleString('zh-CN') : '-' },
|
||||||
|
{ label: '失败时间', value: order.failedAt ? new Date(order.failedAt).toLocaleString('zh-CN') : '-' },
|
||||||
|
{ label: '失败原因', value: order.failedReason || '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (order.refundAmount) {
|
||||||
|
fields.push(
|
||||||
|
{ label: '退款金额', value: `¥${order.refundAmount.toFixed(2)}` },
|
||||||
|
{ label: '退款原因', value: order.refundReason || '-' },
|
||||||
|
{ label: '退款时间', value: order.refundAt ? new Date(order.refundAt).toLocaleString('zh-CN') : '-' },
|
||||||
|
{ label: '强制退款', value: order.forceRefund ? '是' : '否' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-bold">订单详情</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{fields.map(({ label, value }) => (
|
||||||
|
<div key={label} className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="mt-1 break-all text-sm font-medium">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audit Logs */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="mb-3 font-medium text-gray-900">审计日志</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{order.auditLogs.map((log) => (
|
||||||
|
<div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{log.action}</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{log.detail && (
|
||||||
|
<div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>
|
||||||
|
)}
|
||||||
|
{log.operator && (
|
||||||
|
<div className="mt-1 text-xs text-gray-400">操作者: {log.operator}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{order.auditLogs.length === 0 && (
|
||||||
|
<div className="text-center text-sm text-gray-400">暂无日志</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-6 w-full rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/admin/OrderTable.tsx
Normal file
117
src/components/admin/OrderTable.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
userName: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: string;
|
||||||
|
createdAt: string;
|
||||||
|
paidAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
failedReason: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
rechargeRetryable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderTableProps {
|
||||||
|
orders: Order[];
|
||||||
|
onRetry: (orderId: string) => void;
|
||||||
|
onCancel: (orderId: string) => void;
|
||||||
|
onViewDetail: (orderId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
PENDING: { label: '待支付', className: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
PAID: { label: '已支付', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
RECHARGING: { label: '充值中', className: 'bg-blue-100 text-blue-800' },
|
||||||
|
COMPLETED: { label: '已完成', className: 'bg-green-100 text-green-800' },
|
||||||
|
EXPIRED: { label: '已超时', className: 'bg-gray-100 text-gray-800' },
|
||||||
|
CANCELLED: { label: '已取消', className: 'bg-gray-100 text-gray-800' },
|
||||||
|
FAILED: { label: '充值失败', className: 'bg-red-100 text-red-800' },
|
||||||
|
REFUNDING: { label: '退款中', className: 'bg-orange-100 text-orange-800' },
|
||||||
|
REFUNDED: { label: '已退款', className: 'bg-purple-100 text-purple-800' },
|
||||||
|
REFUND_FAILED: { label: '退款失败', className: 'bg-red-100 text-red-800' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }: OrderTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">订单号</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">用户</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">金额</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">状态</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">支付方式</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">创建时间</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{orders.map((order) => {
|
||||||
|
const statusInfo = STATUS_LABELS[order.status] || { label: order.status, className: 'bg-gray-100 text-gray-800' };
|
||||||
|
return (
|
||||||
|
<tr key={order.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewDetail(order.id)}
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{order.id.slice(0, 12)}...
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<div>{order.userName || '-'}</div>
|
||||||
|
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">
|
||||||
|
¥{order.amount.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${statusInfo.className}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||||
|
{order.paymentType === 'alipay' ? '支付宝' : '微信支付'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||||
|
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{order.rechargeRetryable && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRetry(order.id)}
|
||||||
|
className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{order.status === 'PENDING' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onCancel(order.id)}
|
||||||
|
className="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{orders.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-gray-500">暂无订单</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/admin/RefundDialog.tsx
Normal file
99
src/components/admin/RefundDialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface RefundDialogProps {
|
||||||
|
orderId: string;
|
||||||
|
amount: number;
|
||||||
|
onConfirm: (reason: string, force: boolean) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
warning?: string;
|
||||||
|
requireForce?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RefundDialog({
|
||||||
|
orderId,
|
||||||
|
amount,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
warning,
|
||||||
|
requireForce,
|
||||||
|
}: RefundDialogProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [force, setForce] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onConfirm(reason, force);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">确认退款</h3>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-sm text-gray-500">订单号</div>
|
||||||
|
<div className="text-sm font-mono">{orderId}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<div className="text-sm text-gray-500">退款金额</div>
|
||||||
|
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{warning && (
|
||||||
|
<div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">
|
||||||
|
{warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-gray-700">退款原因</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="请输入退款原因(可选)"
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{requireForce && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={force}
|
||||||
|
onChange={(e) => setForce(e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-red-600">强制退款(余额可能扣为负数)</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={loading || (requireForce && !force)}
|
||||||
|
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||||
|
>
|
||||||
|
{loading ? '处理中...' : '确认退款'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/instrumentation.ts
Normal file
6
src/instrumentation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { startTimeoutScheduler } = await import('@/lib/order/timeout');
|
||||||
|
startTimeoutScheduler();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/lib/admin-auth.ts
Normal file
19
src/lib/admin-auth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export function verifyAdminToken(request: NextRequest): boolean {
|
||||||
|
const token = request.nextUrl.searchParams.get('token');
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const env = getEnv();
|
||||||
|
const expected = Buffer.from(env.ADMIN_TOKEN);
|
||||||
|
const received = Buffer.from(token);
|
||||||
|
|
||||||
|
if (expected.length !== received.length) return false;
|
||||||
|
return crypto.timingSafeEqual(expected, received);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorizedResponse() {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
139
src/lib/config.ts
Normal file
139
src/lib/config.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const optionalTrimmedString = z.preprocess((value) => {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === '' ? undefined : trimmed;
|
||||||
|
}, z.string().optional());
|
||||||
|
|
||||||
|
const rawEnvSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
|
||||||
|
SUB2API_BASE_URL: z.string().url(),
|
||||||
|
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||||
|
|
||||||
|
EASY_PAY_PID: optionalTrimmedString,
|
||||||
|
EASY_PAY_PKEY: optionalTrimmedString,
|
||||||
|
EASY_PAY_API_BASE: optionalTrimmedString,
|
||||||
|
EASY_PAY_NOTIFY_URL: optionalTrimmedString,
|
||||||
|
EASY_PAY_RETURN_URL: optionalTrimmedString,
|
||||||
|
EASY_PAY_CID: optionalTrimmedString,
|
||||||
|
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||||
|
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||||
|
|
||||||
|
ZPAY_PID: optionalTrimmedString,
|
||||||
|
ZPAY_PKEY: optionalTrimmedString,
|
||||||
|
ZPAY_API_BASE: optionalTrimmedString,
|
||||||
|
ZPAY_NOTIFY_URL: optionalTrimmedString,
|
||||||
|
ZPAY_RETURN_URL: optionalTrimmedString,
|
||||||
|
ZPAY_CID: optionalTrimmedString,
|
||||||
|
ZPAY_CID_ALIPAY: optionalTrimmedString,
|
||||||
|
ZPAY_CID_WXPAY: optionalTrimmedString,
|
||||||
|
|
||||||
|
ENABLED_PAYMENT_TYPES: z.string().default('alipay,wxpay').transform(v => v.split(',').map(s => s.trim())),
|
||||||
|
|
||||||
|
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||||
|
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
||||||
|
MAX_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().positive()),
|
||||||
|
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
||||||
|
|
||||||
|
ADMIN_TOKEN: z.string().min(1),
|
||||||
|
|
||||||
|
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||||
|
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||||
|
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedEnvSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
SUB2API_BASE_URL: z.string().url(),
|
||||||
|
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||||
|
|
||||||
|
EASY_PAY_PID: z.string().min(1),
|
||||||
|
EASY_PAY_PKEY: z.string().min(1),
|
||||||
|
EASY_PAY_API_BASE: z.string().url(),
|
||||||
|
EASY_PAY_NOTIFY_URL: z.string().url(),
|
||||||
|
EASY_PAY_RETURN_URL: z.string().url(),
|
||||||
|
EASY_PAY_CID: optionalTrimmedString,
|
||||||
|
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||||
|
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||||
|
|
||||||
|
ENABLED_PAYMENT_TYPES: z.array(z.string()),
|
||||||
|
|
||||||
|
ORDER_TIMEOUT_MINUTES: z.number().int().positive(),
|
||||||
|
MIN_RECHARGE_AMOUNT: z.number().positive(),
|
||||||
|
MAX_RECHARGE_AMOUNT: z.number().positive(),
|
||||||
|
PRODUCT_NAME: z.string(),
|
||||||
|
|
||||||
|
ADMIN_TOKEN: z.string().min(1),
|
||||||
|
|
||||||
|
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||||
|
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||||
|
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof resolvedEnvSchema>;
|
||||||
|
|
||||||
|
type RawEnv = z.infer<typeof rawEnvSchema>;
|
||||||
|
|
||||||
|
function pickRequired(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string {
|
||||||
|
const value = raw[key] ?? raw[fallbackKey];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required env: ${String(key)} (fallback: ${String(fallbackKey)})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickOptional(raw: RawEnv, key: keyof RawEnv, fallbackKey: keyof RawEnv): string | undefined {
|
||||||
|
return raw[key] ?? raw[fallbackKey] ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedEnv: Env | null = null;
|
||||||
|
|
||||||
|
export function getEnv(): Env {
|
||||||
|
if (cachedEnv) return cachedEnv;
|
||||||
|
|
||||||
|
const parsed = rawEnvSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||||
|
throw new Error('Invalid environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = parsed.data;
|
||||||
|
const resolved = {
|
||||||
|
DATABASE_URL: raw.DATABASE_URL,
|
||||||
|
SUB2API_BASE_URL: raw.SUB2API_BASE_URL,
|
||||||
|
SUB2API_ADMIN_API_KEY: raw.SUB2API_ADMIN_API_KEY,
|
||||||
|
|
||||||
|
EASY_PAY_PID: pickRequired(raw, 'EASY_PAY_PID', 'ZPAY_PID'),
|
||||||
|
EASY_PAY_PKEY: pickRequired(raw, 'EASY_PAY_PKEY', 'ZPAY_PKEY'),
|
||||||
|
EASY_PAY_API_BASE: pickRequired(raw, 'EASY_PAY_API_BASE', 'ZPAY_API_BASE'),
|
||||||
|
EASY_PAY_NOTIFY_URL: pickRequired(raw, 'EASY_PAY_NOTIFY_URL', 'ZPAY_NOTIFY_URL'),
|
||||||
|
EASY_PAY_RETURN_URL: pickRequired(raw, 'EASY_PAY_RETURN_URL', 'ZPAY_RETURN_URL'),
|
||||||
|
EASY_PAY_CID: pickOptional(raw, 'EASY_PAY_CID', 'ZPAY_CID'),
|
||||||
|
EASY_PAY_CID_ALIPAY: pickOptional(raw, 'EASY_PAY_CID_ALIPAY', 'ZPAY_CID_ALIPAY'),
|
||||||
|
EASY_PAY_CID_WXPAY: pickOptional(raw, 'EASY_PAY_CID_WXPAY', 'ZPAY_CID_WXPAY'),
|
||||||
|
|
||||||
|
ENABLED_PAYMENT_TYPES: raw.ENABLED_PAYMENT_TYPES,
|
||||||
|
|
||||||
|
ORDER_TIMEOUT_MINUTES: raw.ORDER_TIMEOUT_MINUTES,
|
||||||
|
MIN_RECHARGE_AMOUNT: raw.MIN_RECHARGE_AMOUNT,
|
||||||
|
MAX_RECHARGE_AMOUNT: raw.MAX_RECHARGE_AMOUNT,
|
||||||
|
PRODUCT_NAME: raw.PRODUCT_NAME,
|
||||||
|
|
||||||
|
ADMIN_TOKEN: raw.ADMIN_TOKEN,
|
||||||
|
|
||||||
|
NEXT_PUBLIC_APP_URL: raw.NEXT_PUBLIC_APP_URL,
|
||||||
|
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: raw.NEXT_PUBLIC_PAY_HELP_IMAGE_URL,
|
||||||
|
NEXT_PUBLIC_PAY_HELP_TEXT: raw.NEXT_PUBLIC_PAY_HELP_TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedParsed = resolvedEnvSchema.safeParse(resolved);
|
||||||
|
if (!resolvedParsed.success) {
|
||||||
|
console.error('Invalid resolved env variables:', resolvedParsed.error.flatten().fieldErrors);
|
||||||
|
throw new Error('Invalid resolved env variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedEnv = resolvedParsed.data;
|
||||||
|
return cachedEnv;
|
||||||
|
}
|
||||||
14
src/lib/db.ts
Normal file
14
src/lib/db.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||||
|
|
||||||
|
function createPrismaClient() {
|
||||||
|
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
|
||||||
|
const adapter = new PrismaPg({ connectionString });
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma || createPrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||||
97
src/lib/easy-pay/client.ts
Normal file
97
src/lib/easy-pay/client.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
import { generateSign } from './sign';
|
||||||
|
import type { EasyPayCreateResponse, EasyPayQueryResponse, EasyPayRefundResponse } from './types';
|
||||||
|
|
||||||
|
export interface CreatePaymentOptions {
|
||||||
|
outTradeNo: string;
|
||||||
|
amount: string;
|
||||||
|
paymentType: 'alipay' | 'wxpay';
|
||||||
|
clientIp: string;
|
||||||
|
productName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCidList(cid?: string): string | undefined {
|
||||||
|
if (!cid) return undefined;
|
||||||
|
const normalized = cid
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
|
||||||
|
const env = getEnv();
|
||||||
|
if (paymentType === 'alipay') {
|
||||||
|
return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||||
|
}
|
||||||
|
return normalizeCidList(env.EASY_PAY_CID_WXPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPayCreateResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
pid: env.EASY_PAY_PID,
|
||||||
|
type: opts.paymentType,
|
||||||
|
out_trade_no: opts.outTradeNo,
|
||||||
|
notify_url: env.EASY_PAY_NOTIFY_URL,
|
||||||
|
return_url: env.EASY_PAY_RETURN_URL,
|
||||||
|
name: opts.productName,
|
||||||
|
money: opts.amount,
|
||||||
|
clientip: opts.clientIp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cid = resolveCid(opts.paymentType);
|
||||||
|
if (cid) {
|
||||||
|
params.cid = cid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = generateSign(params, env.EASY_PAY_PKEY);
|
||||||
|
params.sign = sign;
|
||||||
|
params.sign_type = 'MD5';
|
||||||
|
|
||||||
|
const formData = new URLSearchParams(params);
|
||||||
|
const response = await fetch(`${env.EASY_PAY_API_BASE}/mapi.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as EasyPayCreateResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`EasyPay create payment failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json() as EasyPayQueryResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<EasyPayRefundResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pid: env.EASY_PAY_PID,
|
||||||
|
key: env.EASY_PAY_PKEY,
|
||||||
|
trade_no: tradeNo,
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
money,
|
||||||
|
});
|
||||||
|
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php?act=refund`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: params,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
const data = await response.json() as EasyPayRefundResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`EasyPay refund failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
19
src/lib/easy-pay/sign.ts
Normal file
19
src/lib/easy-pay/sign.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||||
|
const filtered = Object.entries(params)
|
||||||
|
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||||
|
const signStr = queryString + pkey;
|
||||||
|
return crypto.createHash('md5').update(signStr).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
|
||||||
|
const expected = generateSign(params, pkey);
|
||||||
|
if (expected.length !== sign.length) return false;
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(sign);
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
57
src/lib/easy-pay/types.ts
Normal file
57
src/lib/easy-pay/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface EasyPayCreateParams {
|
||||||
|
pid: string;
|
||||||
|
cid?: string;
|
||||||
|
type: 'alipay' | 'wxpay';
|
||||||
|
out_trade_no: string;
|
||||||
|
notify_url: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
clientip: string;
|
||||||
|
return_url: string;
|
||||||
|
sign?: string;
|
||||||
|
sign_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EasyPayCreateResponse {
|
||||||
|
code: number;
|
||||||
|
msg?: string;
|
||||||
|
trade_no: string;
|
||||||
|
O_id?: string;
|
||||||
|
payurl?: string;
|
||||||
|
qrcode?: string;
|
||||||
|
img?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EasyPayNotifyParams {
|
||||||
|
pid: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
out_trade_no: string;
|
||||||
|
trade_no: string;
|
||||||
|
param?: string;
|
||||||
|
trade_status: string;
|
||||||
|
type: string;
|
||||||
|
sign: string;
|
||||||
|
sign_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EasyPayQueryResponse {
|
||||||
|
code: number;
|
||||||
|
msg?: string;
|
||||||
|
trade_no: string;
|
||||||
|
out_trade_no: string;
|
||||||
|
type: string;
|
||||||
|
pid: string;
|
||||||
|
addtime: string;
|
||||||
|
endtime: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
status: number;
|
||||||
|
param?: string;
|
||||||
|
buyer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EasyPayRefundResponse {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
6
src/lib/order/code-gen.ts
Normal file
6
src/lib/order/code-gen.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function generateRechargeCode(orderId: string): string {
|
||||||
|
const prefix = 's2p_';
|
||||||
|
const maxIdLength = 32 - prefix.length; // 28
|
||||||
|
const truncatedId = orderId.slice(0, maxIdLength);
|
||||||
|
return `${prefix}${truncatedId}`;
|
||||||
|
}
|
||||||
509
src/lib/order/service.ts
Normal file
509
src/lib/order/service.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
import { generateRechargeCode } from './code-gen';
|
||||||
|
import { createPayment } from '@/lib/easy-pay/client';
|
||||||
|
import { verifySign } from '@/lib/easy-pay/sign';
|
||||||
|
import { refund as easyPayRefund } from '@/lib/easy-pay/client';
|
||||||
|
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type { ZPayNotifyParams } from '@/lib/easy-pay/types';
|
||||||
|
import { deriveOrderState, isRefundStatus } from './status';
|
||||||
|
|
||||||
|
const MAX_PENDING_ORDERS = 3;
|
||||||
|
|
||||||
|
export interface CreateOrderInput {
|
||||||
|
userId: number;
|
||||||
|
amount: number;
|
||||||
|
paymentType: 'alipay' | 'wxpay';
|
||||||
|
clientIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderResult {
|
||||||
|
orderId: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
paymentType: 'alipay' | 'wxpay';
|
||||||
|
userName: string;
|
||||||
|
userBalance: number;
|
||||||
|
payUrl?: string | null;
|
||||||
|
qrCode?: string | null;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
const user = await getUser(input.userId);
|
||||||
|
if (user.status !== 'active') {
|
||||||
|
throw new OrderError('USER_INACTIVE', 'User account is disabled', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = await prisma.order.count({
|
||||||
|
where: { userId: input.userId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||||
|
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: input.userId,
|
||||||
|
userEmail: user.email,
|
||||||
|
userName: user.username,
|
||||||
|
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
||||||
|
rechargeCode: '',
|
||||||
|
status: 'PENDING',
|
||||||
|
paymentType: input.paymentType,
|
||||||
|
expiresAt,
|
||||||
|
clientIp: input.clientIp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rechargeCode = generateRechargeCode(order.id);
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { rechargeCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const easyPayResult = await createPayment({
|
||||||
|
outTradeNo: order.id,
|
||||||
|
amount: input.amount.toFixed(2),
|
||||||
|
paymentType: input.paymentType,
|
||||||
|
clientIp: input.clientIp,
|
||||||
|
productName: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
zpayTradeNo: easyPayResult.trade_no,
|
||||||
|
payUrl: easyPayResult.payurl || null,
|
||||||
|
qrCode: easyPayResult.qrcode || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
action: 'ORDER_CREATED',
|
||||||
|
detail: JSON.stringify({ userId: input.userId, amount: input.amount, paymentType: input.paymentType }),
|
||||||
|
operator: `user:${input.userId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId: order.id,
|
||||||
|
amount: input.amount,
|
||||||
|
status: 'PENDING',
|
||||||
|
paymentType: input.paymentType,
|
||||||
|
userName: user.username,
|
||||||
|
userBalance: user.balance,
|
||||||
|
payUrl: easyPayResult.payurl,
|
||||||
|
qrCode: easyPayResult.qrcode,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await prisma.order.delete({ where: { id: order.id } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelOrder(orderId: string, userId: number): Promise<void> {
|
||||||
|
const result = await prisma.order.updateMany({
|
||||||
|
where: { id: orderId, userId, status: 'PENDING' },
|
||||||
|
data: { status: 'CANCELLED', updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||||
|
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
action: 'ORDER_CANCELLED',
|
||||||
|
detail: 'User cancelled order',
|
||||||
|
operator: `user:${userId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCancelOrder(orderId: string): Promise<void> {
|
||||||
|
const result = await prisma.order.updateMany({
|
||||||
|
where: { id: orderId, status: 'PENDING' },
|
||||||
|
data: { status: 'CANCELLED', updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||||
|
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
action: 'ORDER_CANCELLED',
|
||||||
|
detail: 'Admin cancelled order',
|
||||||
|
operator: 'admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePaymentNotify(params: EasyPayNotifyParams): Promise<boolean> {
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
const { sign, ...rest } = params;
|
||||||
|
const paramsForSign: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(rest)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
paramsForSign[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!verifySign(paramsForSign, env.EASY_PAY_PKEY, sign)) {
|
||||||
|
console.error('EasyPay notify: invalid signature');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.trade_status !== 'TRADE_SUCCESS') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: params.out_trade_no },
|
||||||
|
});
|
||||||
|
if (!order) {
|
||||||
|
console.error('EasyPay notify: order not found:', params.out_trade_no);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paidAmount: Prisma.Decimal;
|
||||||
|
try {
|
||||||
|
paidAmount = new Prisma.Decimal(params.money);
|
||||||
|
} catch {
|
||||||
|
console.error('EasyPay notify: invalid money format:', params.money);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (paidAmount.lte(0)) {
|
||||||
|
console.error('EasyPay notify: non-positive money:', params.money);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!paidAmount.equals(order.amount)) {
|
||||||
|
console.warn('EasyPay notify: amount changed, use paid amount', order.amount.toString(), params.money);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.order.updateMany({
|
||||||
|
where: {
|
||||||
|
id: order.id,
|
||||||
|
status: { in: ['PENDING', 'EXPIRED'] },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 'PAID',
|
||||||
|
amount: paidAmount,
|
||||||
|
zpayTradeNo: params.trade_no,
|
||||||
|
paidAt: new Date(),
|
||||||
|
failedAt: null,
|
||||||
|
failedReason: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
action: 'ORDER_PAID',
|
||||||
|
detail: JSON.stringify({
|
||||||
|
previous_status: order.status,
|
||||||
|
trade_no: params.trade_no,
|
||||||
|
expected_amount: order.amount.toString(),
|
||||||
|
paid_amount: paidAmount.toString(),
|
||||||
|
}),
|
||||||
|
operator: 'easy-pay',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Recharge inline to avoid "paid but still recharging" async gaps.
|
||||||
|
await executeRecharge(order.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Payment has been confirmed, always ack notify to avoid endless retries from gateway.
|
||||||
|
console.error('Recharge failed for order:', order.id, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRecharge(orderId: string): Promise<void> {
|
||||||
|
const order = await prisma.order.findUnique({ where: { id: orderId } });
|
||||||
|
if (!order) {
|
||||||
|
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
}
|
||||||
|
if (order.status === 'COMPLETED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRefundStatus(order.status)) {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400);
|
||||||
|
}
|
||||||
|
if (order.status !== 'PAID' && order.status !== 'FAILED') {
|
||||||
|
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createAndRedeem(
|
||||||
|
order.rechargeCode,
|
||||||
|
Number(order.amount),
|
||||||
|
order.userId,
|
||||||
|
`sub2apipay recharge order:${orderId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
action: 'RECHARGE_SUCCESS',
|
||||||
|
detail: JSON.stringify({ rechargeCode: order.rechargeCode, amount: Number(order.amount) }),
|
||||||
|
operator: 'system',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
failedAt: new Date(),
|
||||||
|
failedReason: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
action: 'RECHARGE_FAILED',
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
operator: 'system',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void {
|
||||||
|
if (!order.paidAt) {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefundStatus(order.status)) {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'FAILED' || order.status === 'PAID') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'RECHARGING') {
|
||||||
|
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'COMPLETED') {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryRecharge(orderId: string): Promise<void> {
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRetryAllowed(order);
|
||||||
|
|
||||||
|
const result = await prisma.order.updateMany({
|
||||||
|
where: {
|
||||||
|
id: orderId,
|
||||||
|
status: { in: ['FAILED', 'PAID'] },
|
||||||
|
paidAt: { not: null },
|
||||||
|
},
|
||||||
|
data: { status: 'PAID', failedAt: null, failedReason: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
const latest = await prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
paidAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const derived = deriveOrderState(latest);
|
||||||
|
if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') {
|
||||||
|
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (derived.rechargeStatus === 'success') {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefundStatus(latest.status)) {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
action: 'RECHARGE_RETRY',
|
||||||
|
detail: 'Admin manual retry recharge',
|
||||||
|
operator: 'admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeRecharge(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundInput {
|
||||||
|
orderId: string;
|
||||||
|
reason?: string;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundResult {
|
||||||
|
success: boolean;
|
||||||
|
warning?: string;
|
||||||
|
requireForce?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||||
|
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||||
|
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||||
|
if (order.status !== 'COMPLETED') {
|
||||||
|
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(order.amount);
|
||||||
|
|
||||||
|
if (!input.force) {
|
||||||
|
try {
|
||||||
|
const user = await getUser(order.userId);
|
||||||
|
if (user.balance < amount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
warning: `User balance ${user.balance} is lower than refund ${amount}`,
|
||||||
|
requireForce: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
warning: 'Cannot fetch user balance, use force=true',
|
||||||
|
requireForce: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockResult = await prisma.order.updateMany({
|
||||||
|
where: { id: input.orderId, status: 'COMPLETED' },
|
||||||
|
data: { status: 'REFUNDING' },
|
||||||
|
});
|
||||||
|
if (lockResult.count === 0) {
|
||||||
|
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (order.zpayTradeNo) {
|
||||||
|
await easyPayRefund(order.zpayTradeNo, order.id, amount.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
await subtractBalance(
|
||||||
|
order.userId,
|
||||||
|
amount,
|
||||||
|
`sub2apipay refund order:${order.id}`,
|
||||||
|
`sub2apipay:refund:${order.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: input.orderId },
|
||||||
|
data: {
|
||||||
|
status: 'REFUNDED',
|
||||||
|
refundAmount: new Prisma.Decimal(amount.toFixed(2)),
|
||||||
|
refundReason: input.reason || null,
|
||||||
|
refundAt: new Date(),
|
||||||
|
forceRefund: input.force || false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: input.orderId,
|
||||||
|
action: 'REFUND_SUCCESS',
|
||||||
|
detail: JSON.stringify({ amount, reason: input.reason, force: input.force }),
|
||||||
|
operator: 'admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: input.orderId },
|
||||||
|
data: {
|
||||||
|
status: 'REFUND_FAILED',
|
||||||
|
failedAt: new Date(),
|
||||||
|
failedReason: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
orderId: input.orderId,
|
||||||
|
action: 'REFUND_FAILED',
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
operator: 'admin',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrderError extends Error {
|
||||||
|
code: string;
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
constructor(code: string, message: string, statusCode: number = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'OrderError';
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/lib/order/status.ts
Normal file
66
src/lib/order/status.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export type RechargeStatus =
|
||||||
|
| 'not_paid'
|
||||||
|
| 'paid_pending'
|
||||||
|
| 'recharging'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'closed';
|
||||||
|
|
||||||
|
export interface OrderStatusLike {
|
||||||
|
status: string;
|
||||||
|
paidAt?: Date | string | null;
|
||||||
|
completedAt?: Date | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED_STATUSES = new Set([
|
||||||
|
'EXPIRED',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDING',
|
||||||
|
'REFUNDED',
|
||||||
|
'REFUND_FAILED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||||
|
|
||||||
|
function hasDate(value: Date | string | null | undefined): boolean {
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRefundStatus(status: string): boolean {
|
||||||
|
return REFUND_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRechargeRetryable(order: OrderStatusLike): boolean {
|
||||||
|
return hasDate(order.paidAt) && order.status === 'FAILED' && !isRefundStatus(order.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOrderState(order: OrderStatusLike): {
|
||||||
|
paymentSuccess: boolean;
|
||||||
|
rechargeSuccess: boolean;
|
||||||
|
rechargeStatus: RechargeStatus;
|
||||||
|
} {
|
||||||
|
const paymentSuccess = hasDate(order.paidAt);
|
||||||
|
const rechargeSuccess = hasDate(order.completedAt) || order.status === 'COMPLETED';
|
||||||
|
|
||||||
|
if (rechargeSuccess) {
|
||||||
|
return { paymentSuccess, rechargeSuccess: true, rechargeStatus: 'success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'RECHARGING') {
|
||||||
|
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'recharging' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status === 'FAILED') {
|
||||||
|
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CLOSED_STATUSES.has(order.status)) {
|
||||||
|
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'closed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentSuccess) {
|
||||||
|
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'paid_pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'not_paid' };
|
||||||
|
}
|
||||||
42
src/lib/order/timeout.ts
Normal file
42
src/lib/order/timeout.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
const INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export async function expireOrders(): Promise<number> {
|
||||||
|
const result = await prisma.order.updateMany({
|
||||||
|
where: {
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: { lt: new Date() },
|
||||||
|
},
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count > 0) {
|
||||||
|
console.log(`Expired ${result.count} orders`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTimeoutScheduler(): void {
|
||||||
|
if (timer) return;
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
expireOrders().catch(console.error);
|
||||||
|
|
||||||
|
// Then run every 30 seconds
|
||||||
|
timer = setInterval(() => {
|
||||||
|
expireOrders().catch(console.error);
|
||||||
|
}, INTERVAL_MS);
|
||||||
|
|
||||||
|
console.log('Order timeout scheduler started');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTimeoutScheduler(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
console.log('Order timeout scheduler stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/lib/sub2api/client.ts
Normal file
102
src/lib/sub2api/client.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
|
||||||
|
|
||||||
|
function getHeaders(idempotencyKey?: string): Record<string, string> {
|
||||||
|
const env = getEnv();
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': env.SUB2API_ADMIN_API_KEY,
|
||||||
|
};
|
||||||
|
if (idempotencyKey) {
|
||||||
|
headers['Idempotency-Key'] = idempotencyKey;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser> {
|
||||||
|
const env = getEnv();
|
||||||
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get current user: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data as Sub2ApiUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(userId: number): Promise<Sub2ApiUser> {
|
||||||
|
const env = getEnv();
|
||||||
|
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) throw new Error('USER_NOT_FOUND');
|
||||||
|
throw new Error(`Failed to get user: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data as Sub2ApiUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAndRedeem(
|
||||||
|
code: string,
|
||||||
|
value: number,
|
||||||
|
userId: number,
|
||||||
|
notes: string,
|
||||||
|
): Promise<Sub2ApiRedeemCode> {
|
||||||
|
const env = getEnv();
|
||||||
|
const response = await fetch(
|
||||||
|
`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(`sub2apipay:recharge:${code}`),
|
||||||
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
type: 'balance',
|
||||||
|
value,
|
||||||
|
user_id: userId,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.redeem_code as Sub2ApiRedeemCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subtractBalance(
|
||||||
|
userId: number,
|
||||||
|
amount: number,
|
||||||
|
notes: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const env = getEnv();
|
||||||
|
const response = await fetch(
|
||||||
|
`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(idempotencyKey),
|
||||||
|
body: JSON.stringify({
|
||||||
|
operation: 'subtract',
|
||||||
|
amount,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/sub2api/types.ts
Normal file
23
src/lib/sub2api/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Sub2ApiUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
status: string; // "active", "banned", etc.
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiRedeemCode {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
value: number;
|
||||||
|
status: string;
|
||||||
|
used_by: number;
|
||||||
|
used_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sub2ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
74
src/lib/zpay/client.ts
Normal file
74
src/lib/zpay/client.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { getEnv } from '@/lib/config';
|
||||||
|
import { generateSign } from './sign';
|
||||||
|
import type { ZPayCreateResponse, ZPayQueryResponse, ZPayRefundResponse } from './types';
|
||||||
|
|
||||||
|
export interface CreatePaymentOptions {
|
||||||
|
outTradeNo: string;
|
||||||
|
amount: string; // 金额字符串,如 "10.00"
|
||||||
|
paymentType: 'alipay' | 'wxpay';
|
||||||
|
clientIp: string;
|
||||||
|
productName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment(opts: CreatePaymentOptions): Promise<ZPayCreateResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
pid: env.ZPAY_PID,
|
||||||
|
type: opts.paymentType,
|
||||||
|
out_trade_no: opts.outTradeNo,
|
||||||
|
notify_url: env.ZPAY_NOTIFY_URL,
|
||||||
|
return_url: env.ZPAY_RETURN_URL,
|
||||||
|
name: opts.productName,
|
||||||
|
money: opts.amount,
|
||||||
|
clientip: opts.clientIp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sign = generateSign(params, env.ZPAY_PKEY);
|
||||||
|
params.sign = sign;
|
||||||
|
params.sign_type = 'MD5';
|
||||||
|
|
||||||
|
const formData = new URLSearchParams(params);
|
||||||
|
const response = await fetch(`${env.ZPAY_API_BASE}/mapi.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as ZPayCreateResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`ZPAY create payment failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOrder(outTradeNo: string): Promise<ZPayQueryResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const url = `${env.ZPAY_API_BASE}/api.php?act=order&pid=${env.ZPAY_PID}&key=${env.ZPAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json() as ZPayQueryResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`ZPAY query order failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refund(tradeNo: string, outTradeNo: string, money: string): Promise<ZPayRefundResponse> {
|
||||||
|
const env = getEnv();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
pid: env.ZPAY_PID,
|
||||||
|
key: env.ZPAY_PKEY,
|
||||||
|
trade_no: tradeNo,
|
||||||
|
out_trade_no: outTradeNo,
|
||||||
|
money,
|
||||||
|
});
|
||||||
|
const response = await fetch(`${env.ZPAY_API_BASE}/api.php?act=refund`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: params,
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
const data = await response.json() as ZPayRefundResponse;
|
||||||
|
if (data.code !== 1) {
|
||||||
|
throw new Error(`ZPAY refund failed: ${data.msg || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
19
src/lib/zpay/sign.ts
Normal file
19
src/lib/zpay/sign.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
export function generateSign(params: Record<string, string>, pkey: string): string {
|
||||||
|
const filtered = Object.entries(params)
|
||||||
|
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const queryString = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||||
|
const signStr = queryString + pkey;
|
||||||
|
return crypto.createHash('md5').update(signStr).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySign(params: Record<string, string>, pkey: string, sign: string): boolean {
|
||||||
|
const expected = generateSign(params, pkey);
|
||||||
|
if (expected.length !== sign.length) return false;
|
||||||
|
const a = Buffer.from(expected);
|
||||||
|
const b = Buffer.from(sign);
|
||||||
|
return crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
56
src/lib/zpay/types.ts
Normal file
56
src/lib/zpay/types.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export interface ZPayCreateParams {
|
||||||
|
pid: string;
|
||||||
|
type: 'alipay' | 'wxpay';
|
||||||
|
out_trade_no: string;
|
||||||
|
notify_url: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
clientip: string;
|
||||||
|
return_url: string;
|
||||||
|
sign?: string;
|
||||||
|
sign_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZPayCreateResponse {
|
||||||
|
code: number;
|
||||||
|
msg?: string;
|
||||||
|
trade_no: string;
|
||||||
|
O_id?: string;
|
||||||
|
payurl?: string;
|
||||||
|
qrcode?: string;
|
||||||
|
img?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZPayNotifyParams {
|
||||||
|
pid: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
out_trade_no: string;
|
||||||
|
trade_no: string;
|
||||||
|
param?: string;
|
||||||
|
trade_status: string;
|
||||||
|
type: string;
|
||||||
|
sign: string;
|
||||||
|
sign_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZPayQueryResponse {
|
||||||
|
code: number;
|
||||||
|
msg?: string;
|
||||||
|
trade_no: string;
|
||||||
|
out_trade_no: string;
|
||||||
|
type: string;
|
||||||
|
pid: string;
|
||||||
|
addtime: string;
|
||||||
|
endtime: string;
|
||||||
|
name: string;
|
||||||
|
money: string;
|
||||||
|
status: number;
|
||||||
|
param?: string;
|
||||||
|
buyer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZPayRefundResponse {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
21
src/middleware.ts
Normal file
21
src/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
// IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔)
|
||||||
|
const allowOrigins = process.env.IFRAME_ALLOW_ORIGINS || '';
|
||||||
|
|
||||||
|
const origins = allowOrigins.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (origins.length > 0) {
|
||||||
|
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
15
start.sh
Normal file
15
start.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
PRISMA_BIN=$(find node_modules/.pnpm -path '*/prisma/build/index.js' -type f | head -1)
|
||||||
|
|
||||||
|
if [ -n "$PRISMA_BIN" ]; then
|
||||||
|
node "$PRISMA_BIN" migrate deploy --config prisma.config.ts
|
||||||
|
echo "Migrations complete."
|
||||||
|
else
|
||||||
|
echo "Warning: prisma CLI not found, skipping migrations."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec node server.js
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules", "third-party"]
|
||||||
|
}
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
exclude: ['**/node_modules/**', '**/third-party/**'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
148
zpay.md
Normal file
148
zpay.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
如果您的网站已经集成了易支付接口,那么您可以直接使用该API信息,无需另外开发。
|
||||||
|
API信息(兼容 易支付 接口)
|
||||||
|
接口地址:https://zpayz.cn/
|
||||||
|
|
||||||
|
商户ID(PID):2026022720004756
|
||||||
|
|
||||||
|
商户密钥(PKEY):YifxyCWYTLW3hXD4Ae7xB9KqtVA2474k
|
||||||
|
|
||||||
|
页面跳转支付
|
||||||
|
请求URL
|
||||||
|
https://zpayz.cn/submit.php
|
||||||
|
请求方法
|
||||||
|
POST 或 GET(推荐POST,不容易被劫持或屏蔽)
|
||||||
|
此接口可用于用户前台直接发起支付,使用form表单跳转或拼接成url跳转。
|
||||||
|
请求参数
|
||||||
|
参数 名称 类型 是否必填 描述 范例
|
||||||
|
name 商品名称 String 是 需体现出具体售卖的商品,否则容易被封 iPhone17苹果手机
|
||||||
|
money 订单金额 String 是 最多保留两位小数 5.67
|
||||||
|
type 支付方式 String 是 支付宝:alipay 微信支付:wxpay alipay
|
||||||
|
out_trade_no 商户订单号 Num 是 每个商品不可重复,最多32位 201911914837526544601
|
||||||
|
notify_url 异步通知页面 String 是 交易信息回调页面,不支持带参数 http://www.aaa.com/bbb.php
|
||||||
|
pid 商户唯一标识 String 是 一串字母数字组合 201901151314084206659771
|
||||||
|
cid 支付渠道ID String 否 支持填写多个,使用,隔开,如果不填则随机调用 1234
|
||||||
|
param 附加内容 String 否 会通过notify_url原样返回 金色 256G
|
||||||
|
return_url 跳转页面 String 是 交易完成后浏览器跳转,不支持带参数 http://www.aaa.com/ccc.php
|
||||||
|
sign 签名(参考本页签名算法) String 是 用于验证信息正确性,采用md5加密 28f9583617d9caf66834292b6ab1cc89
|
||||||
|
sign_type 签名方法 String 是 默认为MD5 MD5
|
||||||
|
用法举例
|
||||||
|
https://zpayz.cn/submit.php?name=iphone xs Max 一台&money=0.03&out_trade_no=201911914837526544601¬ify_url=http://www.aaa.com/notify_url.php&pid=201901151314084206659771¶m=金色 256G&return_url=http://www.baidu.com&sign=28f9583617d9caf66834292b6ab1cc89&sign_type=MD5&type=alipay
|
||||||
|
|
||||||
|
成功返回
|
||||||
|
直接跳转到付款页面
|
||||||
|
说明:该页面为收银台,直接访问这个url即可进行付款
|
||||||
|
失败返回
|
||||||
|
{"code":"error","msg":"具体的错误信息"}
|
||||||
|
API接口支付
|
||||||
|
请求URL
|
||||||
|
https://zpayz.cn/mapi.php
|
||||||
|
请求方法
|
||||||
|
POST(方式为form-data)
|
||||||
|
请求参数
|
||||||
|
字段名 变量名 必填 类型 示例值 描述
|
||||||
|
商户ID pid 是 String 1001
|
||||||
|
支付渠道ID cid 否 String 1234 支持填写多个,使用,隔开,如果不填则随机调用
|
||||||
|
支付方式 type 是 String alipay 支付宝:alipay 微信支付:wxpay
|
||||||
|
商户订单号 out_trade_no 是 String 20160806151343349 每个商品不可重复,最多32位
|
||||||
|
异步通知地址 notify_url 是 String http://www.pay.com/notify_url.php 服务器异步通知地址
|
||||||
|
商品名称 name 是 String iPhone17苹果手机 需体现出具体售卖的商品,否则容易被封
|
||||||
|
商品金额 money 是 String 1.00 单位:元,最大2位小数
|
||||||
|
用户IP地址 clientip 是 String 192.168.1.100 用户发起支付的IP地址
|
||||||
|
设备类型 device 否 String pc 根据当前用户浏览器的UA判断,
|
||||||
|
传入用户所使用的浏览器
|
||||||
|
或设备类型,默认为pc
|
||||||
|
业务扩展参数 param 否 String 没有请留空 支付后原样返回
|
||||||
|
签名字符串 sign 是 String 202cb962ac59075b964b07152d234b70 签名算法参考本页底部
|
||||||
|
签名类型 sign_type 是 String MD5 默认为MD5
|
||||||
|
成功返回
|
||||||
|
字段名 变量名 类型 示例值 描述
|
||||||
|
返回状态码 code Int 1 1为成功,其它值为失败
|
||||||
|
返回信息 msg String 失败时返回原因
|
||||||
|
订单号 trade_no String 20160806151343349 支付订单号
|
||||||
|
ZPAY内部订单号 O_id String 123456 ZPAY内部订单号
|
||||||
|
支付跳转url payurl String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则直接跳转到该url支付
|
||||||
|
二维码链接 qrcode String https://xxx.cn/pay/wxpay/202010903/ 如果返回该字段,则根据该url生成二维码
|
||||||
|
二维码图片 img String https://zpayz.cn/qrcode/123.jpg 该字段为付款二维码的图片地址
|
||||||
|
失败返回
|
||||||
|
{"code":"error","msg":"具体的错误信息"}
|
||||||
|
查询单个订单
|
||||||
|
请求URL
|
||||||
|
https://zpayz.cn/api.php?act=order&pid={商户ID}&key={商户密钥}&out_trade_no={商户订单号}
|
||||||
|
请求方法
|
||||||
|
GET
|
||||||
|
请求参数
|
||||||
|
参数 名称 类型 必填 描述 范例
|
||||||
|
act 操作类型 String 是 此API固定值 order
|
||||||
|
pid 商户ID String 是 20220715225121
|
||||||
|
key 商户密钥 String 是 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||||
|
trade_no 系统订单号 String 选择 20160806151343312
|
||||||
|
out_trade_no 商户订单号 String 选择 20160806151343349
|
||||||
|
返回结果
|
||||||
|
字段名 变量名 类型 示例值 描述
|
||||||
|
返回状态码 code Int 1 1为成功,其它值为失败
|
||||||
|
返回信息 msg String 查询订单号成功!
|
||||||
|
易支付订单号 trade_no String 2016080622555342651 易支付订单号
|
||||||
|
商户订单号 out_trade_no String 20160806151343349 商户系统内部的订单号
|
||||||
|
支付方式 type String alipay 支付宝:alipay 微信支付:wxpay
|
||||||
|
商户ID pid String 20220715225121 发起支付的商户ID
|
||||||
|
创建订单时间 addtime String 2016-08-06 22:55:52
|
||||||
|
完成交易时间 endtime String 2016-08-06 22:55:52
|
||||||
|
商品名称 name String VIP会员
|
||||||
|
商品金额 money String 1.00
|
||||||
|
支付状态 status Int 0 1为支付成功,0为未支付
|
||||||
|
业务扩展参数 param String 默认留空
|
||||||
|
支付者账号 buyer String 默认留空
|
||||||
|
提交订单退款
|
||||||
|
请求URL
|
||||||
|
https://zpayz.cn/api.php?act=refund
|
||||||
|
请求方法
|
||||||
|
POST
|
||||||
|
请求参数
|
||||||
|
字段名 变量名 必填 类型 示例值 描述
|
||||||
|
商户ID pid 是 String 20220715225121
|
||||||
|
商户密钥 key 是 String 89unJUB8HZ54Hj7x4nUj56HN4nUzUJ8i
|
||||||
|
易支付订单号 trade_no 特殊可选 String 20160806151343349021 易支付订单号
|
||||||
|
商户订单号 out_trade_no 特殊可选 String 20160806151343349 订单支付时传入的商户订单号,商家自定义且保证商家系统中唯一
|
||||||
|
退款金额 money 是 String 1.50 大多数通道需要与原订单金额一致
|
||||||
|
返回结果
|
||||||
|
字段名 变量名 类型 示例值 描述
|
||||||
|
返回状态码 code Int 1 1为成功,其它值为失败
|
||||||
|
返回信息 msg String 退款成功
|
||||||
|
支付结果通知
|
||||||
|
请求URL
|
||||||
|
服务器异步通知(notify_url)、页面跳转通知(return_url)
|
||||||
|
请求方法
|
||||||
|
GET
|
||||||
|
请求参数
|
||||||
|
参数 名称 类型 描述 范例
|
||||||
|
pid 商户ID Int 201901151314084206659771
|
||||||
|
name 商品名称 String 商品名称不超过100字 iphone
|
||||||
|
money 订单金额 String 最多保留两位小数 5.67
|
||||||
|
out_trade_no 商户订单号 Num 商户系统内部的订单号 201901191324552185692680
|
||||||
|
trade_no 易支付订单号 String 易支付订单号 2019011922001418111011411195
|
||||||
|
param 业务扩展参数 String 会通过notify_url原样返回 金色 256G
|
||||||
|
trade_status 支付状态 String 只有TRADE_SUCCESS是成功 TRADE_SUCCESS
|
||||||
|
type 支付方式 String 包括支付宝、微信 alipay
|
||||||
|
sign 签名(参考本页签名算法) String 用于验证接受信息的正确性 ef6e3c5c6ff45018e8c82fd66fb056dc
|
||||||
|
sign_type 签名类型 String 默认为MD5 MD5
|
||||||
|
如何验证
|
||||||
|
请根据签名算法,验证自己生成的签名与参数中传入的签名是否一致,如果一致则说明是由官方向您发送的真实信息
|
||||||
|
注意事项
|
||||||
|
1.收到回调信息后请返回“success”,否则程序将判定您的回调地址未正确通知到。
|
||||||
|
|
||||||
|
2.同样的通知可能会多次发送给商户系统。商户系统必须能够正确处理重复的通知。
|
||||||
|
|
||||||
|
3.推荐的做法是,当收到通知进行处理时,首先检查对应业务数据的状态,判断该通知是否已经处理过,如果没有处理过再进行处理,如果处理过直接返回结果成功。在对业务数据进行状态检查和处理之前,要采用数据锁进行并发控制,以避免函数重入造成的数据混乱。
|
||||||
|
|
||||||
|
4.特别提醒:商户系统对于支付结果通知的内容一定要做签名验证,并校验返回的订单金额是否与商户侧的订单金额一致,防止数据泄漏导致出现“假通知”,造成资金损失。
|
||||||
|
|
||||||
|
5.对后台通知交互时,如果平台收到商户的应答不是纯字符串success或超过5秒后返回时,平台认为通知失败,平台会通过一定的策略(通知频率为0/15/15/30/180/1800/1800/1800/1800/3600,单位:秒)间接性重新发起通知,尽可能提高通知的成功率,但不保证通知最终能成功。
|
||||||
|
|
||||||
|
MD5签名算法
|
||||||
|
1、将发送或接收到的所有参数按照参数名ASCII码从小到大排序(a-z),sign、sign_type、和空值不参与签名!
|
||||||
|
|
||||||
|
2、将排序后的参数拼接成URL键值对的格式,例如 a=b&c=d&e=f,参数值不要进行url编码。
|
||||||
|
|
||||||
|
3、再将拼接好的字符串与商户密钥KEY进行MD5加密得出sign签名参数,sign = md5 ( a=b&c=d&e=f + KEY ) (注意:+ 为各语言的拼接符,不是字符!),md5结果为小写。
|
||||||
|
|
||||||
|
4、具体签名与发起支付的示例代码可下载SDK查看。
|
||||||
Reference in New Issue
Block a user