diff --git a/messages/de-DE.json b/messages/de-DE.json index 4a36f53..2fed8f0 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -166,6 +166,9 @@ "resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden", "resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet", "resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.", + "verifyYourEmail": "E-Mail bestätigen", + "verificationEmailSent": "Bestätigungs-E-Mail gesendet", + "verificationEmailSentHint": "Wir haben eine Bestätigungs-E-Mail an {email} gesendet. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu bestätigen.", "checkYourEmail": "Überprüfen Sie Ihre E-Mail", "backToLogin": "Zurück zur Anmeldung", "resetPassword": "Passwort zurücksetzen", diff --git a/messages/en-US.json b/messages/en-US.json index 141b153..9bef781 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -157,6 +157,9 @@ "resetPasswordFailed": "Failed to send reset email", "resetPasswordEmailSent": "Reset email sent successfully", "resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.", + "verifyYourEmail": "Verify Your Email", + "verificationEmailSent": "Verification email sent", + "verificationEmailSentHint": "We've sent a verification email to {email}. Please click the link in the email to verify your account.", "checkYourEmail": "Check Your Email", "backToLogin": "Back to Login", "resetPassword": "Reset Password", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index c2434e2..346c005 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -166,6 +166,9 @@ "resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation", "resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès", "resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.", + "verifyYourEmail": "Vérifier votre e-mail", + "verificationEmailSent": "E-mail de vérification envoyé", + "verificationEmailSentHint": "Nous avons envoyé un e-mail de vérification à {email}. Veuillez cliquer sur le lien dans l'e-mail pour vérifier votre compte.", "checkYourEmail": "Vérifiez votre e-mail", "backToLogin": "Retour à la connexion", "resetPassword": "Réinitialiser le mot de passe", diff --git a/messages/it-IT.json b/messages/it-IT.json index 7128995..6846b74 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -166,6 +166,9 @@ "resetPasswordFailed": "Impossibile inviare email di reset", "resetPasswordEmailSent": "Email di reset inviata con successo", "resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.", + "verifyYourEmail": "Verifica la tua Email", + "verificationEmailSent": "Email di verifica inviata", + "verificationEmailSentHint": "Abbiamo inviato un'email di verifica a {email}. Clicca sul link nell'email per verificare il tuo account.", "checkYourEmail": "Controlla la tua Email", "backToLogin": "Torna al Login", "resetPassword": "Reimposta Password", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 7dd24b5..c783e7a 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -157,6 +157,9 @@ "resetPasswordFailed": "リセットメールの送信に失敗しました", "resetPasswordEmailSent": "リセットメールを送信しました", "resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。", + "verifyYourEmail": "メールアドレスを確認", + "verificationEmailSent": "確認メールを送信しました", + "verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。", "checkYourEmail": "メールをご確認ください", "backToLogin": "ログインに戻る", "resetPassword": "パスワードをリセット", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f7e127b..e49a313 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -166,6 +166,9 @@ "resetPasswordFailed": "재설정 이메일 전송 실패", "resetPasswordEmailSent": "재설정 이메일이 전송되었습니다", "resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.", + "verifyYourEmail": "이메일 인증", + "verificationEmailSent": "인증 이메일이 전송되었습니다", + "verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.", "checkYourEmail": "이메일을 확인하세요", "backToLogin": "로그인으로 돌아가기", "resetPassword": "비밀번호 재설정", diff --git a/messages/ug-CN.json b/messages/ug-CN.json index 521f8c2..57ab141 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -166,6 +166,9 @@ "resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى", "resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى", "resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.", + "verifyYourEmail": "ئېلخەتنى دەلىللەش", + "verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى", + "verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.", "checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ", "backToLogin": "كىرىشكە قايتىش", "resetPassword": "پارولنى ئەسلىگە قايتۇرۇش", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 2cb21e3..c2c342b 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -157,6 +157,9 @@ "resetPasswordFailed": "发送重置邮件失败", "resetPasswordEmailSent": "重置邮件已发送", "resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。", + "verifyYourEmail": "验证您的邮箱", + "verificationEmailSent": "验证邮件已发送", + "verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。", "checkYourEmail": "请查收邮件", "backToLogin": "返回登录", "resetPassword": "重置密码", diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 2a6397e..5151a8e 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { authClient } from "@/lib/auth-client"; import Link from "next/link"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; @@ -9,6 +8,7 @@ import { Card, CardBody } from "@/design-system/base/card"; import { Input } from "@/design-system/base/input"; import { PrimaryButton } from "@/design-system/base/button"; import { VStack } from "@/design-system/layout/stack"; +import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action"; export default function ForgotPasswordPage() { const t = useTranslations("auth"); @@ -23,16 +23,13 @@ export default function ForgotPasswordPage() { } setLoading(true); - const { error } = await authClient.requestPasswordReset({ - email, - redirectTo: "/reset-password", - }); + const result = await actionRequestPasswordReset({ email }); - if (error) { - toast.error(error.message ?? t("resetPasswordFailed")); + if (!result.success) { + toast.error(result.message); } else { setSent(true); - toast.success(t("resetPasswordEmailSent")); + toast.success(result.message); } setLoading(false); }; diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 9f0f470..081d091 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -18,6 +18,7 @@ export default function SignUpPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); + const [verificationSent, setVerificationSent] = useState(false); const searchParams = useSearchParams(); const redirectTo = searchParams.get("redirect"); @@ -26,10 +27,10 @@ export default function SignUpPage() { const router = useRouter(); useEffect(() => { - if (!isPending && session?.user?.username && !redirectTo) { + if (!isPending && session?.user?.username && !redirectTo && !verificationSent) { router.push("/folders"); } - }, [session, isPending, router, redirectTo]); + }, [session, isPending, router, redirectTo, verificationSent]); const handleSignUp = async () => { if (!username || !email || !password) { @@ -49,12 +50,38 @@ export default function SignUpPage() { toast.error(error.message ?? t("signUpFailed")); return; } - router.push(redirectTo ?? "/folders"); + setVerificationSent(true); + toast.success(t("verificationEmailSent")); } finally { setLoading(false); } }; + if (verificationSent) { + return ( +
+ + + +

+ {t("verifyYourEmail")} +

+

+ {t("verificationEmailSentHint", { email })} +

+ + {t("backToLogin")} + +
+
+
+
+ ); + } + return (
diff --git a/src/modules/auth/auth-action.ts b/src/modules/auth/auth-action.ts index b491209..02a91f8 100644 --- a/src/modules/auth/auth-action.ts +++ b/src/modules/auth/auth-action.ts @@ -5,7 +5,6 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { ValidateError } from "@/lib/errors"; import { createLogger } from "@/lib/logger"; -import { prisma } from "@/lib/db"; import { ActionInputGetUserProfileByUsername, ActionInputSignIn, @@ -20,7 +19,8 @@ import { import { serviceGetUserProfileByUsername, serviceSignIn, - serviceSignUp + serviceSignUp, + serviceDeleteAccount } from "./auth-service"; // Re-export types for use in components @@ -194,75 +194,11 @@ export async function actionDeleteAccount(): Promise return { success: false, message: "Unauthorized" }; } - const userId = session.user.id; + const result = await serviceDeleteAccount({ userId: session.user.id }); - await prisma.$transaction(async (tx) => { - // Delete in correct order to avoid foreign key constraints - // 1. Revlogs (depend on cards) - await tx.revlog.deleteMany({ - where: { card: { note: { userId } } } - }); - - // 2. Cards (depend on notes and decks) - await tx.card.deleteMany({ - where: { note: { userId } } - }); - - // 3. Notes (depend on note types) - await tx.note.deleteMany({ - where: { userId } - }); - - // 4. Note types - await tx.noteType.deleteMany({ - where: { userId } - }); - - // 5. Deck favorites - await tx.deckFavorite.deleteMany({ - where: { userId } - }); - - // 6. Decks - await tx.deck.deleteMany({ - where: { userId } - }); - - // 7. Follows (both as follower and following) - await tx.follow.deleteMany({ - where: { - OR: [ - { followerId: userId }, - { followingId: userId } - ] - } - }); - - // 8. Dictionary lookups - await tx.dictionaryLookUp.deleteMany({ - where: { userId } - }); - - // 9. Translation history - await tx.translationHistory.deleteMany({ - where: { userId } - }); - - // 10. Sessions - await tx.session.deleteMany({ - where: { userId } - }); - - // 11. Accounts - await tx.account.deleteMany({ - where: { userId } - }); - - // 12. Finally, delete the user - await tx.user.delete({ - where: { id: userId } - }); - }); + if (!result.success) { + return { success: false, message: "Failed to delete account" }; + } return { success: true, message: "Account deleted successfully" }; } catch (e) { diff --git a/src/modules/auth/auth-repository-dto.ts b/src/modules/auth/auth-repository-dto.ts index 37bdb99..495297e 100644 --- a/src/modules/auth/auth-repository-dto.ts +++ b/src/modules/auth/auth-repository-dto.ts @@ -25,3 +25,12 @@ export type RepoInputFindUserById = { export type RepoInputFindUserByEmail = { email: string; }; + +// Delete user cascade types +export type RepoInputDeleteUserCascade = { + userId: string; +}; + +export type RepoOutputDeleteUserCascade = { + success: boolean; +}; diff --git a/src/modules/auth/auth-repository.ts b/src/modules/auth/auth-repository.ts index 3f9031e..95a7154 100644 --- a/src/modules/auth/auth-repository.ts +++ b/src/modules/auth/auth-repository.ts @@ -1,11 +1,16 @@ import { prisma } from "@/lib/db"; +import { createLogger } from "@/lib/logger"; import { RepoInputFindUserByEmail, RepoInputFindUserById, RepoInputFindUserByUsername, - RepoOutputUserProfile + RepoInputDeleteUserCascade, + RepoOutputUserProfile, + RepoOutputDeleteUserCascade } from "./auth-repository-dto"; +const log = createLogger("auth-repository"); + export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise { const user = await prisma.user.findUnique({ where: { username: dto.username }, @@ -62,3 +67,68 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis return user; } + +export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Promise { + const { userId } = dto; + + log.info("Starting cascade delete for user", { userId }); + + await prisma.$transaction(async (tx) => { + await tx.revlog.deleteMany({ + where: { card: { note: { userId } } } + }); + + await tx.card.deleteMany({ + where: { note: { userId } } + }); + + await tx.note.deleteMany({ + where: { userId } + }); + + await tx.noteType.deleteMany({ + where: { userId } + }); + + await tx.deckFavorite.deleteMany({ + where: { userId } + }); + + await tx.deck.deleteMany({ + where: { userId } + }); + + await tx.follow.deleteMany({ + where: { + OR: [ + { followerId: userId }, + { followingId: userId } + ] + } + }); + + await tx.dictionaryLookUp.deleteMany({ + where: { userId } + }); + + await tx.translationHistory.deleteMany({ + where: { userId } + }); + + await tx.session.deleteMany({ + where: { userId } + }); + + await tx.account.deleteMany({ + where: { userId } + }); + + await tx.user.delete({ + where: { id: userId } + }); + }); + + log.info("Cascade delete completed for user", { userId }); + + return { success: true }; +} diff --git a/src/modules/auth/auth-service-dto.ts b/src/modules/auth/auth-service-dto.ts index 6159b94..543a4f8 100644 --- a/src/modules/auth/auth-service-dto.ts +++ b/src/modules/auth/auth-service-dto.ts @@ -38,3 +38,11 @@ export type ServiceOutputUserProfile = { createdAt: Date; updatedAt: Date; } | null; + +export type ServiceInputDeleteAccount = { + userId: string; +}; + +export type ServiceOutputDeleteAccount = { + success: boolean; +}; diff --git a/src/modules/auth/auth-service.ts b/src/modules/auth/auth-service.ts index c971e3d..cc23151 100644 --- a/src/modules/auth/auth-service.ts +++ b/src/modules/auth/auth-service.ts @@ -1,15 +1,18 @@ import { auth } from "@/auth"; import { repoFindUserByUsername, - repoFindUserById + repoFindUserById, + repoDeleteUserCascade } from "./auth-repository"; import { ServiceInputGetUserProfileByUsername, ServiceInputGetUserProfileById, ServiceInputSignIn, ServiceInputSignUp, + ServiceInputDeleteAccount, ServiceOutputAuth, - ServiceOutputUserProfile + ServiceOutputUserProfile, + ServiceOutputDeleteAccount } from "./auth-service-dto"; /** @@ -92,3 +95,7 @@ export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserPr export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise { return await repoFindUserById(dto); } + +export async function serviceDeleteAccount(dto: ServiceInputDeleteAccount): Promise { + return await repoDeleteUserCascade({ userId: dto.userId }); +} diff --git a/src/modules/auth/forgot-password-action-dto.ts b/src/modules/auth/forgot-password-action-dto.ts new file mode 100644 index 0000000..9328a47 --- /dev/null +++ b/src/modules/auth/forgot-password-action-dto.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const schemaActionInputForgotPassword = z.object({ + email: z.string().email("请输入有效的邮箱地址"), +}); + +export type ActionInputForgotPassword = z.infer; + +export interface ActionOutputForgotPassword { + success: boolean; + message: string; +} diff --git a/src/modules/auth/forgot-password-action.ts b/src/modules/auth/forgot-password-action.ts new file mode 100644 index 0000000..6c9ee8f --- /dev/null +++ b/src/modules/auth/forgot-password-action.ts @@ -0,0 +1,35 @@ +"use server"; + +import { createLogger } from "@/lib/logger"; +import { validate } from "@/utils/validate"; +import { ValidateError } from "@/lib/errors"; +import { + schemaActionInputForgotPassword, + type ActionInputForgotPassword, + type ActionOutputForgotPassword, +} from "./forgot-password-action-dto"; +import { serviceRequestPasswordReset } from "./forgot-password-service"; + +const log = createLogger("forgot-password-action"); + +export async function actionRequestPasswordReset( + input: unknown +): Promise { + try { + const dto = validate(input, schemaActionInputForgotPassword) as ActionInputForgotPassword; + + return await serviceRequestPasswordReset({ email: dto.email }); + } catch (e) { + if (e instanceof ValidateError) { + return { + success: false, + message: e.message, + }; + } + log.error("Password reset request failed", { error: e }); + return { + success: false, + message: "发送重置邮件失败,请稍后重试", + }; + } +} diff --git a/src/modules/auth/forgot-password-repository-dto.ts b/src/modules/auth/forgot-password-repository-dto.ts new file mode 100644 index 0000000..27098a6 --- /dev/null +++ b/src/modules/auth/forgot-password-repository-dto.ts @@ -0,0 +1,7 @@ +export type RepoInputFindUserByEmail = { + email: string; +}; + +export type RepoOutputFindUserByEmail = { + id: string; +} | null; diff --git a/src/modules/auth/forgot-password-repository.ts b/src/modules/auth/forgot-password-repository.ts new file mode 100644 index 0000000..6241e11 --- /dev/null +++ b/src/modules/auth/forgot-password-repository.ts @@ -0,0 +1,19 @@ +import { prisma } from "@/lib/db"; +import { createLogger } from "@/lib/logger"; +import { + RepoInputFindUserByEmail, + RepoOutputFindUserByEmail +} from "./forgot-password-repository-dto"; + +const log = createLogger("forgot-password-repository"); + +export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise { + log.debug("Finding user by email", { email: dto.email }); + + const user = await prisma.user.findUnique({ + where: { email: dto.email }, + select: { id: true }, + }); + + return user; +} diff --git a/src/modules/auth/forgot-password-service-dto.ts b/src/modules/auth/forgot-password-service-dto.ts new file mode 100644 index 0000000..4d198c0 --- /dev/null +++ b/src/modules/auth/forgot-password-service-dto.ts @@ -0,0 +1,8 @@ +export type ServiceInputRequestPasswordReset = { + email: string; +}; + +export type ServiceOutputRequestPasswordReset = { + success: boolean; + message: string; +}; diff --git a/src/modules/auth/forgot-password-service.ts b/src/modules/auth/forgot-password-service.ts new file mode 100644 index 0000000..948bb1f --- /dev/null +++ b/src/modules/auth/forgot-password-service.ts @@ -0,0 +1,34 @@ +import { auth } from "@/auth"; +import { createLogger } from "@/lib/logger"; +import { repoFindUserByEmail } from "./forgot-password-repository"; +import { + ServiceInputRequestPasswordReset, + ServiceOutputRequestPasswordReset +} from "./forgot-password-service-dto"; + +const log = createLogger("forgot-password-service"); + +export async function serviceRequestPasswordReset(dto: ServiceInputRequestPasswordReset): Promise { + log.info("Processing password reset request", { email: dto.email }); + + const user = await repoFindUserByEmail({ email: dto.email }); + + if (!user) { + return { + success: false, + message: "该邮箱未注册", + }; + } + + await auth.api.requestPasswordReset({ + body: { + email: dto.email, + redirectTo: "/reset-password", + }, + }); + + return { + success: true, + message: "重置密码邮件已发送,请检查您的邮箱", + }; +} diff --git a/src/modules/card/card-action.ts b/src/modules/card/card-action.ts index 6fcd9e0..faef72c 100644 --- a/src/modules/card/card-action.ts +++ b/src/modules/card/card-action.ts @@ -42,8 +42,8 @@ import { serviceGetCardStats, serviceDeleteCard, serviceGetCardByIdWithNote, + serviceCheckCardOwnership, } from "./card-service"; -import { repoGetCardDeckOwnerId } from "./card-repository"; import { CardQueue } from "../../../generated/prisma/enums"; const log = createLogger("card-action"); @@ -161,8 +161,7 @@ async function checkCardOwnership(cardId: bigint): Promise { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) return false; - const ownerId = await repoGetCardDeckOwnerId(cardId); - return ownerId === session.user.id; + return serviceCheckCardOwnership({ cardId, userId: session.user.id }); } async function getCurrentUserId(): Promise { diff --git a/src/modules/card/card-service-dto.ts b/src/modules/card/card-service-dto.ts index 78f76bc..d3ac33b 100644 --- a/src/modules/card/card-service-dto.ts +++ b/src/modules/card/card-service-dto.ts @@ -34,6 +34,13 @@ export interface ServiceInputGetCardStats { deckId: number; } +export interface ServiceInputCheckCardOwnership { + cardId: bigint; + userId: string; +} + +export type ServiceOutputCheckCardOwnership = boolean; + export type ServiceOutputCard = { id: bigint; noteId: bigint; @@ -94,11 +101,17 @@ export type ServiceOutputReviewResult = { export const SM2_CONFIG = { LEARNING_STEPS: [1, 10], + RELEARNING_STEPS: [10], GRADUATING_INTERVAL_GOOD: 1, GRADUATING_INTERVAL_EASY: 4, EASY_INTERVAL: 4, MINIMUM_FACTOR: 1300, DEFAULT_FACTOR: 2500, + MAXIMUM_INTERVAL: 36500, + EASY_BONUS: 1.3, + HARD_INTERVAL: 1.2, + NEW_INTERVAL: 0.0, + INTERVAL_MODIFIER: 1.0, FACTOR_ADJUSTMENTS: { 1: -200, 2: -150, diff --git a/src/modules/card/card-service.ts b/src/modules/card/card-service.ts index 834100f..3792cfa 100644 --- a/src/modules/card/card-service.ts +++ b/src/modules/card/card-service.ts @@ -11,6 +11,7 @@ import { repoGetCardStats, repoDeleteCard, repoGetCardsByNoteId, + repoGetCardDeckOwnerId, } from "./card-repository"; import { RepoInputUpdateCard, @@ -23,11 +24,13 @@ import { ServiceInputGetNewCards, ServiceInputGetCardsByDeckId, ServiceInputGetCardStats, + ServiceInputCheckCardOwnership, ServiceOutputCard, ServiceOutputCardWithNote, ServiceOutputCardStats, ServiceOutputScheduledCard, ServiceOutputReviewResult, + ServiceOutputCheckCardOwnership, ReviewEase, SM2_CONFIG, } from "./card-service-dto"; @@ -50,7 +53,11 @@ function calculateNextReviewTime(intervalDays: number): Date { return new Date(now + intervalDays * 86400 * 1000); } -function scheduleNewCard(ease: ReviewEase, factor: number): { +function clampInterval(interval: number): number { + return Math.min(Math.max(1, interval), SM2_CONFIG.MAXIMUM_INTERVAL); +} + +function scheduleNewCard(ease: ReviewEase, currentFactor: number): { type: CardType; queue: CardQueue; ivl: number; @@ -63,21 +70,53 @@ function scheduleNewCard(ease: ReviewEase, factor: number): { queue: CardQueue.LEARNING, ivl: 0, due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]), + newFactor: currentFactor, }; } - const ivl = SM2_CONFIG.INITIAL_INTERVALS[ease]; + if (ease === 2) { + if (SM2_CONFIG.LEARNING_STEPS.length >= 2) { + const avgStep = (SM2_CONFIG.LEARNING_STEPS[0] + SM2_CONFIG.LEARNING_STEPS[1]) / 2; + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + avgStep * 60, + newFactor: currentFactor, + }; + } + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, + newFactor: currentFactor, + }; + } + + if (ease === 3) { + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, + newFactor: currentFactor, + }; + } + + const ivl = SM2_CONFIG.EASY_INTERVAL; + const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]; + return { type: CardType.REVIEW, queue: CardQueue.REVIEW, ivl, due: calculateDueDate(ivl), - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]), + newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor), }; } -function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): { +function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: number): { type: CardType; queue: CardQueue; ivl: number; @@ -85,45 +124,88 @@ function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): { newFactor: number; newLeft: number; } { + const steps = SM2_CONFIG.LEARNING_STEPS; + const totalSteps = steps.length; + if (ease === 1) { return { type: CardType.LEARNING, queue: CardQueue.LEARNING, ivl: 0, - due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]), - newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length, + due: Math.floor(Date.now() / 1000) + steps[0] * 60, + newFactor: currentFactor, + newLeft: totalSteps * 1000, }; } const stepIndex = Math.floor(left % 1000); - if (ease === 2 && stepIndex < SM2_CONFIG.LEARNING_STEPS.length - 1) { - const nextStep = stepIndex + 1; + + if (ease === 2) { + if (stepIndex === 0 && steps.length >= 2) { + const avgStep = (steps[0] + steps[1]) / 2; + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + avgStep * 60, + newFactor: currentFactor, + newLeft: left, + }; + } + if (stepIndex < steps.length - 1) { + const nextStep = stepIndex + 1; + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60, + newFactor: currentFactor, + newLeft: nextStep * 1000 + (totalSteps - nextStep), + }; + } + } + + if (ease === 3) { + if (stepIndex < steps.length - 1) { + const nextStep = stepIndex + 1; + return { + type: CardType.LEARNING, + queue: CardQueue.LEARNING, + ivl: 0, + due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60, + newFactor: currentFactor, + newLeft: nextStep * 1000 + (totalSteps - nextStep), + }; + } + + const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD; return { - type: CardType.LEARNING, - queue: CardQueue.LEARNING, - ivl: 0, - due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60, - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]), - newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep), + type: CardType.REVIEW, + queue: CardQueue.REVIEW, + ivl, + due: calculateDueDate(ivl), + newFactor: SM2_CONFIG.DEFAULT_FACTOR, + newLeft: 0, }; } - const ivl = ease === 4 ? SM2_CONFIG.GRADUATING_INTERVAL_EASY : SM2_CONFIG.GRADUATING_INTERVAL_GOOD; + const ivl = SM2_CONFIG.GRADUATING_INTERVAL_EASY; + const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]; + return { type: CardType.REVIEW, queue: CardQueue.REVIEW, ivl, due: calculateDueDate(ivl), - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]), + newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor), newLeft: 0, }; } function scheduleReviewCard( ease: ReviewEase, - ivl: number, - factor: number, + currentIvl: number, + currentFactor: number, lapses: number, ): { type: CardType; @@ -134,27 +216,34 @@ function scheduleReviewCard( newLapses: number; } { if (ease === 1) { + const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]); + const newIvl = Math.max(1, Math.floor(currentIvl * SM2_CONFIG.NEW_INTERVAL)); return { type: CardType.RELEARNING, queue: CardQueue.LEARNING, - ivl: 0, - due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, - newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]), + ivl: newIvl, + due: Math.floor(Date.now() / 1000) + SM2_CONFIG.RELEARNING_STEPS[0] * 60, + newFactor, newLapses: lapses + 1, }; } - const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]); - const factorMultiplier = newFactor / 1000; - let newIvl = Math.floor(ivl * factorMultiplier); + let newFactor: number; + let newIvl: number; if (ease === 2) { - newIvl = Math.max(1, Math.floor(newIvl * 1.2)); - } else if (ease === 4) { - newIvl = Math.floor(newIvl * 1.3); + newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]); + newIvl = Math.floor(currentIvl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER); + } else if (ease === 3) { + newFactor = currentFactor; + newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER); + } else { + newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER); + newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]); } - newIvl = Math.max(1, newIvl); + newIvl = clampInterval(newIvl); + newIvl = Math.max(currentIvl + 1, newIvl); return { type: CardType.REVIEW, @@ -237,7 +326,9 @@ export async function serviceAnswerCard( due: result.due, factor: result.newFactor, reps: card.reps + 1, - left: result.type === CardType.LEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0, + left: result.type === CardType.LEARNING + ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + : 0, mod: Math.floor(Date.now() / 1000), }; scheduled = { @@ -284,7 +375,9 @@ export async function serviceAnswerCard( factor: result.newFactor, reps: card.reps + 1, lapses: result.newLapses, - left: result.type === CardType.RELEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0, + left: result.type === CardType.RELEARNING + ? SM2_CONFIG.RELEARNING_STEPS.length * 1000 + : 0, mod: Math.floor(Date.now() / 1000), }; scheduled = { @@ -382,3 +475,11 @@ export async function serviceDeleteCard(cardId: bigint): Promise { log.info("Deleting card", { cardId: cardId.toString() }); await repoDeleteCard(cardId); } + +export async function serviceCheckCardOwnership( + input: ServiceInputCheckCardOwnership, +): Promise { + log.debug("Checking card ownership", { cardId: input.cardId.toString() }); + const ownerId = await repoGetCardDeckOwnerId(input.cardId); + return ownerId === input.userId; +} diff --git a/src/modules/deck/deck-service.ts b/src/modules/deck/deck-service.ts index a3a276a..258401d 100644 --- a/src/modules/deck/deck-service.ts +++ b/src/modules/deck/deck-service.ts @@ -1,5 +1,3 @@ -"use server"; - import { createLogger } from "@/lib/logger"; import { ServiceInputCreateDeck, diff --git a/src/modules/dictionary/dictionary-repository-dto.ts b/src/modules/dictionary/dictionary-repository-dto.ts index 9536a42..cbc9cae 100644 --- a/src/modules/dictionary/dictionary-repository-dto.ts +++ b/src/modules/dictionary/dictionary-repository-dto.ts @@ -1,5 +1,3 @@ -import { TSharedItem } from "@/shared/dictionary-type"; - export type RepoInputCreateDictionaryLookUp = { userId?: string; text: string; @@ -8,7 +6,29 @@ export type RepoInputCreateDictionaryLookUp = { dictionaryItemId?: number; }; -export type RepoOutputSelectLastLookUpResult = TSharedItem & {id: number} | null; +export type RepoOutputSelectLastLookUpResultEntry = { + id: number; + itemId: number; + ipa: string | null; + definition: string; + partOfSpeech: string | null; + example: string; + createdAt: Date; + updatedAt: Date; +}; + +export type RepoOutputSelectLastLookUpResultItem = { + id: number; + frequency: number; + standardForm: string; + queryLang: string; + definitionLang: string; + createdAt: Date; + updatedAt: Date; + entries: RepoOutputSelectLastLookUpResultEntry[]; +}; + +export type RepoOutputSelectLastLookUpResult = RepoOutputSelectLastLookUpResultItem | null; export type RepoInputCreateDictionaryItem = { standardForm: string; diff --git a/src/modules/dictionary/dictionary-repository.ts b/src/modules/dictionary/dictionary-repository.ts index 7d76735..b8c5489 100644 --- a/src/modules/dictionary/dictionary-repository.ts +++ b/src/modules/dictionary/dictionary-repository.ts @@ -1,6 +1,5 @@ import { stringNormalize } from "@/utils/string"; import { - RepoInputCreateDictionaryEntry, RepoInputCreateDictionaryEntryWithoutItemId, RepoInputCreateDictionaryItem, RepoInputCreateDictionaryLookUp, @@ -30,22 +29,12 @@ export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpR createdAt: 'desc' } }); - if (result && result.dictionaryItem) { - const item = result.dictionaryItem; - return { - id: item.id, - standardForm: item.standardForm, - entries: item.entries.filter(v => !!v).map(v => { - return { - ipa: v.ipa || undefined, - definition: v.definition, - partOfSpeech: v.partOfSpeech || undefined, - example: v.example - }; - }) - }; + + if (!result?.dictionaryItem) { + return null; } - return null; + + return result.dictionaryItem; } export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) { diff --git a/src/modules/dictionary/dictionary-service.ts b/src/modules/dictionary/dictionary-service.ts index 1cfe232..82ed9cf 100644 --- a/src/modules/dictionary/dictionary-service.ts +++ b/src/modules/dictionary/dictionary-service.ts @@ -2,9 +2,23 @@ import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator" import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository"; import { ServiceInputLookUp } from "./dictionary-service-dto"; import { createLogger } from "@/lib/logger"; +import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto"; const log = createLogger("dictionary-service"); +function transformRawItemToSharedItem(rawItem: RepoOutputSelectLastLookUpResultItem) { + return { + id: rawItem.id, + standardForm: rawItem.standardForm, + entries: rawItem.entries.map(entry => ({ + ipa: entry.ipa ?? undefined, + definition: entry.definition, + partOfSpeech: entry.partOfSpeech ?? undefined, + example: entry.example + })) + }; +} + export const serviceLookUp = async (dto: ServiceInputLookUp) => { const { text, @@ -27,7 +41,6 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => { definitionLang ); - // 使用事务确保数据一致性 repoCreateLookUpWithItemAndEntries( { standardForm: response.standardForm, @@ -47,18 +60,20 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => { return response; } else { + const transformedResult = transformRawItemToSharedItem(lastLookUpResult); + repoCreateLookUp({ userId: userId, text: text, queryLang: queryLang, definitionLang: definitionLang, - dictionaryItemId: lastLookUpResult.id + dictionaryItemId: transformedResult.id }).catch(error => { log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) }); }); return { - standardForm: lastLookUpResult.standardForm, - entries: lastLookUpResult.entries + standardForm: transformedResult.standardForm, + entries: transformedResult.entries }; } }; \ No newline at end of file diff --git a/src/modules/ocr/ocr-action.ts b/src/modules/ocr/ocr-action.ts index cdcb77f..0200580 100644 --- a/src/modules/ocr/ocr-action.ts +++ b/src/modules/ocr/ocr-action.ts @@ -1,5 +1,7 @@ "use server"; +import { headers } from "next/headers"; +import { auth } from "@/auth"; import { validate } from "@/utils/validate"; import { ValidateError } from "@/lib/errors"; import { createLogger } from "@/lib/logger"; @@ -13,8 +15,17 @@ export async function actionProcessOCR( input: unknown ): Promise { try { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + log.warn("Unauthorized OCR attempt"); + return { success: false, message: "Unauthorized" }; + } + const validatedInput = validate(input, schemaActionInputProcessOCR); - return serviceProcessOCR(validatedInput); + return serviceProcessOCR({ + ...validatedInput, + userId: session.user.id, + }); } catch (e) { if (e instanceof ValidateError) { return { success: false, message: e.message }; diff --git a/src/modules/ocr/ocr-service-dto.ts b/src/modules/ocr/ocr-service-dto.ts index fd5e41e..9e6ba4e 100644 --- a/src/modules/ocr/ocr-service-dto.ts +++ b/src/modules/ocr/ocr-service-dto.ts @@ -5,6 +5,7 @@ export const schemaServiceInputProcessOCR = z.object({ deckId: z.number().int().positive("Deck ID must be positive"), sourceLanguage: z.string().optional(), targetLanguage: z.string().optional(), + userId: z.string().min(1, "User ID is required"), }); export type ServiceInputProcessOCR = z.infer; diff --git a/src/modules/ocr/ocr-service.ts b/src/modules/ocr/ocr-service.ts index fb1adfc..6815dee 100644 --- a/src/modules/ocr/ocr-service.ts +++ b/src/modules/ocr/ocr-service.ts @@ -1,12 +1,8 @@ -"use server"; - import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator"; -import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository"; -import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository"; -import { repoCreateCard } from "@/modules/card/card-repository"; -import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository"; -import { auth } from "@/auth"; -import { headers } from "next/headers"; +import { serviceCheckOwnership } from "@/modules/deck/deck-service"; +import { serviceCreateNote } from "@/modules/note/note-service"; +import { serviceCreateCard } from "@/modules/card/card-service"; +import { serviceGetNoteTypesByUserId, serviceCreateNoteType } from "@/modules/note-type/note-type-service"; import { createLogger } from "@/lib/logger"; import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto"; import { NoteKind } from "../../../generated/prisma/enums"; @@ -16,7 +12,7 @@ const log = createLogger("ocr-service"); const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)"; async function getOrCreateVocabularyNoteType(userId: string): Promise { - const existingTypes = await repoGetNoteTypesByUserId({ userId }); + const existingTypes = await serviceGetNoteTypesByUserId({ userId }); const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME); if (existing) { @@ -47,7 +43,7 @@ async function getOrCreateVocabularyNoteType(userId: string): Promise { const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }"; - const noteTypeId = await repoCreateNoteType({ + const noteTypeId = await serviceCreateNoteType({ name: VOCABULARY_NOTE_TYPE_NAME, kind: NoteKind.STANDARD, css, @@ -63,19 +59,17 @@ async function getOrCreateVocabularyNoteType(userId: string): Promise { export async function serviceProcessOCR( input: ServiceInputProcessOCR ): Promise { - log.info("Processing OCR request", { deckId: input.deckId }); + log.info("Processing OCR request", { deckId: input.deckId, userId: input.userId }); - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user?.id) { - log.warn("Unauthorized OCR attempt"); - return { success: false, message: "Unauthorized" }; - } - - const deckOwner = await repoGetUserIdByDeckId(input.deckId); - if (deckOwner !== session.user.id) { + const isOwner = await serviceCheckOwnership({ + deckId: input.deckId, + userId: input.userId + }); + + if (!isOwner) { log.warn("Deck ownership mismatch", { deckId: input.deckId, - userId: session.user.id + userId: input.userId }); return { success: false, @@ -110,33 +104,28 @@ export async function serviceProcessOCR( const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown"; const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown"; - const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id); + const noteTypeId = await getOrCreateVocabularyNoteType(input.userId); let pairsCreated = 0; for (const pair of ocrResult.pairs) { try { - const now = Date.now(); - const noteId = await repoCreateNote({ + const { id: noteId } = await serviceCreateNote({ noteTypeId, - userId: session.user.id, + userId: input.userId, fields: [pair.word, pair.definition, sourceLanguage, targetLanguage], tags: ["ocr"], }); - await repoCreateCard({ - id: BigInt(now + pairsCreated), + await serviceCreateCard({ noteId, deckId: input.deckId, ord: 0, - due: pairsCreated + 1, }); - await repoCreateCard({ - id: BigInt(now + pairsCreated + 10000), + await serviceCreateCard({ noteId, deckId: input.deckId, ord: 1, - due: pairsCreated + 1, }); pairsCreated++; diff --git a/src/modules/translator/translator-action.ts b/src/modules/translator/translator-action.ts index f4799a7..a9cd218 100644 --- a/src/modules/translator/translator-action.ts +++ b/src/modules/translator/translator-action.ts @@ -7,8 +7,7 @@ import { } from "./translator-action-dto"; import { ValidateError } from "@/lib/errors"; import { createLogger } from "@/lib/logger"; -import { serviceTranslateText } from "./translator-service"; -import { getAnswer } from "@/lib/bigmodel/llm"; +import { serviceTranslateText, serviceGenIPA, serviceGenLanguage } from "./translator-service"; const log = createLogger("translator-action"); @@ -40,67 +39,12 @@ export const actionTranslateText = async ( * @deprecated 保留此函数以支持旧代码(text-speaker 功能) */ export const genIPA = async (text: string) => { - return ( - "[" + - ( - await getAnswer( - ` -${text} - -请生成以上文本的严式国际音标 -然后直接发给我 -不要附带任何说明 -不要擅自增减符号 -不许用"/"或者"[]"包裹 -`.trim(), - ) - ) - .replaceAll("[", "") - .replaceAll("]", "") + - "]" - ); + return serviceGenIPA({ text }); }; /** * @deprecated 保留此函数以支持旧代码(text-speaker 功能) */ export const genLanguage = async (text: string) => { - const language = await getAnswer([ - { - role: "system", - content: ` -你是一个语言检测工具。请识别文本的语言并返回语言名称。 - -返回语言的标准英文名称,例如: -- 中文: Chinese -- 英语: English -- 日语: Japanese -- 韩语: Korean -- 法语: French -- 德语: German -- 意大利语: Italian -- 葡萄牙语: Portuguese -- 西班牙语: Spanish -- 俄语: Russian -- 阿拉伯语: Arabic -- 印地语: Hindi -- 泰语: Thai -- 越南语: Vietnamese -- 等等... - -如果无法识别语言,返回 "Unknown" - -规则: -1. 只返回语言的标准英文名称 -2. 首字母大写,其余小写 -3. 不要附带任何说明 -4. 不要擅自增减符号 - `.trim() - }, - { - role: "user", - content: `${text}` - } - ]); - return language.trim(); + return serviceGenLanguage({ text }); }; diff --git a/src/modules/translator/translator-service-dto.ts b/src/modules/translator/translator-service-dto.ts index e85a6bc..ffd4076 100644 --- a/src/modules/translator/translator-service-dto.ts +++ b/src/modules/translator/translator-service-dto.ts @@ -10,3 +10,17 @@ export type ServiceInputTranslateText = { }; export type ServiceOutputTranslateText = TSharedTranslationResult; + +// DTO types for deprecated genIPA function +export type ServiceInputGenIPA = { + text: string; +}; + +export type ServiceOutputGenIPA = string; + +// DTO types for deprecated genLanguage function +export type ServiceInputGenLanguage = { + text: string; +}; + +export type ServiceOutputGenLanguage = string; diff --git a/src/modules/translator/translator-service.ts b/src/modules/translator/translator-service.ts index 7c964f9..a6a01f2 100644 --- a/src/modules/translator/translator-service.ts +++ b/src/modules/translator/translator-service.ts @@ -1,6 +1,14 @@ import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator"; +import { getAnswer } from "@/lib/bigmodel/llm"; import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository"; -import { ServiceInputTranslateText, ServiceOutputTranslateText } from "./translator-service-dto"; +import { + ServiceInputTranslateText, + ServiceOutputTranslateText, + ServiceInputGenIPA, + ServiceOutputGenIPA, + ServiceInputGenLanguage, + ServiceOutputGenLanguage, +} from "./translator-service-dto"; import { createLogger } from "@/lib/logger"; const log = createLogger("translator-service"); @@ -71,3 +79,72 @@ export const serviceTranslateText = async ( }; } }; + +export const serviceGenIPA = async ( + dto: ServiceInputGenIPA +): Promise => { + const { text } = dto; + return ( + "[" + + ( + await getAnswer( + ` +${text} + +请生成以上文本的严式国际音标 +然后直接发给我 +不要附带任何说明 +不要擅自增减符号 +不许用"/"或者"[]"包裹 +`.trim(), + ) + ) + .replaceAll("[", "") + .replaceAll("]", "") + + "]" + ); +}; + +export const serviceGenLanguage = async ( + dto: ServiceInputGenLanguage +): Promise => { + const { text } = dto; + const language = await getAnswer([ + { + role: "system", + content: ` +你是一个语言检测工具。请识别文本的语言并返回语言名称。 + +返回语言的标准英文名称,例如: +- 中文: Chinese +- 英语: English +- 日语: Japanese +- 韩语: Korean +- 法语: French +- 德语: German +- 意大利语: Italian +- 葡萄牙语: Portuguese +- 西班牙语: Spanish +- 俄语: Russian +- 阿拉伯语: Arabic +- 印地语: Hindi +- 泰语: Thai +- 越南语: Vietnamese +- 等等... + +如果无法识别语言,返回 "Unknown" + +规则: +1. 只返回语言的标准英文名称 +2. 首字母大写,其余小写 +3. 不要附带任何说明 +4. 不要擅自增减符号 + `.trim() + }, + { + role: "user", + content: `${text}` + } + ]); + return language.trim(); +};