feat: add shared i18n foundation

- add shared locale schemas, key-based message catalogs and locale helpers for zh-CN and en-US

- route API errors, notifications and default AI prompts through locale-aware message lookups with request and settings fallbacks

- cover locale parsing, settings defaults and i18n-aware backend flows with regression tests
This commit is contained in:
SmileQWQ
2026-05-11 08:16:12 +08:00
parent 3edff87727
commit 477fb25682
36 changed files with 3536 additions and 384 deletions

View File

@@ -3,8 +3,11 @@ import cors from '@fastify/cors'
import rateLimit from '@fastify/rate-limit'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { DEFAULT_APP_LOCALE } from '@subtracker/shared'
import { getMessage } from '@subtracker/shared'
import { config } from './config'
import { sendError } from './http'
import { detectRequestLocale } from './i18n'
import { authRoutes } from './routes/auth'
import { subscriptionRoutes } from './routes/subscriptions'
import { statisticsRoutes } from './routes/statistics'
@@ -31,11 +34,11 @@ export async function buildApp() {
await app.register(rateLimit, {
global: false,
errorResponseBuilder: (_request, context) => ({
errorResponseBuilder: (request, context) => ({
statusCode: 429,
error: {
code: 'too_many_attempts',
message: '登录失败次数过多,请稍后再试',
message: getMessage(request.locale ?? DEFAULT_APP_LOCALE, 'api.errors.tooManyAttempts'),
details: {
retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000))
}
@@ -63,11 +66,14 @@ export async function buildApp() {
reply.header('Content-Type', mimeMap[ext] ?? 'application/octet-stream')
return reply.send(file)
} catch {
return sendError(reply, 404, 'not_found', 'Logo not found')
return sendError(reply, 404, 'not_found', 'api.errors.logoNotFound', undefined, {
locale: request.locale ?? DEFAULT_APP_LOCALE
})
}
})
app.addHook('onRequest', async (request, reply) => {
request.locale = detectRequestLocale(request)
const url = request.url.split('?')[0]
if (
request.method === 'OPTIONS' ||
@@ -87,7 +93,9 @@ export async function buildApp() {
const user = await verifyToken(token)
if (!user) {
return sendError(reply, 401, 'unauthorized', '请先登录')
return sendError(reply, 401, 'unauthorized', 'api.errors.unauthorized', undefined, {
locale: request.locale
})
}
request.auth = user
@@ -112,8 +120,9 @@ export async function buildApp() {
app.setErrorHandler((error, _request, reply) => {
app.log.error(error)
const message = error instanceof Error ? error.message : 'Unknown server error'
return sendError(reply, 500, 'internal_error', message)
return sendError(reply, 500, 'internal_error', 'api.errors.internal', undefined, {
locale: _request.locale ?? DEFAULT_APP_LOCALE
})
})
return app

View File

@@ -1,4 +1,5 @@
import 'fastify'
import type { AppLocale } from '@subtracker/shared'
declare module 'fastify' {
interface FastifyRequest {
@@ -6,5 +7,6 @@ declare module 'fastify' {
username: string
mustChangePassword: boolean
}
locale?: AppLocale
}
}

View File

@@ -1,4 +1,6 @@
import type { FastifyReply } from 'fastify'
import { resolveAppLocaleFromAcceptLanguage, type AppLocale } from '@subtracker/shared'
import { translateErrorMessage } from './i18n'
export function sendOk<T>(reply: FastifyReply, data: T, meta?: Record<string, unknown>) {
return reply.status(200).send({ data, meta })
@@ -8,11 +10,35 @@ export function sendCreated<T>(reply: FastifyReply, data: T) {
return reply.status(201).send({ data })
}
export function sendError(reply: FastifyReply, status: number, code: string, message: string, details?: unknown) {
export function sendError(
reply: FastifyReply,
status: number,
code: string,
messageKey: string,
details?: unknown,
options?: {
locale?: AppLocale
params?: Record<string, string | number | boolean | null | undefined>
}
) {
const request = (reply as FastifyReply & {
request?: { locale?: AppLocale; headers?: Record<string, unknown> }
}).request
const requestLocale =
request?.locale ??
resolveAppLocaleFromAcceptLanguage(
typeof request?.headers?.['x-subtracker-locale'] === 'string'
? request.headers['x-subtracker-locale']
: request?.headers?.['accept-language'],
'zh-CN'
)
return reply.status(status).send({
error: {
code,
message,
message: translateErrorMessage(messageKey, {
locale: options?.locale ?? requestLocale,
params: options?.params
}),
details
}
})

30
apps/api/src/i18n.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { FastifyRequest } from 'fastify'
import {
DEFAULT_APP_LOCALE,
getMessage,
type AppLocale,
resolveAppLocaleFromAcceptLanguage
} from '@subtracker/shared'
export type TranslateOptions = {
locale?: AppLocale
params?: Record<string, string | number | boolean | null | undefined>
}
export function detectRequestLocale(request: Pick<FastifyRequest, 'headers'>, fallbackLocale = DEFAULT_APP_LOCALE): AppLocale {
const localeHeader = request.headers['x-subtracker-locale']
if (typeof localeHeader === 'string' && localeHeader.trim()) {
return resolveAppLocaleFromAcceptLanguage(localeHeader, fallbackLocale)
}
const acceptLanguage = request.headers['accept-language']
return resolveAppLocaleFromAcceptLanguage(acceptLanguage, fallbackLocale)
}
export function translateMessage(messageKey: string, options?: TranslateOptions) {
return getMessage(options?.locale ?? DEFAULT_APP_LOCALE, messageKey, options?.params)
}
export function translateErrorMessage(messageKey: string, options?: TranslateOptions) {
return translateMessage(messageKey, options)
}

View File

@@ -20,7 +20,9 @@ export async function aiRoutes(app: FastifyInstance) {
try {
return sendOk(reply, await getDashboardAiSummary())
} catch (error) {
return sendError(reply, 400, 'ai_summary_fetch_failed', error instanceof Error ? error.message : 'AI summary fetch failed')
return sendError(reply, 400, 'ai_summary_fetch_failed', error instanceof Error ? error.message : 'api.errors.ai.summaryFetchFailed', undefined, {
locale: _request.locale
})
}
})
@@ -28,7 +30,14 @@ export async function aiRoutes(app: FastifyInstance) {
try {
return sendOk(reply, await generateDashboardAiSummary())
} catch (error) {
return sendError(reply, 400, 'ai_summary_generate_failed', error instanceof Error ? error.message : 'AI summary generate failed')
return sendError(
reply,
400,
'ai_summary_generate_failed',
error instanceof Error ? error.message : 'api.errors.ai.summaryGenerateFailed',
undefined,
{ locale: _request.locale }
)
}
})
@@ -37,7 +46,9 @@ export async function aiRoutes(app: FastifyInstance) {
if (request.body) {
const parsed = AiConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidAiConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
return sendOk(
reply,
@@ -47,7 +58,9 @@ export async function aiRoutes(app: FastifyInstance) {
return sendOk(reply, await testAiConnection())
} catch (error) {
return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'AI test failed')
return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'api.errors.ai.connectionTestFailed', undefined, {
locale: request.locale
})
}
})
@@ -56,14 +69,23 @@ export async function aiRoutes(app: FastifyInstance) {
if (request.body) {
const parsed = AiConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidAiConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
return sendOk(reply, await testAiVisionConnection(normalizeAiConfigPayload(parsed.data)))
}
return sendOk(reply, await testAiVisionConnection())
} catch (error) {
return sendError(reply, 400, 'ai_vision_test_failed', error instanceof Error ? error.message : 'AI vision test failed')
return sendError(
reply,
400,
'ai_vision_test_failed',
error instanceof Error ? error.message : 'api.errors.ai.visionTestFailed',
undefined,
{ locale: request.locale }
)
}
})
@@ -71,7 +93,9 @@ export async function aiRoutes(app: FastifyInstance) {
try {
return sendOk(reply, await recognizeSubscriptionByAi(request.body))
} catch (error) {
return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'AI recognition failed')
return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'api.errors.ai.recognitionFailed', undefined, {
locale: request.locale
})
}
})
}

View File

@@ -1,5 +1,11 @@
import { FastifyInstance } from 'fastify'
import { ChangeCredentialsSchema, ForgotPasswordRequestSchema, ForgotPasswordResetSchema, LoginSchema } from '@subtracker/shared'
import {
ChangeCredentialsSchema,
ForgotPasswordRequestSchema,
ForgotPasswordResetSchema,
LoginSchema,
getMessage
} from '@subtracker/shared'
import { z } from 'zod'
import { sendError, sendOk } from '../http'
import { changeCredentials, changeDefaultPassword, loginWithCredentials } from '../services/auth.service'
@@ -15,10 +21,10 @@ function resolveLoginValidationMessage(body: unknown) {
const username = payload.username?.trim() ?? ''
const password = payload.password?.trim() ?? ''
if (!username && !password) return '请输入用户名和密码'
if (!username) return '请输入用户名'
if (!password) return '请输入密码'
return '登录信息格式不正确'
if (!username && !password) return 'auth.validation.usernameAndPasswordRequired'
if (!username) return 'auth.validation.usernameRequired'
if (!password) return 'auth.validation.passwordRequired'
return 'auth.validation.loginPayloadInvalid'
}
export async function authRoutes(app: FastifyInstance) {
@@ -42,7 +48,9 @@ export async function authRoutes(app: FastifyInstance) {
async (request, reply) => {
const parsed = LoginSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', resolveLoginValidationMessage(request.body), parsed.error.flatten())
return sendError(reply, 422, 'validation_error', resolveLoginValidationMessage(request.body), parsed.error.flatten(), {
locale: request.locale
})
}
const result = await loginWithCredentials(parsed.data.username, parsed.data.password, {
@@ -50,7 +58,9 @@ export async function authRoutes(app: FastifyInstance) {
rememberDays: parsed.data.rememberDays
})
if (!result) {
return sendError(reply, 401, 'invalid_credentials', '用户名或密码错误')
return sendError(reply, 401, 'invalid_credentials', 'api.errors.auth.invalidCredentials', undefined, {
locale: request.locale
})
}
return sendOk(reply, result)
@@ -59,7 +69,9 @@ export async function authRoutes(app: FastifyInstance) {
app.get('/auth/me', async (request, reply) => {
if (!request.auth) {
return sendError(reply, 401, 'unauthorized', '请先登录')
return sendError(reply, 401, 'unauthorized', 'api.errors.unauthorized', undefined, {
locale: request.locale
})
}
return sendOk(reply, {
@@ -70,12 +82,16 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/auth/change-credentials', async (request, reply) => {
const parsed = ChangeCredentialsSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid credentials payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidCredentialsPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await changeCredentials(parsed.data)
if (!result) {
return sendError(reply, 401, 'invalid_credentials', '原用户名或原密码错误')
return sendError(reply, 401, 'invalid_credentials', 'api.errors.auth.currentCredentialsInvalid', undefined, {
locale: request.locale
})
}
return sendOk(reply, result)
@@ -89,12 +105,16 @@ export async function authRoutes(app: FastifyInstance) {
.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid password payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidPasswordPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await changeDefaultPassword(parsed.data.newPassword)
if (!result) {
return sendError(reply, 400, 'default_password_change_not_allowed', 'Default password change is not allowed')
return sendError(reply, 400, 'default_password_change_not_allowed', 'api.errors.auth.defaultPasswordChangeNotAllowed', undefined, {
locale: request.locale
})
}
return sendOk(reply, result)
@@ -107,11 +127,11 @@ export async function authRoutes(app: FastifyInstance) {
rateLimit: {
max: 3,
timeWindow: 10 * 60 * 1000,
errorResponseBuilder: (_request, context) => ({
errorResponseBuilder: (request, context) => ({
statusCode: 429,
error: {
code: 'forgot_password_request_rate_limited',
message: '验证码发送过于频繁,请稍后再试',
message: getMessage(request.locale ?? 'zh-CN', 'api.errors.auth.forgotPasswordRequestRateLimited'),
details: {
retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000))
}
@@ -123,14 +143,26 @@ export async function authRoutes(app: FastifyInstance) {
async (request, reply) => {
const parsed = ForgotPasswordRequestSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid forgot password request payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidForgotPasswordRequestPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await requestForgotPasswordChallenge(parsed.data.username, request.ip)
const result = await requestForgotPasswordChallenge(parsed.data.username, request.ip, request.locale)
if (!result.ok) {
return sendError(reply, result.error.status, result.error.code, result.error.message, {
retryAfterSeconds: result.error.retryAfterSeconds
})
return sendError(
reply,
result.error.status,
result.error.code,
result.error.message,
{
retryAfterSeconds: result.error.retryAfterSeconds
},
{
locale: request.locale,
params: result.error.messageParams
}
)
}
return sendOk(reply, { accepted: true })
@@ -144,11 +176,11 @@ export async function authRoutes(app: FastifyInstance) {
rateLimit: {
max: 5,
timeWindow: 10 * 60 * 1000,
errorResponseBuilder: (_request, context) => ({
errorResponseBuilder: (request, context) => ({
statusCode: 429,
error: {
code: 'forgot_password_reset_rate_limited',
message: '验证失败次数过多,请稍后再试',
message: getMessage(request.locale ?? 'zh-CN', 'api.errors.auth.forgotPasswordResetRateLimited'),
details: {
retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000))
}
@@ -160,7 +192,9 @@ export async function authRoutes(app: FastifyInstance) {
async (request, reply) => {
const parsed = ForgotPasswordResetSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid forgot password reset payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidForgotPasswordResetPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await resetPasswordWithForgotPasswordCode({
@@ -169,9 +203,19 @@ export async function authRoutes(app: FastifyInstance) {
})
if (!result.ok) {
return sendError(reply, result.error.status, result.error.code, result.error.message, {
retryAfterSeconds: result.error.retryAfterSeconds
})
return sendError(
reply,
result.error.status,
result.error.code,
result.error.message,
{
retryAfterSeconds: result.error.retryAfterSeconds
},
{
locale: request.locale,
params: result.error.messageParams
}
)
}
return sendOk(reply, result.result)

View File

@@ -23,7 +23,9 @@ export async function calendarRoutes(app: FastifyInstance) {
const parsed = querySchema.safeParse(request.query)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidQuery', parsed.error.flatten(), {
locale: request.locale
})
}
const timezone = await getAppTimezone()

View File

@@ -14,7 +14,9 @@ export async function exchangeRateRoutes(app: FastifyInstance) {
const latest = await ensureExchangeRates()
return sendOk(reply, latest)
} catch (error) {
return sendError(reply, 500, 'refresh_failed', 'Failed to refresh exchange rates', error)
return sendError(reply, 500, 'refresh_failed', 'api.errors.exchangeRates.refreshFailed', error, {
locale: _.locale
})
}
})
}

View File

@@ -13,33 +13,43 @@ export async function importRoutes(app: FastifyInstance) {
app.post('/import/wallos/inspect', async (request, reply) => {
const parsed = WallosImportInspectSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid Wallos inspect payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWallosInspectPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
return sendOk(reply, await inspectWallosImportFile(parsed.data))
} catch (error) {
return sendError(reply, 400, 'wallos_inspect_failed', error instanceof Error ? error.message : 'Wallos inspect failed')
return sendError(reply, 400, 'wallos_inspect_failed', error instanceof Error ? error.message : 'api.errors.imports.wallosInspectFailed', undefined, {
locale: request.locale
})
}
})
app.post('/import/wallos/commit', async (request, reply) => {
const parsed = WallosImportCommitSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid Wallos commit payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWallosCommitPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
return sendOk(reply, await commitWallosImport(parsed.data))
} catch (error) {
return sendError(reply, 400, 'wallos_commit_failed', error instanceof Error ? error.message : 'Wallos import failed')
return sendError(reply, 400, 'wallos_commit_failed', error instanceof Error ? error.message : 'api.errors.imports.wallosCommitFailed', undefined, {
locale: request.locale
})
}
})
app.post('/import/subtracker/inspect', async (request, reply) => {
const parsed = SubtrackerBackupInspectSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid SubTracker backup inspect payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubtrackerBackupInspectPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
@@ -49,7 +59,9 @@ export async function importRoutes(app: FastifyInstance) {
reply,
400,
'subtracker_backup_inspect_failed',
error instanceof Error ? error.message : 'SubTracker backup inspect failed'
error instanceof Error ? error.message : 'api.errors.imports.subtrackerBackupInspectFailed',
undefined,
{ locale: request.locale }
)
}
})
@@ -57,7 +69,9 @@ export async function importRoutes(app: FastifyInstance) {
app.post('/import/subtracker/commit', async (request, reply) => {
const parsed = SubtrackerBackupCommitSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid SubTracker backup commit payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubtrackerBackupCommitPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
@@ -67,7 +81,9 @@ export async function importRoutes(app: FastifyInstance) {
reply,
400,
'subtracker_backup_commit_failed',
error instanceof Error ? error.message : 'SubTracker backup restore failed'
error instanceof Error ? error.message : 'api.errors.imports.subtrackerBackupCommitFailed',
undefined,
{ locale: request.locale }
)
}
})

View File

@@ -11,6 +11,7 @@ import {
TelegramConfigSchema
} from '@subtracker/shared'
import { sendError, sendOk } from '../http'
import { detectRequestLocale } from '../i18n'
import {
sendTestEmailNotification,
sendTestEmailNotificationWithConfig,
@@ -51,11 +52,15 @@ export async function notificationRoutes(app: FastifyInstance) {
app.put('/notifications/webhook', async (request, reply) => {
const parsed = NotificationWebhookSettingsSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookSettingsPayload', parsed.error.flatten(), {
locale: request.locale
})
}
if (parsed.data.enabled && !parsed.data.url) {
return sendError(reply, 422, 'validation_error', '启用 Webhook 时必须填写 URL')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookUrlRequired', undefined, {
locale: request.locale
})
}
const saved = await upsertPrimaryWebhookEndpoint(parsed.data)
@@ -63,24 +68,31 @@ export async function notificationRoutes(app: FastifyInstance) {
})
app.post('/notifications/scan-debug', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
const parsed = NotificationScanDebugSchema.safeParse(request.body ?? {})
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid scan debug payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidScanDebugPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await scanRenewalNotifications(parsed.data.now ? new Date(parsed.data.now) : new Date(), {
dryRun: parsed.data.dryRun,
includeDebugCandidates: true
includeDebugCandidates: true,
locale
})
return sendOk(reply, result)
})
app.post('/notifications/test/email', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = EmailNotificationTestSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid email config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidEmailConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
await sendTestEmailNotificationWithConfig({
emailProvider: parsed.data.emailProvider,
@@ -99,115 +111,142 @@ export async function notificationRoutes(app: FastifyInstance) {
from: parsed.data.resendConfig.from ?? '',
to: parsed.data.resendConfig.to ?? ''
}
})
}, { locale })
} else {
await sendTestEmailNotification()
await sendTestEmailNotification({ locale })
}
return sendOk(reply, { success: true })
} catch (error) {
return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'Email test failed')
return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.emailTestFailed', undefined, {
locale: request.locale
})
}
})
app.post('/notifications/test/pushplus', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = PushPlusConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid PushPlus config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidPushplusConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await sendTestPushplusNotificationWithConfig({
token: parsed.data.token ?? '',
topic: parsed.data.topic ?? ''
})
}, { locale })
return sendOk(reply, result)
}
const result = await sendTestPushplusNotification()
const result = await sendTestPushplusNotification({ locale })
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'PushPlus test failed')
return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.pushplusTestFailed', undefined, {
locale: request.locale
})
}
})
app.post('/notifications/test/telegram', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = TelegramConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid Telegram config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTelegramConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await sendTestTelegramNotificationWithConfig({
botToken: parsed.data.botToken ?? '',
chatId: parsed.data.chatId ?? ''
})
}, { locale })
return sendOk(reply, result)
}
const result = await sendTestTelegramNotification()
const result = await sendTestTelegramNotification({ locale })
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 400, 'telegram_test_failed', error instanceof Error ? error.message : 'Telegram test failed')
return sendError(reply, 400, 'telegram_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.telegramTestFailed', undefined, {
locale: request.locale
})
}
})
app.post('/notifications/test/serverchan', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = ServerchanConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid Server 酱 config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidServerchanConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await sendTestServerchanNotificationWithConfig({
sendkey: parsed.data.sendkey ?? ''
})
}, { locale })
return sendOk(reply, result)
}
const result = await sendTestServerchanNotification()
const result = await sendTestServerchanNotification({ locale })
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 400, 'serverchan_test_failed', error instanceof Error ? error.message : 'Serverchan test failed')
return sendError(reply, 400, 'serverchan_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.serverchanTestFailed', undefined, {
locale: request.locale
})
}
})
app.post('/notifications/test/gotify', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = GotifyConfigSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid Gotify config payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidGotifyConfigPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await sendTestGotifyNotificationWithConfig({
url: parsed.data.url ?? '',
token: parsed.data.token ?? '',
ignoreSsl: parsed.data.ignoreSsl ?? false
})
}, { locale })
return sendOk(reply, result)
}
const result = await sendTestGotifyNotification()
const result = await sendTestGotifyNotification({ locale })
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 400, 'gotify_test_failed', error instanceof Error ? error.message : 'Gotify test failed')
return sendError(reply, 400, 'gotify_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.gotifyTestFailed', undefined, {
locale: request.locale
})
}
})
app.post('/notifications/test/webhook', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
try {
if (request.body) {
const parsed = NotificationWebhookSettingsSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookSettingsPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await sendTestWebhookNotificationWithConfig(parsed.data)
const result = await sendTestWebhookNotificationWithConfig(parsed.data, { locale })
return sendOk(reply, result)
}
const result = await sendTestWebhookNotification()
const result = await sendTestWebhookNotification({ locale })
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'Webhook test failed')
return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.webhookTestFailed', undefined, {
locale: request.locale
})
}
})
}

View File

@@ -2,10 +2,13 @@ import { FastifyInstance } from 'fastify'
import {
DEFAULT_ADVANCE_REMINDER_RULES,
DEFAULT_OVERDUE_REMINDER_RULES,
SettingsSchema
SettingsSchema,
getMessage,
type AppLocale
} from '@subtracker/shared'
import { prisma } from '../db'
import { sendError, sendOk } from '../http'
import { detectRequestLocale } from '../i18n'
import { getAppSettings, setSetting } from '../services/settings.service'
import { validateNotificationTargetUrl } from '../services/notification-url.service'
import {
@@ -34,23 +37,46 @@ function hasDirectForgotPasswordChannelEnabled(settings: {
)
}
function validateSettingsPayload(settings: Awaited<ReturnType<typeof getAppSettings>>) {
function validateSettingsPayload(
settings: Awaited<ReturnType<typeof getAppSettings>>,
locale: AppLocale = 'zh-CN'
) {
const labels = {
smtpHost: getMessage(locale, 'settings.labels.smtpHost'),
port: getMessage(locale, 'common.labels.port'),
username: getMessage(locale, 'common.labels.username'),
password: getMessage(locale, 'common.labels.password'),
from: getMessage(locale, 'common.labels.from'),
to: getMessage(locale, 'common.labels.to'),
resendApiUrl: getMessage(locale, 'settings.labels.resendApiUrl'),
resendApiKey: getMessage(locale, 'settings.labels.resendApiKey'),
botToken: getMessage(locale, 'settings.labels.botToken'),
chatId: getMessage(locale, 'settings.labels.chatId'),
sendKey: getMessage(locale, 'settings.labels.sendKey'),
url: getMessage(locale, 'common.labels.url'),
token: getMessage(locale, 'common.labels.token'),
providerName: getMessage(locale, 'settings.labels.providerName'),
model: getMessage(locale, 'common.labels.model'),
apiBaseUrl: getMessage(locale, 'settings.labels.apiBaseUrl'),
apiKey: getMessage(locale, 'settings.labels.apiKey')
}
if (settings.emailNotificationsEnabled) {
const missingEmailFields =
settings.emailProvider === 'resend'
? [
['Resend API URL', settings.resendConfig.apiBaseUrl],
['Resend API Key', settings.resendConfig.apiKey],
['发件人', settings.resendConfig.from],
['收件人', settings.resendConfig.to]
[labels.resendApiUrl, settings.resendConfig.apiBaseUrl],
[labels.resendApiKey, settings.resendConfig.apiKey],
[labels.from, settings.resendConfig.from],
[labels.to, settings.resendConfig.to]
]
: [
['SMTP Host', settings.smtpConfig.host],
['端口', settings.smtpConfig.port],
['用户名', settings.smtpConfig.username],
['密码', settings.smtpConfig.password],
['发件人', settings.smtpConfig.from],
['收件人', settings.smtpConfig.to]
[labels.smtpHost, settings.smtpConfig.host],
[labels.port, settings.smtpConfig.port],
[labels.username, settings.smtpConfig.username],
[labels.password, settings.smtpConfig.password],
[labels.from, settings.smtpConfig.from],
[labels.to, settings.smtpConfig.to]
]
const missingEmailLabels = missingEmailFields
@@ -58,55 +84,71 @@ function validateSettingsPayload(settings: Awaited<ReturnType<typeof getAppSetti
.map(([label]) => label)
if (missingEmailLabels.length) {
throw new Error(`启用邮箱通知时必须填写:${missingEmailLabels.join('、')}`)
throw new Error(
getMessage(locale, 'api.errors.settings.emailFieldsRequired', {
fields: missingEmailLabels.join(locale === 'en-US' ? ', ' : '、')
})
)
}
}
if (settings.pushplusNotificationsEnabled && !settings.pushplusConfig.token.trim()) {
throw new Error('启用 PushPlus 时必须填写 Token')
throw new Error(getMessage(locale, 'api.errors.settings.pushplusTokenRequired'))
}
const missingTelegramFields = [
['Bot Token', settings.telegramConfig.botToken],
['Chat ID', settings.telegramConfig.chatId]
[labels.botToken, settings.telegramConfig.botToken],
[labels.chatId, settings.telegramConfig.chatId]
]
.filter(([, value]) => !String(value ?? '').trim())
.map(([label]) => label)
if (settings.telegramNotificationsEnabled && missingTelegramFields.length) {
throw new Error(`启用 Telegram 通知时必须填写:${missingTelegramFields.join('、')}`)
throw new Error(
getMessage(locale, 'api.errors.settings.telegramFieldsRequired', {
fields: missingTelegramFields.join(locale === 'en-US' ? ', ' : '、')
})
)
}
if (settings.serverchanNotificationsEnabled && !settings.serverchanConfig.sendkey.trim()) {
throw new Error('启用 Server 酱时必须填写 SendKey')
throw new Error(getMessage(locale, 'api.errors.settings.serverchanSendKeyRequired'))
}
if (settings.gotifyNotificationsEnabled) {
const missingGotifyFields = [
['URL', settings.gotifyConfig.url],
['Token', settings.gotifyConfig.token]
[labels.url, settings.gotifyConfig.url],
[labels.token, settings.gotifyConfig.token]
]
.filter(([, value]) => !String(value ?? '').trim())
.map(([label]) => label)
if (missingGotifyFields.length) {
throw new Error(`启用 Gotify 时必须填写:${missingGotifyFields.join('、')}`)
throw new Error(
getMessage(locale, 'api.errors.settings.gotifyFieldsRequired', {
fields: missingGotifyFields.join(locale === 'en-US' ? ', ' : '、')
})
)
}
validateNotificationTargetUrl(settings.gotifyConfig.url.trim(), 'Gotify URL')
}
const missingAiFields = [
['Provider 名称', settings.aiConfig.providerName],
['Model', settings.aiConfig.model],
['API Base URL', settings.aiConfig.baseUrl],
['API Key', settings.aiConfig.apiKey]
[labels.providerName, settings.aiConfig.providerName],
[labels.model, settings.aiConfig.model],
[labels.apiBaseUrl, settings.aiConfig.baseUrl],
[labels.apiKey, settings.aiConfig.apiKey]
]
.filter(([, value]) => !String(value ?? '').trim())
.map(([label]) => label)
if (settings.aiConfig.enabled && missingAiFields.length) {
throw new Error(`启用 AI 能力时必须填写:${missingAiFields.join('、')}`)
throw new Error(
getMessage(locale, 'api.errors.settings.aiFieldsRequired', {
fields: missingAiFields.join(locale === 'en-US' ? ', ' : '、')
})
)
}
}
@@ -154,9 +196,12 @@ export async function settingsRoutes(app: FastifyInstance) {
})
app.patch('/settings', async (request, reply) => {
const locale = request.locale ?? detectRequestLocale(request)
const parsed = SettingsSchema.partial().safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid settings payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSettingsPayload', parsed.error.flatten(), {
locale
})
}
const currentSettings = await getAppSettings()
@@ -165,7 +210,9 @@ export async function settingsRoutes(app: FastifyInstance) {
try {
normalizedReminderSettings = normalizeReminderSettingsPayload(parsed.data, currentSettings)
} catch (error) {
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules')
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidReminderRules', undefined, {
locale
})
}
const nextSettings = {
@@ -198,16 +245,20 @@ export async function settingsRoutes(app: FastifyInstance) {
}
try {
validateSettingsPayload(nextSettings)
validateSettingsPayload(nextSettings, locale)
} catch (error) {
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid settings payload')
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidSettingsPayload', undefined, {
locale
})
}
if (
parsed.data.forgotPasswordEnabled === true &&
!hasDirectForgotPasswordChannelEnabled(nextSettings)
) {
return sendError(reply, 422, 'validation_error', '请先启用至少一个可直达的通知渠道,再开启忘记密码')
return sendError(reply, 422, 'validation_error', 'api.errors.auth.forgotPasswordChannelRequired', undefined, {
locale
})
}
if (!hasDirectForgotPasswordChannelEnabled(nextSettings)) {

View File

@@ -3,12 +3,14 @@ import type { Prisma } from '@prisma/client'
import { z } from 'zod'
import { prisma } from '../db'
import { sendCreated, sendError, sendOk } from '../http'
import type { AppLocale } from '@subtracker/shared'
import {
CreateSubscriptionSchema,
LogoSearchSchema,
LogoUploadSchema,
RenewSubscriptionSchema,
UpdateSubscriptionSchema
UpdateSubscriptionSchema,
getMessage
} from '@subtracker/shared'
import {
appendSubscriptionOrder,
@@ -89,6 +91,17 @@ function parseBatchStatus(input: unknown) {
.safeParse(input)
}
function getSubscriptionValidationMessageKey(error: string) {
switch (error) {
case 'Only active subscriptions can be paused in batch mode':
return 'api.errors.subscriptions.batchPauseOnlyActive'
case 'Only active subscriptions can be cancelled in batch mode':
return 'api.errors.subscriptions.batchCancelOnlyActive'
default:
return error
}
}
function normalizeSubscriptionPayloadWebsiteUrl<T extends Record<string, unknown>>(
payload: T
): { payload: T; websiteUrlError: string | null } {
@@ -114,6 +127,7 @@ async function runBatchAction(
ids: string[],
action: (id: string) => Promise<void>,
options?: {
locale?: AppLocale
validate?: (rows: Array<{ id: string; status: string }>) => string | null
}
) {
@@ -136,7 +150,7 @@ async function runBatchAction(
failures: [
{
id: missingId ?? 'unknown',
message: 'Subscription not found'
message: 'api.errors.subscriptions.notFound'
}
]
}
@@ -149,7 +163,7 @@ async function runBatchAction(
failureCount: ids.length,
failures: ids.map((id) => ({
id,
message: validationError
message: getSubscriptionValidationMessageKey(validationError)
}))
}
}
@@ -164,7 +178,7 @@ async function runBatchAction(
} catch (error) {
failures.push({
id,
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : getMessage(options?.locale ?? 'zh-CN', 'common.errors.requestFailed')
})
}
}
@@ -205,7 +219,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/logo/search', async (request, reply) => {
const parsed = LogoSearchSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid logo search payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoSearchPayload', parsed.error.flatten(), {
locale: request.locale
})
}
return sendOk(reply, await searchSubscriptionLogos(parsed.data))
@@ -218,26 +234,34 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.delete('/subscriptions/logo/library/:filename', async (request, reply) => {
const parsed = z.object({ filename: z.string().min(1).max(255) }).safeParse(request.params)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid logo filename', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoFilename', parsed.error.flatten(), {
locale: request.locale
})
}
try {
return sendOk(reply, await deleteLocalLogoFromLibrary(parsed.data.filename))
} catch (error) {
return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'Logo delete failed')
return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoDeleteFailed', undefined, {
locale: request.locale
})
}
})
app.post('/subscriptions/logo/upload', async (request, reply) => {
const parsed = LogoUploadSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid logo upload payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoUploadPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
return sendOk(reply, await saveUploadedLogo(parsed.data))
} catch (error) {
return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'Logo upload failed')
return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoUploadFailed', undefined, {
locale: request.locale
})
}
})
@@ -248,13 +272,17 @@ export async function subscriptionRoutes(app: FastifyInstance) {
}).safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid logo import payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoImportPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
return sendOk(reply, await importRemoteLogo(parsed.data))
} catch (error) {
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed')
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoImportFailed', undefined, {
locale: request.locale
})
}
})
@@ -267,7 +295,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
const parsed = querySchema.safeParse(request.query)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidQuery', parsed.error.flatten(), {
locale: request.locale
})
}
const where: Record<string, unknown> = {}
@@ -313,7 +343,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
}).safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid reorder payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidReorderPayload', parsed.error.flatten(), {
locale: request.locale
})
}
await setSubscriptionOrder(parsed.data.ids)
@@ -323,7 +355,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/batch/renew', async (request, reply) => {
const parsed = parseBatchIds(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid batch renew payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchRenewPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await runBatchAction(parsed.data.ids, async (id) => {
@@ -336,7 +370,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/batch/status', async (request, reply) => {
const parsed = parseBatchStatus(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid batch status payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchStatusPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await runBatchAction(parsed.data.ids, async (id) => {
@@ -352,7 +388,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/batch/pause', async (request, reply) => {
const parsed = parseBatchIds(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid batch pause payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchPausePayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await runBatchAction(
@@ -364,8 +402,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
})
},
{
locale: request.locale,
validate: (rows) =>
rows.some((row) => row.status !== 'active') ? 'Only active subscriptions can be paused in batch mode' : null
rows.some((row) => row.status !== 'active') ? 'api.errors.subscriptions.batchPauseOnlyActive' : null
}
)
@@ -375,7 +414,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/batch/cancel', async (request, reply) => {
const parsed = parseBatchIds(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid batch cancel payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchCancelPayload', parsed.error.flatten(), {
locale: request.locale
})
}
const result = await runBatchAction(
@@ -387,8 +428,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
})
},
{
locale: request.locale,
validate: (rows) =>
rows.some((row) => row.status !== 'active') ? 'Only active subscriptions can be cancelled in batch mode' : null
rows.some((row) => row.status !== 'active') ? 'api.errors.subscriptions.batchCancelOnlyActive' : null
}
)
@@ -398,7 +440,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/batch/delete', async (request, reply) => {
const parsed = parseBatchIds(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid batch delete payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchDeletePayload', parsed.error.flatten(), {
locale: request.locale
})
}
const rows = await prisma.subscription.findMany({
@@ -414,10 +458,12 @@ export async function subscriptionRoutes(app: FastifyInstance) {
if (rows.length !== parsed.data.ids.length) {
const existing = new Set(rows.map((item) => item.id))
const missingId = parsed.data.ids.find((id) => !existing.has(id))
return sendError(reply, 404, 'not_found', 'Subscription not found', {
return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', {
successCount: 0,
failureCount: 1,
failures: [{ id: missingId ?? 'unknown', message: 'Subscription not found' }]
failures: [{ id: missingId ?? 'unknown', message: 'api.errors.subscriptions.notFound' }]
}, {
locale: request.locale
})
}
@@ -428,7 +474,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
if (row.status === 'active') {
failures.push({
id: row.id,
message: 'Active subscriptions cannot be deleted directly'
message: 'api.errors.subscriptions.activeDeleteBlocked'
})
continue
}
@@ -442,7 +488,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
} catch (error) {
failures.push({
id: row.id,
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : getMessage(request.locale ?? 'zh-CN', 'common.errors.requestFailed')
})
}
}
@@ -457,7 +503,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.get('/subscriptions/:id', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const row = await prisma.subscription.findUnique({
@@ -466,7 +514,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
})
if (!row) {
return sendError(reply, 404, 'not_found', 'Subscription not found')
return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, {
locale: request.locale
})
}
const timezone = await getAppTimezone()
@@ -477,7 +527,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.get('/subscriptions/:id/payment-records', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const records = await prisma.paymentRecord.findMany({
@@ -491,16 +543,20 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions', async (request, reply) => {
const normalizedPayload = normalizeSubscriptionPayloadWebsiteUrl((request.body ?? {}) as Record<string, unknown>)
if (normalizedPayload.websiteUrlError) {
return sendError(reply, 422, 'validation_error', 'websiteUrl 格式无效,请填写合法网址', {
return sendError(reply, 422, 'validation_error', 'api.errors.subscriptions.websiteUrlInvalid', {
fieldErrors: {
websiteUrl: [normalizedPayload.websiteUrlError]
}
}, {
locale: request.locale
})
}
const parsed = CreateSubscriptionSchema.safeParse(normalizedPayload.payload)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionPayload', parsed.error.flatten(), {
locale: request.locale
})
}
let normalizedLogo
@@ -510,7 +566,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
logoSource: parsed.data.logoSource ?? null
})
} catch (error) {
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed')
return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoImportFailed', undefined, {
locale: request.locale
})
}
const tagIds = normalizeTagIds(parsed.data.tagIds)
@@ -519,7 +577,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
try {
reminderFields = await resolveSubscriptionReminderFields(parsed.data)
} catch (error) {
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules')
return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidReminderRules', undefined, {
locale: request.locale
})
}
const timezone = await getAppTimezone()
@@ -566,21 +626,27 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.patch('/subscriptions/:id', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const normalizedPayload = normalizeSubscriptionPayloadWebsiteUrl((request.body ?? {}) as Record<string, unknown>)
if (normalizedPayload.websiteUrlError) {
return sendError(reply, 422, 'validation_error', 'websiteUrl 格式无效,请填写合法网址', {
return sendError(reply, 422, 'validation_error', 'api.errors.subscriptions.websiteUrlInvalid', {
fieldErrors: {
websiteUrl: [normalizedPayload.websiteUrlError]
}
}, {
locale: request.locale
})
}
const parsed = UpdateSubscriptionSchema.safeParse(normalizedPayload.payload)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid update payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidUpdatePayload', parsed.error.flatten(), {
locale: request.locale
})
}
const payload = parsed.data
@@ -603,7 +669,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
})
if (!existing) {
throw new Error('Subscription not found')
throw new Error('api.errors.subscriptions.notFound')
}
const normalizedNextRenewalDate =
@@ -660,21 +726,29 @@ export async function subscriptionRoutes(app: FastifyInstance) {
return sendOk(reply, flattenSubscriptionTags(updated))
} catch (error) {
if (error instanceof Error && error.message.includes('Logo')) {
return sendError(reply, 400, 'logo_import_failed', error.message)
return sendError(reply, 400, 'logo_import_failed', error.message, undefined, {
locale: request.locale
})
}
return sendError(reply, 404, 'not_found', 'Subscription not found')
return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, {
locale: request.locale
})
}
})
app.post('/subscriptions/:id/renew', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const parsed = RenewSubscriptionSchema.safeParse(request.body ?? {})
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid renew payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidRenewPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
@@ -688,14 +762,18 @@ export async function subscriptionRoutes(app: FastifyInstance) {
return sendOk(reply, result)
} catch (error) {
return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'Renew failed')
return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'api.errors.subscriptions.renewFailed', undefined, {
locale: request.locale
})
}
})
app.post('/subscriptions/:id/pause', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const updated = await prisma.subscription.update({
@@ -709,7 +787,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.post('/subscriptions/:id/cancel', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
const updated = await prisma.subscription.update({
@@ -723,7 +803,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
app.delete('/subscriptions/:id', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid subscription id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, {
locale: request.locale
})
}
try {
@@ -733,11 +815,15 @@ export async function subscriptionRoutes(app: FastifyInstance) {
})
if (!existing) {
return sendError(reply, 404, 'not_found', 'Subscription not found')
return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, {
locale: request.locale
})
}
if (existing.status === 'active') {
return sendError(reply, 422, 'subscription_delete_not_allowed', '正常中的订阅不能直接删除,请先暂停或停用')
return sendError(reply, 422, 'subscription_delete_not_allowed', 'api.errors.subscriptions.activeDeleteNotAllowed', undefined, {
locale: request.locale
})
}
await prisma.subscription.delete({
@@ -747,7 +833,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
await removeSubscriptionOrder(params.data.id)
return sendOk(reply, { id: params.data.id, deleted: true })
} catch {
return sendError(reply, 404, 'not_found', 'Subscription not found')
return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, {
locale: request.locale
})
}
})
}

View File

@@ -13,7 +13,9 @@ export async function tagRoutes(app: FastifyInstance) {
app.post('/tags', async (request, reply) => {
const parsed = TagSchema.safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid tag payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
@@ -27,19 +29,25 @@ export async function tagRoutes(app: FastifyInstance) {
})
return sendCreated(reply, created)
} catch (error) {
return sendError(reply, 409, 'conflict', 'Tag name already exists', error)
return sendError(reply, 409, 'conflict', 'api.errors.tags.nameExists', error, {
locale: request.locale
})
}
})
app.patch('/tags/:id', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid tag id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagId', undefined, {
locale: request.locale
})
}
const parsed = TagSchema.partial().refine((value) => Object.keys(value).length > 0, 'Empty update payload').safeParse(request.body)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid tag payload', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagPayload', parsed.error.flatten(), {
locale: request.locale
})
}
try {
@@ -54,14 +62,18 @@ export async function tagRoutes(app: FastifyInstance) {
})
return sendOk(reply, updated)
} catch (error) {
return sendError(reply, 409, 'conflict', 'Tag update failed', error)
return sendError(reply, 409, 'conflict', 'api.errors.tags.updateFailed', error, {
locale: request.locale
})
}
})
app.delete('/tags/:id', async (request, reply) => {
const params = z.object({ id: z.string() }).safeParse(request.params)
if (!params.success) {
return sendError(reply, 422, 'validation_error', 'Invalid tag id')
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagId', undefined, {
locale: request.locale
})
}
try {
@@ -77,7 +89,9 @@ export async function tagRoutes(app: FastifyInstance) {
return sendOk(reply, { id: params.data.id, deleted: true })
} catch (error) {
return sendError(reply, 404, 'not_found', 'Tag not found', error)
return sendError(reply, 404, 'not_found', 'api.errors.tags.notFound', error, {
locale: request.locale
})
}
})
}

View File

@@ -12,14 +12,18 @@ export async function versionRoutes(app: FastifyInstance) {
.safeParse(request.query)
if (!parsed.success) {
return sendError(reply, 422, 'validation_error', 'Invalid currentVersion query', parsed.error.flatten())
return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidCurrentVersionQuery', parsed.error.flatten(), {
locale: request.locale
})
}
try {
const summary = await getVersionUpdateSummary(parsed.data.currentVersion)
return sendOk(reply, summary)
} catch (error) {
return sendError(reply, 502, 'version_update_fetch_failed', error instanceof Error ? error.message : 'Failed to fetch releases')
return sendError(reply, 502, 'version_update_fetch_failed', error instanceof Error ? error.message : 'api.errors.version.updateFetchFailed', undefined, {
locale: request.locale
})
}
})
}

View File

@@ -1,14 +1,14 @@
import crypto from 'node:crypto'
import {
DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT,
DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT,
formatAiSummaryPreviewText,
getDefaultAiDashboardSummaryPreviewPrompt,
getDefaultAiDashboardSummaryPrompt,
type AiDashboardSummaryDto,
type DashboardOverview
} from '@subtracker/shared'
import { ensureAiSummaryConfig } from './ai.service'
import { getOverviewStatistics } from './statistics.service'
import { getAiConfig } from './settings.service'
import { getAiConfig, getSystemDefaultLocale } from './settings.service'
type CachedDashboardSummary = {
scope: 'dashboard-overview'
@@ -59,14 +59,14 @@ function logAiSummary(stage: string, details?: Record<string, unknown>) {
console.log(`[ai-summary] ${stage}`, details)
}
function resolveDashboardSummaryPrompt(promptTemplate?: string | null) {
async function resolveDashboardSummaryPrompt(promptTemplate?: string | null) {
const normalized = String(promptTemplate ?? '').trim()
return normalized || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT
return normalized || getDefaultAiDashboardSummaryPrompt(await getSystemDefaultLocale())
}
function resolveDashboardSummaryPreviewPrompt() {
return DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT
async function resolveDashboardSummaryPreviewPrompt() {
return getDefaultAiDashboardSummaryPreviewPrompt(await getSystemDefaultLocale())
}
function extractChatCompletionText(payload: ChatCompletionPayload) {
@@ -208,7 +208,7 @@ async function requestDashboardSummaryPreviewMarkdown(params: {
messages: [
{
role: 'system',
content: resolveDashboardSummaryPreviewPrompt()
content: await resolveDashboardSummaryPreviewPrompt()
},
{
role: 'user',
@@ -261,7 +261,7 @@ async function requestDashboardSummaryMarkdown(params: {
messages: [
{
role: 'system',
content: resolveDashboardSummaryPrompt(params.promptTemplate)
content: await resolveDashboardSummaryPrompt(params.promptTemplate)
},
{
role: 'user',

View File

@@ -1,9 +1,9 @@
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
import { createWorker, type Worker } from 'tesseract.js'
import { AiRecognizeSubscriptionSchema, DEFAULT_AI_SUBSCRIPTION_PROMPT } from '@subtracker/shared'
import { AiRecognizeSubscriptionSchema, getDefaultAiSubscriptionPrompt, type AppLocale } from '@subtracker/shared'
import type { AiRecognitionResultDto } from '@subtracker/shared'
import { getAiConfig } from './settings.service'
import { getAiConfig, getSystemDefaultLocale } from './settings.service'
export type AiSettings = Awaited<ReturnType<typeof getAiConfig>>
@@ -115,8 +115,9 @@ async function extractTextFromImageWithOcr(imageBase64: string) {
return (result.data.text || '').trim()
}
function buildRecognitionSystemPrompt(aiConfig: AiSettings, forceJsonPromptOnly = false) {
const basePrompt = aiConfig.promptTemplate?.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT
async function buildRecognitionSystemPrompt(aiConfig: AiSettings, forceJsonPromptOnly = false, locale?: AppLocale) {
const resolvedLocale = locale ?? (await getSystemDefaultLocale())
const basePrompt = aiConfig.promptTemplate?.trim() || getDefaultAiSubscriptionPrompt(resolvedLocale)
if (!forceJsonPromptOnly) {
return basePrompt
}
@@ -175,7 +176,7 @@ async function requestStructuredJsonCompletion(params: {
messages: [
{
role: 'system',
content: buildRecognitionSystemPrompt(params.aiConfig, promptOnlyJson)
content: await buildRecognitionSystemPrompt(params.aiConfig, promptOnlyJson)
},
{
role: 'user',

View File

@@ -3,6 +3,9 @@ import https from 'node:https'
import nodemailer from 'nodemailer'
import {
DEFAULT_RESEND_API_URL,
DEFAULT_APP_LOCALE,
getMessage,
type AppLocale,
type EmailConfigInput,
type GotifyConfigInput,
type ResendConfigInput,
@@ -12,7 +15,7 @@ import {
type WebhookEventType
} from '@subtracker/shared'
import { dispatchWebhookEvent } from './webhook.service'
import { getAppTimezone, getNotificationChannelSettings, getSetting, setSetting } from './settings.service'
import { getAppTimezone, getNotificationChannelSettings, getSetting, getSystemDefaultLocale, setSetting } from './settings.service'
import { validateNotificationTargetUrl } from './notification-url.service'
import { toIsoDate } from '../utils/date'
import { formatDateInTimezone } from '../utils/timezone'
@@ -58,6 +61,18 @@ type ForgotPasswordNotificationPayload = {
expiresInMinutes: number
}
type NotificationLocaleContext = {
locale?: AppLocale
}
type DirectChannelDispatchOptions = {
channel: 'email' | 'pushplus' | 'telegram' | 'serverchan' | 'gotify'
enabled: boolean
disabledMessage: string
alreadySentMessage: string
send: (message: DirectNotificationMessage) => Promise<void>
}
const NOTIFICATION_DEDUP_KEY_PREFIX = 'notification:'
export const NOTIFICATION_DEDUP_RETENTION_DAYS = 30
@@ -87,19 +102,8 @@ export async function cleanupOldNotificationDedupSettings(
return result.count
}
const CHANNEL_LABELS: Record<NotificationChannelResult['channel'], string> = {
webhook: 'Webhook',
email: '邮箱',
pushplus: 'PushPlus',
telegram: 'Telegram',
serverchan: 'Server 酱',
gotify: 'Gotify'
}
const CHANNEL_STATUS_LABELS: Record<NotificationChannelResult['status'], string> = {
success: '成功',
skipped: '跳过',
failed: '失败'
async function resolveNotificationLocale(locale?: AppLocale): Promise<AppLocale> {
return locale ?? (await getSystemDefaultLocale().catch(() => DEFAULT_APP_LOCALE))
}
function getNotificationLogName(params: NotificationDispatchParams) {
@@ -107,17 +111,17 @@ function getNotificationLogName(params: NotificationDispatchParams) {
return typeof name === 'string' && name.trim() ? name.trim() : params.resourceKey
}
function formatChannelResult(result: NotificationChannelResult) {
const label = CHANNEL_LABELS[result.channel]
const status = CHANNEL_STATUS_LABELS[result.status]
function formatChannelResult(result: NotificationChannelResult, locale: AppLocale) {
const label = getMessage(locale, `notifications.channels.${result.channel}`)
const status = getMessage(locale, `notifications.status.${result.status}`)
return result.message ? `${label}${status}${result.message}` : `${label}${status}`
}
function logNotificationDispatch(params: NotificationDispatchParams, results: NotificationChannelResult[]) {
function logNotificationDispatch(params: NotificationDispatchParams, results: NotificationChannelResult[], locale: AppLocale) {
const successCount = results.filter((result) => result.status === 'success').length
const failed = results.filter((result) => result.status === 'failed')
const skipped = results.filter((result) => result.status === 'skipped')
const details = results.map(formatChannelResult).join('')
const details = results.map((result) => formatChannelResult(result, locale)).join('')
const baseMessage = `[notification] ${getNotificationLogName(params)}:通知渠道 ${successCount} 个成功,${failed.length} 个失败,${skipped.length} 个跳过。${details}`
if (failed.length) {
@@ -200,28 +204,19 @@ function resolveDispatchParamsForChannel(
})
}
function buildForgotPasswordTitle() {
return 'SubTracker 密码重置验证码'
function buildForgotPasswordTitle(locale: AppLocale) {
return getMessage(locale, 'notifications.forgotPassword.title')
}
function buildForgotPasswordBody(payload: ForgotPasswordNotificationPayload) {
function buildForgotPasswordBody(payload: ForgotPasswordNotificationPayload, locale: AppLocale) {
return [
`用户名:${payload.username}`,
`验证码:${payload.code}`,
`有效期:${payload.expiresInMinutes} 分钟`,
'如果这不是你的操作,请忽略本次通知。'
getMessage(locale, 'notifications.forgotPassword.username', { username: payload.username }),
getMessage(locale, 'notifications.forgotPassword.code', { code: payload.code }),
getMessage(locale, 'notifications.forgotPassword.expiresInMinutes', { minutes: payload.expiresInMinutes }),
getMessage(locale, 'notifications.forgotPassword.ignoreHint')
].join('\n')
}
function buildForgotPasswordMessage(payload: ForgotPasswordNotificationPayload): DirectNotificationMessage {
const text = buildForgotPasswordBody(payload)
return {
title: buildForgotPasswordTitle(),
text,
html: `<pre>${text}</pre>`
}
}
async function sendSmtpEmailWithConfig(message: DirectNotificationMessage, config: EmailConfigInput) {
const { host, port, secure, username, password, from, to } = config
if (!host || !port || !username || !password || !from || !to) {
@@ -293,14 +288,6 @@ async function sendEmailWithProvider(
await sendSmtpEmailWithConfig(message, smtpConfig)
}
type DirectChannelDispatchOptions = {
channel: 'email' | 'pushplus' | 'telegram' | 'serverchan' | 'gotify'
enabled: boolean
disabledMessage: string
alreadySentMessage: string
send: (message: DirectNotificationMessage) => Promise<void>
}
async function dispatchDirectChannelNotification(
params: NotificationDispatchParams,
options: DirectChannelDispatchOptions
@@ -332,7 +319,9 @@ async function dispatchDirectChannelNotification(
}
}
async function sendEmailNotification(params: NotificationDispatchParams): Promise<NotificationChannelResult> {
async function sendEmailNotification(
params: NotificationDispatchParams
): Promise<NotificationChannelResult> {
const settings = await getNotificationChannelSettings()
return dispatchDirectChannelNotification(params, {
channel: 'email',
@@ -409,7 +398,9 @@ async function sendPushplusWithConfig(
}
}
async function sendPushplusNotification(params: NotificationDispatchParams): Promise<NotificationChannelResult> {
async function sendPushplusNotification(
params: NotificationDispatchParams
): Promise<NotificationChannelResult> {
const settings = await getNotificationChannelSettings()
return dispatchDirectChannelNotification(params, {
channel: 'pushplus',
@@ -454,7 +445,9 @@ async function sendTelegramWithConfig(message: DirectNotificationMessage, config
}
}
async function sendTelegramNotification(params: NotificationDispatchParams): Promise<NotificationChannelResult> {
async function sendTelegramNotification(
params: NotificationDispatchParams
): Promise<NotificationChannelResult> {
const settings = await getNotificationChannelSettings()
return dispatchDirectChannelNotification(params, {
channel: 'telegram',
@@ -514,7 +507,9 @@ async function sendServerchanWithConfig(message: DirectNotificationMessage, conf
}
}
async function sendServerchanNotification(params: NotificationDispatchParams): Promise<NotificationChannelResult> {
async function sendServerchanNotification(
params: NotificationDispatchParams
): Promise<NotificationChannelResult> {
const settings = await getNotificationChannelSettings()
return dispatchDirectChannelNotification(params, {
channel: 'serverchan',
@@ -573,7 +568,9 @@ async function sendGotifyWithConfig(message: DirectNotificationMessage, config:
})
}
async function sendGotifyNotification(params: NotificationDispatchParams): Promise<NotificationChannelResult> {
async function sendGotifyNotification(
params: NotificationDispatchParams
): Promise<NotificationChannelResult> {
const settings = await getNotificationChannelSettings()
return dispatchDirectChannelNotification(params, {
channel: 'gotify',
@@ -584,11 +581,15 @@ async function sendGotifyNotification(params: NotificationDispatchParams): Promi
})
}
export async function dispatchNotificationEvent(params: NotificationDispatchParams) {
export async function dispatchNotificationEvent(
params: NotificationDispatchParams,
context: NotificationLocaleContext = {}
) {
const results: NotificationChannelResult[] = []
const locale = await resolveNotificationLocale(context.locale)
try {
const webhookResult = await dispatchWebhookEvent(params)
const webhookResult = await dispatchWebhookEvent(params, { locale })
results.push(webhookResult)
} catch (error) {
results.push({
@@ -633,52 +634,66 @@ export async function dispatchNotificationEvent(params: NotificationDispatchPara
}))) as NotificationChannelResult
results.push(gotifyResult)
logNotificationDispatch(params, results)
logNotificationDispatch(params, results, locale)
return results
}
function buildTestReminderPayload() {
function buildTestReminderPayload(locale: AppLocale) {
return {
name: '测试订阅',
name: getMessage(locale, 'notifications.tests.subscriptionName'),
nextRenewalDate: '',
amount: 19.9,
currency: 'CNY',
tagNames: ['测试标签'],
tagNames: [getMessage(locale, 'notifications.tests.tagName')],
websiteUrl: 'https://example.com',
notes: '这是一条测试通知',
notes: getMessage(locale, 'notifications.tests.note'),
phase: 'upcoming',
daysUntilRenewal: 3,
daysOverdue: 0
}
}
async function buildTestReminderMessage() {
async function buildTestReminderMessage(locale: AppLocale) {
const timezone = await getAppTimezone()
return buildNotificationMessage({
eventType: 'subscription.reminder_due',
resourceKey: 'test:notification',
periodKey: `${toIsoDate(new Date(), timezone)}:upcoming`,
payload: {
...buildTestReminderPayload(),
...buildTestReminderPayload(locale),
nextRenewalDate: formatDateInTimezone(new Date(), timezone)
}
})
}
export async function sendTestEmailNotification() {
export async function sendTestEmailNotification(context: NotificationLocaleContext = {}) {
const settings = await getNotificationChannelSettings()
if (!settings.emailNotificationsEnabled) {
throw new Error('邮箱通知未启用或配置不完整')
}
await sendEmailWithProvider(await buildTestReminderMessage(), settings.emailProvider, settings.smtpConfig, settings.resendConfig)
const locale = await resolveNotificationLocale(context.locale)
await sendEmailWithProvider(
await buildTestReminderMessage(locale),
settings.emailProvider,
settings.smtpConfig,
settings.resendConfig
)
}
export async function sendForgotPasswordVerificationCode(payload: ForgotPasswordNotificationPayload) {
export async function sendForgotPasswordVerificationCode(
payload: ForgotPasswordNotificationPayload,
context: NotificationLocaleContext = {}
) {
const settings = await getNotificationChannelSettings()
const results: NotificationChannelResult[] = []
const message = buildForgotPasswordMessage(payload)
const locale = await resolveNotificationLocale(context.locale)
const message: DirectNotificationMessage = {
title: buildForgotPasswordTitle(locale),
text: buildForgotPasswordBody(payload, locale),
html: `<pre>${buildForgotPasswordBody(payload, locale)}</pre>`
}
if (settings.emailNotificationsEnabled) {
try {
@@ -762,17 +777,24 @@ export async function sendTestEmailNotificationWithConfig(config: {
emailProvider: 'smtp' | 'resend'
smtpConfig: EmailConfigInput
resendConfig: ResendConfigInput
}) {
await sendEmailWithProvider(await buildTestReminderMessage(), config.emailProvider, config.smtpConfig, config.resendConfig)
}, context: NotificationLocaleContext = {}) {
const locale = await resolveNotificationLocale(context.locale)
await sendEmailWithProvider(
await buildTestReminderMessage(locale),
config.emailProvider,
config.smtpConfig,
config.resendConfig
)
}
export async function sendTestPushplusNotification() {
export async function sendTestPushplusNotification(context: NotificationLocaleContext = {}) {
const settings = await getNotificationChannelSettings()
if (!settings.pushplusNotificationsEnabled) {
throw new Error('PushPlus 通知未启用或配置不完整')
}
await sendPushplusWithConfig(await buildTestReminderMessage(), settings.pushplusConfig)
const locale = await resolveNotificationLocale(context.locale)
await sendPushplusWithConfig(await buildTestReminderMessage(locale), settings.pushplusConfig)
return {
accepted: true,
@@ -780,57 +802,76 @@ export async function sendTestPushplusNotification() {
}
}
export async function sendTestPushplusNotificationWithConfig(config: PushPlusConfigInput) {
return sendPushplusWithConfig(await buildTestReminderMessage(), config)
export async function sendTestPushplusNotificationWithConfig(
config: PushPlusConfigInput,
context: NotificationLocaleContext = {}
) {
const locale = await resolveNotificationLocale(context.locale)
return sendPushplusWithConfig(await buildTestReminderMessage(locale), config)
}
export async function sendTestTelegramNotification() {
export async function sendTestTelegramNotification(context: NotificationLocaleContext = {}) {
const settings = await getNotificationChannelSettings()
if (!settings.telegramNotificationsEnabled) {
throw new Error('Telegram 通知未启用或配置不完整')
}
await sendTelegramWithConfig(await buildTestReminderMessage(), settings.telegramConfig)
const locale = await resolveNotificationLocale(context.locale)
await sendTelegramWithConfig(await buildTestReminderMessage(locale), settings.telegramConfig)
return { success: true }
}
export async function sendTestTelegramNotificationWithConfig(config: TelegramConfigInput) {
await sendTelegramWithConfig(await buildTestReminderMessage(), config)
export async function sendTestTelegramNotificationWithConfig(
config: TelegramConfigInput,
context: NotificationLocaleContext = {}
) {
const locale = await resolveNotificationLocale(context.locale)
await sendTelegramWithConfig(await buildTestReminderMessage(locale), config)
return { success: true }
}
export async function sendTestServerchanNotification() {
export async function sendTestServerchanNotification(context: NotificationLocaleContext = {}) {
const settings = await getNotificationChannelSettings()
if (!settings.serverchanNotificationsEnabled) {
throw new Error('Server 酱通知未启用或配置不完整')
}
await sendServerchanWithConfig(await buildTestReminderMessage(), settings.serverchanConfig)
const locale = await resolveNotificationLocale(context.locale)
await sendServerchanWithConfig(await buildTestReminderMessage(locale), settings.serverchanConfig)
return { success: true }
}
export async function sendTestServerchanNotificationWithConfig(config: ServerchanConfigInput) {
await sendServerchanWithConfig(await buildTestReminderMessage(), config)
export async function sendTestServerchanNotificationWithConfig(
config: ServerchanConfigInput,
context: NotificationLocaleContext = {}
) {
const locale = await resolveNotificationLocale(context.locale)
await sendServerchanWithConfig(await buildTestReminderMessage(locale), config)
return { success: true }
}
export async function sendTestGotifyNotification() {
export async function sendTestGotifyNotification(context: NotificationLocaleContext = {}) {
const settings = await getNotificationChannelSettings()
if (!settings.gotifyNotificationsEnabled) {
throw new Error('Gotify 通知未启用或配置不完整')
}
await sendGotifyWithConfig(await buildTestReminderMessage(), settings.gotifyConfig)
const locale = await resolveNotificationLocale(context.locale)
await sendGotifyWithConfig(await buildTestReminderMessage(locale), settings.gotifyConfig)
return { success: true }
}
export async function sendTestGotifyNotificationWithConfig(config: GotifyConfigInput) {
await sendGotifyWithConfig(await buildTestReminderMessage(), config)
export async function sendTestGotifyNotificationWithConfig(
config: GotifyConfigInput,
context: NotificationLocaleContext = {}
) {
const locale = await resolveNotificationLocale(context.locale)
await sendGotifyWithConfig(await buildTestReminderMessage(locale), config)
return { success: true }
}

View File

@@ -1,4 +1,5 @@
import { createHash, randomInt } from 'node:crypto'
import type { AppLocale } from '@subtracker/shared'
import { getNotificationChannelSettings, getSetting, setSetting } from './settings.service'
import { getStoredCredentials, resetPasswordForStoredUsername } from './auth.service'
import { sendForgotPasswordVerificationCode } from './channel-notification.service'
@@ -27,6 +28,33 @@ type StoredRateLimitRecord = {
windowStartedAt: number
}
type ForgotPasswordError = {
status: number
code: string
message: string
retryAfterSeconds?: number
messageParams?: Record<string, string | number | boolean | null | undefined>
}
type ForgotPasswordFailure = {
ok: false
error: ForgotPasswordError
}
type ForgotPasswordRequestResult =
| {
ok: true
accepted: true
}
| ForgotPasswordFailure
type ForgotPasswordResetResult =
| {
ok: true
result: NonNullable<Awaited<ReturnType<typeof resetPasswordForStoredUsername>>>
}
| ForgotPasswordFailure
function hashVerificationCode(code: string) {
return createHash('sha256').update(code).digest('hex')
}
@@ -95,15 +123,19 @@ export async function clearForgotPasswordChallenge() {
await setSetting(FORGOT_PASSWORD_CHALLENGE_KEY, null)
}
export async function requestForgotPasswordChallenge(username: string, remoteAddress: string) {
export async function requestForgotPasswordChallenge(
username: string,
remoteAddress: string,
locale?: AppLocale
): Promise<ForgotPasswordRequestResult> {
if (!(await isForgotPasswordEnabled())) {
return {
ok: false as const,
error: {
status: 403,
code: 'forgot_password_disabled',
message: '当前未开启忘记密码,或未配置可用通知渠道'
}
message: 'api.errors.auth.forgotPasswordDisabled'
} satisfies ForgotPasswordError
}
}
@@ -120,9 +152,9 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd
error: {
status: 429,
code: 'forgot_password_request_rate_limited',
message: '验证码发送过于频繁,请稍后再试',
message: 'api.errors.auth.forgotPasswordRequestRateLimited',
retryAfterSeconds
}
} satisfies ForgotPasswordError
}
}
@@ -143,9 +175,9 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd
error: {
status: 429,
code: 'forgot_password_request_cooldown',
message: '验证码刚刚发送过,请稍后再试',
message: 'api.errors.auth.forgotPasswordRequestCooldown',
retryAfterSeconds: Math.max(1, Math.ceil(remainingCooldownMs / 1000))
}
} satisfies ForgotPasswordError
}
}
}
@@ -155,7 +187,7 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd
username: credentials.username,
code,
expiresInMinutes: CHALLENGE_TTL_MS / 60_000
})
}, { locale })
if (!dispatchResults.some((item) => item.status === 'success')) {
return {
@@ -163,8 +195,8 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd
error: {
status: 400,
code: 'forgot_password_delivery_failed',
message: '验证码发送失败,请检查通知配置'
}
message: 'api.errors.auth.forgotPasswordDeliveryFailed'
} satisfies ForgotPasswordError
}
}
@@ -187,15 +219,15 @@ export async function resetPasswordWithForgotPasswordCode(input: {
code: string
newPassword: string
remoteAddress: string
}) {
}): Promise<ForgotPasswordResetResult> {
if (!(await isForgotPasswordEnabled())) {
return {
ok: false as const,
error: {
status: 403,
code: 'forgot_password_disabled',
message: '当前未开启忘记密码,或未配置可用通知渠道'
}
message: 'api.errors.auth.forgotPasswordDisabled'
} satisfies ForgotPasswordError
}
}
@@ -212,9 +244,9 @@ export async function resetPasswordWithForgotPasswordCode(input: {
error: {
status: 429,
code: 'forgot_password_reset_rate_limited',
message: '验证失败次数过多,请稍后再试',
message: 'api.errors.auth.forgotPasswordResetRateLimited',
retryAfterSeconds
}
} satisfies ForgotPasswordError
}
}
@@ -227,8 +259,8 @@ export async function resetPasswordWithForgotPasswordCode(input: {
error: {
status: 400,
code: 'forgot_password_challenge_not_found',
message: '验证码无效或已失效'
}
message: 'api.errors.auth.forgotPasswordChallengeNotFound'
} satisfies ForgotPasswordError
}
}
@@ -239,8 +271,8 @@ export async function resetPasswordWithForgotPasswordCode(input: {
error: {
status: 400,
code: 'forgot_password_attempts_exhausted',
message: '验证码尝试次数已用尽,请重新获取'
}
message: 'api.errors.auth.forgotPasswordAttemptsExhausted'
} satisfies ForgotPasswordError
}
}
@@ -260,8 +292,17 @@ export async function resetPasswordWithForgotPasswordCode(input: {
error: {
status: 400,
code: 'forgot_password_code_invalid',
message: nextAttempts > 0 ? `验证码错误,还可重试 ${nextAttempts}` : '验证码错误次数过多,请重新获取'
}
message:
nextAttempts > 0
? 'api.errors.auth.forgotPasswordCodeInvalidWithAttempts'
: 'api.errors.auth.forgotPasswordCodeInvalid',
messageParams:
nextAttempts > 0
? {
attempts: nextAttempts
}
: undefined
} satisfies ForgotPasswordError
}
}
@@ -272,8 +313,8 @@ export async function resetPasswordWithForgotPasswordCode(input: {
error: {
status: 400,
code: 'forgot_password_reset_failed',
message: '密码重置失败'
}
message: 'api.errors.auth.forgotPasswordResetFailed'
} satisfies ForgotPasswordError
}
}

View File

@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import { type AppLocale } from '@subtracker/shared'
import { prisma } from '../db'
import { toIsoDate } from '../utils/date'
import { dispatchNotificationEvent, type NotificationChannelResult } from './channel-notification.service'
@@ -63,6 +64,7 @@ export type NotificationScanOverrides = Partial<
> & {
dryRun?: boolean
includeDebugCandidates?: boolean
locale?: AppLocale
}
type ReminderSubscriptionLike = {
@@ -106,8 +108,6 @@ type ReminderDebugCandidate = {
}>
}
type ReminderSummarySection = NotificationSummarySection & { phase: ReminderPhase }
type ReminderMatch = {
eventType: 'subscription.reminder_due' | 'subscription.overdue'
phase: ReminderPhase
@@ -441,7 +441,7 @@ export async function scanRenewalNotifications(
message: 'dry_run'
}
]
: await dispatchNotificationEvent(buildDispatchParamsFromDedupEntries([entry]))
: await dispatchNotificationEvent(buildDispatchParamsFromDedupEntries([entry]), { locale: appSettings.locale })
notifications.push({
subscriptionId: entry.subscriptionId,
@@ -477,7 +477,7 @@ export async function scanRenewalNotifications(
message: 'dry_run'
}
]
: await dispatchNotificationEvent(mergedParams)
: await dispatchNotificationEvent(mergedParams, { locale: appSettings.locale })
notifications.push({
subscriptionId: 'merged:summary',

View File

@@ -1,9 +1,11 @@
import {
AiConfigSchema,
DEFAULT_APP_LOCALE,
DEFAULT_RESEND_API_URL,
DEFAULT_AI_CONFIG,
DEFAULT_TIMEZONE,
SettingsSchema,
type AppLocale,
type SettingsInput
} from '@subtracker/shared'
import { prisma } from '../db'
@@ -82,6 +84,7 @@ export async function getAppSettings(): Promise<SettingsInput> {
const rows = await prisma.setting.findMany()
const settingsMap = new Map(rows.map((row) => [row.key, row.valueJson]))
const systemDefaultLocale = readSettingsValue(settingsMap, 'systemDefaultLocale', DEFAULT_APP_LOCALE)
const baseCurrency = readSettingsValue(settingsMap, 'baseCurrency', config.baseCurrency)
const timezoneFallback = normalizeAppTimezone(process.env.TZ ?? DEFAULT_TIMEZONE)
const timezone = readSettingsValue(settingsMap, 'timezone', timezoneFallback)
@@ -134,6 +137,7 @@ export async function getAppSettings(): Promise<SettingsInput> {
const aiConfig = AiConfigSchema.parse(readSettingsValue<unknown>(settingsMap, 'aiConfig', DEFAULT_AI_CONFIG))
return SettingsSchema.parse({
systemDefaultLocale,
baseCurrency,
timezone,
defaultNotifyDays: deriveNotifyDaysBeforeFromAdvanceRules(defaultAdvanceReminderRules) || defaultNotifyDays,
@@ -176,6 +180,10 @@ export async function getAiConfig() {
return AiConfigSchema.parse(await getSetting<unknown>('aiConfig', DEFAULT_AI_CONFIG))
}
export async function getSystemDefaultLocale(): Promise<AppLocale> {
return getSetting<AppLocale>('systemDefaultLocale', DEFAULT_APP_LOCALE)
}
export async function getDefaultAdvanceReminderRulesSetting() {
const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays)
const notifyOnDueDay = await getSetting('notifyOnDueDay', true)

View File

@@ -3,12 +3,15 @@ import https from 'node:https'
import { Prisma } from '@prisma/client'
import {
DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE,
DEFAULT_APP_LOCALE,
NotificationWebhookSettingsSchema,
getMessage,
type AppLocale,
type NotificationWebhookSettingsInput,
type WebhookEventType
} from '@subtracker/shared'
import { prisma } from '../db'
import { getSetting, setSetting } from './settings.service'
import { getSetting, getSystemDefaultLocale, setSetting } from './settings.service'
import { validateNotificationTargetUrl } from './notification-url.service'
import {
buildDispatchParamsFromDedupEntries,
@@ -26,6 +29,10 @@ export type WebhookTestResult = {
responseBody: string
}
type WebhookLocaleContext = {
locale?: AppLocale
}
const PRIMARY_WEBHOOK_SETTINGS_KEY = 'notificationWebhook'
function defaultWebhookSettings(): NotificationWebhookSettingsInput {
@@ -97,6 +104,10 @@ function applyPayloadTemplate(template: string, params: { eventType: WebhookEven
return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`{{${key}}}`, value), template)
}
async function resolveWebhookLocale(locale?: AppLocale) {
return locale ?? (await getSystemDefaultLocale().catch(() => DEFAULT_APP_LOCALE))
}
async function sendWebhookRequest(
input: NotificationWebhookSettingsInput,
params: { eventType: WebhookEventType | 'test'; payload: DeliveryPayload }
@@ -146,32 +157,36 @@ export async function upsertPrimaryWebhookEndpoint(input: PrimaryWebhookInput) {
return normalized
}
export async function sendTestWebhookNotification() {
export async function sendTestWebhookNotification(context: WebhookLocaleContext = {}) {
const endpoint = await getPrimaryWebhookEndpoint()
if (!endpoint.url) {
throw new Error('Webhook 配置不完整,请先填写 URL')
throw new Error('api.errors.notifications.webhookConfigIncomplete')
}
return sendTestWebhookNotificationWithConfig(endpoint)
return sendTestWebhookNotificationWithConfig(endpoint, context)
}
export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhookInput): Promise<WebhookTestResult> {
export async function sendTestWebhookNotificationWithConfig(
input: PrimaryWebhookInput,
context: WebhookLocaleContext = {}
): Promise<WebhookTestResult> {
const normalized = normalizeWebhookSettings(input)
if (!normalized.url) {
throw new Error('Webhook 配置不完整,请先填写 URL')
throw new Error('api.errors.notifications.webhookConfigIncomplete')
}
const locale = await resolveWebhookLocale(context.locale)
const result = await sendWebhookRequest(normalized, {
eventType: 'test',
payload: {
id: 'test-subscription',
name: '测试订阅',
name: getMessage(locale, 'notifications.tests.subscriptionName'),
amount: 10,
currency: 'USD',
nextRenewalDate: new Date().toISOString(),
tagNames: ['测试标签'],
tagNames: [getMessage(locale, 'notifications.tests.tagName')],
websiteUrl: 'https://example.com/test-subscription',
notes: '这是一条测试通知',
notes: getMessage(locale, 'notifications.tests.note'),
phase: 'upcoming',
daysUntilRenewal: 5,
daysOverdue: 0
@@ -179,7 +194,7 @@ export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhoo
})
if (result.statusCode >= 400) {
throw new Error(`Webhook 测试失败:HTTP ${result.statusCode} ${result.responseBody || ''}`.trim())
throw new Error(`${getMessage(locale, 'api.errors.notifications.webhookTestFailed')}: HTTP ${result.statusCode} ${result.responseBody || ''}`.trim())
}
return {
@@ -285,7 +300,10 @@ async function updateWebhookDeliveryRecords(
)
}
export async function dispatchWebhookEvent(params: NotificationDispatchParams) {
export async function dispatchWebhookEvent(
params: NotificationDispatchParams,
_context: WebhookLocaleContext = {}
) {
const config = normalizeWebhookSettings(await getPrimaryWebhookEndpoint())
if (!config.enabled || !config.url) {
return {

View File

@@ -164,6 +164,34 @@ describe('ai routes', () => {
expect(res.json().error.message).toContain('视觉输入能力')
})
it('returns english ai errors when X-SubTracker-Locale is en-US', async () => {
summaryRouteMocks.testAiVisionConnectionMock.mockRejectedValue(new Error('AI vision test failed'))
const res = await app.inject({
method: 'POST',
url: '/ai/test-vision',
headers: {
'X-SubTracker-Locale': 'en-US'
},
payload: {
enabled: true,
providerName: 'Custom',
providerPreset: 'custom',
baseUrl: 'https://api.example.com',
apiKey: 'token',
model: 'vision-model',
timeoutMs: 30000,
capabilities: {
vision: false,
structuredOutput: true
}
}
})
expect(res.statusCode).toBe(400)
expect(res.json().error.message).toContain('AI vision test failed')
})
it('returns dashboard ai summary state', async () => {
summaryRouteMocks.getDashboardAiSummaryMock.mockResolvedValue({
scope: 'dashboard-overview',

View File

@@ -80,6 +80,38 @@ describe('auth routes', () => {
expect(res.json().error.message).toBe('请输入用户名和密码')
})
it('returns english validation and auth errors when X-SubTracker-Locale is en-US', async () => {
const validationRes = await app.inject({
method: 'POST',
url: '/auth/login',
headers: {
'X-SubTracker-Locale': 'en-US'
},
payload: {
username: '',
password: ''
}
})
expect(validationRes.statusCode).toBe(422)
expect(validationRes.json().error.message).toBe('Enter username and password')
const authRes = await app.inject({
method: 'POST',
url: '/auth/login',
headers: {
'X-SubTracker-Locale': 'en-US'
},
payload: {
username: 'admin',
password: 'wrong-password'
}
})
expect(authRes.statusCode).toBe(401)
expect(authRes.json().error.message).toBe('Incorrect username or password')
})
it('returns mustChangePassword in login response', async () => {
authMocks.loginWithCredentialsMock.mockResolvedValue({
token: 'token',

View File

@@ -12,7 +12,9 @@ const notificationMocks = vi.hoisted(() => ({
sendTestServerchanNotificationWithConfigMock: vi.fn(),
sendTestGotifyNotificationMock: vi.fn(),
sendTestGotifyNotificationWithConfigMock: vi.fn(),
scanRenewalNotificationsMock: vi.fn()
scanRenewalNotificationsMock: vi.fn(),
sendTestWebhookNotificationMock: vi.fn(),
sendTestWebhookNotificationWithConfigMock: vi.fn()
}))
vi.mock('../../src/services/channel-notification.service', () => ({
@@ -41,8 +43,8 @@ vi.mock('../../src/services/webhook.service', () => ({
payloadTemplate: '{}',
ignoreSsl: false
})),
sendTestWebhookNotification: vi.fn(),
sendTestWebhookNotificationWithConfig: vi.fn(),
sendTestWebhookNotification: notificationMocks.sendTestWebhookNotificationMock,
sendTestWebhookNotificationWithConfig: notificationMocks.sendTestWebhookNotificationWithConfigMock,
upsertPrimaryWebhookEndpoint: vi.fn()
}))
@@ -65,6 +67,8 @@ describe('notification routes', () => {
notificationMocks.sendTestGotifyNotificationMock.mockReset()
notificationMocks.sendTestGotifyNotificationWithConfigMock.mockReset()
notificationMocks.scanRenewalNotificationsMock.mockReset()
notificationMocks.sendTestWebhookNotificationMock.mockReset()
notificationMocks.sendTestWebhookNotificationWithConfigMock.mockReset()
notificationMocks.sendTestEmailNotificationMock.mockResolvedValue({ success: true })
notificationMocks.sendTestEmailNotificationWithConfigMock.mockResolvedValue({ success: true })
notificationMocks.sendTestPushplusNotificationMock.mockResolvedValue({ accepted: true, message: 'ok' })
@@ -75,6 +79,8 @@ describe('notification routes', () => {
notificationMocks.sendTestServerchanNotificationWithConfigMock.mockResolvedValue({ success: true })
notificationMocks.sendTestGotifyNotificationMock.mockResolvedValue({ success: true })
notificationMocks.sendTestGotifyNotificationWithConfigMock.mockResolvedValue({ success: true })
notificationMocks.sendTestWebhookNotificationMock.mockResolvedValue({ success: true, statusCode: 200, responseBody: '' })
notificationMocks.sendTestWebhookNotificationWithConfigMock.mockResolvedValue({ success: true, statusCode: 200, responseBody: '' })
notificationMocks.scanRenewalNotificationsMock.mockResolvedValue({
processedCount: 1,
matchedReminderCount: 1,
@@ -128,24 +134,27 @@ describe('notification routes', () => {
})
expect(res.statusCode).toBe(200)
expect(notificationMocks.sendTestEmailNotificationWithConfigMock).toHaveBeenCalledWith({
emailProvider: 'resend',
smtpConfig: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
from: '',
to: ''
expect(notificationMocks.sendTestEmailNotificationWithConfigMock).toHaveBeenCalledWith(
{
emailProvider: 'resend',
smtpConfig: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
from: '',
to: ''
},
resendConfig: {
apiBaseUrl: 'https://api.resend.com/emails',
apiKey: 're_test',
from: 'SubTracker <noreply@example.com>',
to: 'user@example.com'
}
},
resendConfig: {
apiBaseUrl: 'https://api.resend.com/emails',
apiKey: 're_test',
from: 'SubTracker <noreply@example.com>',
to: 'user@example.com'
}
})
{ locale: 'zh-CN' }
)
})
it('tests serverchan notification with stored config', async () => {
@@ -179,15 +188,45 @@ describe('notification routes', () => {
url: '/notifications/scan-debug',
payload: {
now: '2026-05-01T17:15:00.000+08:00'
},
headers: {
'X-SubTracker-Locale': 'en-US'
}
})
expect(res.statusCode).toBe(200)
expect(notificationMocks.scanRenewalNotificationsMock).toHaveBeenCalledWith(new Date('2026-05-01T17:15:00.000+08:00'), {
dryRun: true,
includeDebugCandidates: true
includeDebugCandidates: true,
locale: 'en-US'
})
expect(res.json().data.processedCount).toBe(1)
})
it('passes locale to webhook test with payload', async () => {
const res = await app.inject({
method: 'POST',
url: '/notifications/test/webhook',
headers: {
'X-SubTracker-Locale': 'en-US'
},
payload: {
enabled: true,
url: 'https://example.com/hook',
requestMethod: 'POST',
headers: 'Content-Type: application/json',
payloadTemplate: '{}',
ignoreSsl: false
}
})
expect(res.statusCode).toBe(200)
expect(notificationMocks.sendTestWebhookNotificationWithConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/hook'
}),
{ locale: 'en-US' }
)
})
})

View File

@@ -135,6 +135,32 @@ describe('settings routes validation', () => {
expect(res.json().error.message).toContain('启用 AI 能力时必须填写')
})
it('returns english validation errors when locale header is en-US', async () => {
const res = await app.inject({
method: 'PATCH',
url: '/settings',
headers: {
'X-SubTracker-Locale': 'en-US'
},
payload: {
emailNotificationsEnabled: true,
emailProvider: 'smtp',
smtpConfig: {
host: '',
port: 587,
secure: false,
username: '',
password: '',
from: '',
to: ''
}
}
})
expect(res.statusCode).toBe(422)
expect(res.json().error.message).toBe('To enable Email notifications, fill in: SMTP Host, Username, Password, From, To')
})
it('accepts dashboard summary switch without forcing AI recognition to be enabled', async () => {
const res = await app.inject({
method: 'PATCH',

View File

@@ -260,7 +260,7 @@ describe('subscription routes', () => {
expect(res.json().data).toMatchObject({
successCount: 1,
failureCount: 1,
failures: [{ id: 'sub_1', message: 'Active subscriptions cannot be deleted directly' }]
failures: [{ id: 'sub_1', message: 'api.errors.subscriptions.activeDeleteBlocked' }]
})
})
@@ -293,6 +293,24 @@ describe('subscription routes', () => {
expect(res.json().error.code).toBe('subscription_delete_not_allowed')
})
it('returns english delete-not-allowed errors when X-SubTracker-Locale is en-US', async () => {
routeMocks.prismaMock.subscription.findUnique.mockResolvedValue({
id: 'sub_1',
status: 'active'
})
const res = await app.inject({
method: 'DELETE',
url: '/subscriptions/sub_1',
headers: {
'X-SubTracker-Locale': 'en-US'
}
})
expect(res.statusCode).toBe(422)
expect(res.json().error.message).toBe('Active subscriptions cannot be deleted directly. Pause or cancel them first.')
})
it('allows deleting a paused subscription directly', async () => {
routeMocks.prismaMock.subscription.findUnique.mockResolvedValue({
id: 'sub_1',

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_AI_CONFIG, DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, type DashboardOverview } from '@subtracker/shared'
import { DEFAULT_AI_CONFIG, type DashboardOverview } from '@subtracker/shared'
const aiSummaryMocks = vi.hoisted(() => ({
getAiConfigMock: vi.fn(),
@@ -7,7 +7,8 @@ const aiSummaryMocks = vi.hoisted(() => ({
}))
vi.mock('../../src/services/settings.service', () => ({
getAiConfig: aiSummaryMocks.getAiConfigMock
getAiConfig: aiSummaryMocks.getAiConfigMock,
getSystemDefaultLocale: vi.fn(async () => 'en-US')
}))
vi.mock('../../src/services/statistics.service', () => ({
@@ -287,10 +288,10 @@ describe('ai summary service', () => {
await generateDashboardAiSummary()
const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body))
expect(requestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim().slice(0, 20))
expect(requestBody.messages[0].content).toContain('subscription operations summary assistant')
const previewRequestBody = JSON.parse(String((((fetchMock.mock.calls[1] as unknown) as [unknown, RequestInit])[1])?.body))
expect(previewRequestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT.trim().slice(0, 20))
expect(previewRequestBody.messages[0].content).toContain('summary compression assistant')
})
it('always uses dedicated dashboard summary prompt even if recognition prompt is customized', async () => {
@@ -322,7 +323,7 @@ describe('ai summary service', () => {
await generateDashboardAiSummary()
const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body))
expect(requestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim().slice(0, 20))
expect(requestBody.messages[0].content).toContain('subscription operations summary assistant')
expect(requestBody.messages[0].content).not.toContain('只返回 JSON')
})

View File

@@ -20,7 +20,8 @@ const recognizeMock = vi.fn(async () => ({
}))
vi.mock('../../src/services/settings.service', () => ({
getAiConfig: vi.fn(async () => mockedSettings.aiConfig)
getAiConfig: vi.fn(async () => mockedSettings.aiConfig),
getSystemDefaultLocale: vi.fn(async () => 'en-US')
}))
vi.mock('tesseract.js', () => ({
@@ -126,6 +127,28 @@ describe('ai service', () => {
expect(secondBody.messages[0].content).toContain('合法 JSON 对象')
})
it('uses english default prompt when system default locale is en-US', async () => {
const fetchMock = vi.fn(async () =>
jsonResponse({
choices: [
{
message: {
content: '{"name":"Netflix"}'
}
}
]
})
)
vi.stubGlobal('fetch', fetchMock)
await recognizeSubscriptionByAi({
text: 'Netflix 9.99 USD monthly'
})
const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body))
expect(requestBody.messages[0].content).toContain('subscription billing extractor')
})
it('uses OCR text path when vision capability is disabled', async () => {
mockedSettings.aiConfig.capabilities.vision = false

View File

@@ -64,4 +64,40 @@ describe('forgot password service', () => {
const { isForgotPasswordEnabled } = await import('../../src/services/forgot-password.service')
await expect(isForgotPasswordEnabled()).resolves.toBe(true)
})
it('passes locale to forgot-password notification dispatch', async () => {
const store = new Map<string, unknown>([['forgotPasswordEnabled', true]])
forgotPasswordState.getSettingMock.mockImplementation(async (key: string, fallback: unknown) =>
store.has(key) ? store.get(key) : fallback
)
forgotPasswordState.setSettingMock.mockImplementation(async (key: string, value: unknown) => {
store.set(key, value)
})
forgotPasswordState.getNotificationChannelSettingsMock.mockResolvedValue({
emailNotificationsEnabled: true,
pushplusNotificationsEnabled: false,
telegramNotificationsEnabled: false,
serverchanNotificationsEnabled: false,
gotifyNotificationsEnabled: false
})
forgotPasswordState.getStoredCredentialsMock.mockResolvedValue({
username: 'admin',
passwordHash: 'hash',
passwordSalt: 'salt'
})
forgotPasswordState.sendForgotPasswordVerificationCodeMock.mockResolvedValue([
{ channel: 'email', status: 'success' }
])
const { requestForgotPasswordChallenge } = await import('../../src/services/forgot-password.service')
const result = await requestForgotPasswordChallenge('admin', '127.0.0.1', 'en-US')
expect(result.ok).toBe(true)
expect(forgotPasswordState.sendForgotPasswordVerificationCodeMock).toHaveBeenCalledWith(
expect.objectContaining({
username: 'admin'
}),
{ locale: 'en-US' }
)
})
})

View File

@@ -12,10 +12,16 @@
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./i18n": {
"types": "./src/i18n.ts",
"import": "./dist/i18n.js",
"require": "./dist/i18n.cjs",
"default": "./dist/i18n.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"build": "tsup src/index.ts src/i18n.ts --format esm,cjs --dts",
"test": "vitest run",
"lint": "tsc --noEmit"
},

View File

@@ -0,0 +1,73 @@
import type { AppLocale } from './locale-core'
import { DEFAULT_APP_LOCALE, normalizeAppLocale, resolveAppLocaleFromAcceptLanguage } from './locale-core'
import zhCnMessages from './locales/zh-CN'
import enUsMessages from './locales/en-US'
type MessageValue = string | MessageTree
type MessageTree = {
[key: string]: MessageValue
}
export type TranslationParams = Record<string, string | number | boolean | null | undefined>
export const sharedMessages = {
'zh-CN': zhCnMessages,
'en-US': enUsMessages
} as const satisfies Record<AppLocale, MessageTree>
export const SUPPORTED_APP_LOCALES = Object.freeze(Object.keys(sharedMessages) as AppLocale[])
function isMessageTree(value: MessageValue | undefined): value is MessageTree {
return typeof value === 'object' && value !== null
}
function getNestedMessage(tree: MessageTree, key: string): string | undefined {
const segments = key.split('.')
let current: MessageValue | undefined = tree
for (const segment of segments) {
if (!isMessageTree(current)) return undefined
current = current[segment]
}
return typeof current === 'string' ? current : undefined
}
function interpolateMessage(template: string, params?: TranslationParams) {
if (!params) return template
return template.replace(/\{(\w+)\}/g, (_match, key) => {
const value = params[key]
return value === undefined || value === null ? `{${key}}` : String(value)
})
}
export function getMessage(locale: AppLocale, key: string, params?: TranslationParams) {
const normalizedLocale = normalizeAppLocale(locale)
const template =
getNestedMessage(sharedMessages[normalizedLocale], key) ??
getNestedMessage(sharedMessages[DEFAULT_APP_LOCALE], key) ??
key
return interpolateMessage(template, params)
}
export function detectLocaleFromAcceptLanguage(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE) {
return resolveAppLocaleFromAcceptLanguage(value, fallback)
}
export function getDefaultAiSubscriptionPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) {
return getMessage(locale, 'ai.prompts.subscription.default')
}
export function getDefaultAiDashboardSummaryPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) {
return getMessage(locale, 'ai.prompts.dashboard.summary.default')
}
export function getDefaultAiDashboardSummaryPreviewPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) {
return getMessage(locale, 'ai.prompts.dashboard.preview.default')
}
export const DEFAULT_AI_SUBSCRIPTION_PROMPT = getDefaultAiSubscriptionPrompt(DEFAULT_APP_LOCALE)
export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = getDefaultAiDashboardSummaryPrompt(DEFAULT_APP_LOCALE)
export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = getDefaultAiDashboardSummaryPreviewPrompt(DEFAULT_APP_LOCALE)

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
AppLocaleSchema,
AiDashboardSummaryStatusSchema,
CreateSubscriptionSchema,
DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT,
@@ -7,7 +8,12 @@ import {
DEFAULT_ADVANCE_REMINDER_RULES,
DEFAULT_OVERDUE_REMINDER_RULES,
formatAiSummaryPreviewText,
getDefaultAiDashboardSummaryPreviewPrompt,
getDefaultAiDashboardSummaryPrompt,
getDefaultAiSubscriptionPrompt,
normalizeWebsiteUrlInput,
normalizeAppLocale,
resolveAppLocaleFromAcceptLanguage,
SettingsSchema,
SubtrackerBackupCommitSchema,
SubtrackerBackupInspectSchema,
@@ -33,6 +39,7 @@ describe('shared schema', () => {
it('should provide reminder-related setting defaults', () => {
const parsed = SettingsSchema.parse({})
expect(parsed.systemDefaultLocale).toBe('zh-CN')
expect(parsed.timezone).toBe('Asia/Shanghai')
expect(parsed.defaultNotifyDays).toBe(3)
expect(parsed.defaultAdvanceReminderRules).toBe(DEFAULT_ADVANCE_REMINDER_RULES)
@@ -114,6 +121,20 @@ describe('shared schema', () => {
expect(() => AiDashboardSummaryStatusSchema.parse('unknown')).toThrow()
})
it('should normalize app locale values and accept-language headers', () => {
expect(AppLocaleSchema.parse('en-US')).toBe('en-US')
expect(normalizeAppLocale('en')).toBe('en-US')
expect(normalizeAppLocale('ZH-hans-CN')).toBe('zh-CN')
expect(resolveAppLocaleFromAcceptLanguage('en-GB,en;q=0.9,zh-CN;q=0.8')).toBe('en-US')
expect(resolveAppLocaleFromAcceptLanguage('', 'en-US')).toBe('en-US')
})
it('should provide locale-aware default ai prompts', () => {
expect(getDefaultAiSubscriptionPrompt('en-US')).toContain('subscription billing extractor')
expect(getDefaultAiDashboardSummaryPrompt('en-US')).toContain('subscription operations summary assistant')
expect(getDefaultAiDashboardSummaryPreviewPrompt('en-US')).toContain('summary compression assistant')
})
it('formats ai summary preview text into multiple readable lines', () => {
expect(
formatAiSummaryPreviewText(

View File

@@ -1,4 +1,12 @@
import { z } from 'zod'
import {
AppLocaleSchema,
DEFAULT_APP_LOCALE,
LOCALE_PREFERENCE_STORAGE_KEY,
normalizeAppLocale,
resolveAppLocaleFromAcceptLanguage,
type AppLocale
} from './locale-core'
const WEBSITE_URL_ERROR_MESSAGE = '请输入合法网址,例如 https://example.com'
const FQDN_LABEL_RE = /^[a-z_\u00a1-\uffff0-9-]+$/i
@@ -107,66 +115,6 @@ function formatWebsiteUrl(url: URL): string {
return href
}
export const DEFAULT_AI_SUBSCRIPTION_PROMPT = `你是订阅账单信息提取助手。请从输入的文本或截图中提取订阅信息,并且只返回 JSON。
输出字段:
- name
- description
- amount
- currency
- billingIntervalCount
- billingIntervalUnit(day|week|month|quarter|year)
- startDate(YYYY-MM-DD)
- nextRenewalDate(YYYY-MM-DD)
- notifyDaysBefore
- websiteUrl
- notes
- confidence(0~1)
- rawText
规则:
1. 不确定就留空,不要猜。
2. 金额必须是数字。
3. 币种必须是 3 位大写代码,例如 CNY、USD。
4. 周期单位必须在 day/week/month/quarter/year 中。
5. 只返回 JSON不要返回 Markdown。`
export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = `你是订阅运营摘要助手。请基于用户当前的订阅统计数据,输出一份简洁、准确、可执行的 Markdown 总结。
目标:
1. 帮助用户快速理解当前订阅规模、支出结构、预算压力和近期续订风险。
2. 总结数据中的明显模式、异常点和需要关注的事项。
3. 给出中性、可执行、但不依赖具体服务功能知识的建议。
硬性要求:
- 只能基于输入数据分析,不要虚构事实。
- 不要假设你了解某个订阅服务的功能细节。
- 不要输出“取消某服务更省钱”“某两个服务功能重叠”之类的建议。
- 不要臆测用户偏好、使用频率或用途。
- 不要输出 JSON不要输出代码块只输出 Markdown 正文。
输出建议结构:
## 总览
## 支出结构
## 近期风险
## 值得注意的模式
## 中性建议
写作要求:
- 使用简体中文。
- 结论明确,少空话。
- 每个小节控制在 2~5 条要点内。
- 如果某部分没有明显异常,直接说明“暂无显著异常”或“整体平稳”。`
export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = `你是订阅统计摘要压缩助手。请根据已经生成好的完整 AI 总结,提炼出一个默认折叠展示用的超简短摘要。
硬性要求:
- 只输出简体中文纯文本,不要输出 Markdown不要输出代码块。
- 输出 2 到 3 行,每行一句,自然换行。
- 不要输出标题,不要输出项目符号,不要编号。
- 只保留最重要的结论:订阅规模、预算压力、近期风险。
- 不要发散,不要补充原文没有的信息。
- 如果原文信息有限,就直接给出 1 到 2 句自然语言摘要。`
function normalizePreviewSource(text: string) {
return String(text ?? '')
.replace(/\r\n/g, '\n')
@@ -189,6 +137,27 @@ export function formatAiSummaryPreviewText(text: string) {
return normalizePreviewSource(text)
}
export {
DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT,
DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT,
DEFAULT_AI_SUBSCRIPTION_PROMPT,
SUPPORTED_APP_LOCALES,
detectLocaleFromAcceptLanguage,
getDefaultAiDashboardSummaryPreviewPrompt,
getDefaultAiDashboardSummaryPrompt,
getDefaultAiSubscriptionPrompt,
getMessage,
sharedMessages
} from './i18n'
export {
type AppLocale,
AppLocaleSchema,
DEFAULT_APP_LOCALE,
LOCALE_PREFERENCE_STORAGE_KEY,
normalizeAppLocale,
resolveAppLocaleFromAcceptLanguage
} from './locale-core'
export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired'])
export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year'])
export const WebhookRequestMethodSchema = z.enum(['POST', 'PUT', 'PATCH', 'DELETE'])
@@ -374,6 +343,7 @@ export const AiConfigSchema = z.object({
})
export const SettingsSchema = z.object({
systemDefaultLocale: AppLocaleSchema.default(DEFAULT_APP_LOCALE),
baseCurrency: z.string().length(3).default('CNY').transform((v) => v.toUpperCase()),
timezone: TimeZoneSchema.default(DEFAULT_TIMEZONE),
defaultNotifyDays: z.number().int().min(0).max(365).default(3),

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
export const AppLocaleSchema = z.enum(['zh-CN', 'en-US'])
export type AppLocale = z.infer<typeof AppLocaleSchema>
export const DEFAULT_APP_LOCALE: AppLocale = 'zh-CN'
export const LOCALE_PREFERENCE_STORAGE_KEY = 'subtracker-locale-preference'
const APP_LOCALE_ALIASES: Record<string, AppLocale> = {
zh: 'zh-CN',
'zh-cn': 'zh-CN',
'zh-hans': 'zh-CN',
'zh-hans-cn': 'zh-CN',
en: 'en-US',
'en-us': 'en-US'
}
function tryNormalizeAppLocale(value: unknown): AppLocale | null {
const normalized = String(value ?? '').trim()
if (!normalized) return null
const lower = normalized.toLowerCase()
if (APP_LOCALE_ALIASES[lower]) {
return APP_LOCALE_ALIASES[lower]
}
for (const [prefix, locale] of Object.entries(APP_LOCALE_ALIASES)) {
if (lower.startsWith(`${prefix}-`)) {
return locale
}
}
return null
}
export function normalizeAppLocale(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE): AppLocale {
return tryNormalizeAppLocale(value) ?? fallback
}
export function resolveAppLocaleFromAcceptLanguage(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE) {
const raw = String(value ?? '').trim()
if (!raw) return fallback
for (const segment of raw.split(',')) {
const candidate = segment.split(';')[0]?.trim()
if (!candidate) continue
const locale = tryNormalizeAppLocale(candidate)
if (locale) {
return locale
}
}
return fallback
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff