新增积分系统并接入新版首页工具箱
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, normalizeMembershipConfig,
normalizeMembershipUserOverrides, normalizeMembershipUserOverrides,
} from "@/lib/membership/config"; } 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()); const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -85,6 +93,8 @@ type User = {
role: "guest" | "regular" | "admin"; role: "guest" | "regular" | "admin";
isActive: boolean; isActive: boolean;
monthlyImageLimit: number | null; monthlyImageLimit: number | null;
pointsBalance: number;
pointsGiftedTotal: number;
createdAt: string; createdAt: string;
}; };
@ -119,6 +129,7 @@ type ImageGenConfig = {
supportsNegativePrompt?: boolean; // 是否支持反向提示词 supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关 supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子 supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图 enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green" loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
}; };
@ -178,6 +189,8 @@ type SiteSettings = {
canvasAutoModeDefaultModelId: string | null; canvasAutoModeDefaultModelId: string | null;
lovartComposerPrefix: string; lovartComposerPrefix: string;
lovartComposerPreviewTexts: string[]; lovartComposerPreviewTexts: string[];
pointsSystemConfig: PointsSystemConfig;
pointsPackages: PointsPackage[];
lovartPromoText: string; lovartPromoText: string;
lovartPromoActionText: string; lovartPromoActionText: string;
lovartHeroTitle: string; lovartHeroTitle: string;
@ -316,6 +329,7 @@ export function AdminSettingsClient() {
const [userSearch, setUserSearch] = useState(""); const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(1); const [userPage, setUserPage] = useState(1);
const [userLimit] = useState(10); const [userLimit] = useState(10);
const [giftPointsDraftByUser, setGiftPointsDraftByUser] = useState<Record<string, string>>({});
// 批量设置出图限额状态 // 批量设置出图限额状态
const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false); const [showBatchLimitDialog, setShowBatchLimitDialog] = useState(false);
@ -415,6 +429,8 @@ export function AdminSettingsClient() {
"帮我把这张产品图做成高级电商海报", "帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案", "基于参考图延展 3 套同风格方案",
], ],
pointsSystemConfig: defaultPointsSystemConfig,
pointsPackages: defaultPointsPackages,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级", lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroTitle: "由壹点AI 让设计更简单",
@ -430,6 +446,9 @@ export function AdminSettingsClient() {
const [membershipOverridesDraft, setMembershipOverridesDraft] = useState( const [membershipOverridesDraft, setMembershipOverridesDraft] = useState(
JSON.stringify(defaultMembershipUserOverrides, null, 2) JSON.stringify(defaultMembershipUserOverrides, null, 2)
); );
const [pointsPackagesDraft, setPointsPackagesDraft] = useState(
JSON.stringify(defaultPointsPackages, null, 2)
);
useEffect(() => { useEffect(() => {
if (siteSettingsData) { if (siteSettingsData) {
@ -439,6 +458,12 @@ export function AdminSettingsClient() {
const normalizedMembershipOverrides = normalizeMembershipUserOverrides( const normalizedMembershipOverrides = normalizeMembershipUserOverrides(
(siteSettingsData as any).membershipUserOverrides (siteSettingsData as any).membershipUserOverrides
); );
const normalizedPointsSystemConfig = normalizePointsSystemConfig(
(siteSettingsData as any).pointsSystemConfig
);
const normalizedPointsPackages = normalizePointsPackages(
(siteSettingsData as any).pointsPackages
);
setSiteSettings({ setSiteSettings({
siteName: siteSettingsData.siteName || "AI Chatbot", siteName: siteSettingsData.siteName || "AI Chatbot",
@ -476,6 +501,8 @@ export function AdminSettingsClient() {
"帮我把这张产品图做成高级电商海报", "帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案", "基于参考图延展 3 套同风格方案",
], ],
pointsSystemConfig: normalizedPointsSystemConfig,
pointsPackages: normalizedPointsPackages,
lovartPromoText: lovartPromoText:
siteSettingsData.lovartPromoText || siteSettingsData.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
@ -491,6 +518,7 @@ export function AdminSettingsClient() {
}); });
setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2)); setMembershipConfigDraft(JSON.stringify(normalizedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2)); setMembershipOverridesDraft(JSON.stringify(normalizedMembershipOverrides, null, 2));
setPointsPackagesDraft(JSON.stringify(normalizedPointsPackages, null, 2));
} }
}, [siteSettingsData]); }, [siteSettingsData]);
@ -864,6 +892,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false, supportsNegativePrompt: false,
supportsWatermark: false, supportsWatermark: false,
supportsSeed: false, supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true, enableLoadingPlaceholder: true,
loadingColor: "purple", loadingColor: "purple",
} as ImageGenConfig, } 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) => { const handleDeleteUser = async (userId: string) => {
if (!confirm("确定要删除此用户吗?")) return; if (!confirm("确定要删除此用户吗?")) return;
try { try {
@ -988,6 +1043,7 @@ export function AdminSettingsClient() {
try { try {
let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>; let parsedMembershipConfig: ReturnType<typeof normalizeMembershipConfig>;
let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>; let parsedMembershipOverrides: ReturnType<typeof normalizeMembershipUserOverrides>;
let parsedPointsPackages: ReturnType<typeof normalizePointsPackages>;
try { try {
parsedMembershipConfig = normalizeMembershipConfig( parsedMembershipConfig = normalizeMembershipConfig(
@ -1007,10 +1063,18 @@ export function AdminSettingsClient() {
return; return;
} }
try {
parsedPointsPackages = normalizePointsPackages(JSON.parse(pointsPackagesDraft));
} catch {
toast.error("积分套餐 JSON 格式错误,请检查后再保存");
return;
}
const payload = { const payload = {
...siteSettings, ...siteSettings,
membershipConfig: parsedMembershipConfig, membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides, membershipUserOverrides: parsedMembershipOverrides,
pointsPackages: parsedPointsPackages,
}; };
const response = await fetch("/api/site-settings", { const response = await fetch("/api/site-settings", {
@ -1022,6 +1086,7 @@ export function AdminSettingsClient() {
setSiteSettings(payload); setSiteSettings(payload);
setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2)); setMembershipConfigDraft(JSON.stringify(parsedMembershipConfig, null, 2));
setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2)); setMembershipOverridesDraft(JSON.stringify(parsedMembershipOverrides, null, 2));
setPointsPackagesDraft(JSON.stringify(parsedPointsPackages, null, 2));
toast.success("网站设置保存成功"); toast.success("网站设置保存成功");
mutateSiteSettings(); mutateSiteSettings();
} catch (error) { } catch (error) {
@ -1488,6 +1553,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false, supportsNegativePrompt: false,
supportsWatermark: false, supportsWatermark: false,
supportsSeed: false, supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true, enableLoadingPlaceholder: true,
loadingColor: "purple", loadingColor: "purple",
}, },
@ -1729,6 +1795,10 @@ export function AdminSettingsClient() {
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"} 月度出图限额: {user.monthlyImageLimit ? `${user.monthlyImageLimit} 张/月` : "无限制"}
</div> </div>
<div className="text-sm text-muted-foreground">
剩余积分: {Number(user.pointsBalance || 0).toLocaleString()} | 累计赠送:{" "}
{Number(user.pointsGiftedTotal || 0).toLocaleString()}
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 配额设置 */} {/* 配额设置 */}
@ -1778,6 +1848,33 @@ export function AdminSettingsClient() {
> >
{user.isActive ? "禁用" : "激活"} {user.isActive ? "禁用" : "激活"}
</Button> </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)}> <Button variant="destructive" size="sm" onClick={() => handleDeleteUser(user.id)}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -2860,6 +2957,88 @@ export function AdminSettingsClient() {
</div> </div>
</div> </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="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="showAutoModel">显示"自动"模型选项</Label> <Label htmlFor="showAutoModel">显示"自动"模型选项</Label>
@ -4367,6 +4546,29 @@ export function AdminSettingsClient() {
<p className="text-xs text-muted-foreground">Gemini: 1-2, 阿里: 1-6</p> <p className="text-xs text-muted-foreground">Gemini: 1-2, 阿里: 1-6</p>
</div> </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"> <div className="space-y-2">
<Label>占位图颜色主题</Label> <Label>占位图颜色主题</Label>
<Select <Select
@ -4501,6 +4703,7 @@ export function AdminSettingsClient() {
supportsNegativePrompt: false, supportsNegativePrompt: false,
supportsWatermark: false, supportsWatermark: false,
supportsSeed: false, supportsSeed: false,
creditCostPerImage: 0,
enableLoadingPlaceholder: true, enableLoadingPlaceholder: true,
loadingColor: "purple", loadingColor: "purple",
}, },

View File

@ -35,6 +35,8 @@ import {
createStreamId, createStreamId,
deleteChatById, deleteChatById,
getChatById, getChatById,
getPointsSystemConfig,
getUserById,
getMessageCountByUserId, getMessageCountByUserId,
getMessagesByChatId, getMessagesByChatId,
saveChat, saveChat,
@ -45,6 +47,7 @@ import {
getSubModelsByParentModelId, getSubModelsByParentModelId,
getAiProviderById, getAiProviderById,
checkUserImageQuota, checkUserImageQuota,
consumeUserPoints,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema"; import type { DBMessage } from "@/lib/db/schema";
@ -796,6 +799,8 @@ async function saveGeneratedImages(
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)]; const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
for (const match of matches) { for (const match of matches) {
const altText = match[1] || ""; const altText = match[1] || "";
const thumbnailUrl = match[2]; const thumbnailUrl = match[2];
@ -812,11 +817,52 @@ async function saveGeneratedImages(
prompt, prompt,
modelId, modelId,
}); });
savedImageCount += 1;
console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`); console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) { } catch (error) {
console.error("[Gallery] Failed to save image record:", 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 分钟,用于支持图片编辑等耗时任务 export const maxDuration = 600; // 10 分钟,用于支持图片编辑等耗时任务
@ -1602,6 +1648,16 @@ export async function POST(request: Request) {
(languageModel as any).provider === "ruxa-image" || (languageModel as any).provider === "ruxa-image" ||
(languageModel as any).provider === "huanke-gemini"; (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) { if (isImageGenerationModel) {
const quota = await checkUserImageQuota(session.user.id); 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 ?? '无限制'}`); 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 图片生成独立处理 ========== // ========== Gemini 图片生成独立处理 ==========
@ -1823,7 +1911,7 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
actualModelId finalModelId
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError); console.error("[Chat API] ❌ Failed to save Gemini message to database:", saveError);
@ -1922,7 +2010,7 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
volcanoModel.model.modelId volcanoModel.model.id
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] Failed to save failover message:", saveError); console.error("[Chat API] Failed to save failover message:", saveError);
@ -2277,7 +2365,7 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
usedModelId finalModelId
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError); console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError);
@ -2624,7 +2712,7 @@ export async function POST(request: Request) {
id, id,
messageId, messageId,
userPrompt, userPrompt,
usedModelId finalModelId
); );
} catch (saveError) { } catch (saveError) {
console.error("[Chat API] ❌ Failed to save Volcano Vision message to database:", 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, id,
messageId, messageId,
userPrompt, userPrompt,
actualModelId finalModelId
); );
return; return;
} catch (error) { } catch (error) {

View File

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

View File

@ -14,6 +14,9 @@ const updateUserSchema = z.object({
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
email: z.string().email().optional(), email: z.string().email().optional(),
monthlyImageLimit: z.number().int().min(0).nullable().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) { export async function GET(request: Request) {
@ -49,9 +52,21 @@ export async function PATCH(request: Request) {
await requireAdmin(); await requireAdmin();
const body = await request.json(); 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) { if (result.length === 0) {
return new ChatSDKError("bad_request:api", "User not found").toResponse(); 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(); return new ChatSDKError("offline:admin").toResponse();
} }
} }

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import {
ArrowRight, ArrowRight,
ArrowUp, ArrowUp,
Bell, Bell,
Briefcase,
ChevronDown, ChevronDown,
Clapperboard, Clapperboard,
Folder, Folder,
@ -93,6 +94,15 @@ type ModelOption = {
supportsImageGen?: boolean; supportsImageGen?: boolean;
}; };
type NavLink = {
id: string;
name: string;
url: string;
order: number;
isEnabled: boolean;
toolboxGroup: number;
};
type PublicAuthSettings = { type PublicAuthSettings = {
isDingTalkEnabled?: boolean; isDingTalkEnabled?: boolean;
isGoogleEnabled?: boolean; isGoogleEnabled?: boolean;
@ -300,10 +310,19 @@ export function LovartHome({
models: ModelOption[]; models: ModelOption[];
}>(isAuthenticated ? "/api/models" : null, fetcher); }>(isAuthenticated ? "/api/models" : null, fetcher);
const { data: navLinksData } = useSWR<{ links: NavLink[] }>(
"/api/nav-links",
fetcher
);
const { data: siteSettingsData } = useSWR<{ const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null; canvasDefaultModelId?: string | null;
lovartComposerPrefix?: string | null; lovartComposerPrefix?: string | null;
lovartComposerPreviewTexts?: string[]; lovartComposerPreviewTexts?: string[];
toolbox1Name?: string | null;
toolbox1Enabled?: boolean;
toolbox2Name?: string | null;
toolbox2Enabled?: boolean;
}>("/api/site-settings", fetcher); }>("/api/site-settings", fetcher);
const { data: authSettingsData } = useSWR<PublicAuthSettings>( const { data: authSettingsData } = useSWR<PublicAuthSettings>(
@ -436,6 +455,22 @@ export function LovartHome({
return (canvasData?.data || []).length > 4; return (canvasData?.data || []).length > 4;
}, [canvasData?.data]); }, [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( const canUseGoogleLogin = Boolean(
authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured authSettingsData?.isGoogleEnabled && authSettingsData?.isGoogleConfigured
); );
@ -758,6 +793,23 @@ export function LovartHome({
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); 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}${ const composerPlaceholder = `${composerFixedPrefix}${typedPreviewText}${
previewCursorVisible ? " |" : "" previewCursorVisible ? " |" : ""
}`; }`;
@ -998,6 +1050,68 @@ export function LovartHome({
> >
<Info className="h-[18px] w-[18px]" /> <Info className="h-[18px] w-[18px]" />
</button> </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" ? ( {user?.type === "admin" ? (
<button <button
type="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, "when": 1771004300000,
"tag": "0034_add_toolbox_fields", "tag": "0034_add_toolbox_fields",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1771150800000,
"tag": "0035_add_points_system",
"breakpoints": true
} }
] ]
} }

View File

@ -49,12 +49,18 @@ import {
authSettings, authSettings,
loginToken, loginToken,
generatedImage, generatedImage,
pointsLedger,
} from "./schema"; } from "./schema";
import { generateHashedPassword } from "./utils"; import { generateHashedPassword } from "./utils";
import { import {
isAiModelAgentAuto, isAiModelAgentAuto,
withAgentAutoModelFlag, withAgentAutoModelFlag,
} from "./ai-model-flags"; } from "./ai-model-flags";
import {
defaultPointsSystemConfig,
normalizePointsSystemConfig,
POINTS_SYSTEM_CONFIG_KEY,
} from "../points/config";
// Optionally, if not using email/pass login, you can // Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth // use the Drizzle adapter for Auth.js / NextAuth
@ -678,6 +684,8 @@ export async function getAllUsers({
role: user.role, role: user.role,
isActive: user.isActive, isActive: user.isActive,
monthlyImageLimit: user.monthlyImageLimit, monthlyImageLimit: user.monthlyImageLimit,
pointsBalance: user.pointsBalance,
pointsGiftedTotal: user.pointsGiftedTotal,
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
}) })
@ -717,28 +725,60 @@ export async function updateUser({
role, role,
isActive, isActive,
monthlyImageLimit, monthlyImageLimit,
pointsBalance,
giftPoints,
pointsNote,
operatorId,
}: { }: {
id: string; id: string;
email?: string; email?: string;
role?: "guest" | "regular" | "admin"; role?: "guest" | "regular" | "admin";
isActive?: boolean; isActive?: boolean;
monthlyImageLimit?: number | null; monthlyImageLimit?: number | null;
pointsBalance?: number | null;
giftPoints?: number;
pointsNote?: string;
operatorId?: string;
}) { }) {
try { try {
const updateData: Record<string, unknown> = { updatedAt: new Date() }; return await db.transaction(async (tx) => {
if (email !== undefined) updateData.email = email; const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (role !== undefined) updateData.role = role; if (email !== undefined) updateData.email = email;
if (isActive !== undefined) updateData.isActive = isActive; if (role !== undefined) updateData.role = role;
if (monthlyImageLimit !== undefined) { if (isActive !== undefined) updateData.isActive = isActive;
// 0 或空转为 null(表示无限制) if (monthlyImageLimit !== undefined) {
updateData.monthlyImageLimit = monthlyImageLimit || null; // 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 const updatedUsers = await tx
.update(user) .update(user)
.set(updateData) .set(updateData)
.where(eq(user.id, id)) .where(eq(user.id, id))
.returning(); .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) { } catch (_error) {
throw new ChatSDKError("bad_request:database", "Failed to update user"); 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() { export async function getAllSystemSettings() {
try { try {
return await db.select().from(systemSettings); return await db.select().from(systemSettings);
@ -1119,6 +1225,7 @@ interface ImageGenConfig {
supportsNegativePrompt?: boolean; supportsNegativePrompt?: boolean;
supportsWatermark?: boolean; supportsWatermark?: boolean;
supportsSeed?: boolean; supportsSeed?: boolean;
creditCostPerImage?: number;
enableLoadingPlaceholder?: boolean; enableLoadingPlaceholder?: boolean;
loadingColor?: string; loadingColor?: string;
} }

View File

@ -26,12 +26,31 @@ export const user = pgTable("User", {
.default("regular"), .default("regular"),
isActive: boolean("isActive").notNull().default(true), isActive: boolean("isActive").notNull().default(true),
monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制 monthlyImageLimit: integer("monthlyImageLimit"), // 每月出图限额,NULL或0表示无限制
pointsBalance: integer("pointsBalance").notNull().default(0), // 当前剩余积分
pointsGiftedTotal: integer("pointsGiftedTotal").notNull().default(0), // 管理员累计赠送积分
createdAt: timestamp("createdAt").notNull().defaultNow(), createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}); });
export type User = InferSelectModel<typeof user>; 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", { export const chat = pgTable("Chat", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
createdAt: timestamp("createdAt").notNull(), 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;
}