新增积分系统并接入新版首页工具箱
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-14 14:24:20 +08:00
parent a6f55c7c21
commit 2c1f936e11
12 changed files with 819 additions and 24 deletions

View File

@ -73,6 +73,14 @@ import {
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
import {
defaultPointsPackages,
defaultPointsSystemConfig,
normalizePointsPackages,
normalizePointsSystemConfig,
type PointsPackage,
type PointsSystemConfig,
} from "@/lib/points/config";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -85,6 +93,8 @@ type User = {
role: "guest" | "regular" | "admin";
isActive: boolean;
monthlyImageLimit: number | null;
pointsBalance: number;
pointsGiftedTotal: number;
createdAt: string;
};
@ -119,6 +129,7 @@ type ImageGenConfig = {
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
};
@ -178,6 +189,8 @@ type SiteSettings = {
canvasAutoModeDefaultModelId: string | null;
lovartComposerPrefix: string;
lovartComposerPreviewTexts: string[];
pointsSystemConfig: PointsSystemConfig;
pointsPackages: PointsPackage[];
lovartPromoText: string;
lovartPromoActionText: string;
lovartHeroTitle: string;
@ -316,6 +329,7 @@ export function AdminSettingsClient() {
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(1);
const [userLimit] = useState(10);
const [giftPointsDraftByUser, setGiftPointsDraftByUser] = useState<Record<string, string>>({});
// 批量设置出图限额状态
const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false);
@ -415,6 +429,8 @@ export function AdminSettingsClient() {
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
pointsSystemConfig: defaultPointsSystemConfig,
pointsPackages: defaultPointsPackages,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
@ -430,6 +446,9 @@ export function AdminSettingsClient() {
const [membershipOverridesDraft, setMembershipOverridesDraft] = useState(
JSON.stringify(defaultMembershipUserOverrides, null, 2)
);
const [pointsPackagesDraft, setPointsPackagesDraft] = useState(
JSON.stringify(defaultPointsPackages, null, 2)
);
useEffect(() => {
if (siteSettingsData) {
@ -439,6 +458,12 @@ export function AdminSettingsClient() {
const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
(siteSettingsData as any).membershipUserOverrides
);
const normalizedPointsSystemConfig = normalizePointsSystemConfig(
(siteSettingsData as any).pointsSystemConfig
);
const normalizedPointsPackages = normalizePointsPackages(
(siteSettingsData as any).pointsPackages
);
setSiteSettings({
siteName: siteSettingsData.siteName || "AI Chatbot",
@ -476,6 +501,8 @@ export function AdminSettingsClient() {
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
pointsSystemConfig: normalizedPointsSystemConfig,
pointsPackages: normalizedPointsPackages,
lovartPromoText:
siteSettingsData.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
@ -491,6 +518,7 @@ export function AdminSettingsClient() {
});
setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2));
setPointsPackagesDraft(JSON.stringify(normalizedPointsPackages, null, 2));
}
}, [siteSettingsData]);
@ -864,6 +892,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
} as ImageGenConfig,
@ -942,6 +971,32 @@ export function AdminSettingsClient() {
}
};
const handleGiftPoints = async (userId: string) => {
const rawValue = giftPointsDraftByUser[userId] || "";
const points = Number.parseInt(rawValue, 10);
if (!Number.isFinite(points) || points <= 0) {
toast.error("请输入有效的赠送积分(大于 0)");
return;
}
try {
const response = await fetch("/api/admin/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: userId, giftPoints: points }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || data?.error || "赠送失败");
}
toast.success(`已赠送 ${points} 积分`);
setGiftPointsDraftByUser((prev) => ({ ...prev, [userId]: "" }));
mutateUsers();
} catch (error) {
toast.error(error instanceof Error ? error.message : "赠送积分失败");
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm("确定要删除此用户吗?")) return;
try {
@ -988,6 +1043,7 @@ export function AdminSettingsClient() {
try {
let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>;
let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
let parsedPointsPackages: ReturnType<typeof normalizePointsPackages>;
try {
parsedMembershipConfig = normalizeMembershipConfig(
@ -1007,10 +1063,18 @@ export function AdminSettingsClient() {
return;
}
try {
parsedPointsPackages = normalizePointsPackages(JSON.parse(pointsPackagesDraft));
} catch {
toast.error("积分套餐 JSON 格式错误,请检查后再保存");
return;
}
const payload = {
...siteSettings,
membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides,
pointsPackages: parsedPointsPackages,
};
const response = await fetch("/api/site-settings", {
@ -1022,6 +1086,7 @@ export function AdminSettingsClient() {
setSiteSettings(payload);
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
setPointsPackagesDraft(JSON.stringify(parsedPointsPackages, null, 2));
toast.success("网站设置保存成功");
mutateSiteSettings();
} catch (error) {
@ -1488,6 +1553,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
@ -1729,6 +1795,10 @@ export function AdminSettingsClient() {
<div className="text-sm text-muted-foreground">
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
</div>
<div className="text-sm text-muted-foreground">
剩余积分: {Number(user.pointsBalance || 0).toLocaleString()} | 累计赠送:{" "}
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
</div>
</div>
<div className="flex items-center gap-2">
{/* 配额设置 */}
@ -1778,6 +1848,33 @@ export function AdminSettingsClient() {
>
{user.isActive ? "禁用" : "激活"}
</Button>
<div className="flex items-center gap-1">
<Input
type="number"
min="1"
placeholder="赠送积分"
className="h-8 w-24 text-xs"
value={giftPointsDraftByUser[user.id] ?? ""}
onChange={(e) =>
setGiftPointsDraftByUser((prev) => ({
...prev,
[user.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleGiftPoints(user.id);
}
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleGiftPoints(user.id)}
>
赠送
</Button>
</div>
<Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)}>
<Trash2 className="h-4 w-4" />
</Button>
@ -2860,6 +2957,88 @@ export function AdminSettingsClient() {
</div>
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">积分系统配置</Label>
<div className="text-xs text-muted-foreground mt-1">
可开启按模型扣积分、配置积分套餐;后续接入支付后可直接用于充值。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setSiteSettings({
...siteSettings,
pointsSystemConfig: defaultPointsSystemConfig,
});
setPointsPackagesDraft(JSON.stringify(defaultPointsPackages, null, 2));
toast.success("已恢复积分配置默认模板");
}}
>
恢复默认模板
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="pointsEnabled">启用积分消耗</Label>
<div className="text-xs text-muted-foreground">
开启后按模型单价和图片张数扣除积分
</div>
</div>
<Switch
id="pointsEnabled"
checked={siteSettings.pointsSystemConfig?.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
enabled: checked,
},
})
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="pointsBlockOnInsufficient">余额不足时阻止生图</Label>
<div className="text-xs text-muted-foreground">
开启后积分不足直接返回错误,不执行生图任务
</div>
</div>
<Switch
id="pointsBlockOnInsufficient"
checked={siteSettings.pointsSystemConfig?.blockOnInsufficient !== false}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
blockOnInsufficient: checked,
},
})
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="pointsPackagesDraft">积分套餐 JSON</Label>
<Textarea
id="pointsPackagesDraft"
value={pointsPackagesDraft}
onChange={(e) => setPointsPackagesDraft(e.target.value)}
rows={8}
className="font-mono text-xs"
/>
<div className="text-xs text-muted-foreground">
字段示例:`id`、`name`、`points`、`price`、`currency`、`isEnabled`。
</div>
</div>
</div>
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
@ -4366,6 +4545,29 @@ export function AdminSettingsClient() {
/>
<p className="text-xs text-muted-foreground">Gemini: 1-2, 阿里: 1-6</p>
</div>
<div className="space-y-2">
<Label>每张图片积分消耗</Label>
<Input
type="number"
min={0}
step={1}
value={modelForm.imageGenConfig?.creditCostPerImage ?? 0}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditCostPerImage: Math.max(
0,
Number.parseInt(e.target.value, 10) || 0
),
},
})}
/>
<p className="text-xs text-muted-foreground">
开启积分系统后,将按“单价 × 实际出图张数”扣除积分。
</p>
</div>
<div className="space-y-2">
<Label>占位图颜色主题</Label>
@ -4501,6 +4703,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},

View File

@ -35,6 +35,8 @@ import {
createStreamId,
deleteChatById,
getChatById,
getPointsSystemConfig,
getUserById,
getMessageCountByUserId,
getMessagesByChatId,
saveChat,
@ -45,6 +47,7 @@ import {
getSubModelsByParentModelId,
getAiProviderById,
checkUserImageQuota,
consumeUserPoints,
} from "@/lib/db/queries";
import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema";
@ -796,6 +799,8 @@ async function saveGeneratedImages(
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
@ -812,11 +817,52 @@ async function saveGeneratedImages(
prompt,
modelId,
});
savedImageCount += 1;
console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) {
console.error("[Gallery] Failed to save image record:", error);
}
}
if (savedImageCount <= 0) return;
try {
const pointsConfig = await getPointsSystemConfig();
if (!pointsConfig.enabled) return;
const isUuidModelId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
modelId
);
if (!isUuidModelId) return;
const modelConfig = await getAiModelById({ id: modelId });
const rawUnitCost = Number((modelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0);
const unitCost = Number.isFinite(rawUnitCost) ? Math.max(0, Math.round(rawUnitCost)) : 0;
if (unitCost <= 0) return;
const totalCost = unitCost * savedImageCount;
const consumeResult = await consumeUserPoints({
userId,
amount: totalCost,
reason: "image_generation",
modelId,
chatId,
messageId,
note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`,
});
if (!consumeResult.ok) {
console.warn(
`[Points] Failed to consume points after generation. user=${userId} model=${modelId} cost=${totalCost}`
);
} else {
console.log(
`[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}`
);
}
} catch (error) {
console.error("[Points] Failed to consume points after image generation:", error);
}
}
export const maxDuration = 600; // 10 分钟,用于支持图片编辑等耗时任务
@ -1601,6 +1647,16 @@ export async function POST(request: Request) {
(languageModel as any).provider === "alibaba-image" ||
(languageModel as any).provider === "ruxa-image" ||
(languageModel as any).provider === "huanke-gemini";
const isUuidModelId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
finalModelId
);
const currentModelConfig = isUuidModelId
? await getAiModelById({ id: finalModelId }).catch(() => undefined)
: undefined;
const modelCreditCostPerImage = Number(
(currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0
);
if (isImageGenerationModel) {
const quota = await checkUserImageQuota(session.user.id);
@ -1619,6 +1675,38 @@ export async function POST(request: Request) {
}
console.log(`[Chat API] ✅ 图片配额检查通过: ${quota.used}/${quota.limit ?? '无限制'}`);
const pointsConfig = await getPointsSystemConfig();
const normalizedUnitCost = Number.isFinite(modelCreditCostPerImage)
? Math.max(0, Math.round(modelCreditCostPerImage))
: 0;
if (pointsConfig.enabled && normalizedUnitCost > 0 && pointsConfig.blockOnInsufficient !== false) {
const requestedImageCount = Math.max(
1,
Math.round(
Number(
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1
)
)
);
const expectedCost = normalizedUnitCost * requestedImageCount;
const userData = await getUserById({ id: session.user.id });
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
if (currentBalance < expectedCost) {
return NextResponse.json(
{
error: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${expectedCost}(${normalizedUnitCost} x ${requestedImageCount} 张)。`,
balance: currentBalance,
expectedCost,
unitCost: normalizedUnitCost,
},
{ status: 402 }
);
}
}
}
// ========== Gemini 图片生成独立处理 ==========
@ -1823,7 +1911,7 @@ export async function POST(request: Request) {
id,
messageId,
userPrompt,
actualModelId
finalModelId
);
} catch (saveError) {
console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError);
@ -1922,7 +2010,7 @@ export async function POST(request: Request) {
id,
messageId,
userPrompt,
volcanoModel.model.modelId
volcanoModel.model.id
);
} catch (saveError) {
console.error("[Chat API] Failed to save failover message:", saveError);
@ -2277,7 +2365,7 @@ export async function POST(request: Request) {
id,
messageId,
userPrompt,
usedModelId
finalModelId
);
} catch (saveError) {
console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError);
@ -2624,7 +2712,7 @@ export async function POST(request: Request) {
id,
messageId,
userPrompt,
usedModelId
finalModelId
);
} catch (saveError) {
console.error("[Chat API] ❌ Failed to save Volcano Vision message to database:", saveError);
@ -2898,7 +2986,7 @@ export async function POST(request: Request) {
id,
messageId,
userPrompt,
actualModelId
finalModelId
);
return;
} catch (error) {

View File

@ -20,6 +20,7 @@ const imageGenConfigSchema = z.object({
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
}).optional();

View File

@ -14,6 +14,9 @@ const updateUserSchema = z.object({
isActive: z.boolean().optional(),
email: z.string().email().optional(),
monthlyImageLimit: z.number().int().min(0).nullable().optional(),
pointsBalance: z.number().int().min(0).nullable().optional(),
giftPoints: z.number().int().min(1).max(100000).optional(),
pointsNote: z.string().max(200).optional(),
});
export async function GET(request: Request) {
@ -49,9 +52,21 @@ export async function PATCH(request: Request) {
await requireAdmin();
const body = await request.json();
const { id, role, isActive, email, monthlyImageLimit } = updateUserSchema.parse(body);
const { id, role, isActive, email, monthlyImageLimit, pointsBalance, giftPoints, pointsNote } =
updateUserSchema.parse(body);
const result = await updateUser({ id, role, isActive, email, monthlyImageLimit });
const session = await requireAdmin();
const result = await updateUser({
id,
role,
isActive,
email,
monthlyImageLimit,
pointsBalance,
giftPoints,
pointsNote,
operatorId: session.user.id,
});
if (result.length === 0) {
return new ChatSDKError("bad_request:api", "User not found").toResponse();
@ -90,4 +105,3 @@ export async function DELETE(request: Request) {
return new ChatSDKError("offline:admin").toResponse();
}
}

View File

@ -10,6 +10,14 @@ import {
normalizeMembershipConfig,
normalizeMembershipUserOverrides,
} from "@/lib/membership/config";
import {
defaultPointsPackages,
defaultPointsSystemConfig,
normalizePointsPackages,
normalizePointsSystemConfig,
POINTS_PACKAGES_KEY,
POINTS_SYSTEM_CONFIG_KEY,
} from "@/lib/points/config";
const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
@ -86,6 +94,8 @@ export async function GET() {
lovartComposerPrefixSetting,
membershipConfigSetting,
membershipOverridesSetting,
pointsSystemConfigSetting,
pointsPackagesSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -130,6 +140,18 @@ export async function GET() {
.where(eq(systemSettings.key, MEMBERSHIP_USER_OVERRIDES_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, POINTS_SYSTEM_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, POINTS_PACKAGES_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -145,6 +167,10 @@ export async function GET() {
const membershipUserOverrides = normalizeMembershipUserOverrides(
membershipOverridesSetting?.value
);
const pointsSystemConfig = normalizePointsSystemConfig(
pointsSystemConfigSetting?.value
);
const pointsPackages = normalizePointsPackages(pointsPackagesSetting?.value);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -181,6 +207,8 @@ export async function GET() {
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
pointsPackages,
};
const response = NextResponse.json({
@ -198,6 +226,8 @@ export async function GET() {
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
pointsPackages,
});
// 禁用缓存,确保总是获取最新设置
@ -249,6 +279,8 @@ const updateSchema = z.object({
lovartComposerPreviewTexts: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
membershipConfig: z.unknown().optional(),
membershipUserOverrides: z.unknown().optional(),
pointsSystemConfig: z.unknown().optional(),
pointsPackages: z.unknown().optional(),
});
// PUT /api/site-settings - 更新网站设置(仅管理员)
@ -273,6 +305,8 @@ export async function PUT(request: NextRequest) {
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
pointsPackages,
...siteSettingsPayload
} = data;
@ -410,6 +444,46 @@ export async function PUT(request: NextRequest) {
});
}
if (pointsSystemConfig !== undefined) {
const normalized = normalizePointsSystemConfig(pointsSystemConfig);
await db
.insert(systemSettings)
.values({
key: POINTS_SYSTEM_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (pointsPackages !== undefined) {
const normalized = normalizePointsPackages(pointsPackages);
await db
.insert(systemSettings)
.values({
key: POINTS_PACKAGES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...updated,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
@ -433,6 +507,14 @@ export async function PUT(request: NextRequest) {
membershipUserOverrides !== undefined
? normalizeMembershipUserOverrides(membershipUserOverrides)
: defaultMembershipUserOverrides,
pointsSystemConfig:
pointsSystemConfig !== undefined
? normalizePointsSystemConfig(pointsSystemConfig)
: defaultPointsSystemConfig,
pointsPackages:
pointsPackages !== undefined
? normalizePointsPackages(pointsPackages)
: defaultPointsPackages,
});
} else {
// 创建新设置
@ -591,6 +673,46 @@ export async function PUT(request: NextRequest) {
});
}
if (pointsSystemConfig !== undefined) {
const normalized = normalizePointsSystemConfig(pointsSystemConfig);
await db
.insert(systemSettings)
.values({
key: POINTS_SYSTEM_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (pointsPackages !== undefined) {
const normalized = normalizePointsPackages(pointsPackages);
await db
.insert(systemSettings)
.values({
key: POINTS_PACKAGES_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
return NextResponse.json({
...created,
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
@ -614,6 +736,14 @@ export async function PUT(request: NextRequest) {
membershipUserOverrides !== undefined
? normalizeMembershipUserOverrides(membershipUserOverrides)
: defaultMembershipUserOverrides,
pointsSystemConfig:
pointsSystemConfig !== undefined
? normalizePointsSystemConfig(pointsSystemConfig)
: defaultPointsSystemConfig,
pointsPackages:
pointsPackages !== undefined
? normalizePointsPackages(pointsPackages)
: defaultPointsPackages,
});
}
} catch (error) {

View File

@ -5,6 +5,8 @@ import { db } from "@/lib/db";
import { systemSettings } from "@/lib/db/schema";
import {
checkUserImageQuota,
getPointsSystemConfig,
getUserById,
getUserMonthlyImageCount,
} from "@/lib/db/queries";
import {
@ -27,7 +29,7 @@ export async function GET() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const [configSetting, overridesSetting, quota, totalImages] = await Promise.all([
const [configSetting, overridesSetting, quota, totalImages, pointsConfig, dbUser] = await Promise.all([
db
.select()
.from(systemSettings)
@ -42,6 +44,8 @@ export async function GET() {
.then((rows) => rows[0]),
checkUserImageQuota(session.user.id),
getUserMonthlyImageCount(session.user.id),
getPointsSystemConfig(),
getUserById({ id: session.user.id }),
]);
const config = configSetting?.value
@ -59,11 +63,15 @@ export async function GET() {
);
const currentPlan = getMembershipPlanById(config, currentPlanId);
const currentCredits =
quota.remaining !== null ? Math.max(0, quota.remaining) : currentPlan.monthlyCredits;
const currentCredits = pointsConfig.enabled
? Math.max(0, Number(dbUser?.pointsBalance || 0))
: quota.remaining !== null
? Math.max(0, quota.remaining)
: currentPlan.monthlyCredits;
return NextResponse.json({
config,
pointsSystemConfig: pointsConfig,
currentPlanId,
currentPlan,
summary: {

View File

@ -9,6 +9,7 @@ import {
ArrowRight,
ArrowUp,
Bell,
Briefcase,
ChevronDown,
Clapperboard,
Folder,
@ -93,6 +94,15 @@ type ModelOption = {
supportsImageGen?: boolean;
};
type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number;
};
type PublicAuthSettings = {
isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean;
@ -300,10 +310,19 @@ export function LovartHome({
models: ModelOption[];
}>(isAuthenticated ? "/api/models" : null, fetcher);
const { data: navLinksData } = useSWR<{ links: NavLink[] }>(
"/api/nav-links",
fetcher
);
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPrefix?: string | null;
lovartComposerPreviewTexts?: string[];
toolbox1Name?: string | null;
toolbox1Enabled?: boolean;
toolbox2Name?: string | null;
toolbox2Enabled?: boolean;
}>("/api/site-settings", fetcher);
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
@ -436,6 +455,22 @@ export function LovartHome({
return (canvasData?.data || []).length > 4;
}, [canvasData?.data]);
const toolbox1Links = useMemo(
() =>
(navLinksData?.links || [])
.filter((link) => (link.toolboxGroup || 1) === 1 && link.isEnabled !== false)
.sort((a, b) => a.order - b.order),
[navLinksData?.links]
);
const toolbox2Links = useMemo(
() =>
(navLinksData?.links || [])
.filter((link) => link.toolboxGroup === 2 && link.isEnabled !== false)
.sort((a, b) => a.order - b.order),
[navLinksData?.links]
);
const canUseGoogleLogin = Boolean(
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
);
@ -758,6 +793,23 @@ export function LovartHome({
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const openNavLink = (url: string) => {
const value = String(url || "").trim();
if (!value) return;
if (/^https?:\/\//i.test(value)) {
window.open(value, "_blank", "noopener,noreferrer");
return;
}
if (value.startsWith("/")) {
router.push(value);
return;
}
window.open(`https://${value}`, "_blank", "noopener,noreferrer");
};
const composerPlaceholder = `${composerFixedPrefix}${typedPreviewText}${
previewCursorVisible ? " |" : ""
}`;
@ -998,6 +1050,68 @@ export function LovartHome({
>
<Info className="h-[18px] w-[18px]" />
</button>
{siteSettingsData?.toolbox1Enabled !== false && toolbox1Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
title={siteSettingsData?.toolbox1Name || "工具箱"}
>
<Briefcase className="h-[18px] w-[18px]" />
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox1Name || "工具箱"}
</div>
<div className="space-y-1">
{toolbox1Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{siteSettingsData?.toolbox2Enabled === true && toolbox2Links.length > 0 ? (
<HoverCard openDelay={80} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
title={siteSettingsData?.toolbox2Name || "工具箱2"}
>
<Briefcase className="h-[18px] w-[18px]" />
</button>
</HoverCardTrigger>
<HoverCardContent side="right" align="start" className="w-[248px] rounded-2xl p-2">
<div className="px-2 pb-1 pt-1 text-xs font-medium text-black/50">
{siteSettingsData?.toolbox2Name || "工具箱2"}
</div>
<div className="space-y-1">
{toolbox2Links.map((link) => (
<button
key={link.id}
type="button"
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-[13px] text-black/80 hover:bg-black/5"
onClick={() => openNavLink(link.url)}
>
<span className="line-clamp-1">{link.name}</span>
<ArrowRight className="h-3.5 w-3.5 text-black/35" />
</button>
))}
</div>
</HoverCardContent>
</HoverCard>
) : null}
{user?.type === "admin" ? (
<button
type="button"

View File

@ -0,0 +1,19 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "pointsBalance" integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "pointsGiftedTotal" integer NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS "PointsLedger" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"amount" integer NOT NULL,
"reason" varchar(64) NOT NULL,
"modelId" varchar(128),
"chatId" uuid,
"messageId" uuid,
"note" text,
"createdBy" uuid REFERENCES "User"("id"),
"createdAt" timestamp DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "points_ledger_user_created_idx"
ON "PointsLedger" ("userId", "createdAt" DESC);

View File

@ -148,6 +148,13 @@
"when": 1771004300000,
"tag": "0034_add_toolbox_fields",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1771150800000,
"tag": "0035_add_points_system",
"breakpoints": true
}
]
}

View File

@ -49,12 +49,18 @@ import {
authSettings,
loginToken,
generatedImage,
pointsLedger,
} from "./schema";
import { generateHashedPassword } from "./utils";
import {
isAiModelAgentAuto,
withAgentAutoModelFlag,
} from "./ai-model-flags";
import {
defaultPointsSystemConfig,
normalizePointsSystemConfig,
POINTS_SYSTEM_CONFIG_KEY,
} from "../points/config";
// Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth
@ -678,6 +684,8 @@ export async function getAllUsers({
role: user.role,
isActive: user.isActive,
monthlyImageLimit: user.monthlyImageLimit,
pointsBalance: user.pointsBalance,
pointsGiftedTotal: user.pointsGiftedTotal,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
})
@ -717,28 +725,60 @@ export async function updateUser({
role,
isActive,
monthlyImageLimit,
pointsBalance,
giftPoints,
pointsNote,
operatorId,
}: {
id: string;
email?: string;
role?: "guest" | "regular" | "admin";
isActive?: boolean;
monthlyImageLimit?: number | null;
pointsBalance?: number | null;
giftPoints?: number;
pointsNote?: string;
operatorId?: string;
}) {
try {
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (email !== undefined) updateData.email = email;
if (role !== undefined) updateData.role = role;
if (isActive !== undefined) updateData.isActive = isActive;
if (monthlyImageLimit !== undefined) {
// 0 或空转为 null(表示无限制)
updateData.monthlyImageLimit = monthlyImageLimit || null;
}
return await db.transaction(async (tx) => {
const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (email !== undefined) updateData.email = email;
if (role !== undefined) updateData.role = role;
if (isActive !== undefined) updateData.isActive = isActive;
if (monthlyImageLimit !== undefined) {
// 0 或空转为 null(表示无限制)
updateData.monthlyImageLimit = monthlyImageLimit || null;
}
if (pointsBalance !== undefined) {
updateData.pointsBalance = Math.max(0, Number(pointsBalance) || 0);
}
if (giftPoints !== undefined && giftPoints !== 0) {
updateData.pointsBalance = sql`GREATEST(0, COALESCE(${user.pointsBalance}, 0) + ${giftPoints})`;
updateData.pointsGiftedTotal = sql`GREATEST(0, COALESCE(${user.pointsGiftedTotal}, 0) + ${Math.max(
0,
giftPoints
)})`;
}
return await db
.update(user)
.set(updateData)
.where(eq(user.id, id))
.returning();
const updatedUsers = await tx
.update(user)
.set(updateData)
.where(eq(user.id, id))
.returning();
if (updatedUsers.length > 0 && giftPoints !== undefined && giftPoints !== 0) {
await tx.insert(pointsLedger).values({
userId: id,
amount: giftPoints,
reason: "gift",
note: pointsNote || "管理员赠送积分",
createdBy: operatorId,
});
}
return updatedUsers;
});
} catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to update user");
}
@ -769,6 +809,72 @@ export async function getSystemSetting({ key }: { key: string }) {
}
}
export async function getPointsSystemConfig() {
try {
const setting = await getSystemSetting({ key: POINTS_SYSTEM_CONFIG_KEY });
return setting?.value
? normalizePointsSystemConfig(setting.value)
: defaultPointsSystemConfig;
} catch (_error) {
return defaultPointsSystemConfig;
}
}
export async function consumeUserPoints({
userId,
amount,
reason = "image_generation",
modelId,
chatId,
messageId,
note,
}: {
userId: string;
amount: number;
reason?: string;
modelId?: string | null;
chatId?: string | null;
messageId?: string | null;
note?: string;
}) {
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: true, balance: null as number | null };
}
try {
return await db.transaction(async (tx) => {
const updated = await tx
.update(user)
.set({
pointsBalance: sql`${user.pointsBalance} - ${amount}`,
updatedAt: new Date(),
})
.where(and(eq(user.id, userId), gte(user.pointsBalance, amount)))
.returning({
balance: user.pointsBalance,
});
if (updated.length === 0) {
return { ok: false, balance: null as number | null };
}
await tx.insert(pointsLedger).values({
userId,
amount: -amount,
reason,
modelId: modelId || null,
chatId: chatId || null,
messageId: messageId || null,
note: note || "图片生成消耗积分",
});
return { ok: true, balance: updated[0]?.balance ?? null };
});
} catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to consume user points");
}
}
export async function getAllSystemSettings() {
try {
return await db.select().from(systemSettings);
@ -1119,6 +1225,7 @@ interface ImageGenConfig {
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
enableLoadingPlaceholder?: boolean;
loadingColor?: string;
}

View File

@ -26,12 +26,31 @@ export const user = pgTable("User", {
.default("regular"),
isActive: boolean("isActive").notNull().default(true),
monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制
pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分
pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type User = InferSelectModel<typeof user>;
export const pointsLedger = pgTable("PointsLedger", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
amount: integer("amount").notNull(), // 正数=赠送/充值,负数=消耗
reason: varchar("reason", { length: 64 }).notNull(), // gift | image_generation | recharge | adjust
modelId: varchar("modelId", { length: 128 }),
chatId: uuid("chatId"),
messageId: uuid("messageId"),
note: text("note"),
createdBy: uuid("createdBy").references(() => user.id),
createdAt: timestamp("createdAt").notNull().defaultNow(),
});
export type PointsLedger = InferSelectModel<typeof pointsLedger>;
export const chat = pgTable("Chat", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
createdAt: timestamp("createdAt").notNull(),

85
lib/points/config.ts Normal file
View File

@ -0,0 +1,85 @@
export const POINTS_SYSTEM_CONFIG_KEY = "points_system_config";
export const POINTS_PACKAGES_KEY = "points_packages";
export type PointsSystemConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
};
export type PointsPackage = {
id: string;
name: string;
points: number;
price: number;
currency: string;
isEnabled: boolean;
};
export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false,
blockOnInsufficient: true,
};
export const defaultPointsPackages: PointsPackage[] = [
{
id: "starter-1000",
name: "入门包",
points: 1000,
price: 9.9,
currency: "CNY",
isEnabled: true,
},
{
id: "pro-5000",
name: "专业包",
points: 5000,
price: 39.9,
currency: "CNY",
isEnabled: true,
},
];
export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") {
return defaultPointsSystemConfig;
}
const raw = value as Partial<PointsSystemConfig>;
return {
enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false,
};
}
export function normalizePointsPackages(value: unknown): PointsPackage[] {
if (!Array.isArray(value)) {
return defaultPointsPackages;
}
const normalized = value
.map((item, index) => {
if (!item || typeof item !== "object") return null;
const raw = item as Partial<PointsPackage>;
const id = String(raw.id || `package-${index + 1}`).trim();
const name = String(raw.name || "").trim();
const points = Number(raw.points);
const price = Number(raw.price);
const currency = String(raw.currency || "CNY").trim().toUpperCase();
if (!id || !name || !Number.isFinite(points) || points <= 0) return null;
if (!Number.isFinite(price) || price < 0) return null;
return {
id: id.slice(0, 64),
name: name.slice(0, 64),
points: Math.round(points),
price: Number(price.toFixed(2)),
currency: currency.slice(0, 8) || "CNY",
isEnabled: raw.isEnabled !== false,
} satisfies PointsPackage;
})
.filter((item): item is PointsPackage => Boolean(item))
.slice(0, 30);
return normalized.length > 0 ? normalized : defaultPointsPackages;
}