refactor: 修复 modules 三层架构违规

- auth: actionDeleteAccount 改用 service+repo,forgot-password 完整三层实现
- card: serviceCheckCardOwnership 替代直接调用 repository
- deck: 移除 service 层的 use server 指令
- dictionary: 数据转换逻辑从 repository 移到 service
- ocr: 认证移到 action 层,跨模块调用改用 service
- translator: genIPA/genLanguage 改用 service 层
This commit is contained in:
2026-03-11 09:40:53 +08:00
parent e68e24a9fb
commit 804c28ada9
34 changed files with 599 additions and 235 deletions

View File

@@ -166,6 +166,9 @@
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden", "resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet", "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.", "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", "checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung", "backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen", "resetPassword": "Passwort zurücksetzen",

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "Failed to send reset email", "resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully", "resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.", "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", "checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login", "backToLogin": "Back to Login",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",

View File

@@ -166,6 +166,9 @@
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation", "resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès", "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.", "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", "checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",

View File

@@ -166,6 +166,9 @@
"resetPasswordFailed": "Impossibile inviare email di reset", "resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo", "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.", "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", "checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login", "backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password", "resetPassword": "Reimposta Password",

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "リセットメールの送信に失敗しました", "resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました", "resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。", "resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"verifyYourEmail": "メールアドレスを確認",
"verificationEmailSent": "確認メールを送信しました",
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
"checkYourEmail": "メールをご確認ください", "checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る", "backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット", "resetPassword": "パスワードをリセット",

View File

@@ -166,6 +166,9 @@
"resetPasswordFailed": "재설정 이메일 전송 실패", "resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다", "resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.", "resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"verifyYourEmail": "이메일 인증",
"verificationEmailSent": "인증 이메일이 전송되었습니다",
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
"checkYourEmail": "이메일을 확인하세요", "checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기", "backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정", "resetPassword": "비밀번호 재설정",

View File

@@ -166,6 +166,9 @@
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى", "resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى", "resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.", "resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ", "checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش", "backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش", "resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "发送重置邮件失败", "resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送", "resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。", "resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"verifyYourEmail": "验证您的邮箱",
"verificationEmailSent": "验证邮件已发送",
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
"checkYourEmail": "请查收邮件", "checkYourEmail": "请查收邮件",
"backToLogin": "返回登录", "backToLogin": "返回登录",
"resetPassword": "重置密码", "resetPassword": "重置密码",

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; 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 { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
@@ -23,16 +23,13 @@ export default function ForgotPasswordPage() {
} }
setLoading(true); setLoading(true);
const { error } = await authClient.requestPasswordReset({ const result = await actionRequestPasswordReset({ email });
email,
redirectTo: "/reset-password",
});
if (error) { if (!result.success) {
toast.error(error.message ?? t("resetPasswordFailed")); toast.error(result.message);
} else { } else {
setSent(true); setSent(true);
toast.success(t("resetPasswordEmailSent")); toast.success(result.message);
} }
setLoading(false); setLoading(false);
}; };

View File

@@ -18,6 +18,7 @@ export default function SignUpPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -26,10 +27,10 @@ export default function SignUpPage() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) { if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
router.push("/folders"); router.push("/folders");
} }
}, [session, isPending, router, redirectTo]); }, [session, isPending, router, redirectTo, verificationSent]);
const handleSignUp = async () => { const handleSignUp = async () => {
if (!username || !email || !password) { if (!username || !email || !password) {
@@ -49,12 +50,38 @@ export default function SignUpPage() {
toast.error(error.message ?? t("signUpFailed")); toast.error(error.message ?? t("signUpFailed"));
return; return;
} }
router.push(redirectTo ?? "/folders"); setVerificationSent(true);
toast.success(t("verificationEmailSent"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (verificationSent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("verifyYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("verificationEmailSentHint", { email })}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
<Card className="w-96"> <Card className="w-96">

View File

@@ -5,7 +5,6 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { prisma } from "@/lib/db";
import { import {
ActionInputGetUserProfileByUsername, ActionInputGetUserProfileByUsername,
ActionInputSignIn, ActionInputSignIn,
@@ -20,7 +19,8 @@ import {
import { import {
serviceGetUserProfileByUsername, serviceGetUserProfileByUsername,
serviceSignIn, serviceSignIn,
serviceSignUp serviceSignUp,
serviceDeleteAccount
} from "./auth-service"; } from "./auth-service";
// Re-export types for use in components // Re-export types for use in components
@@ -194,75 +194,11 @@ export async function actionDeleteAccount(): Promise<ActionOutputDeleteAccount>
return { success: false, message: "Unauthorized" }; return { success: false, message: "Unauthorized" };
} }
const userId = session.user.id; const result = await serviceDeleteAccount({ userId: session.user.id });
await prisma.$transaction(async (tx) => { if (!result.success) {
// Delete in correct order to avoid foreign key constraints return { success: false, message: "Failed to delete account" };
// 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 }
});
});
return { success: true, message: "Account deleted successfully" }; return { success: true, message: "Account deleted successfully" };
} catch (e) { } catch (e) {

View File

@@ -25,3 +25,12 @@ export type RepoInputFindUserById = {
export type RepoInputFindUserByEmail = { export type RepoInputFindUserByEmail = {
email: string; email: string;
}; };
// Delete user cascade types
export type RepoInputDeleteUserCascade = {
userId: string;
};
export type RepoOutputDeleteUserCascade = {
success: boolean;
};

View File

@@ -1,11 +1,16 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { import {
RepoInputFindUserByEmail, RepoInputFindUserByEmail,
RepoInputFindUserById, RepoInputFindUserById,
RepoInputFindUserByUsername, RepoInputFindUserByUsername,
RepoOutputUserProfile RepoInputDeleteUserCascade,
RepoOutputUserProfile,
RepoOutputDeleteUserCascade
} from "./auth-repository-dto"; } from "./auth-repository-dto";
const log = createLogger("auth-repository");
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> { export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
@@ -62,3 +67,68 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
return user; return user;
} }
export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Promise<RepoOutputDeleteUserCascade> {
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 };
}

View File

@@ -38,3 +38,11 @@ export type ServiceOutputUserProfile = {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} | null; } | null;
export type ServiceInputDeleteAccount = {
userId: string;
};
export type ServiceOutputDeleteAccount = {
success: boolean;
};

View File

@@ -1,15 +1,18 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { import {
repoFindUserByUsername, repoFindUserByUsername,
repoFindUserById repoFindUserById,
repoDeleteUserCascade
} from "./auth-repository"; } from "./auth-repository";
import { import {
ServiceInputGetUserProfileByUsername, ServiceInputGetUserProfileByUsername,
ServiceInputGetUserProfileById, ServiceInputGetUserProfileById,
ServiceInputSignIn, ServiceInputSignIn,
ServiceInputSignUp, ServiceInputSignUp,
ServiceInputDeleteAccount,
ServiceOutputAuth, ServiceOutputAuth,
ServiceOutputUserProfile ServiceOutputUserProfile,
ServiceOutputDeleteAccount
} from "./auth-service-dto"; } from "./auth-service-dto";
/** /**
@@ -92,3 +95,7 @@ export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserPr
export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> { export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> {
return await repoFindUserById(dto); return await repoFindUserById(dto);
} }
export async function serviceDeleteAccount(dto: ServiceInputDeleteAccount): Promise<ServiceOutputDeleteAccount> {
return await repoDeleteUserCascade({ userId: dto.userId });
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const schemaActionInputForgotPassword = z.object({
email: z.string().email("请输入有效的邮箱地址"),
});
export type ActionInputForgotPassword = z.infer<typeof schemaActionInputForgotPassword>;
export interface ActionOutputForgotPassword {
success: boolean;
message: string;
}

View File

@@ -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<ActionOutputForgotPassword> {
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: "发送重置邮件失败,请稍后重试",
};
}
}

View File

@@ -0,0 +1,7 @@
export type RepoInputFindUserByEmail = {
email: string;
};
export type RepoOutputFindUserByEmail = {
id: string;
} | null;

View File

@@ -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<RepoOutputFindUserByEmail> {
log.debug("Finding user by email", { email: dto.email });
const user = await prisma.user.findUnique({
where: { email: dto.email },
select: { id: true },
});
return user;
}

View File

@@ -0,0 +1,8 @@
export type ServiceInputRequestPasswordReset = {
email: string;
};
export type ServiceOutputRequestPasswordReset = {
success: boolean;
message: string;
};

View File

@@ -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<ServiceOutputRequestPasswordReset> {
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: "重置密码邮件已发送,请检查您的邮箱",
};
}

View File

@@ -42,8 +42,8 @@ import {
serviceGetCardStats, serviceGetCardStats,
serviceDeleteCard, serviceDeleteCard,
serviceGetCardByIdWithNote, serviceGetCardByIdWithNote,
serviceCheckCardOwnership,
} from "./card-service"; } from "./card-service";
import { repoGetCardDeckOwnerId } from "./card-repository";
import { CardQueue } from "../../../generated/prisma/enums"; import { CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-action"); const log = createLogger("card-action");
@@ -161,8 +161,7 @@ async function checkCardOwnership(cardId: bigint): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false; if (!session?.user?.id) return false;
const ownerId = await repoGetCardDeckOwnerId(cardId); return serviceCheckCardOwnership({ cardId, userId: session.user.id });
return ownerId === session.user.id;
} }
async function getCurrentUserId(): Promise<string | null> { async function getCurrentUserId(): Promise<string | null> {

View File

@@ -34,6 +34,13 @@ export interface ServiceInputGetCardStats {
deckId: number; deckId: number;
} }
export interface ServiceInputCheckCardOwnership {
cardId: bigint;
userId: string;
}
export type ServiceOutputCheckCardOwnership = boolean;
export type ServiceOutputCard = { export type ServiceOutputCard = {
id: bigint; id: bigint;
noteId: bigint; noteId: bigint;
@@ -94,11 +101,17 @@ export type ServiceOutputReviewResult = {
export const SM2_CONFIG = { export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10], LEARNING_STEPS: [1, 10],
RELEARNING_STEPS: [10],
GRADUATING_INTERVAL_GOOD: 1, GRADUATING_INTERVAL_GOOD: 1,
GRADUATING_INTERVAL_EASY: 4, GRADUATING_INTERVAL_EASY: 4,
EASY_INTERVAL: 4, EASY_INTERVAL: 4,
MINIMUM_FACTOR: 1300, MINIMUM_FACTOR: 1300,
DEFAULT_FACTOR: 2500, DEFAULT_FACTOR: 2500,
MAXIMUM_INTERVAL: 36500,
EASY_BONUS: 1.3,
HARD_INTERVAL: 1.2,
NEW_INTERVAL: 0.0,
INTERVAL_MODIFIER: 1.0,
FACTOR_ADJUSTMENTS: { FACTOR_ADJUSTMENTS: {
1: -200, 1: -200,
2: -150, 2: -150,

View File

@@ -11,6 +11,7 @@ import {
repoGetCardStats, repoGetCardStats,
repoDeleteCard, repoDeleteCard,
repoGetCardsByNoteId, repoGetCardsByNoteId,
repoGetCardDeckOwnerId,
} from "./card-repository"; } from "./card-repository";
import { import {
RepoInputUpdateCard, RepoInputUpdateCard,
@@ -23,11 +24,13 @@ import {
ServiceInputGetNewCards, ServiceInputGetNewCards,
ServiceInputGetCardsByDeckId, ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats, ServiceInputGetCardStats,
ServiceInputCheckCardOwnership,
ServiceOutputCard, ServiceOutputCard,
ServiceOutputCardWithNote, ServiceOutputCardWithNote,
ServiceOutputCardStats, ServiceOutputCardStats,
ServiceOutputScheduledCard, ServiceOutputScheduledCard,
ServiceOutputReviewResult, ServiceOutputReviewResult,
ServiceOutputCheckCardOwnership,
ReviewEase, ReviewEase,
SM2_CONFIG, SM2_CONFIG,
} from "./card-service-dto"; } from "./card-service-dto";
@@ -50,7 +53,11 @@ function calculateNextReviewTime(intervalDays: number): Date {
return new Date(now + intervalDays * 86400 * 1000); 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; type: CardType;
queue: CardQueue; queue: CardQueue;
ivl: number; ivl: number;
@@ -63,21 +70,53 @@ function scheduleNewCard(ease: ReviewEase, factor: number): {
queue: CardQueue.LEARNING, queue: CardQueue.LEARNING,
ivl: 0, ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, 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 { return {
type: CardType.REVIEW, type: CardType.REVIEW,
queue: CardQueue.REVIEW, queue: CardQueue.REVIEW,
ivl, ivl,
due: calculateDueDate(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; type: CardType;
queue: CardQueue; queue: CardQueue;
ivl: number; ivl: number;
@@ -85,45 +124,88 @@ function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): {
newFactor: number; newFactor: number;
newLeft: number; newLeft: number;
} { } {
const steps = SM2_CONFIG.LEARNING_STEPS;
const totalSteps = steps.length;
if (ease === 1) { if (ease === 1) {
return { return {
type: CardType.LEARNING, type: CardType.LEARNING,
queue: CardQueue.LEARNING, queue: CardQueue.LEARNING,
ivl: 0, ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, due: Math.floor(Date.now() / 1000) + steps[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]), newFactor: currentFactor,
newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length, newLeft: totalSteps * 1000,
}; };
} }
const stepIndex = Math.floor(left % 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 { return {
type: CardType.LEARNING, type: CardType.REVIEW,
queue: CardQueue.LEARNING, queue: CardQueue.REVIEW,
ivl: 0, ivl,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60, due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]), newFactor: SM2_CONFIG.DEFAULT_FACTOR,
newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep), 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 { return {
type: CardType.REVIEW, type: CardType.REVIEW,
queue: CardQueue.REVIEW, queue: CardQueue.REVIEW,
ivl, ivl,
due: calculateDueDate(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, newLeft: 0,
}; };
} }
function scheduleReviewCard( function scheduleReviewCard(
ease: ReviewEase, ease: ReviewEase,
ivl: number, currentIvl: number,
factor: number, currentFactor: number,
lapses: number, lapses: number,
): { ): {
type: CardType; type: CardType;
@@ -134,27 +216,34 @@ function scheduleReviewCard(
newLapses: number; newLapses: number;
} { } {
if (ease === 1) { 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 { return {
type: CardType.RELEARNING, type: CardType.RELEARNING,
queue: CardQueue.LEARNING, queue: CardQueue.LEARNING,
ivl: 0, ivl: newIvl,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60, due: Math.floor(Date.now() / 1000) + SM2_CONFIG.RELEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]), newFactor,
newLapses: lapses + 1, newLapses: lapses + 1,
}; };
} }
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]); let newFactor: number;
const factorMultiplier = newFactor / 1000; let newIvl: number;
let newIvl = Math.floor(ivl * factorMultiplier);
if (ease === 2) { if (ease === 2) {
newIvl = Math.max(1, Math.floor(newIvl * 1.2)); newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]);
} else if (ease === 4) { newIvl = Math.floor(currentIvl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER);
newIvl = Math.floor(newIvl * 1.3); } 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 { return {
type: CardType.REVIEW, type: CardType.REVIEW,
@@ -237,7 +326,9 @@ export async function serviceAnswerCard(
due: result.due, due: result.due,
factor: result.newFactor, factor: result.newFactor,
reps: card.reps + 1, 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), mod: Math.floor(Date.now() / 1000),
}; };
scheduled = { scheduled = {
@@ -284,7 +375,9 @@ export async function serviceAnswerCard(
factor: result.newFactor, factor: result.newFactor,
reps: card.reps + 1, reps: card.reps + 1,
lapses: result.newLapses, 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), mod: Math.floor(Date.now() / 1000),
}; };
scheduled = { scheduled = {
@@ -382,3 +475,11 @@ export async function serviceDeleteCard(cardId: bigint): Promise<void> {
log.info("Deleting card", { cardId: cardId.toString() }); log.info("Deleting card", { cardId: cardId.toString() });
await repoDeleteCard(cardId); await repoDeleteCard(cardId);
} }
export async function serviceCheckCardOwnership(
input: ServiceInputCheckCardOwnership,
): Promise<ServiceOutputCheckCardOwnership> {
log.debug("Checking card ownership", { cardId: input.cardId.toString() });
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
return ownerId === input.userId;
}

View File

@@ -1,5 +1,3 @@
"use server";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { import {
ServiceInputCreateDeck, ServiceInputCreateDeck,

View File

@@ -1,5 +1,3 @@
import { TSharedItem } from "@/shared/dictionary-type";
export type RepoInputCreateDictionaryLookUp = { export type RepoInputCreateDictionaryLookUp = {
userId?: string; userId?: string;
text: string; text: string;
@@ -8,7 +6,29 @@ export type RepoInputCreateDictionaryLookUp = {
dictionaryItemId?: number; 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 = { export type RepoInputCreateDictionaryItem = {
standardForm: string; standardForm: string;

View File

@@ -1,6 +1,5 @@
import { stringNormalize } from "@/utils/string"; import { stringNormalize } from "@/utils/string";
import { import {
RepoInputCreateDictionaryEntry,
RepoInputCreateDictionaryEntryWithoutItemId, RepoInputCreateDictionaryEntryWithoutItemId,
RepoInputCreateDictionaryItem, RepoInputCreateDictionaryItem,
RepoInputCreateDictionaryLookUp, RepoInputCreateDictionaryLookUp,
@@ -30,22 +29,12 @@ export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpR
createdAt: 'desc' createdAt: 'desc'
} }
}); });
if (result && result.dictionaryItem) {
const item = result.dictionaryItem; if (!result?.dictionaryItem) {
return { return null;
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
};
})
};
} }
return null;
return result.dictionaryItem;
} }
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) { export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {

View File

@@ -2,9 +2,23 @@ import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator"
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository"; import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
import { ServiceInputLookUp } from "./dictionary-service-dto"; import { ServiceInputLookUp } from "./dictionary-service-dto";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto";
const log = createLogger("dictionary-service"); 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) => { export const serviceLookUp = async (dto: ServiceInputLookUp) => {
const { const {
text, text,
@@ -27,7 +41,6 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
definitionLang definitionLang
); );
// 使用事务确保数据一致性
repoCreateLookUpWithItemAndEntries( repoCreateLookUpWithItemAndEntries(
{ {
standardForm: response.standardForm, standardForm: response.standardForm,
@@ -47,18 +60,20 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
return response; return response;
} else { } else {
const transformedResult = transformRawItemToSharedItem(lastLookUpResult);
repoCreateLookUp({ repoCreateLookUp({
userId: userId, userId: userId,
text: text, text: text,
queryLang: queryLang, queryLang: queryLang,
definitionLang: definitionLang, definitionLang: definitionLang,
dictionaryItemId: lastLookUpResult.id dictionaryItemId: transformedResult.id
}).catch(error => { }).catch(error => {
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) }); log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
}); });
return { return {
standardForm: lastLookUpResult.standardForm, standardForm: transformedResult.standardForm,
entries: lastLookUpResult.entries entries: transformedResult.entries
}; };
} }
}; };

View File

@@ -1,5 +1,7 @@
"use server"; "use server";
import { headers } from "next/headers";
import { auth } from "@/auth";
import { validate } from "@/utils/validate"; import { validate } from "@/utils/validate";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
@@ -13,8 +15,17 @@ export async function actionProcessOCR(
input: unknown input: unknown
): Promise<ActionOutputProcessOCR> { ): Promise<ActionOutputProcessOCR> {
try { 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); const validatedInput = validate(input, schemaActionInputProcessOCR);
return serviceProcessOCR(validatedInput); return serviceProcessOCR({
...validatedInput,
userId: session.user.id,
});
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {
return { success: false, message: e.message }; return { success: false, message: e.message };

View File

@@ -5,6 +5,7 @@ export const schemaServiceInputProcessOCR = z.object({
deckId: z.number().int().positive("Deck ID must be positive"), deckId: z.number().int().positive("Deck ID must be positive"),
sourceLanguage: z.string().optional(), sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(), targetLanguage: z.string().optional(),
userId: z.string().min(1, "User ID is required"),
}); });
export type ServiceInputProcessOCR = z.infer<typeof schemaServiceInputProcessOCR>; export type ServiceInputProcessOCR = z.infer<typeof schemaServiceInputProcessOCR>;

View File

@@ -1,12 +1,8 @@
"use server";
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator"; import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository"; import { serviceCheckOwnership } from "@/modules/deck/deck-service";
import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository"; import { serviceCreateNote } from "@/modules/note/note-service";
import { repoCreateCard } from "@/modules/card/card-repository"; import { serviceCreateCard } from "@/modules/card/card-service";
import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository"; import { serviceGetNoteTypesByUserId, serviceCreateNoteType } from "@/modules/note-type/note-type-service";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto"; import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
import { NoteKind } from "../../../generated/prisma/enums"; import { NoteKind } from "../../../generated/prisma/enums";
@@ -16,7 +12,7 @@ const log = createLogger("ocr-service");
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)"; const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> { async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const existingTypes = await repoGetNoteTypesByUserId({ userId }); const existingTypes = await serviceGetNoteTypesByUserId({ userId });
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME); const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
if (existing) { if (existing) {
@@ -47,7 +43,7 @@ async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }"; 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, name: VOCABULARY_NOTE_TYPE_NAME,
kind: NoteKind.STANDARD, kind: NoteKind.STANDARD,
css, css,
@@ -63,19 +59,17 @@ async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
export async function serviceProcessOCR( export async function serviceProcessOCR(
input: ServiceInputProcessOCR input: ServiceInputProcessOCR
): Promise<ServiceOutputProcessOCR> { ): Promise<ServiceOutputProcessOCR> {
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() }); const isOwner = await serviceCheckOwnership({
if (!session?.user?.id) { deckId: input.deckId,
log.warn("Unauthorized OCR attempt"); userId: input.userId
return { success: false, message: "Unauthorized" }; });
}
const deckOwner = await repoGetUserIdByDeckId(input.deckId); if (!isOwner) {
if (deckOwner !== session.user.id) {
log.warn("Deck ownership mismatch", { log.warn("Deck ownership mismatch", {
deckId: input.deckId, deckId: input.deckId,
userId: session.user.id userId: input.userId
}); });
return { return {
success: false, success: false,
@@ -110,33 +104,28 @@ export async function serviceProcessOCR(
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown"; const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown"; const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id); const noteTypeId = await getOrCreateVocabularyNoteType(input.userId);
let pairsCreated = 0; let pairsCreated = 0;
for (const pair of ocrResult.pairs) { for (const pair of ocrResult.pairs) {
try { try {
const now = Date.now(); const { id: noteId } = await serviceCreateNote({
const noteId = await repoCreateNote({
noteTypeId, noteTypeId,
userId: session.user.id, userId: input.userId,
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage], fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
tags: ["ocr"], tags: ["ocr"],
}); });
await repoCreateCard({ await serviceCreateCard({
id: BigInt(now + pairsCreated),
noteId, noteId,
deckId: input.deckId, deckId: input.deckId,
ord: 0, ord: 0,
due: pairsCreated + 1,
}); });
await repoCreateCard({ await serviceCreateCard({
id: BigInt(now + pairsCreated + 10000),
noteId, noteId,
deckId: input.deckId, deckId: input.deckId,
ord: 1, ord: 1,
due: pairsCreated + 1,
}); });
pairsCreated++; pairsCreated++;

View File

@@ -7,8 +7,7 @@ import {
} from "./translator-action-dto"; } from "./translator-action-dto";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { serviceTranslateText } from "./translator-service"; import { serviceTranslateText, serviceGenIPA, serviceGenLanguage } from "./translator-service";
import { getAnswer } from "@/lib/bigmodel/llm";
const log = createLogger("translator-action"); const log = createLogger("translator-action");
@@ -40,67 +39,12 @@ export const actionTranslateText = async (
* @deprecated 保留此函数以支持旧代码text-speaker 功能) * @deprecated 保留此函数以支持旧代码text-speaker 功能)
*/ */
export const genIPA = async (text: string) => { export const genIPA = async (text: string) => {
return ( return serviceGenIPA({ text });
"[" +
(
await getAnswer(
`
<text>${text}</text>
请生成以上文本的严式国际音标
然后直接发给我
不要附带任何说明
不要擅自增减符号
不许用"/"或者"[]"包裹
`.trim(),
)
)
.replaceAll("[", "")
.replaceAll("]", "") +
"]"
);
}; };
/** /**
* @deprecated 保留此函数以支持旧代码text-speaker 功能) * @deprecated 保留此函数以支持旧代码text-speaker 功能)
*/ */
export const genLanguage = async (text: string) => { export const genLanguage = async (text: string) => {
const language = await getAnswer([ return serviceGenLanguage({ text });
{
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>${text}</text>`
}
]);
return language.trim();
}; };

View File

@@ -10,3 +10,17 @@ export type ServiceInputTranslateText = {
}; };
export type ServiceOutputTranslateText = TSharedTranslationResult; 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;

View File

@@ -1,6 +1,14 @@
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator"; import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
import { getAnswer } from "@/lib/bigmodel/llm";
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository"; 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"; import { createLogger } from "@/lib/logger";
const log = createLogger("translator-service"); const log = createLogger("translator-service");
@@ -71,3 +79,72 @@ export const serviceTranslateText = async (
}; };
} }
}; };
export const serviceGenIPA = async (
dto: ServiceInputGenIPA
): Promise<ServiceOutputGenIPA> => {
const { text } = dto;
return (
"[" +
(
await getAnswer(
`
<text>${text}</text>
请生成以上文本的严式国际音标
然后直接发给我
不要附带任何说明
不要擅自增减符号
不许用"/"或者"[]"包裹
`.trim(),
)
)
.replaceAll("[", "")
.replaceAll("]", "") +
"]"
);
};
export const serviceGenLanguage = async (
dto: ServiceInputGenLanguage
): Promise<ServiceOutputGenLanguage> => {
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>${text}</text>`
}
]);
return language.trim();
};