Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9820a2ee | ||
|
|
a3f3fa83f1 | ||
|
|
2590145a2c | ||
|
|
e2018cbcf9 | ||
|
|
a1d3f3b639 | ||
|
|
58d4c7efbf | ||
|
|
a7089936a4 | ||
|
|
6bca9853b3 | ||
|
|
33e4a811f3 | ||
|
|
0a94cecad8 | ||
|
|
b3730b567e | ||
|
|
9af7133d93 | ||
|
|
1f2d0499ed | ||
|
|
ae3aa2e0e4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# third-party source code (local reference only)
|
# third-party source code (local reference only)
|
||||||
/third-party
|
/third-party
|
||||||
|
|
||||||
|
# Claude Code project instructions (contains sensitive deployment info)
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025-present touwaeriol
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
114
src/__tests__/app/api/user-route.test.ts
Normal file
114
src/__tests__/app/api/user-route.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const mockGetCurrentUserByToken = vi.fn();
|
||||||
|
const mockGetUser = vi.fn();
|
||||||
|
const mockGetSystemConfig = vi.fn();
|
||||||
|
const mockQueryMethodLimits = vi.fn();
|
||||||
|
const mockGetSupportedTypes = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/sub2api/client', () => ({
|
||||||
|
getCurrentUserByToken: (...args: unknown[]) => mockGetCurrentUserByToken(...args),
|
||||||
|
getUser: (...args: unknown[]) => mockGetUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/config', () => ({
|
||||||
|
getEnv: () => ({
|
||||||
|
MIN_RECHARGE_AMOUNT: 1,
|
||||||
|
MAX_RECHARGE_AMOUNT: 1000,
|
||||||
|
MAX_DAILY_RECHARGE_AMOUNT: 10000,
|
||||||
|
PAY_HELP_IMAGE_URL: undefined,
|
||||||
|
PAY_HELP_TEXT: undefined,
|
||||||
|
STRIPE_PUBLISHABLE_KEY: 'pk_test',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/order/limits', () => ({
|
||||||
|
queryMethodLimits: (...args: unknown[]) => mockQueryMethodLimits(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/payment', () => ({
|
||||||
|
initPaymentProviders: vi.fn(),
|
||||||
|
paymentRegistry: {
|
||||||
|
getSupportedTypes: (...args: unknown[]) => mockGetSupportedTypes(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/pay-utils', () => ({
|
||||||
|
getPaymentDisplayInfo: (type: string) => ({
|
||||||
|
channel: type === 'alipay_direct' ? 'alipay' : type,
|
||||||
|
provider: type,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/locale', () => ({
|
||||||
|
resolveLocale: () => 'zh',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/system-config', () => ({
|
||||||
|
getSystemConfig: (...args: unknown[]) => mockGetSystemConfig(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET } from '@/app/api/user/route';
|
||||||
|
|
||||||
|
function createRequest() {
|
||||||
|
return new NextRequest('https://pay.example.com/api/user?user_id=1&token=test-token');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/user', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetCurrentUserByToken.mockResolvedValue({ id: 1 });
|
||||||
|
mockGetUser.mockResolvedValue({ id: 1, status: 'active' });
|
||||||
|
mockGetSupportedTypes.mockReturnValue(['alipay', 'wxpay', 'stripe']);
|
||||||
|
mockQueryMethodLimits.mockResolvedValue({
|
||||||
|
alipay: { maxDailyAmount: 1000 },
|
||||||
|
wxpay: { maxDailyAmount: 1000 },
|
||||||
|
stripe: { maxDailyAmount: 1000 },
|
||||||
|
});
|
||||||
|
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||||
|
if (key === 'ENABLED_PAYMENT_TYPES') return undefined;
|
||||||
|
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters enabled payment types by ENABLED_PAYMENT_TYPES config', async () => {
|
||||||
|
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||||
|
if (key === 'ENABLED_PAYMENT_TYPES') return 'alipay,wxpay';
|
||||||
|
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await GET(createRequest());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay']);
|
||||||
|
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to supported payment types when ENABLED_PAYMENT_TYPES is empty', async () => {
|
||||||
|
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||||
|
if (key === 'ENABLED_PAYMENT_TYPES') return ' ';
|
||||||
|
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await GET(createRequest());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay', 'stripe']);
|
||||||
|
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay', 'stripe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to supported payment types when ENABLED_PAYMENT_TYPES is undefined', async () => {
|
||||||
|
const response = await GET(createRequest());
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay', 'stripe']);
|
||||||
|
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay', 'stripe']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,7 +193,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('EasyPay does not use isMobile flag itself (delegates to frontend)', async () => {
|
it('EasyPay forwards isMobile to client for device=jump on mobile', async () => {
|
||||||
mockEasyPayCreatePayment.mockResolvedValue({
|
mockEasyPayCreatePayment.mockResolvedValue({
|
||||||
code: 1,
|
code: 1,
|
||||||
trade_no: 'EP-003',
|
trade_no: 'EP-003',
|
||||||
@@ -212,16 +212,14 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
|||||||
|
|
||||||
await provider.createPayment(request);
|
await provider.createPayment(request);
|
||||||
|
|
||||||
// EasyPay client is called the same way regardless of isMobile
|
// EasyPay client receives isMobile so it can set device=jump
|
||||||
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
|
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
outTradeNo: 'order-ep-003',
|
outTradeNo: 'order-ep-003',
|
||||||
paymentType: 'alipay',
|
paymentType: 'alipay',
|
||||||
|
isMobile: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// No isMobile parameter forwarded to the underlying client
|
|
||||||
const callArgs = mockEasyPayCreatePayment.mock.calls[0][0];
|
|
||||||
expect(callArgs).not.toHaveProperty('isMobile');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['min-h-screen', isDark ? 'bg-slate-950' : 'bg-slate-100'].join(' ')}>
|
<div data-theme={theme} className={['min-h-screen', isDark ? 'bg-slate-950' : 'bg-slate-100'].join(' ')}>
|
||||||
<div className="px-2 pt-2 sm:px-3 sm:pt-3">
|
<div className="px-2 pt-2 sm:px-3 sm:pt-3">
|
||||||
<nav
|
<nav
|
||||||
className={[
|
className={[
|
||||||
|
|||||||
@@ -3,19 +3,17 @@ import { z } from 'zod';
|
|||||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
const updateChannelSchema = z
|
const updateChannelSchema = z.object({
|
||||||
.object({
|
group_id: z.number().int().positive().optional(),
|
||||||
group_id: z.number().int().positive().optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
name: z.string().min(1).max(100).optional(),
|
platform: z.string().min(1).max(50).optional(),
|
||||||
platform: z.string().min(1).max(50).optional(),
|
rate_multiplier: z.number().positive().optional(),
|
||||||
rate_multiplier: z.number().positive().optional(),
|
description: z.string().max(500).nullable().optional(),
|
||||||
description: z.string().max(500).nullable().optional(),
|
models: z.union([z.array(z.string()), z.string()]).nullable().optional(),
|
||||||
models: z.array(z.string()).nullable().optional(),
|
features: z.union([z.record(z.string(), z.unknown()), z.string()]).nullable().optional(),
|
||||||
features: z.record(z.string(), z.unknown()).nullable().optional(),
|
sort_order: z.number().int().min(0).optional(),
|
||||||
sort_order: z.number().int().min(0).optional(),
|
enabled: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||||
|
|||||||
@@ -61,10 +61,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
||||||
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (body.name !== undefined && (typeof body.name !== 'string' || body.name.trim() === '')) {
|
||||||
|
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (body.name !== undefined && body.name.length > 100) {
|
||||||
|
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { 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<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
|
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
|
||||||
if (body.name !== undefined) data.name = body.name;
|
if (body.name !== undefined) data.name = body.name.trim();
|
||||||
if (body.description !== undefined) data.description = body.description;
|
if (body.description !== undefined) data.description = body.description;
|
||||||
if (body.price !== undefined) data.price = body.price;
|
if (body.price !== undefined) data.price = body.price;
|
||||||
if (body.original_price !== undefined) data.originalPrice = body.original_price;
|
if (body.original_price !== undefined) data.originalPrice = body.original_price;
|
||||||
|
|||||||
@@ -93,8 +93,14 @@ export async function POST(request: NextRequest) {
|
|||||||
product_name,
|
product_name,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!group_id || !name || price === undefined) {
|
if (!group_id || price === undefined) {
|
||||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必填字段: group_id, price' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof name !== 'string' || name.trim() === '') {
|
||||||
|
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (name.length > 100) {
|
||||||
|
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
|
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
|
||||||
@@ -126,7 +132,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const plan = await prisma.subscriptionPlan.create({
|
const plan = await prisma.subscriptionPlan.create({
|
||||||
data: {
|
data: {
|
||||||
groupId: Number(group_id),
|
groupId: Number(group_id),
|
||||||
name,
|
name: name.trim(),
|
||||||
description: description ?? null,
|
description: description ?? null,
|
||||||
price,
|
price,
|
||||||
originalPrice: original_price ?? null,
|
originalPrice: original_price ?? null,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import { createOrder } from '@/lib/order/service';
|
import { createOrder } from '@/lib/order/service';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { paymentRegistry } from '@/lib/payment';
|
import { paymentRegistry } from '@/lib/payment';
|
||||||
|
import { getEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
|
||||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||||
import { handleApiError } from '@/lib/utils/api';
|
import { handleApiError } from '@/lib/utils/api';
|
||||||
|
|
||||||
@@ -59,8 +60,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate payment type is enabled
|
// Validate payment type is enabled (registry + ENABLED_PAYMENT_TYPES config)
|
||||||
if (!paymentRegistry.getSupportedTypes().includes(payment_type)) {
|
const enabledTypes = await getEnabledPaymentTypes();
|
||||||
|
if (!enabledTypes.includes(payment_type)) {
|
||||||
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
|
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
|||||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||||
import { resolveLocale } from '@/lib/locale';
|
import { resolveLocale } from '@/lib/locale';
|
||||||
import { getSystemConfig } from '@/lib/system-config';
|
import { getSystemConfig } from '@/lib/system-config';
|
||||||
|
import { resolveEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
|
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
|
||||||
@@ -40,13 +41,19 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const env = getEnv();
|
const env = getEnv();
|
||||||
initPaymentProviders();
|
initPaymentProviders();
|
||||||
const enabledTypes = paymentRegistry.getSupportedTypes();
|
const supportedTypes = paymentRegistry.getSupportedTypes();
|
||||||
const [user, methodLimits, balanceDisabledVal] = await Promise.all([
|
|
||||||
getUser(userId),
|
// getUser 与 config 查询并行;config 完成后立即启动 queryMethodLimits
|
||||||
queryMethodLimits(enabledTypes),
|
const configPromise = Promise.all([
|
||||||
|
getSystemConfig('ENABLED_PAYMENT_TYPES'),
|
||||||
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
|
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
|
||||||
]);
|
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal]) => {
|
||||||
const balanceDisabled = balanceDisabledVal === 'true';
|
const enabledTypes = resolveEnabledPaymentTypes(supportedTypes, configuredPaymentTypesRaw);
|
||||||
|
const methodLimits = await queryMethodLimits(enabledTypes);
|
||||||
|
return { enabledTypes, methodLimits, balanceDisabled: balanceDisabledVal === 'true' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [user, { enabledTypes, methodLimits, balanceDisabled }] = await Promise.all([getUser(userId), configPromise]);
|
||||||
|
|
||||||
// 收集 sublabel 覆盖
|
// 收集 sublabel 覆盖
|
||||||
const sublabelOverrides: Record<string, string> = {};
|
const sublabelOverrides: Record<string, string> = {};
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ body {
|
|||||||
sans-serif;
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar - Dark theme */
|
/* Scrollbar - Light theme (default) */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #475569 #1e293b;
|
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
@@ -29,18 +29,40 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: #1e293b;
|
background: #f1f5f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background: #475569;
|
background: #cbd5e1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
background: #64748b;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-corner {
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar - Dark theme */
|
||||||
|
[data-theme='dark'],
|
||||||
|
[data-theme='dark'] * {
|
||||||
|
scrollbar-color: #475569 #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] *::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] *::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] *::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] *::-webkit-scrollbar-corner {
|
||||||
background: #1e293b;
|
background: #1e293b;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function PayContent() {
|
|||||||
const [showTopUpForm, setShowTopUpForm] = useState(false);
|
const [showTopUpForm, setShowTopUpForm] = useState(false);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
||||||
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
||||||
|
const [userLoaded, setUserLoaded] = useState(false);
|
||||||
|
|
||||||
const [config, setConfig] = useState<AppConfig>({
|
const [config, setConfig] = useState<AppConfig>({
|
||||||
enabledPaymentTypes: [],
|
enabledPaymentTypes: [],
|
||||||
@@ -217,7 +218,10 @@ function PayContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setUserLoaded(true);
|
||||||
|
}
|
||||||
}, [token, locale]);
|
}, [token, locale]);
|
||||||
|
|
||||||
// 加载渠道和订阅套餐
|
// 加载渠道和订阅套餐
|
||||||
@@ -487,8 +491,8 @@ function PayContent() {
|
|||||||
|
|
||||||
// ── 渲染 ──
|
// ── 渲染 ──
|
||||||
// R7: 检查是否所有入口都关闭(无可用充值方式 且 无订阅套餐)
|
// R7: 检查是否所有入口都关闭(无可用充值方式 且 无订阅套餐)
|
||||||
const allEntriesClosed = channelsLoaded && !canTopUp && !hasPlans;
|
const allEntriesClosed = channelsLoaded && userLoaded && !canTopUp && !hasPlans;
|
||||||
const showMainTabs = channelsLoaded && !allEntriesClosed && (hasChannels || hasPlans);
|
const showMainTabs = channelsLoaded && userLoaded && !allEntriesClosed && (hasChannels || hasPlans);
|
||||||
const pageTitle = showMainTabs
|
const pageTitle = showMainTabs
|
||||||
? pickLocaleText(locale, '选择适合你的 充值/订阅服务', 'Choose Your Recharge / Subscription')
|
? pickLocaleText(locale, '选择适合你的 充值/订阅服务', 'Choose Your Recharge / Subscription')
|
||||||
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
|
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
|
||||||
@@ -613,7 +617,7 @@ function PayContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 加载中 */}
|
{/* 加载中 */}
|
||||||
{!channelsLoaded && config.enabledPaymentTypes.length === 0 && (
|
{(!channelsLoaded || !userLoaded) && !allEntriesClosed && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||||
@@ -887,7 +891,7 @@ function PayContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 无渠道配置:传统充值UI ── */}
|
{/* ── 无渠道配置:传统充值UI ── */}
|
||||||
{channelsLoaded && !showMainTabs && canTopUp && !selectedPlan && (
|
{channelsLoaded && userLoaded && !showMainTabs && canTopUp && !selectedPlan && (
|
||||||
<>
|
<>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
activeMobileTab === 'pay' ? (
|
activeMobileTab === 'pay' ? (
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function PayPageLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-theme={isDark ? 'dark' : 'light'}
|
||||||
className={[
|
className={[
|
||||||
'relative w-full overflow-hidden',
|
'relative w-full overflow-hidden',
|
||||||
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
|
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface CreatePaymentOptions {
|
|||||||
clientIp: string;
|
clientIp: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
returnUrl?: string;
|
returnUrl?: string;
|
||||||
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCidList(cid?: string): string | undefined {
|
function normalizeCidList(cid?: string): string | undefined {
|
||||||
@@ -68,6 +69,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
|||||||
params.cid = cid;
|
params.cid = cid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.isMobile) {
|
||||||
|
params.device = 'mobile';
|
||||||
|
}
|
||||||
|
|
||||||
const sign = generateSign(params, env.EASY_PAY_PKEY);
|
const sign = generateSign(params, env.EASY_PAY_PKEY);
|
||||||
params.sign = sign;
|
params.sign = sign;
|
||||||
params.sign_type = 'MD5';
|
params.sign_type = 'MD5';
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export class EasyPayProvider implements PaymentProvider {
|
|||||||
clientIp: request.clientIp || '127.0.0.1',
|
clientIp: request.clientIp || '127.0.0.1',
|
||||||
productName: request.subject,
|
productName: request.subject,
|
||||||
returnUrl: request.returnUrl,
|
returnUrl: request.returnUrl,
|
||||||
|
isMobile: request.isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tradeNo: result.trade_no,
|
tradeNo: result.trade_no,
|
||||||
payUrl: result.payurl,
|
payUrl: (request.isMobile && result.payurl2) || result.payurl,
|
||||||
qrCode: result.qrcode,
|
qrCode: result.qrcode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface EasyPayCreateResponse {
|
|||||||
trade_no: string;
|
trade_no: string;
|
||||||
O_id?: string;
|
O_id?: string;
|
||||||
payurl?: string;
|
payurl?: string;
|
||||||
|
payurl2?: string;
|
||||||
qrcode?: string;
|
qrcode?: string;
|
||||||
img?: string;
|
img?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/lib/payment/resolve-enabled-types.ts
Normal file
30
src/lib/payment/resolve-enabled-types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { getSystemConfig } from '@/lib/system-config';
|
||||||
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ENABLED_PAYMENT_TYPES 配置过滤支持的支付类型。
|
||||||
|
* configuredTypes 为 undefined 或空字符串时回退到全部支持类型。
|
||||||
|
*/
|
||||||
|
export function resolveEnabledPaymentTypes(supportedTypes: string[], configuredTypes: string | undefined): string[] {
|
||||||
|
if (configuredTypes === undefined) return supportedTypes;
|
||||||
|
|
||||||
|
const configuredTypeSet = new Set(
|
||||||
|
configuredTypes
|
||||||
|
.split(',')
|
||||||
|
.map((type) => type.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
if (configuredTypeSet.size === 0) return supportedTypes;
|
||||||
|
|
||||||
|
return supportedTypes.filter((type) => configuredTypeSet.has(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前启用的支付类型(结合 registry 支持类型 + 数据库 ENABLED_PAYMENT_TYPES 配置)。
|
||||||
|
*/
|
||||||
|
export async function getEnabledPaymentTypes(): Promise<string[]> {
|
||||||
|
initPaymentProviders();
|
||||||
|
const supportedTypes = paymentRegistry.getSupportedTypes();
|
||||||
|
const configuredTypes = await getSystemConfig('ENABLED_PAYMENT_TYPES');
|
||||||
|
return resolveEnabledPaymentTypes(supportedTypes, configuredTypes);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user