8 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
14 changed files with 104 additions and 39 deletions

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ next-env.d.ts
# third-party source code (local reference only)
/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

@@ -193,7 +193,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
).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({
code: 1,
trade_no: 'EP-003',
@@ -212,16 +212,14 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
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.objectContaining({
outTradeNo: 'order-ep-003',
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 (
<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

@@ -3,19 +3,17 @@ import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
const updateChannelSchema = z
.object({
group_id: z.number().int().positive().optional(),
name: z.string().min(1).max(100).optional(),
platform: z.string().min(1).max(50).optional(),
rate_multiplier: z.number().positive().optional(),
description: z.string().max(500).nullable().optional(),
models: z.array(z.string()).nullable().optional(),
features: z.record(z.string(), z.unknown()).nullable().optional(),
sort_order: z.number().int().min(0).optional(),
enabled: z.boolean().optional(),
})
.strict();
const updateChannelSchema = z.object({
group_id: z.number().int().positive().optional(),
name: z.string().min(1).max(100).optional(),
platform: z.string().min(1).max(50).optional(),
rate_multiplier: z.number().positive().optional(),
description: z.string().max(500).nullable().optional(),
models: z.union([z.array(z.string()), z.string()]).nullable().optional(),
features: z.union([z.record(z.string(), z.unknown()), z.string()]).nullable().optional(),
sort_order: z.number().int().min(0).optional(),
enabled: z.boolean().optional(),
});
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
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)) {
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

@@ -8,7 +8,6 @@ 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'));
const userId = Number(request.nextUrl.searchParams.get('user_id'));
@@ -54,10 +53,7 @@ export async function GET(request: NextRequest) {
return { enabledTypes, methodLimits, balanceDisabled: balanceDisabledVal === 'true' };
});
const [user, { enabledTypes, methodLimits, balanceDisabled }] = await Promise.all([
getUser(userId),
configPromise,
]);
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

@@ -9,6 +9,7 @@ export interface CreatePaymentOptions {
clientIp: string;
productName: string;
returnUrl?: string;
isMobile?: boolean;
}
function normalizeCidList(cid?: string): string | undefined {
@@ -68,6 +69,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
params.cid = cid;
}
if (opts.isMobile) {
params.device = 'mobile';
}
const sign = generateSign(params, env.EASY_PAY_PKEY);
params.sign = sign;
params.sign_type = 'MD5';

View File

@@ -29,11 +29,12 @@ export class EasyPayProvider implements PaymentProvider {
clientIp: request.clientIp || '127.0.0.1',
productName: request.subject,
returnUrl: request.returnUrl,
isMobile: request.isMobile,
});
return {
tradeNo: result.trade_no,
payUrl: result.payurl,
payUrl: (request.isMobile && result.payurl2) || result.payurl,
qrCode: result.qrcode,
};
}

View File

@@ -18,6 +18,7 @@ export interface EasyPayCreateResponse {
trade_no: string;
O_id?: string;
payurl?: string;
payurl2?: string;
qrcode?: string;
img?: string;
}