feat: 添加每日签到功能和sitemap生成

refactor: 优化TTS配置获取逻辑并提取为独立模块
fix: 修正新用户积分初始化环境变量名称
style: 更新播客生成页面UI和文案
docs: 修改提示词模板格式和内容
build: 添加next-sitemap依赖和配置文件
This commit is contained in:
hex2077
2025-08-21 23:03:02 +08:00
parent 043b0e39f8
commit f9db0215e0
17 changed files with 447 additions and 60 deletions

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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 });
}
}

View File

@@ -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,

View File

@@ -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 /> */}
{/* 推荐播客 - 水平滚动 */}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
);
};

View 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
}
}

View File

@@ -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; // 抛出错误以便调用方处理
}
}