mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-02 23:20:25 +08:00
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:
@@ -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
|
||||
|
||||
2
apps/api/src/fastify.d.ts
vendored
2
apps/api/src/fastify.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
30
apps/api/src/i18n.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
73
packages/shared/src/i18n.ts
Normal file
73
packages/shared/src/i18n.ts
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
54
packages/shared/src/locale-core.ts
Normal file
54
packages/shared/src/locale-core.ts
Normal 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
|
||||
}
|
||||
1183
packages/shared/src/locales/en-US.ts
Normal file
1183
packages/shared/src/locales/en-US.ts
Normal file
File diff suppressed because it is too large
Load Diff
1182
packages/shared/src/locales/zh-CN.ts
Normal file
1182
packages/shared/src/locales/zh-CN.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user