14 Commits

Author SHA1 Message Date
erio
af9820a2ee fix: 易支付移动端使用 payurl2 进行微信H5支付 2026-03-16 22:35:27 +08:00
erio
a3f3fa83f1 chore: add MIT license
Closes #11
2026-03-16 14:07:26 +08:00
erio
2590145a2c fix: 易支付移动端传 device=jump 以支持微信H5支付唤起 2026-03-16 13:47:56 +08:00
erio
e2018cbcf9 fix: 渠道 PUT schema 兼容字符串类型的 models/features 字段
前端 linesToJsonString 传的是 JSON 字符串,而 .strict() schema
只接受数组/对象,导致所有渠道编辑保存失败"参数校验失败"。
移除 .strict(),models/features 改为 union 接受 string | array/record。
2026-03-16 05:33:24 +08:00
erio
a1d3f3b639 chore: 从 git 中移除 CLAUDE.md 并加入 gitignore 2026-03-15 19:23:23 +08:00
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
17 changed files with 264 additions and 42 deletions

3
.gitignore vendored
View File

@@ -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
View 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.

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

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

View File

@@ -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={[

View File

@@ -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);

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)) { 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;

View File

@@ -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,

View File

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

View File

@@ -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> = {};

View File

@@ -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;
} }

View File

@@ -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' ? (

View File

@@ -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',

View File

@@ -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';

View File

@@ -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,
}; };
} }

View File

@@ -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;
} }

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