新增积分系统并接入新版首页工具箱
This commit is contained in:
parent
a6f55c7c21
commit
2c1f936e11
@ -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",
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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"
|
||||
|
||||
19
lib/db/migrations/0035_add_points_system.sql
Normal file
19
lib/db/migrations/0035_add_points_system.sql
Normal 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);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
85
lib/points/config.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user