9 Commits

Author SHA1 Message Date
erio
58d4c7efbf fix: 滚动条主题适配 + 套餐 API 输入校验补全
- 滚动条默认浅色,data-theme="dark" 下切换深色
- admin layout / PayPageLayout 根 div 加 data-theme 属性
- 套餐 POST/PUT: name 类型、空值、长度(100)校验 + trim
- 套餐 PUT: 补全 sort_order 非负整数校验
2026-03-15 17:24:44 +08:00
erio
a7089936a4 fix: 修复页面加载时闪现「入口未开放」的问题
allEntriesClosed 判断需要等 userLoaded 和 channelsLoaded 都完成,
避免 channelsLoaded 先完成但 config 还未加载时误判为入口关闭。
2026-03-15 12:03:27 +08:00
erio
6bca9853b3 style: fix prettier formatting in user route 2026-03-15 03:14:47 +08:00
erio
33e4a811f3 fix: 提取 resolveEnabledPaymentTypes 共享函数,下单接口同步校验 + 恢复并发
- 将 resolveEnabledPaymentTypes 提取到 src/lib/payment/resolve-enabled-types.ts
- /api/orders 下单时也校验 ENABLED_PAYMENT_TYPES 配置,防止绕过前端直接调用
- /api/user 恢复 queryMethodLimits 与 getUser 并发执行,避免性能退化
2026-03-15 02:56:28 +08:00
eriol touwa
0a94cecad8 Merge pull request #10 from gopkg-dev/main
fix: ENABLED_PAYMENT_TYPES configuration not being applied correctly
2026-03-15 02:54:22 +08:00
Karen
b3730b567e Merge pull request #1 from gopkg-dev/copilot/fix-enabled-payment-types-issue
Honor `ENABLED_PAYMENT_TYPES` in `/api/user` response config
2026-03-14 22:08:52 +08:00
copilot-swe-agent[bot]
9af7133d93 test: cover ENABLED_PAYMENT_TYPES fallback paths
Co-authored-by: gopkg-dev <58848833+gopkg-dev@users.noreply.github.com>
2026-03-14 13:52:02 +00:00
copilot-swe-agent[bot]
1f2d0499ed fix: honor ENABLED_PAYMENT_TYPES in user config api
Co-authored-by: gopkg-dev <58848833+gopkg-dev@users.noreply.github.com>
2026-03-14 13:49:40 +00:00
copilot-swe-agent[bot]
ae3aa2e0e4 Initial plan 2026-03-14 13:46:39 +00:00
10 changed files with 218 additions and 23 deletions

View 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']);
});
});

View File

@@ -35,7 +35,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
};
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">
<nav
className={[

View File

@@ -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)) {
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> = {};
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.price !== undefined) data.price = body.price;
if (body.original_price !== undefined) data.originalPrice = body.original_price;

View File

@@ -93,8 +93,14 @@ export async function POST(request: NextRequest) {
product_name,
} = body;
if (!group_id || !name || price === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
if (!group_id || price === undefined) {
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) {
@@ -126,7 +132,7 @@ export async function POST(request: NextRequest) {
const plan = await prisma.subscriptionPlan.create({
data: {
groupId: Number(group_id),
name,
name: name.trim(),
description: description ?? null,
price,
originalPrice: original_price ?? null,

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { createOrder } from '@/lib/order/service';
import { getEnv } from '@/lib/config';
import { paymentRegistry } from '@/lib/payment';
import { getEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
import { handleApiError } from '@/lib/utils/api';
@@ -59,8 +60,9 @@ export async function POST(request: NextRequest) {
}
}
// Validate payment type is enabled
if (!paymentRegistry.getSupportedTypes().includes(payment_type)) {
// Validate payment type is enabled (registry + ENABLED_PAYMENT_TYPES config)
const enabledTypes = await getEnabledPaymentTypes();
if (!enabledTypes.includes(payment_type)) {
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
}

View File

@@ -6,6 +6,7 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
import { resolveLocale } from '@/lib/locale';
import { getSystemConfig } from '@/lib/system-config';
import { resolveEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
export async function GET(request: NextRequest) {
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
@@ -40,13 +41,19 @@ export async function GET(request: NextRequest) {
const env = getEnv();
initPaymentProviders();
const enabledTypes = paymentRegistry.getSupportedTypes();
const [user, methodLimits, balanceDisabledVal] = await Promise.all([
getUser(userId),
queryMethodLimits(enabledTypes),
const supportedTypes = paymentRegistry.getSupportedTypes();
// getUser 与 config 查询并行config 完成后立即启动 queryMethodLimits
const configPromise = Promise.all([
getSystemConfig('ENABLED_PAYMENT_TYPES'),
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
]);
const balanceDisabled = balanceDisabledVal === 'true';
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal]) => {
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 覆盖
const sublabelOverrides: Record<string, string> = {};

View File

@@ -17,10 +17,10 @@ body {
sans-serif;
}
/* Scrollbar - Dark theme */
/* Scrollbar - Light theme (default) */
* {
scrollbar-width: thin;
scrollbar-color: #475569 #1e293b;
scrollbar-color: #cbd5e1 #f1f5f9;
}
*::-webkit-scrollbar {
@@ -29,18 +29,40 @@ body {
}
*::-webkit-scrollbar-track {
background: #1e293b;
background: #f1f5f9;
}
*::-webkit-scrollbar-thumb {
background: #475569;
background: #cbd5e1;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #64748b;
background: #94a3b8;
}
*::-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;
}

View File

@@ -82,6 +82,7 @@ function PayContent() {
const [showTopUpForm, setShowTopUpForm] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
const [channelsLoaded, setChannelsLoaded] = useState(false);
const [userLoaded, setUserLoaded] = useState(false);
const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: [],
@@ -217,7 +218,10 @@ function PayContent() {
}
}
}
} catch {}
} catch {
} finally {
setUserLoaded(true);
}
}, [token, locale]);
// 加载渠道和订阅套餐
@@ -487,8 +491,8 @@ function PayContent() {
// ── 渲染 ──
// R7: 检查是否所有入口都关闭(无可用充值方式 且 无订阅套餐)
const allEntriesClosed = channelsLoaded && !canTopUp && !hasPlans;
const showMainTabs = channelsLoaded && !allEntriesClosed && (hasChannels || hasPlans);
const allEntriesClosed = channelsLoaded && userLoaded && !canTopUp && !hasPlans;
const showMainTabs = channelsLoaded && userLoaded && !allEntriesClosed && (hasChannels || hasPlans);
const pageTitle = showMainTabs
? pickLocaleText(locale, '选择适合你的 充值/订阅服务', 'Choose Your Recharge / Subscription')
: 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="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(' ')}>
@@ -887,7 +891,7 @@ function PayContent() {
)}
{/* ── 无渠道配置传统充值UI ── */}
{channelsLoaded && !showMainTabs && canTopUp && !selectedPlan && (
{channelsLoaded && userLoaded && !showMainTabs && canTopUp && !selectedPlan && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (

View File

@@ -26,6 +26,7 @@ export default function PayPageLayout({
return (
<div
data-theme={isDark ? 'dark' : 'light'}
className={[
'relative w-full overflow-hidden',
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',

View 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);
}