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

@@ -227,7 +227,8 @@ def _parse_arguments():
parser.add_argument("--model", default="gpt-3.5-turbo", help="OpenAI model to use (default: gpt-3.5-turbo).")
parser.add_argument("--threads", type=int, default=1, help="Number of threads to use for audio generation (default: 1).")
parser.add_argument("--output-language", type=str, default=None, help="Language for the podcast overview and script (default: Chinese).")
parser.add_argument("--usetime", type=str, default=None, help="Specific time to be mentioned in the podcast script, e.g., '今天', '昨天'.")
parser.add_argument("--usetime", type=str, default=None, help="Specific time to be mentioned in the podcast script, e.g., 10 minutes, 1 hour.")
return parser.parse_args()
def _load_configuration():

View File

@@ -19,7 +19,7 @@
**2. Output Language**
* **{{outlang}}**.
* ** Make sure the language of the output content is {{outlang}} **.
</Additional Customizations>
<INSTRUCTIONS>
@@ -96,7 +96,8 @@
- Return summary in clean markdown format
- Do not include markdown code block tags (```markdown ```)
- Use standard markdown syntax for formatting (headers, lists, etc.)
- Use ### for main headings
- Use ## for main headings
- Use ### to indicate a categorical question
- Use #### for subheadings where appropriate
- Use bullet points (- item) for lists
- Ensure proper indentation and spacing

View File

@@ -1,4 +1,4 @@
* **Output Format:** No explanatory text{{outlang}}
* **Output Format:** No explanatory textMake sure the language of the output content is {{outlang}}
* **End Format:** Before concluding, review and summarize the previous speeches, which are concise, concise, powerful and thought-provoking.
<podcast_generation_system>
@@ -57,8 +57,16 @@ You are a master podcast scriptwriter, adept at transforming diverse input conte
6. **Length & Pacing:**
* **Target Duration:** Create a transcript that would result in approximately {{usetime}} of audio (around 800-1000 words total).
* **Balanced Speaking Turns:** Aim for a natural conversational flow among speakers rather than extended monologues by one person. Prioritize the most important information from the source content.
* **Target Duration & Word Count:** Create a transcript that would result in approximately {{usetime}} of audio. Use the following word count guidelines:
* "Under 5 minutes": Aim for 800-1000 words.
* "5-10 minutes": Aim for 1000-2000 words.
* "10-15 minutes": Aim for 2000-3000 words.
* **Content Coverage Mandate:** The primary goal is to ensure that **every distinct topic, key fact, or main idea** present in the `<source_content>` is mentioned or referenced in the final transcript. No major informational point should be completely omitted.
* **Prioritization Strategy:** While all topics must be covered, you must allocate speaking time and discussion depth according to their importance.
* **Key Topics:** Dedicate more dialogue, examples, and analysis from multiple hosts to the most central and significant points from the source material. These should form the core of the conversation.
* **Secondary Topics:** Less critical information or minor details should be handled more concisely. They can be introduced as quick facts by the "Expert" host, used as transitional statements by the moderator, or briefly acknowledged without extensive discussion. This ensures they are included without disrupting the flow or consuming disproportionate time.
7. **Copy & Replacement:**
If a hyphen connects English letters and numbers or letters on both sides, replace it with a space.

1
web/.gitignore vendored
View File

@@ -46,3 +46,4 @@ Thumbs.db
drizzle
.claude/
/.public/
/src/app/api/points/add-by-user

View File

@@ -0,0 +1,49 @@
// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
// 必须项,你的网站域名
siteUrl: process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000',
// (可选) 自动生成 robots.txt 文件,默认为 false
generateRobotsTxt: true,
// (可选) 自定义 robots.txt 的内容
robotsTxtOptions: {
policies: [
{
userAgent: '*',
allow: '/',
},
{
userAgent: 'Googlebot',
disallow: ['/private'],
},
],
// (可选) 在 robots.txt 中添加额外的 sitemap
// additionalSitemaps: [
// 'https://www.your-domain.com/server-sitemap.xml',
// ],
},
// (可选) 排除特定的路由
exclude: ['/api/*'],
// 这个函数会在构建时执行
// additionalPaths: async (config) => {
// // 示例:从外部 API 获取所有博客文章的 slug
// const response = await fetch('https://api.example.com/posts');
// const posts = await response.json(); // 假设返回 [{ slug: 'post-1', updatedAt: '2023-01-01' }, ...]
// // 将文章数据转换为 next-sitemap 需要的格式
// const paths = posts.map(post => ({
// loc: `/blog/${post.slug}`, // URL 路径
// changefreq: 'weekly',
// priority: 0.7,
// lastmod: new Date(post.updatedAt).toISOString(), // 最后修改时间
// }));
// // 返回一个 Promise解析为一个路径数组
// return paths;
// },
};

40
web/package-lock.json generated
View File

@@ -27,6 +27,7 @@
"globby": "^14.1.0",
"lucide-react": "^0.424.0",
"next": "^14.2.5",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.40",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -78,6 +79,12 @@
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz",
"integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="
},
"node_modules/@corex/deepmerge": {
"version": "4.0.43",
"resolved": "https://registry.npmjs.org/@corex/deepmerge/-/deepmerge-4.0.43.tgz",
"integrity": "sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==",
"license": "MIT"
},
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -7861,6 +7868,39 @@
}
}
},
"node_modules/next-sitemap": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/next-sitemap/-/next-sitemap-4.2.3.tgz",
"integrity": "sha512-vjdCxeDuWDzldhCnyFCQipw5bfpl4HmZA7uoo3GAaYGjGgfL4Cxb1CiztPuWGmS+auYs7/8OekRS8C2cjdAsjQ==",
"funding": [
{
"url": "https://github.com/iamvishnusankar/next-sitemap.git"
}
],
"license": "MIT",
"dependencies": {
"@corex/deepmerge": "^4.0.43",
"@next/env": "^13.4.3",
"fast-glob": "^3.2.12",
"minimist": "^1.2.8"
},
"bin": {
"next-sitemap": "bin/next-sitemap.mjs",
"next-sitemap-cjs": "bin/next-sitemap.cjs"
},
"engines": {
"node": ">=14.18"
},
"peerDependencies": {
"next": "*"
}
},
"node_modules/next-sitemap/node_modules/@next/env": {
"version": "13.5.11",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.11.tgz",
"integrity": "sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==",
"license": "MIT"
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "next-sitemap",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
@@ -28,8 +29,10 @@
"dotenv": "^17.2.1",
"drizzle-orm": "^0.44.4",
"framer-motion": "^11.3.8",
"globby": "^14.1.0",
"lucide-react": "^0.424.0",
"next": "^14.2.5",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.40",
"react": "^18.3.1",
"react-dom": "^18.3.1",

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