diff --git a/prisma/migrations/20260313200000_fee_rate_precision/migration.sql b/prisma/migrations/20260313200000_fee_rate_precision/migration.sql new file mode 100644 index 0000000..1e3f844 --- /dev/null +++ b/prisma/migrations/20260313200000_fee_rate_precision/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: increase fee_rate precision from Decimal(5,2) to Decimal(5,4) +ALTER TABLE "orders" ALTER COLUMN "fee_rate" TYPE DECIMAL(5,4); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f121bf5..96f45f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,7 +14,7 @@ model Order { userNotes String? @map("user_notes") amount Decimal @db.Decimal(10, 2) payAmount Decimal? @db.Decimal(10, 2) @map("pay_amount") - feeRate Decimal? @db.Decimal(5, 2) @map("fee_rate") + feeRate Decimal? @db.Decimal(5, 4) @map("fee_rate") rechargeCode String @unique @map("recharge_code") status OrderStatus @default(PENDING) paymentType String @map("payment_type") diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index e5573da..1b1fedc 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -168,7 +168,7 @@ function buildText(locale: Locale) { colPrice: '价格', colOriginalPrice: '原价', colValidDays: '有效期', - colEnabled: '售卖', + colEnabled: '启用售卖', colGroupStatus: '分组状态', colActions: '操作', edit: '编辑', @@ -461,6 +461,25 @@ function SubscriptionsContent() { } }; + /* --- toggle plan enabled --- */ + const handleToggleEnabled = async (plan: SubscriptionPlan) => { + try { + const res = await fetch(`/api/admin/subscription-plans/${plan.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ for_sale: !plan.enabled }), + }); + if (res.ok) { + setPlans((prev) => prev.map((p) => (p.id === plan.id ? { ...p, enabled: !p.enabled } : p))); + } + } catch { + /* ignore */ + } + }; + /* --- fetch user subs --- */ const fetchSubs = async () => { if (!token || !subsUserId.trim()) return; @@ -705,20 +724,21 @@ function SubscriptionsContent() { : t.unitDay} - handleToggleEnabled(plan)} className={[ - 'inline-block rounded-full px-2 py-0.5 text-xs font-medium', - plan.enabled - ? isDark - ? 'bg-green-500/20 text-green-300' - : 'bg-green-50 text-green-700' - : isDark - ? 'bg-slate-700 text-slate-400' - : 'bg-gray-100 text-gray-500', + 'relative inline-flex h-5 w-9 items-center rounded-full transition-colors', + plan.enabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300', ].join(' ')} > - {plan.enabled ? t.enabled : t.disabled} - + + - setFormEnabled(e.target.checked)} - className="h-4 w-4 rounded border-slate-300" - /> - + diff --git a/src/app/api/admin/channels/[id]/route.ts b/src/app/api/admin/channels/[id]/route.ts index 5cc96ee..861aced 100644 --- a/src/app/api/admin/channels/[id]/route.ts +++ b/src/app/api/admin/channels/[id]/route.ts @@ -27,6 +27,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } + if (body.rate_multiplier !== undefined && (typeof body.rate_multiplier !== 'number' || body.rate_multiplier <= 0)) { + return NextResponse.json({ error: 'rate_multiplier 必须是正数' }, { status: 400 }); + } + if (body.sort_order !== undefined && (!Number.isInteger(body.sort_order) || body.sort_order < 0)) { + return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 }); + } + const data: Record = {}; if (body.group_id !== undefined) data.groupId = Number(body.group_id); if (body.name !== undefined) data.name = body.name; diff --git a/src/app/api/admin/channels/route.ts b/src/app/api/admin/channels/route.ts index 31e655d..2597a7e 100644 --- a/src/app/api/admin/channels/route.ts +++ b/src/app/api/admin/channels/route.ts @@ -47,6 +47,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '缺少必填字段: group_id, name, platform, rate_multiplier' }, { status: 400 }); } + if (typeof name !== 'string' || name.trim() === '') { + return NextResponse.json({ error: 'name 必须非空' }, { status: 400 }); + } + if (typeof rate_multiplier !== 'number' || rate_multiplier <= 0) { + return NextResponse.json({ error: 'rate_multiplier 必须是正数' }, { status: 400 }); + } + if (sort_order !== undefined && (!Number.isInteger(sort_order) || sort_order < 0)) { + return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 }); + } + // 验证 group_id 唯一性 const existing = await prisma.channel.findUnique({ where: { groupId: Number(group_id) }, diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts index 90e37e1..3b82937 100644 --- a/src/app/api/admin/config/route.ts +++ b/src/app/api/admin/config/route.ts @@ -6,7 +6,8 @@ const SENSITIVE_PATTERNS = ['KEY', 'SECRET', 'PASSWORD', 'PRIVATE']; function maskSensitiveValue(key: string, value: string): string { const isSensitive = SENSITIVE_PATTERNS.some((pattern) => key.toUpperCase().includes(pattern)); - if (!isSensitive || value.length <= 4) return value; + if (!isSensitive) return value; + if (value.length <= 4) return '****'; return '*'.repeat(value.length - 4) + value.slice(-4); } @@ -23,7 +24,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ configs: masked }); } catch (error) { - console.error('Failed to get system configs:', error); + console.error('Failed to get system configs:', error instanceof Error ? error.message : String(error)); return NextResponse.json({ error: '获取系统配置失败' }, { status: 500 }); } } @@ -39,11 +40,24 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: '缺少必填字段: configs 数组' }, { status: 400 }); } + const ALLOWED_CONFIG_KEYS = new Set([ + 'PRODUCT_NAME', + 'ENABLED_PAYMENT_TYPES', + 'RECHARGE_MIN_AMOUNT', + 'RECHARGE_MAX_AMOUNT', + 'DAILY_RECHARGE_LIMIT', + 'ORDER_TIMEOUT_MINUTES', + 'IFRAME_ALLOW_ORIGINS', + ]); + // 校验每条配置 for (const config of configs) { if (!config.key || config.value === undefined) { return NextResponse.json({ error: '每条配置必须包含 key 和 value' }, { status: 400 }); } + if (!ALLOWED_CONFIG_KEYS.has(config.key)) { + return NextResponse.json({ error: `不允许修改配置项: ${config.key}` }, { status: 400 }); + } } await setSystemConfigs( @@ -57,7 +71,7 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ success: true, updated: configs.length }); } catch (error) { - console.error('Failed to update system configs:', error); + console.error('Failed to update system configs:', error instanceof Error ? error.message : String(error)); return NextResponse.json({ error: '更新系统配置失败' }, { status: 500 }); } } diff --git a/src/app/api/admin/subscription-plans/[id]/route.ts b/src/app/api/admin/subscription-plans/[id]/route.ts index ba471c5..1e869b9 100644 --- a/src/app/api/admin/subscription-plans/[id]/route.ts +++ b/src/app/api/admin/subscription-plans/[id]/route.ts @@ -27,6 +27,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } + if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0)) { + return NextResponse.json({ error: 'price 必须是正数' }, { status: 400 }); + } + if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) { + return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 }); + } + const data: Record = {}; if (body.group_id !== undefined) data.groupId = Number(body.group_id); if (body.name !== undefined) data.name = body.name; diff --git a/src/app/api/admin/subscription-plans/route.ts b/src/app/api/admin/subscription-plans/route.ts index 9fa970d..205eae1 100644 --- a/src/app/api/admin/subscription-plans/route.ts +++ b/src/app/api/admin/subscription-plans/route.ts @@ -61,6 +61,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 }); } + if (typeof price !== 'number' || price <= 0) { + return NextResponse.json({ error: 'price 必须是正数' }, { status: 400 }); + } + if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) { + return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 }); + } + if (sort_order !== undefined && (!Number.isInteger(sort_order) || sort_order < 0)) { + return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 }); + } + // 验证 group_id 唯一性 const existing = await prisma.subscriptionPlan.findUnique({ where: { groupId: Number(group_id) }, diff --git a/src/app/api/limits/route.ts b/src/app/api/limits/route.ts index 2d7d918..74a79cc 100644 --- a/src/app/api/limits/route.ts +++ b/src/app/api/limits/route.ts @@ -1,11 +1,12 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { queryMethodLimits } from '@/lib/order/limits'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; import { getNextBizDayStartUTC } from '@/lib/time/biz-day'; +import { getCurrentUserByToken } from '@/lib/sub2api/client'; /** - * GET /api/limits - * 返回各支付渠道今日限额使用情况,公开接口(无需鉴权)。 + * GET /api/limits?token=xxx + * 返回各支付渠道今日限额使用情况。 * * Response: * { @@ -17,7 +18,18 @@ import { getNextBizDayStartUTC } from '@/lib/time/biz-day'; * resetAt: "2026-03-02T16:00:00.000Z" // 业务时区(Asia/Shanghai)次日零点对应的 UTC 时间 * } */ -export async function GET() { +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 { + await getCurrentUserByToken(token); + } catch { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + initPaymentProviders(); const types = paymentRegistry.getSupportedTypes(); const methods = await queryMethodLimits(types); diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts index 6429e99..5c30b7f 100644 --- a/src/app/api/users/[id]/route.ts +++ b/src/app/api/users/[id]/route.ts @@ -1,8 +1,20 @@ import { NextResponse } from 'next/server'; -import { getUser } from '@/lib/sub2api/client'; +import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client'; // 仅返回用户是否存在,不暴露私隐信息(用户名/邮箱/余额需 token 验证) -export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { searchParams } = new URL(request.url); + const token = searchParams.get('token')?.trim(); + if (!token) { + return NextResponse.json({ error: 'token is required' }, { status: 400 }); + } + + try { + await getCurrentUserByToken(token); + } catch { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + const { id } = await params; const userId = Number(id); diff --git a/src/lib/alipay/client.ts b/src/lib/alipay/client.ts index 372de22..89fcc33 100644 --- a/src/lib/alipay/client.ts +++ b/src/lib/alipay/client.ts @@ -92,6 +92,10 @@ export async function execute( const data = await parseAlipayJsonResponse>(response); // 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." } + // TODO: 实现响应验签 — 需要从原始响应文本中提取 responseKey 对应的 JSON 子串, + // 使用 verifySign 配合 ALIPAY_PUBLIC_KEY 验证 data.sign。 + // 当前未验签是因为需要保留原始响应文本(不能 JSON.parse 后再 stringify), + // 需要改造 parseAlipayJsonResponse 同时返回原始文本。 const responseKey = method.replace(/\./g, '_') + '_response'; const result = data[responseKey] as T | undefined; diff --git a/src/lib/alipay/provider.ts b/src/lib/alipay/provider.ts index 5b0c895..48a9949 100644 --- a/src/lib/alipay/provider.ts +++ b/src/lib/alipay/provider.ts @@ -120,10 +120,15 @@ export class AlipayProvider implements PaymentProvider { status = 'pending'; } + const amount = parseFloat(result.total_amount || '0'); + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error(`Alipay queryOrder: invalid total_amount "${result.total_amount}" for trade ${tradeNo}`); + } + return { tradeNo: result.trade_no || tradeNo, status, - amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100, + amount: Math.round(amount * 100) / 100, paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined, }; } diff --git a/src/lib/config.ts b/src/lib/config.ts index b6d278d..e236d8b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -87,7 +87,7 @@ const envSchema = z.object({ .pipe(z.number().min(0).optional()), PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'), - ADMIN_TOKEN: z.string().min(1), + ADMIN_TOKEN: z.string().min(16), NEXT_PUBLIC_APP_URL: z.string().url(), PAY_HELP_IMAGE_URL: optionalTrimmedString, diff --git a/src/lib/order/fee.ts b/src/lib/order/fee.ts index 28b5c72..63d3079 100644 --- a/src/lib/order/fee.ts +++ b/src/lib/order/fee.ts @@ -35,10 +35,10 @@ const ROUND_UP = 0; * feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数) * payAmount = rechargeAmount + feeAmount */ -export function calculatePayAmount(rechargeAmount: number, feeRate: number): number { - if (feeRate <= 0) return rechargeAmount; +export function calculatePayAmount(rechargeAmount: number, feeRate: number): string { + if (feeRate <= 0) return rechargeAmount.toFixed(2); const amount = new Prisma.Decimal(rechargeAmount); const rate = new Prisma.Decimal(feeRate.toString()); const feeAmount = amount.mul(rate).div(100).toDecimalPlaces(2, ROUND_UP); - return amount.plus(feeAmount).toNumber(); + return amount.plus(feeAmount).toFixed(2); } diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index 58304dd..4d59f2b 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -158,7 +158,8 @@ export async function createOrder(input: CreateOrderInput): Promise { @@ -169,8 +170,8 @@ export async function createOrder(input: CreateOrderInput): Promise 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null, + payAmount: new Prisma.Decimal(payAmountStr), + feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(4)) : null, rechargeCode: '', status: 'PENDING', paymentType: input.paymentType, @@ -213,9 +214,9 @@ export async function createOrder(input: CreateOrderInput): Promise