feat: 订阅管理增强、商品名称配置、余额充值开关

- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey)
- R2: "分组状态"列名改为"Sub2API 状态"
- R3: 订阅套餐可配置支付商品名称(productName)
- R4: 订阅订单校验 subscription_type 必须为 subscription
- R5: 渠道管理配置余额充值商品名前缀/后缀
- R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝
- R7: 所有入口关闭时显示"入口被管理员关闭"提示
- fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
This commit is contained in:
erio
2026-03-14 00:43:00 +08:00
parent 1bb11ee32b
commit 6c61c3f877
16 changed files with 873 additions and 32 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscription_plans" ADD COLUMN "product_name" TEXT;

View File

@@ -116,6 +116,7 @@ model SubscriptionPlan {
validityDays Int @default(30) @map("validity_days")
validityUnit String @default("day") @map("validity_unit") // day | week | month
features String? @db.Text
productName String? @map("product_name")
forSale Boolean @default(false) @map("for_sale")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")

View File

@@ -0,0 +1,349 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockGetEnv } = vi.hoisted(() => ({
mockGetEnv: vi.fn(() => ({
EASY_PAY_PID: '1001',
EASY_PAY_PKEY: 'test-merchant-secret-key',
EASY_PAY_API_BASE: 'https://pay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
EASY_PAY_CID: undefined,
EASY_PAY_CID_ALIPAY: undefined,
EASY_PAY_CID_WXPAY: undefined,
})),
}));
vi.mock('@/lib/config', () => ({
getEnv: mockGetEnv,
}));
const { mockGenerateSign, signCallSnapshots } = vi.hoisted(() => {
const snapshots: Record<string, string>[][] = [];
return {
signCallSnapshots: snapshots,
mockGenerateSign: vi.fn((...args: unknown[]) => {
// Snapshot params at call time (before caller mutates the object)
snapshots.push(args.map((a) => (typeof a === 'object' && a ? { ...a } : a)) as Record<string, string>[]);
return 'mocked-sign-value';
}),
};
});
vi.mock('@/lib/easy-pay/sign', () => ({
generateSign: mockGenerateSign,
}));
import { createPayment, queryOrder } from '@/lib/easy-pay/client';
describe('EasyPay client', () => {
beforeEach(() => {
vi.clearAllMocks();
signCallSnapshots.length = 0;
});
describe('createPayment', () => {
it('should build correct params and POST to mapi.php', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
code: 1,
trade_no: 'EP20260313000001',
payurl: 'https://pay.example.com/pay/EP20260313000001',
}),
{ headers: { 'content-type': 'application/json' } },
),
) as typeof fetch;
const result = await createPayment({
outTradeNo: 'order-001',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Test Product',
});
expect(result.code).toBe(1);
expect(result.trade_no).toBe('EP20260313000001');
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://pay.example.com/mapi.php');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
const body = new URLSearchParams(init.body as string);
expect(body.get('pid')).toBe('1001');
expect(body.get('type')).toBe('alipay');
expect(body.get('out_trade_no')).toBe('order-001');
expect(body.get('money')).toBe('10.00');
expect(body.get('name')).toBe('Test Product');
expect(body.get('clientip')).toBe('127.0.0.1');
expect(body.get('notify_url')).toBe('https://pay.example.com/api/easy-pay/notify');
expect(body.get('return_url')).toBe('https://pay.example.com/pay/result');
expect(body.get('sign')).toBe('mocked-sign-value');
expect(body.get('sign_type')).toBe('MD5');
});
it('should call generateSign with correct params (without sign/sign_type)', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await createPayment({
outTradeNo: 'order-002',
amount: '20.00',
paymentType: 'wxpay',
clientIp: '10.0.0.1',
productName: 'Another Product',
});
expect(mockGenerateSign).toHaveBeenCalledTimes(1);
const [signParams, pkey] = signCallSnapshots[signCallSnapshots.length - 1] as [Record<string, string>, string];
expect(pkey).toBe('test-merchant-secret-key');
// sign and sign_type should not be in the params passed to generateSign
expect(signParams).not.toHaveProperty('sign');
expect(signParams).not.toHaveProperty('sign_type');
expect(signParams.type).toBe('wxpay');
});
it('should throw when API returns code !== 1', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ code: -1, msg: 'Invalid parameter' }),
{ headers: { 'content-type': 'application/json' } },
),
) as typeof fetch;
await expect(
createPayment({
outTradeNo: 'order-003',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Product',
}),
).rejects.toThrow('EasyPay create payment failed: Invalid parameter');
});
it('should throw with "unknown error" when msg is absent', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 0 }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await expect(
createPayment({
outTradeNo: 'order-004',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Product',
}),
).rejects.toThrow('EasyPay create payment failed: unknown error');
});
it('should not include cid when no CID env vars are set', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await createPayment({
outTradeNo: 'order-005',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Product',
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = new URLSearchParams(init.body as string);
expect(body.has('cid')).toBe(false);
});
});
describe('createPayment CID routing', () => {
it('should use EASY_PAY_CID_ALIPAY for alipay payment type', async () => {
mockGetEnv.mockReturnValue({
EASY_PAY_PID: '1001',
EASY_PAY_PKEY: 'test-merchant-secret-key',
EASY_PAY_API_BASE: 'https://pay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
EASY_PAY_CID: '100',
EASY_PAY_CID_ALIPAY: '200',
EASY_PAY_CID_WXPAY: '300',
});
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await createPayment({
outTradeNo: 'order-cid-1',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Product',
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = new URLSearchParams(init.body as string);
expect(body.get('cid')).toBe('200');
});
it('should use EASY_PAY_CID_WXPAY for wxpay payment type', async () => {
mockGetEnv.mockReturnValue({
EASY_PAY_PID: '1001',
EASY_PAY_PKEY: 'test-merchant-secret-key',
EASY_PAY_API_BASE: 'https://pay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
EASY_PAY_CID: '100',
EASY_PAY_CID_ALIPAY: '200',
EASY_PAY_CID_WXPAY: '300',
});
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await createPayment({
outTradeNo: 'order-cid-2',
amount: '10.00',
paymentType: 'wxpay',
clientIp: '127.0.0.1',
productName: 'Product',
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = new URLSearchParams(init.body as string);
expect(body.get('cid')).toBe('300');
});
it('should fall back to EASY_PAY_CID when channel-specific CID is not set', async () => {
mockGetEnv.mockReturnValue({
EASY_PAY_PID: '1001',
EASY_PAY_PKEY: 'test-merchant-secret-key',
EASY_PAY_API_BASE: 'https://pay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
EASY_PAY_CID: '100',
EASY_PAY_CID_ALIPAY: undefined,
EASY_PAY_CID_WXPAY: undefined,
});
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await createPayment({
outTradeNo: 'order-cid-3',
amount: '10.00',
paymentType: 'alipay',
clientIp: '127.0.0.1',
productName: 'Product',
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = new URLSearchParams(init.body as string);
expect(body.get('cid')).toBe('100');
});
});
describe('queryOrder', () => {
it('should call GET api.php with correct query parameters', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
code: 1,
trade_no: 'EP20260313000001',
out_trade_no: 'order-001',
type: 'alipay',
pid: '1001',
addtime: '2026-03-13 10:00:00',
endtime: '2026-03-13 10:01:00',
name: 'Test Product',
money: '10.00',
status: 1,
}),
{ headers: { 'content-type': 'application/json' } },
),
) as typeof fetch;
const result = await queryOrder('order-001');
expect(result.code).toBe(1);
expect(result.trade_no).toBe('EP20260313000001');
expect(result.status).toBe(1);
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toContain('https://pay.example.com/api.php');
expect(url).toContain('act=order');
expect(url).toContain('pid=1001');
expect(url).toContain('key=test-merchant-secret-key');
expect(url).toContain('out_trade_no=order-001');
});
it('should throw when API returns code !== 1', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ code: -1, msg: 'Order not found' }),
{ headers: { 'content-type': 'application/json' } },
),
) as typeof fetch;
await expect(queryOrder('nonexistent-order')).rejects.toThrow(
'EasyPay query order failed: Order not found',
);
});
it('should throw with "unknown error" when msg is absent', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 0 }), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
await expect(queryOrder('order-err')).rejects.toThrow(
'EasyPay query order failed: unknown error',
);
});
it('should parse all response fields correctly', async () => {
const mockResponse = {
code: 1,
trade_no: 'EP20260313000002',
out_trade_no: 'order-010',
type: 'wxpay',
pid: '1001',
addtime: '2026-03-13 12:00:00',
endtime: '2026-03-13 12:05:00',
name: 'Premium Plan',
money: '99.00',
status: 1,
param: 'custom-param',
buyer: 'buyer@example.com',
};
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(mockResponse), {
headers: { 'content-type': 'application/json' },
}),
) as typeof fetch;
const result = await queryOrder('order-010');
expect(result).toEqual(mockResponse);
});
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import { generateSign, verifySign } from '@/lib/easy-pay/sign';
const TEST_PKEY = 'test-merchant-secret-key';
describe('EasyPay MD5 Sign', () => {
const testParams: Record<string, string> = {
pid: '1001',
type: 'alipay',
out_trade_no: 'order-001',
notify_url: 'https://pay.example.com/api/easy-pay/notify',
return_url: 'https://pay.example.com/pay/result',
name: 'Test Product',
money: '10.00',
clientip: '127.0.0.1',
};
describe('generateSign', () => {
it('should generate a valid MD5 hex string', () => {
const sign = generateSign(testParams, TEST_PKEY);
expect(sign).toBeTruthy();
expect(sign).toMatch(/^[0-9a-f]{32}$/);
});
it('should produce consistent signatures for same input', () => {
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(testParams, TEST_PKEY);
expect(sign1).toBe(sign2);
});
it('should sort parameters alphabetically', () => {
const reversed: Record<string, string> = {};
const keys = Object.keys(testParams).reverse();
for (const key of keys) {
reversed[key] = testParams[key];
}
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(reversed, TEST_PKEY);
expect(sign1).toBe(sign2);
});
it('should filter out empty values', () => {
const paramsWithEmpty = { ...testParams, empty_field: '' };
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(paramsWithEmpty, TEST_PKEY);
expect(sign1).toBe(sign2);
});
it('should exclude sign field from signing', () => {
const paramsWithSign = { ...testParams, sign: 'old_sign' };
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(paramsWithSign, TEST_PKEY);
expect(sign1).toBe(sign2);
});
it('should exclude sign_type field from signing', () => {
const paramsWithSignType = { ...testParams, sign_type: 'MD5' };
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(paramsWithSignType, TEST_PKEY);
expect(sign1).toBe(sign2);
});
it('should produce correct MD5 hash for known input', () => {
// Manually compute expected: sorted keys → query string → append pkey → MD5
const sorted = Object.entries(testParams)
.filter(([, v]) => v !== '')
.sort(([a], [b]) => a.localeCompare(b));
const queryString = sorted.map(([k, v]) => `${k}=${v}`).join('&');
const expected = crypto
.createHash('md5')
.update(queryString + TEST_PKEY)
.digest('hex');
const sign = generateSign(testParams, TEST_PKEY);
expect(sign).toBe(expected);
});
it('should produce different signatures for different pkeys', () => {
const sign1 = generateSign(testParams, TEST_PKEY);
const sign2 = generateSign(testParams, 'different-key');
expect(sign1).not.toBe(sign2);
});
it('should produce different signatures for different params', () => {
const sign1 = generateSign(testParams, TEST_PKEY);
const modified = { ...testParams, money: '99.99' };
const sign2 = generateSign(modified, TEST_PKEY);
expect(sign1).not.toBe(sign2);
});
});
describe('verifySign', () => {
it('should return true for a valid signature', () => {
const sign = generateSign(testParams, TEST_PKEY);
const valid = verifySign(testParams, TEST_PKEY, sign);
expect(valid).toBe(true);
});
it('should return false for an invalid signature', () => {
const valid = verifySign(testParams, TEST_PKEY, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
expect(valid).toBe(false);
});
it('should return false for tampered params', () => {
const sign = generateSign(testParams, TEST_PKEY);
const tampered = { ...testParams, money: '999.99' };
const valid = verifySign(tampered, TEST_PKEY, sign);
expect(valid).toBe(false);
});
it('should return false for wrong pkey', () => {
const sign = generateSign(testParams, TEST_PKEY);
const valid = verifySign(testParams, 'wrong-key', sign);
expect(valid).toBe(false);
});
it('should return false when sign length differs (timing-safe guard)', () => {
const valid = verifySign(testParams, TEST_PKEY, 'short');
expect(valid).toBe(false);
});
it('should use timing-safe comparison (same length, different content)', () => {
const sign = generateSign(testParams, TEST_PKEY);
// Flip the first character to create a same-length but different sign
const flipped = (sign[0] === 'a' ? 'b' : 'a') + sign.slice(1);
const valid = verifySign(testParams, TEST_PKEY, flipped);
expect(valid).toBe(false);
});
});
});

View File

@@ -110,6 +110,15 @@ function getTexts(locale: Locale) {
syncImportSuccess: (n: number) => `Successfully imported ${n} channel(s)`,
yes: 'Yes',
no: 'No',
rechargeConfig: 'Recharge Configuration',
productNamePrefix: 'Product Name Prefix',
productNameSuffix: 'Product Name Suffix',
preview: 'Preview',
enableBalanceRecharge: 'Enable Balance Recharge',
saveConfig: 'Save',
savingConfig: 'Saving...',
configSaved: 'Configuration saved',
configSaveFailed: 'Failed to save configuration',
}
: {
missingToken: '缺少管理员凭证',
@@ -163,6 +172,15 @@ function getTexts(locale: Locale) {
syncImportSuccess: (n: number) => `成功导入 ${n} 个渠道`,
yes: '是',
no: '否',
rechargeConfig: '充值配置',
productNamePrefix: '商品名前缀',
productNameSuffix: '商品名后缀',
preview: '预览',
enableBalanceRecharge: '启用余额充值',
saveConfig: '保存',
savingConfig: '保存中...',
configSaved: '配置已保存',
configSaveFailed: '保存配置失败',
};
}
@@ -224,6 +242,12 @@ function ChannelsContent() {
const [form, setForm] = useState<ChannelFormData>(emptyForm);
const [saving, setSaving] = useState(false);
// Recharge config state (R5, R6)
const [rcPrefix, setRcPrefix] = useState('');
const [rcSuffix, setRcSuffix] = useState('');
const [rcBalanceEnabled, setRcBalanceEnabled] = useState(true);
const [rcSaving, setRcSaving] = useState(false);
// Sync modal state
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncGroups, setSyncGroups] = useState<Sub2ApiGroup[]>([]);
@@ -254,9 +278,55 @@ function ChannelsContent() {
}
}, [token]);
// Fetch recharge config
const fetchRechargeConfig = useCallback(async () => {
if (!token) return;
try {
const res = await fetch(`/api/admin/config?token=${encodeURIComponent(token)}`);
if (res.ok) {
const data = await res.json();
const configs: { key: string; value: string }[] = data.configs ?? [];
for (const c of configs) {
if (c.key === 'PRODUCT_NAME_PREFIX') setRcPrefix(c.value);
if (c.key === 'PRODUCT_NAME_SUFFIX') setRcSuffix(c.value);
if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true');
}
}
} catch { /* ignore */ }
}, [token]);
const saveRechargeConfig = async () => {
setRcSaving(true);
setError('');
try {
const res = await fetch('/api/admin/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
configs: [
{ key: 'PRODUCT_NAME_PREFIX', value: rcPrefix.trim(), group: 'payment', label: '商品名前缀' },
{ key: 'PRODUCT_NAME_SUFFIX', value: rcSuffix.trim(), group: 'payment', label: '商品名后缀' },
{ key: 'BALANCE_PAYMENT_DISABLED', value: rcBalanceEnabled ? 'false' : 'true', group: 'payment', label: '余额充值禁用' },
],
}),
});
if (!res.ok) {
setError(t.configSaveFailed);
}
} catch {
setError(t.configSaveFailed);
} finally {
setRcSaving(false);
}
};
useEffect(() => {
fetchChannels();
}, [fetchChannels]);
fetchRechargeConfig();
}, [fetchChannels, fetchRechargeConfig]);
// ── Missing token ──
@@ -537,6 +607,76 @@ function ChannelsContent() {
</div>
)}
{/* Recharge config card (R5, R6) */}
<div
className={[
'mb-4 rounded-xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['text-sm font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{t.rechargeConfig}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={labelCls}>{t.productNamePrefix}</label>
<input
type="text"
value={rcPrefix}
onChange={(e) => setRcPrefix(e.target.value)}
className={inputCls}
placeholder="Sub2API"
/>
</div>
<div>
<label className={labelCls}>{t.productNameSuffix}</label>
<input
type="text"
value={rcSuffix}
onChange={(e) => setRcSuffix(e.target.value)}
className={inputCls}
placeholder="CNY"
/>
</div>
<div>
<label className={labelCls}>{t.preview}</label>
<div className={['rounded-lg border px-3 py-2 text-sm', isDark ? 'border-slate-600 bg-slate-700 text-slate-300' : 'border-slate-300 bg-slate-50 text-slate-600'].join(' ')}>
{`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()}
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setRcBalanceEnabled(!rcBalanceEnabled)}
className={[
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors',
rcBalanceEnabled ? 'bg-emerald-500' : isDark ? 'bg-slate-600' : 'bg-slate-300',
].join(' ')}
>
<span
className={[
'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
rcBalanceEnabled ? 'translate-x-4.5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
<span className={['text-sm', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
{t.enableBalanceRecharge}
</span>
</div>
<button
type="button"
onClick={saveRechargeConfig}
disabled={rcSaving}
className="inline-flex items-center rounded-lg bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
{rcSaving ? t.savingConfig : t.saveConfig}
</button>
</div>
</div>
{/* Channel table */}
<div
className={[

View File

@@ -27,6 +27,7 @@ interface SubscriptionPlan {
groupWeeklyLimit: number | null;
groupMonthlyLimit: number | null;
groupModelScopes: string[] | null;
productName: string | null;
}
interface Sub2ApiGroup {
@@ -103,7 +104,7 @@ function buildText(locale: Locale) {
colOriginalPrice: 'Original Price',
colValidDays: 'Validity',
colEnabled: 'For Sale',
colGroupStatus: 'Group Status',
colGroupStatus: 'Sub2API Status',
colActions: 'Actions',
edit: 'Edit',
delete: 'Delete',
@@ -112,10 +113,12 @@ function buildText(locale: Locale) {
groupExists: 'Exists',
groupMissing: 'Missing',
noPlans: 'No plans configured',
searchUserId: 'Search by user ID',
searchUserId: 'Email / Username / Notes / API Key',
search: 'Search',
noSubs: 'No subscription records found',
enterUserId: 'Enter a user ID to search',
enterUserId: 'Enter a keyword to search users',
fieldProductName: 'Payment Product Name',
fieldProductNamePlaceholder: 'Leave empty for default',
saveFailed: 'Failed to save plan',
deleteFailed: 'Failed to delete plan',
loadFailed: 'Failed to load data',
@@ -183,7 +186,7 @@ function buildText(locale: Locale) {
colOriginalPrice: '原价',
colValidDays: '有效期',
colEnabled: '启用售卖',
colGroupStatus: '分组状态',
colGroupStatus: 'Sub2API 状态',
colActions: '操作',
edit: '编辑',
delete: '删除',
@@ -192,10 +195,12 @@ function buildText(locale: Locale) {
groupExists: '存在',
groupMissing: '缺失',
noPlans: '暂无套餐配置',
searchUserId: '按用户 ID 搜索',
searchUserId: '邮箱/用户名/备注/API Key',
search: '搜索',
noSubs: '未找到订阅记录',
enterUserId: '输入用户 ID 进行搜索',
enterUserId: '输入关键词搜索用户',
fieldProductName: '支付商品名称',
fieldProductNamePlaceholder: '留空使用默认名称',
saveFailed: '保存套餐失败',
deleteFailed: '删除套餐失败',
loadFailed: '加载数据失败',
@@ -332,10 +337,15 @@ function SubscriptionsContent() {
const [formFeatures, setFormFeatures] = useState('');
const [formSortOrder, setFormSortOrder] = useState('0');
const [formEnabled, setFormEnabled] = useState(true);
const [formProductName, setFormProductName] = useState('');
const [saving, setSaving] = useState(false);
/* --- subs state --- */
const [subsUserId, setSubsUserId] = useState('');
const [subsKeyword, setSubsKeyword] = useState('');
const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>([]);
const [searchDropdownOpen, setSearchDropdownOpen] = useState(false);
const [searchTimer, setSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [subs, setSubs] = useState<Sub2ApiSubscription[]>([]);
const [subsUser, setSubsUser] = useState<SubsUserInfo | null>(null);
const [subsLoading, setSubsLoading] = useState(false);
@@ -396,6 +406,7 @@ function SubscriptionsContent() {
setFormFeatures('');
setFormSortOrder('0');
setFormEnabled(true);
setFormProductName('');
setModalOpen(true);
};
@@ -411,6 +422,7 @@ function SubscriptionsContent() {
setFormFeatures((plan.features ?? []).join('\n'));
setFormSortOrder(String(plan.sortOrder));
setFormEnabled(plan.enabled);
setFormProductName(plan.productName ?? '');
setModalOpen(true);
};
@@ -438,6 +450,7 @@ function SubscriptionsContent() {
.filter(Boolean),
sort_order: parseInt(formSortOrder, 10) || 0,
for_sale: formEnabled,
product_name: formProductName.trim() || null,
};
try {
const url = editingPlan
@@ -502,6 +515,39 @@ function SubscriptionsContent() {
}
};
/* --- search users (R1) --- */
const handleKeywordChange = (value: string) => {
setSubsKeyword(value);
if (searchTimer) clearTimeout(searchTimer);
if (!value.trim()) {
setSearchResults([]);
setSearchDropdownOpen(false);
return;
}
const timer = setTimeout(async () => {
try {
const res = await fetch(
`/api/admin/sub2api/search-users?token=${encodeURIComponent(token)}&keyword=${encodeURIComponent(value.trim())}`,
);
if (res.ok) {
const data = await res.json();
setSearchResults(data.users ?? []);
setSearchDropdownOpen(true);
}
} catch {
/* ignore */
}
}, 300);
setSearchTimer(timer);
};
const selectUser = (user: { id: number; email: string; username: string }) => {
setSubsUserId(String(user.id));
setSubsKeyword(`${user.email} #${user.id}`);
setSearchDropdownOpen(false);
setSearchResults([]);
};
/* --- fetch user subs --- */
const fetchSubs = async () => {
if (!token || !subsUserId.trim()) return;
@@ -883,19 +929,54 @@ function SubscriptionsContent() {
{/* ====== Tab: User Subscriptions ====== */}
{activeTab === 'subs' && (
<>
{/* Search bar */}
{/* Search bar (R1: fuzzy search) */}
<div className="mb-4 flex gap-2">
<input
type="text"
value={subsUserId}
onChange={(e) => setSubsUserId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchSubs()}
placeholder={t.searchUserId}
className={[inputCls, 'max-w-xs'].join(' ')}
/>
<div className="relative max-w-sm flex-1">
<input
type="text"
value={subsKeyword}
onChange={(e) => handleKeywordChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchDropdownOpen(false);
fetchSubs();
}
}}
onFocus={() => { if (searchResults.length > 0) setSearchDropdownOpen(true); }}
placeholder={t.searchUserId}
className={inputCls}
/>
{/* Dropdown */}
{searchDropdownOpen && searchResults.length > 0 && (
<div
className={[
'absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border shadow-lg',
isDark ? 'border-slate-600 bg-slate-800' : 'border-slate-200 bg-white',
].join(' ')}
>
{searchResults.map((u) => (
<button
key={u.id}
type="button"
onClick={() => selectUser(u)}
className={[
'w-full px-3 py-2 text-left text-sm transition-colors',
isDark ? 'hover:bg-slate-700 text-slate-200' : 'hover:bg-slate-50 text-slate-800',
].join(' ')}
>
<div className="font-medium">{u.email}</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
{u.username} #{u.id}
{u.notes && <span className="ml-2 opacity-70">({u.notes})</span>}
</div>
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={fetchSubs}
onClick={() => { setSearchDropdownOpen(false); fetchSubs(); }}
disabled={subsLoading || !subsUserId.trim()}
className={[
'inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
@@ -1192,6 +1273,18 @@ function SubscriptionsContent() {
/>
</div>
{/* Product Name (R3) */}
<div>
<label className={labelCls}>{t.fieldProductName}</label>
<input
type="text"
value={formProductName}
onChange={(e) => setFormProductName(e.target.value)}
placeholder={t.fieldProductNamePlaceholder}
className={inputCls}
/>
</div>
{/* Enabled */}
<div className="flex items-center gap-2">
<button

View File

@@ -48,6 +48,9 @@ export async function PUT(request: NextRequest) {
'DAILY_RECHARGE_LIMIT',
'ORDER_TIMEOUT_MINUTES',
'IFRAME_ALLOW_ORIGINS',
'PRODUCT_NAME_PREFIX',
'PRODUCT_NAME_SUFFIX',
'BALANCE_PAYMENT_DISABLED',
]);
// 校验每条配置

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { searchUsers } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const keyword = request.nextUrl.searchParams.get('keyword')?.trim();
if (!keyword) {
return NextResponse.json({ users: [] });
}
try {
const users = await searchUsers(keyword);
return NextResponse.json({ users });
} catch (error) {
console.error('Failed to search users:', error instanceof Error ? error.message : String(error));
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
}
}

View File

@@ -48,6 +48,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
data.validityUnit = body.validity_unit;
}
if (body.features !== undefined) data.features = body.features ? JSON.stringify(body.features) : null;
if (body.product_name !== undefined) data.productName = body.product_name?.trim() || null;
if (body.for_sale !== undefined) data.forSale = body.for_sale;
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
@@ -69,6 +70,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: plan.forSale,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
});

View File

@@ -44,6 +44,7 @@ export async function GET(request: NextRequest) {
groupWeeklyLimit: group?.weekly_limit_usd ?? null,
groupMonthlyLimit: group?.monthly_limit_usd ?? null,
groupModelScopes: group?.supported_model_scopes ?? null,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
};
@@ -62,7 +63,7 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order } = body;
const { group_id, name, description, price, original_price, validity_days, validity_unit, features, for_sale, sort_order, product_name } = body;
if (!group_id || !name || price === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { status: 400 });
@@ -103,6 +104,7 @@ export async function POST(request: NextRequest) {
validityDays: validity_days ?? 30,
validityUnit: ['day', 'week', 'month'].includes(validity_unit) ? validity_unit : 'day',
features: features ? JSON.stringify(features) : null,
productName: product_name?.trim() || null,
forSale: for_sale ?? false,
sortOrder: sort_order ?? 0,
},
@@ -122,6 +124,7 @@ export async function POST(request: NextRequest) {
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: plan.forSale,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
},

View File

@@ -53,6 +53,7 @@ export async function GET(request: NextRequest) {
validityDays: plan.validityDays,
validityUnit: plan.validityUnit,
features: plan.features ? JSON.parse(plan.features) : [],
productName: plan.productName ?? null,
platform: group?.platform ?? null,
rateMultiplier: group?.rate_multiplier ?? null,
limits: groupInfo,

View File

@@ -5,6 +5,7 @@ import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
import { resolveLocale } from '@/lib/locale';
import { getSystemConfig } from '@/lib/system-config';
export async function GET(request: NextRequest) {
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
@@ -40,7 +41,12 @@ export async function GET(request: NextRequest) {
const env = getEnv();
initPaymentProviders();
const enabledTypes = paymentRegistry.getSupportedTypes();
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(enabledTypes)]);
const [user, methodLimits, balanceDisabledVal] = await Promise.all([
getUser(userId),
queryMethodLimits(enabledTypes),
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
]);
const balanceDisabled = balanceDisabledVal === 'true';
// 收集 sublabel 覆盖
const sublabelOverrides: Record<string, string> = {};
@@ -84,6 +90,7 @@ export async function GET(request: NextRequest) {
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey:
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
balanceDisabled,
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
},
});

View File

@@ -43,6 +43,7 @@ interface AppConfig {
helpImageUrl?: string | null;
helpText?: string | null;
stripePublishableKey?: string | null;
balanceDisabled?: boolean;
}
function PayContent() {
@@ -123,6 +124,8 @@ function PayContent() {
const MAX_PENDING = 3;
const pendingBlocked = pendingCount >= MAX_PENDING;
// R6: 余额充值是否被禁用
const balanceDisabled = config.balanceDisabled === true;
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
const hasChannels = channels.length > 0;
// 是否有可售卖套餐
@@ -196,6 +199,7 @@ function PayContent() {
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
balanceDisabled: cfgData.config.balanceDisabled ?? false,
});
if (cfgData.config.sublabelOverrides) {
applySublabelOverrides(cfgData.config.sublabelOverrides);
@@ -463,7 +467,9 @@ function PayContent() {
};
// ── 渲染 ──
const showMainTabs = channelsLoaded && (hasChannels || hasPlans);
// R7: 检查是否所有入口都关闭
const allEntriesClosed = channelsLoaded && balanceDisabled && !hasPlans;
const showMainTabs = channelsLoaded && !allEntriesClosed && (hasChannels || hasPlans);
const pageTitle = showMainTabs
? pickLocaleText(locale, '选择适合你的 订阅套餐', 'Choose Your Plan')
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
@@ -576,12 +582,33 @@ function PayContent() {
</div>
)}
{/* R7: 所有入口关闭提示 */}
{allEntriesClosed && (activeMobileTab === 'pay' || !isMobile) && (
<div className={[
'rounded-2xl border p-8 text-center',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}>
<div className={['text-4xl mb-4'].join(' ')}>
<svg className={['mx-auto h-12 w-12', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
</div>
<p className={['text-lg font-medium mb-2', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{pickLocaleText(locale, '充值/订阅入口已被管理员关闭', 'Recharge / Subscription entry has been closed by admin')}
</p>
<p className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{pickLocaleText(locale, '如有疑问,请联系管理员', 'Please contact the administrator if you have questions')}
</p>
</div>
)}
{/* ── 有渠道配置新版UI ── */}
{channelsLoaded && showMainTabs && (activeMobileTab === 'pay' || !isMobile) && !selectedPlan && !showTopUpForm && (
<>
<MainTabs activeTab={mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} isDark={isDark} locale={locale} />
<MainTabs activeTab={balanceDisabled ? 'subscribe' : mainTab} onTabChange={setMainTab} showSubscribeTab={hasPlans} showTopUpTab={!balanceDisabled} isDark={isDark} locale={locale} />
{mainTab === 'topup' && (
{mainTab === 'topup' && !balanceDisabled && (
<div className="mt-6">
{/* 按量付费说明 banner */}
<div className={[
@@ -766,7 +793,7 @@ function PayContent() {
)}
{/* ── 无渠道配置传统充值UI ── */}
{channelsLoaded && !showMainTabs && config.enabledPaymentTypes.length > 0 && !selectedPlan && (
{channelsLoaded && !showMainTabs && !balanceDisabled && config.enabledPaymentTypes.length > 0 && !selectedPlan && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (

View File

@@ -8,17 +8,22 @@ interface MainTabsProps {
activeTab: 'topup' | 'subscribe';
onTabChange: (tab: 'topup' | 'subscribe') => void;
showSubscribeTab: boolean;
showTopUpTab?: boolean;
isDark: boolean;
locale: Locale;
}
export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, isDark, locale }: MainTabsProps) {
export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, showTopUpTab = true, isDark, locale }: MainTabsProps) {
if (!showSubscribeTab) return null;
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [
{ key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') },
{ key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') },
];
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [];
if (showTopUpTab) {
tabs.push({ key: 'topup', label: pickLocaleText(locale, '按量付费', 'Pay-as-you-go') });
}
tabs.push({ key: 'subscribe', label: pickLocaleText(locale, '包月套餐', 'Subscription') });
// 只有一个 tab 时不显示切换器
if (tabs.length <= 1) return null;
return (
<div

View File

@@ -13,6 +13,7 @@ import { deriveOrderState, isRefundStatus } from './status';
import { pickLocaleText, type Locale } from '@/lib/locale';
import { getBizDayStartUTC } from '@/lib/time/biz-day';
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
import { getSystemConfig, getSystemConfigs } from '@/lib/system-config';
const MAX_PENDING_ORDERS = 3;
/** Decimal(10,2) 允许的最大金额 */
@@ -59,8 +60,21 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const orderType = input.orderType ?? 'balance';
// ── 订阅订单前置校验 ──
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string } | null = null;
let subscriptionPlan: { id: string; groupId: number; price: Prisma.Decimal; validityDays: number; validityUnit: string; name: string; productName: string | null } | null = null;
let subscriptionGroupName = '';
// R6: 余额充值禁用检查
if (orderType === 'balance') {
const balanceDisabled = await getSystemConfig('BALANCE_PAYMENT_DISABLED');
if (balanceDisabled === 'true') {
throw new OrderError(
'BALANCE_PAYMENT_DISABLED',
message(locale, '余额充值已被管理员关闭', 'Balance recharge has been disabled by the administrator'),
403,
);
}
}
if (orderType === 'subscription') {
if (!input.planId) {
throw new OrderError('INVALID_INPUT', message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'), 400);
@@ -78,6 +92,14 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
410,
);
}
// R4: 校验分组必须为订阅类型
if (group.subscription_type !== 'subscription') {
throw new OrderError(
'GROUP_TYPE_MISMATCH',
message(locale, '该分组不是订阅类型,无法购买订阅', 'This group is not a subscription type'),
400,
);
}
subscriptionGroupName = group?.name || plan.name;
subscriptionPlan = plan;
// 订阅订单金额使用服务端套餐价格,不信任客户端
@@ -216,13 +238,28 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
returnUrl = orderResultUrl;
}
// R3+R5: 构建支付商品名称
let paymentSubject: string;
if (subscriptionPlan) {
// R3: 订阅订单优先使用套餐自定义商品名称
paymentSubject = subscriptionPlan.productName || `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`;
} else {
// R5: 余额订单使用前缀/后缀配置
const nameConfigs = await getSystemConfigs(['PRODUCT_NAME_PREFIX', 'PRODUCT_NAME_SUFFIX']);
const prefix = nameConfigs['PRODUCT_NAME_PREFIX']?.trim();
const suffix = nameConfigs['PRODUCT_NAME_SUFFIX']?.trim();
if (prefix || suffix) {
paymentSubject = `${prefix || ''} ${payAmountStr} ${suffix || ''}`.trim();
} else {
paymentSubject = `${env.PRODUCT_NAME} ${payAmountStr} CNY`;
}
}
const paymentResult = await provider.createPayment({
orderId: order.id,
amount: payAmountNum,
paymentType: input.paymentType,
subject: subscriptionPlan
? `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`
: `${env.PRODUCT_NAME} ${payAmountStr} CNY`,
subject: paymentSubject,
notifyUrl,
returnUrl,
clientIp: input.clientIp,

View File

@@ -227,6 +227,26 @@ export async function subtractBalance(
}
}
// ── 用户搜索 API ──
export async function searchUsers(keyword: string): Promise<{ id: number; email: string; username: string; notes?: string }[]> {
const env = getEnv();
const response = await fetch(
`${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`,
{
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(`Failed to search users: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as { id: number; email: string; username: string; notes?: string }[];
}
export async function addBalance(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`, {