translator: add daily translation quota for deepl (#1275)

This commit is contained in:
Huang Xin
2025-05-30 01:24:09 +08:00
committed by GitHub
parent 7c21f48d68
commit 6c86917098
6 changed files with 73 additions and 10 deletions
@@ -98,7 +98,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
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;
+1 -1
View File
@@ -65,7 +65,7 @@ export function useTranslator({
),
);
return results;
return enablePolishing ? polish(results, targetLanguage) : results;
}
setLoading(true);
@@ -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<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
@@ -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,
};
}
+7 -1
View File
@@ -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;
+5 -3
View File
@@ -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;
+14 -1
View File
@@ -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>(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<string | null> => {
// 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