From 6c869170989cb907004e8b015edc5aa1c6af6bbf Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 30 May 2025 01:24:09 +0800 Subject: [PATCH] translator: add daily translation quota for deepl (#1275) --- .../components/annotator/TranslatorPopup.tsx | 2 +- apps/readest-app/src/hooks/useTranslator.ts | 2 +- .../src/pages/api/deepl/translate.ts | 48 +++++++++++++++++-- apps/readest-app/src/services/constants.ts | 8 +++- apps/readest-app/src/types/user.ts | 8 ++-- apps/readest-app/src/utils/access.ts | 15 +++++- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx index a1b24b5b..3cc6444a 100644 --- a/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/TranslatorPopup.tsx @@ -98,7 +98,7 @@ const TranslatorPopup: React.FC = ({ setTranslation(null); try { - const input = text.replaceAll('\n', ' ').trim(); + const input = text.replaceAll('\n', '').trim(); const result = await translate([input]); const translatedText = result[0]; const detectedSource = null; diff --git a/apps/readest-app/src/hooks/useTranslator.ts b/apps/readest-app/src/hooks/useTranslator.ts index d9cf861c..926915af 100644 --- a/apps/readest-app/src/hooks/useTranslator.ts +++ b/apps/readest-app/src/hooks/useTranslator.ts @@ -65,7 +65,7 @@ export function useTranslator({ ), ); - return results; + return enablePolishing ? polish(results, targetLanguage) : results; } setLoading(true); diff --git a/apps/readest-app/src/pages/api/deepl/translate.ts b/apps/readest-app/src/pages/api/deepl/translate.ts index a69a8de1..798c2e15 100644 --- a/apps/readest-app/src/pages/api/deepl/translate.ts +++ b/apps/readest-app/src/pages/api/deepl/translate.ts @@ -2,11 +2,18 @@ import crypto from 'crypto'; import { NextApiRequest, NextApiResponse } from 'next'; import { corsAllMethods, runMiddleware } from '@/utils/cors'; import { supabase } from '@/utils/supabase'; -import { getUserPlan } from '@/utils/access'; +import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access'; const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate'; const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate'; +const ErrorCodes = { + UNAUTHORIZED: 'Unauthorized', + DEEPL_API_ERROR: 'DeepL API Error', + DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded', + INTERNAL_SERVER_ERROR: 'Internal Server Error', +}; + interface KVNamespace { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; @@ -101,7 +108,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } + // if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED }); + return await callDeepLAPI( + user?.id, + token, singleText, sourceLang, targetLang, @@ -115,11 +126,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { return res.status(200).json({ translations }); } catch (error) { console.error('Error proxying DeepL request:', error); - return res.status(500).json({ error: 'Internal Server Error' }); + if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) { + return res.status(429).json({ error: ErrorCodes.DAILY_QUOTA_EXCEEDED }); + } + return res.status(500).json({ error: ErrorCodes.INTERNAL_SERVER_ERROR }); } }; async function callDeepLAPI( + userId: string | undefined, + token: string | undefined, text: string, sourceLang: string, targetLang: string, @@ -128,11 +144,22 @@ async function callDeepLAPI( translationsKV: KVNamespace | undefined, useCache: boolean, ) { + let dailyUsageKey = ''; + if (userId && token) { + const { quota: dailyQuota } = getDailyTranslationPlanData(token); + const currentDate = new Date().toISOString().split('T')[0]!; + dailyUsageKey = `daily_usage:${currentDate}:${userId}`; + const dailyUsage = (await translationsKV?.get(dailyUsageKey)) || '0'; + if (dailyQuota <= parseInt(dailyUsage) + text.length) { + throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED); + } + } + const isV2Api = apiUrl.endsWith('/v2/translate'); // TODO: this should be processed in the client, but for now, we need to do it here // please remove this when most clients are updated - const input = text.replaceAll('\n', ' ').trim(); + const input = text.replaceAll('\n', '').trim(); const requestBody = { text: isV2Api ? [input] : input, source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang), @@ -166,6 +193,20 @@ async function callDeepLAPI( translatedText = data.data; } + let newDailyUsage = 0; + if (dailyUsageKey && translationsKV) { + try { + const usage = translatedText.length + text.length; + const dailyUsage = (await translationsKV.get(dailyUsageKey)) || '0'; + newDailyUsage = parseInt(dailyUsage) + usage; + await translationsKV.put(dailyUsageKey, newDailyUsage.toString(), { + expirationTtl: 86400 * 30, + }); + } catch (cacheError) { + console.error('Cache storage error:', cacheError); + } + } + if (useCache && translationsKV && translatedText) { try { const cacheKey = generateCacheKey(text, sourceLang, targetLang); @@ -177,6 +218,7 @@ async function callDeepLAPI( return { text: translatedText, + daily_usage: newDailyUsage, detected_source_language: detectedSourceLanguage, }; } diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 466ec0cd..8189b497 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -11,7 +11,7 @@ import { ViewSettings, } from '@/types/book'; import { ReadSettings, SystemSettings } from '@/types/settings'; -import { UserStorageQuota } from '@/types/user'; +import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user'; import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config'; import { stubTranslation as _ } from '@/utils/misc'; @@ -527,6 +527,12 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = { pro: 10 * 1024 * 1024 * 1024, }; +export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = { + free: 100 * 1024, + plus: 1 * 1024 * 1024, + pro: 10 * 1024 * 1024, +}; + export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250; export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true; export const LONG_HOLD_THRESHOLD = 500; diff --git a/apps/readest-app/src/types/user.ts b/apps/readest-app/src/types/user.ts index 4f793aca..1fd23e7e 100644 --- a/apps/readest-app/src/types/user.ts +++ b/apps/readest-app/src/types/user.ts @@ -1,10 +1,12 @@ -export type UserStorageQuota = { +export interface UserQuota { free: number; plus: number; pro: number; -}; +} -export type UserPlan = keyof UserStorageQuota; +export type UserPlan = keyof UserQuota; +export type UserStorageQuota = UserQuota; +export type UserDailyTranslationQuota = UserQuota; export type QuotaType = { name: string; diff --git a/apps/readest-app/src/utils/access.ts b/apps/readest-app/src/utils/access.ts index 8f7a465e..f6df285d 100644 --- a/apps/readest-app/src/utils/access.ts +++ b/apps/readest-app/src/utils/access.ts @@ -1,6 +1,6 @@ import { jwtDecode } from 'jwt-decode'; import { UserPlan } from '@/types/user'; -import { DEFAULT_STORAGE_QUOTA } from '@/services/constants'; +import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants'; import { isWebAppPlatform } from '@/services/environment'; import { supabase } from '@/utils/supabase'; @@ -29,6 +29,19 @@ export const getStoragePlanData = (token: string) => { }; }; +export const getDailyTranslationPlanData = (token: string) => { + const data = jwtDecode(token) || {}; + const plan = data['plan'] || 'free'; + const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0'); + const quota = + fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free']; + + return { + plan, + quota, + }; +}; + export const getAccessToken = async (): Promise => { // In browser context there might be two instances of supabase one in the app route // and the other in the pages route, and they might have different sessions