feat: 添加每日签到功能和sitemap生成
refactor: 优化TTS配置获取逻辑并提取为独立模块 fix: 修正新用户积分初始化环境变量名称 style: 更新播客生成页面UI和文案 docs: 修改提示词模板格式和内容 build: 添加next-sitemap依赖和配置文件
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { startPodcastGenerationTask } from '@/lib/podcastApi';
|
||||
import type { PodcastGenerationRequest } from '@/types';
|
||||
import type { PodcastGenerationRequest } from '@/types'; // 导入 SettingsFormData
|
||||
import { getSessionData } from '@/lib/server-actions';
|
||||
import { getUserPoints } from '@/lib/points'; // 导入 getUserPoints
|
||||
import { fetchAndCacheProvidersLocal } from '@/lib/config-local'; // 导入 getTTSProviders
|
||||
|
||||
|
||||
const enableTTSConfigPage = process.env.NEXT_PUBLIC_ENABLE_TTS_CONFIG_PAGE === 'true'; // 定义环境变量
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionData();
|
||||
@@ -16,6 +20,35 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const body: PodcastGenerationRequest = await request.json();
|
||||
|
||||
// 参数校验
|
||||
if (!body.input_txt_content || body.input_txt_content.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求正文不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!body.tts_provider || body.tts_provider.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'TTS服务提供商不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
let podUsers: any[] = [];
|
||||
try {
|
||||
podUsers = JSON.parse(body.podUsers_json_content || '[]');
|
||||
if (podUsers.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请至少选择一位播客说话人' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '播客说话人配置格式无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 1. 查询用户积分
|
||||
const currentPoints = await getUserPoints(userId);
|
||||
@@ -29,8 +62,62 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 校验语言和时长
|
||||
const allowedLanguages = ['Chinese', 'English', 'Japanese'];
|
||||
if (!body.output_language || !allowedLanguages.includes(body.output_language)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的输出语言' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const allowedDurations = ['Under 5 minutes', '5-10 minutes', '10-15 minutes'];
|
||||
if (!body.usetime || !allowedDurations.includes(body.usetime)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无效的播客时长' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 根据 enableTTSConfigPage 构建最终的 request
|
||||
let finalRequest: PodcastGenerationRequest;
|
||||
if (enableTTSConfigPage) {
|
||||
// 如果启用配置页面,则直接使用前端传入的 body
|
||||
if (body.tts_providers_config_content === undefined || body.api_key === undefined || body.base_url === undefined || body.model === undefined) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '缺少前端传入的TTS配置信息' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
finalRequest = body as PodcastGenerationRequest;
|
||||
|
||||
} else {
|
||||
// 如果未启用配置页面,则在后端获取 TTS 配置
|
||||
const settings = await fetchAndCacheProvidersLocal();
|
||||
if (!settings || !settings.apikey || !settings.model) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '后端TTS配置不完整,请检查后端配置文件。' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
finalRequest = {
|
||||
input_txt_content: body.input_txt_content,
|
||||
tts_provider: body.tts_provider,
|
||||
podUsers_json_content: body.podUsers_json_content,
|
||||
usetime: body.usetime,
|
||||
output_language: body.output_language,
|
||||
tts_providers_config_content: JSON.stringify(settings),
|
||||
api_key: settings.apikey,
|
||||
base_url: settings.baseurl,
|
||||
model: settings.model,
|
||||
} as PodcastGenerationRequest;
|
||||
}
|
||||
|
||||
const callback_url = process.env.NEXT_PUBLIC_PODCAST_CALLBACK_URL || "" // 从环境变量获取
|
||||
finalRequest.callback_url = callback_url;
|
||||
// 积分足够,继续生成播客
|
||||
const result = await startPodcastGenerationTask(body, userId);
|
||||
const result = await startPodcastGenerationTask(finalRequest, userId);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!userHasPointsAccount) {
|
||||
console.log(`用户 ${userId} 不存在积分账户,正在初始化...`);
|
||||
try {
|
||||
const pointsPerPodcastDay = parseInt(process.env.POINTS_PER_PODCAST_DAY || '100', 10);
|
||||
const pointsPerPodcastDay = parseInt(process.env.POINTS_PER_PODCAST_INIT || '100', 10);
|
||||
await createPointsAccount(userId, pointsPerPodcastDay); // 调用封装的创建积分账户函数
|
||||
await recordPointsTransaction(userId, pointsPerPodcastDay, "initial_bonus", "新用户注册,初始积分奖励"); // 调用封装的记录流水函数
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUserPoints, deductUserPoints } from "@/lib/points"; // 导入 deductUserPoints
|
||||
import { getUserPoints, deductUserPoints, addPointsToUser, hasUserSignedToday } from "@/lib/points"; // 导入 deductUserPoints, addPointsToUser, hasUserSignedToday
|
||||
import { NextResponse, NextRequest } from "next/server"; // 导入 NextRequest
|
||||
import { getSessionData } from "@/lib/server-actions"; // 导入 getSessionData
|
||||
|
||||
@@ -77,4 +77,36 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSessionData();
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const fixedPointsToAdd = parseInt(process.env.POINTS_PER_SIGN_IN || '5', 10); // 签到积分固定从环境变量获取,默认5分
|
||||
const fixedReasonCode = "sign_in";
|
||||
const description = "每日签到"; // 描述固定
|
||||
|
||||
try {
|
||||
// 1. 判断今日是否已签到
|
||||
const hasSignedToday = await hasUserSignedToday(userId, fixedReasonCode);
|
||||
if (hasSignedToday) {
|
||||
return NextResponse.json({ success: false, error: "Already signed in today" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. 调用增加积分的方法
|
||||
await addPointsToUser(userId, fixedPointsToAdd, fixedReasonCode, description);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Points added successfully" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error adding points:", error);
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: false, error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,17 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// 定义缓存变量和缓存过期时间
|
||||
let ttsProvidersCache: any = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟,单位毫秒
|
||||
import { fetchAndCacheProvidersLocal } from '@/lib/config-local';
|
||||
|
||||
// 获取 tts_providers.json 文件内容
|
||||
export async function GET() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (ttsProvidersCache && (now - cacheTimestamp < CACHE_DURATION)) {
|
||||
console.log('从缓存中返回 tts_providers.json 数据');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: ttsProvidersCache,
|
||||
});
|
||||
}
|
||||
|
||||
// 缓存无效或不存在,读取文件并更新缓存
|
||||
const ttsProvidersName = process.env.TTS_PROVIDERS_NAME;
|
||||
if (!ttsProvidersName) {
|
||||
throw new Error('TTS_PROVIDERS_NAME 环境变量未设置');
|
||||
}
|
||||
const configPath = path.join(process.cwd(), '..', 'config', ttsProvidersName);
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
// 更新缓存
|
||||
ttsProvidersCache = config;
|
||||
cacheTimestamp = now;
|
||||
const config = await fetchAndCacheProvidersLocal();
|
||||
console.log('重新加载并缓存 tts_providers.json 数据');
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '无法读取TTS提供商配置文件' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -329,6 +329,8 @@ export default function HomePage() {
|
||||
isGenerating={isGenerating}
|
||||
credits={credits}
|
||||
settings={settings} // 传递 settings
|
||||
onSignInSuccess={fetchCreditsAndUserInfo} // 传递 onSignInSuccess
|
||||
enableTTSConfigPage={enableTTSConfigPage} // 传递 enableTTSConfigPage
|
||||
/>
|
||||
|
||||
{/* 最近生成 - 紧凑布局 */}
|
||||
@@ -348,7 +350,7 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 定价部分 */}
|
||||
{/* 定价部分 todo */}
|
||||
{/* <PricingSection /> */}
|
||||
|
||||
{/* 推荐播客 - 水平滚动 */}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { cn, formatTime, downloadFile } from '@/lib/utils';
|
||||
import AudioVisualizer from './AudioVisualizer';
|
||||
import { useIsSmallScreen } from '@/hooks/useMediaQuery'; // 导入新的 Hook
|
||||
import type { AudioPlayerState, PodcastItem } from '@/types';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { useToast, ToastContainer } from '@/components/Toast';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
podcast: PodcastItem;
|
||||
@@ -36,7 +36,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
||||
}) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const { success: toastSuccess } = useToast(); // 使用 useToast Hook
|
||||
const { toasts, success: toastSuccess, removeToast } = useToast(); // 使用 useToast Hook
|
||||
|
||||
const [playerState, setPlayerState] = useState<Omit<AudioPlayerState, 'isPlaying'>>({
|
||||
currentTime: 0,
|
||||
@@ -399,6 +399,11 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ToastContainer
|
||||
toasts={toasts}
|
||||
onRemove={removeToast}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,25 +35,29 @@ interface PodcastCreatorProps {
|
||||
isGenerating?: boolean;
|
||||
credits: number;
|
||||
settings: SettingsFormData | null; // 新增 settings 属性
|
||||
onSignInSuccess: () => void; // 新增 onSignInSuccess 属性
|
||||
enableTTSConfigPage: boolean; // 新增 enableTTSConfigPage 属性
|
||||
}
|
||||
|
||||
const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
onGenerate,
|
||||
isGenerating = false,
|
||||
credits,
|
||||
settings // 解构 settings 属性
|
||||
settings, // 解构 settings 属性
|
||||
onSignInSuccess, // 解构 onSignInSuccess 属性
|
||||
enableTTSConfigPage // 解构 enableTTSConfigPage 属性
|
||||
}) => {
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'Make sure the language of the output content is Chinese', label: '简体中文' },
|
||||
{ value: 'Make sure the language of the output content is English', label: 'English' },
|
||||
{ value: 'Make sure the language of the output content is Japanese', label: '日本語' },
|
||||
{ value: 'Chinese', label: '简体中文' },
|
||||
{ value: 'English', label: 'English' },
|
||||
{ value: 'Japanese', label: '日本語' },
|
||||
];
|
||||
|
||||
const durationOptions = [
|
||||
{ value: 'Under 5 minutes', label: '5分钟以内' },
|
||||
{ value: '5-10 minutes', label: '5-10分钟' },
|
||||
{ value: '15-20 minutes', label: '15-20分钟' },
|
||||
{ value: '25-30 minutes', label: '25-30分钟' },
|
||||
{ value: '10-15 minutes', label: '10-15分钟' },
|
||||
];
|
||||
|
||||
const [topic, setTopic] = useState('');
|
||||
@@ -85,7 +89,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const [selectedConfigName, setSelectedConfigName] = useState<string>(''); // 新增状态来存储配置文件的名称
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { toasts, error } = useToast(); // 使用 useToast hook, 引入 success
|
||||
const { toasts, error, success, removeToast } = useToast(); // 使用 useToast hook, 引入 success
|
||||
const { data: session } = useSession(); // 获取 session
|
||||
|
||||
const handleSubmit = async () => { // 修改为 async 函数
|
||||
@@ -115,14 +119,15 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const request: PodcastGenerationRequest = {
|
||||
tts_provider: selectedConfigName.replace('.json', ''),
|
||||
input_txt_content: inputTxtContent,
|
||||
tts_providers_config_content: JSON.stringify(settings),
|
||||
podUsers_json_content: JSON.stringify(selectedPodcastVoices[selectedConfigName] || []),
|
||||
api_key: settings?.apikey,
|
||||
base_url: settings?.baseurl,
|
||||
model: settings?.model,
|
||||
callback_url: process.env.NEXT_PUBLIC_PODCAST_CALLBACK_URL || "https://your-callback-url.com/podcast-status", // 从环境变量获取
|
||||
usetime: duration,
|
||||
output_language: language,
|
||||
...(enableTTSConfigPage ? {
|
||||
tts_providers_config_content: JSON.stringify(settings),
|
||||
api_key: settings?.apikey,
|
||||
base_url: settings?.baseurl,
|
||||
model: settings?.model,
|
||||
} : {})
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -135,7 +140,35 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
} catch (err) {
|
||||
console.error("播客生成失败:", err);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleSignIn = async () => {
|
||||
if (!session?.user) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/points', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
success("签到成功", data.message);
|
||||
onSignInSuccess(); // 签到成功后调用回调
|
||||
} else {
|
||||
error("签到失败", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("签到请求失败:", err);
|
||||
error("签到失败", "网络错误或服务器无响应");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -271,7 +304,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
setCustomInstructions(e.target.value);
|
||||
setItem('podcast-custom-instructions', e.target.value); // 实时保存到 localStorage
|
||||
}}
|
||||
placeholder="添加自定义指令(可选)..."
|
||||
placeholder="添加自定义指令(可选)... 例如:固定的开场白和结束语,文案脚本语境,输出内容的重点"
|
||||
className="w-full h-16 resize-none border-none outline-none text-sm placeholder-neutral-400 bg-white"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
@@ -391,6 +424,19 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
</svg>
|
||||
<span className="truncate">{credits}</span>
|
||||
</div>
|
||||
|
||||
{/* 签到按钮 */}
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
"btn-secondary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
|
||||
isGenerating && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
签到
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{/* 创作按钮 */}
|
||||
<button
|
||||
@@ -454,7 +500,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={() => {}} /> {/* 添加 ToastContainer */}
|
||||
<ToastContainer
|
||||
toasts={toasts}
|
||||
onRemove={removeToast}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { AiOutlineShareAlt } from 'react-icons/ai';
|
||||
import { useToast } from './Toast'; // 确保路径正确
|
||||
import { useToast, ToastContainer} from './Toast'; // 确保路径正确
|
||||
import { usePathname } from 'next/navigation'; // next/navigation 用于获取当前路径
|
||||
|
||||
interface ShareButtonProps {
|
||||
@@ -10,7 +10,7 @@ interface ShareButtonProps {
|
||||
}
|
||||
|
||||
const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
|
||||
const { success, error } = useToast();
|
||||
const { toasts, success, error, removeToast } = useToast();
|
||||
const pathname = usePathname(); // 获取当前路由路径
|
||||
|
||||
const handleShare = async () => {
|
||||
@@ -28,6 +28,7 @@ const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`text-neutral-500 hover:text-black transition-colors text-sm ${className}`}
|
||||
@@ -35,6 +36,12 @@ const ShareButton: React.FC<ShareButtonProps> = ({ className }) => {
|
||||
>
|
||||
<AiOutlineShareAlt className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<ToastContainer
|
||||
toasts={toasts}
|
||||
onRemove={removeToast}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
40
web/src/lib/config-local.ts
Normal file
40
web/src/lib/config-local.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
'use server'
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// 定义缓存变量和缓存过期时间
|
||||
let ttsProvidersCache: any = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30分钟,单位毫秒
|
||||
|
||||
// 获取 tts_providers.json 文件内容
|
||||
export async function fetchAndCacheProvidersLocal() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否有效
|
||||
if (ttsProvidersCache && (now - cacheTimestamp < CACHE_DURATION)) {
|
||||
console.log('从缓存中返回 tts_providers.json 数据');
|
||||
return ttsProvidersCache
|
||||
}
|
||||
|
||||
// 缓存无效或不存在,读取文件并更新缓存
|
||||
const ttsProvidersName = process.env.TTS_PROVIDERS_NAME;
|
||||
if (!ttsProvidersName) {
|
||||
throw new Error('TTS_PROVIDERS_NAME 环境变量未设置');
|
||||
}
|
||||
const configPath = path.join(process.cwd(), '..', 'config', ttsProvidersName);
|
||||
const configContent = await fs.readFile(configPath, 'utf-8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
// 更新缓存
|
||||
ttsProvidersCache = config;
|
||||
cacheTimestamp = now;
|
||||
console.log('重新加载并缓存 tts_providers.json 数据');
|
||||
|
||||
return ttsProvidersCache
|
||||
} catch (error) {
|
||||
console.error('Error reading tts_providers.json:', error);
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,62 @@ export async function deductUserPoints(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加用户积分。
|
||||
* @param userId 用户ID
|
||||
* @param pointsToAdd 要增加的积分数量
|
||||
* @param reasonCode 交易原因代码 (例如: "initial_bonus", "purchase")
|
||||
* @param description 交易描述 (可选)
|
||||
* @returns Promise<void>
|
||||
* @throws Error 如果操作失败
|
||||
*/
|
||||
export async function addPointsToUser(
|
||||
userId: string,
|
||||
pointsToAdd: number,
|
||||
reasonCode: string,
|
||||
description?: string
|
||||
): Promise<void> {
|
||||
|
||||
// if (pointsToAdd <= 0) {
|
||||
// throw new Error("增加积分数量必须大于0。");
|
||||
// }
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// 1. 获取用户当前积分
|
||||
const [account] = await tx
|
||||
.select({ totalPoints: schema.pointsAccounts.totalPoints })
|
||||
.from(schema.pointsAccounts)
|
||||
.where(eq(schema.pointsAccounts.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`用户 ${userId} 不存在积分账户。`);
|
||||
}
|
||||
|
||||
const currentPoints = account.totalPoints;
|
||||
const newPoints = currentPoints + pointsToAdd;
|
||||
|
||||
// 2. 记录积分交易流水
|
||||
await tx.insert(schema.pointsTransactions).values({
|
||||
userId: userId,
|
||||
pointsChange: pointsToAdd, // 增加为正数
|
||||
reasonCode: reasonCode,
|
||||
description: description,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 3. 更新积分账户
|
||||
await tx
|
||||
.update(schema.pointsAccounts)
|
||||
.set({
|
||||
totalPoints: newPoints,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.pointsAccounts.userId, userId));
|
||||
|
||||
console.log(`用户 ${userId} 积分成功增加 ${pointsToAdd},当前积分 ${newPoints}`);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 查询用户积分明细。
|
||||
* @param userId 用户ID
|
||||
@@ -176,4 +232,32 @@ export async function getUserPointsTransactions(
|
||||
console.error(`查询用户 ${userId} 积分明细失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 检查用户今天是否已签到。
|
||||
* @param userId 用户ID
|
||||
* @param reasonCode 交易原因代码 (例如: "sign_in")
|
||||
* @returns Promise<boolean> 如果今天已签到则返回 true,否则返回 false
|
||||
*/
|
||||
export async function hasUserSignedToday(userId: string, reasonCode: string): Promise<boolean> {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // 设置为今天开始时间
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1); // 设置为明天开始时间
|
||||
|
||||
const transactions = await db
|
||||
.select()
|
||||
.from(schema.pointsTransactions)
|
||||
.where(
|
||||
sql`${schema.pointsTransactions.userId} = ${userId} AND ${schema.pointsTransactions.reasonCode} = ${reasonCode} AND ${schema.pointsTransactions.createdAt} >= ${today.toISOString()} AND ${schema.pointsTransactions.createdAt} < ${tomorrow.toISOString()}`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return transactions.length > 0;
|
||||
} catch (error) {
|
||||
console.error(`检查用户 ${userId} 今日签到记录失败:`, error);
|
||||
throw error; // 抛出错误以便调用方处理
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user