mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat: enhance billing error handling and upgrade prompts
- Updated error codes in the error-codes enum to better reflect billing scenarios, introducing BILLING_PLAN_QUOTA_EXCEEDED and BILLING_STORAGE_QUOTA_EXCEEDED. - Modified the PhotoAssetService and BillingPlanService to throw specific BizExceptions for storage and plan quota exceedances. - Implemented a new BillingUpgradeModal to prompt users for upgrades based on error categories. - Enhanced error handling in photo upload and sync actions to present upgrade prompts when necessary. - Updated localization files to include new strings for upgrade prompts in both English and Chinese. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -26,7 +26,8 @@ export enum ErrorCode {
|
||||
PHOTO_MANIFEST_GENERATION_FAILED = 31,
|
||||
|
||||
// Billing / Subscription
|
||||
BILLING_QUOTA_EXCEEDED = 40,
|
||||
BILLING_PLAN_QUOTA_EXCEEDED = 40,
|
||||
BILLING_STORAGE_QUOTA_EXCEEDED = 41,
|
||||
}
|
||||
|
||||
export interface ErrorDescriptor {
|
||||
@@ -108,8 +109,12 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
|
||||
httpStatus: 500,
|
||||
message: 'Photo manifest generation failed',
|
||||
},
|
||||
[ErrorCode.BILLING_QUOTA_EXCEEDED]: {
|
||||
[ErrorCode.BILLING_PLAN_QUOTA_EXCEEDED]: {
|
||||
httpStatus: 402,
|
||||
message: 'Usage quota exceeded',
|
||||
},
|
||||
[ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED]: {
|
||||
httpStatus: 402,
|
||||
message: 'Storage quota exceeded',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1828,7 +1828,7 @@ export class PhotoAssetService {
|
||||
totalBytes: usage.totalBytes,
|
||||
fileCount: usage.fileCount,
|
||||
})
|
||||
throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, {
|
||||
throw new BizException(ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED, {
|
||||
message: `托管存储空间已超出套餐上限:当前已用 ${formatBytesForDisplay(
|
||||
usage.totalBytes,
|
||||
)},套餐上限 ${formatBytesForDisplay(capacity)}。请清理空间或升级存储方案后再试。`,
|
||||
@@ -1843,7 +1843,7 @@ export class PhotoAssetService {
|
||||
totalBytes: usage.totalBytes,
|
||||
fileCount: usage.fileCount,
|
||||
})
|
||||
throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, {
|
||||
throw new BizException(ErrorCode.BILLING_STORAGE_QUOTA_EXCEEDED, {
|
||||
message: `托管存储空间不足:当前已用 ${formatBytesForDisplay(
|
||||
usage.totalBytes,
|
||||
)},上传后预计 ${formatBytesForDisplay(projectedBytes)},已超过套餐上限 ${formatBytesForDisplay(
|
||||
|
||||
@@ -137,7 +137,7 @@ export class BillingPlanService {
|
||||
|
||||
if (used + incomingItems > quota.monthlyAssetProcessLimit) {
|
||||
const remaining = Math.max(quota.monthlyAssetProcessLimit - used, 0)
|
||||
throw new BizException(ErrorCode.BILLING_QUOTA_EXCEEDED, {
|
||||
throw new BizException(ErrorCode.BILLING_PLAN_QUOTA_EXCEEDED, {
|
||||
message: `当月新增照片额度不足,可用剩余:${remaining},请求新增:${incomingItems}。升级订阅后即可提升限额。`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,3 +72,70 @@ export function getRequestErrorMessage(error: unknown, fallback?: string): strin
|
||||
|
||||
return fallback ?? getI18n().t('errors.request.generic')
|
||||
}
|
||||
|
||||
const parseNumberLike = (value: unknown): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getRequestStatusCode(error: unknown): number | null {
|
||||
if (error instanceof FetchError) {
|
||||
const status = error.statusCode ?? (error as FetchErrorWithPayload).response?.status
|
||||
if (typeof status === 'number' && Number.isFinite(status)) {
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error) {
|
||||
const candidate = (error as { statusCode?: unknown }).statusCode
|
||||
const parsedCandidate = parseNumberLike(candidate)
|
||||
if (parsedCandidate !== null) {
|
||||
return parsedCandidate
|
||||
}
|
||||
|
||||
const responseStatus = (error as { response?: { status?: unknown } }).response?.status
|
||||
const parsedResponse = parseNumberLike(responseStatus)
|
||||
if (parsedResponse !== null) {
|
||||
return parsedResponse
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const extractPayloadCode = (payload: unknown): number | null => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
return parseNumberLike((payload as { code?: unknown }).code)
|
||||
}
|
||||
|
||||
export function getRequestErrorCode(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const directCode = parseNumberLike((error as { code?: unknown }).code)
|
||||
if (directCode !== null) {
|
||||
return directCode
|
||||
}
|
||||
|
||||
const dataCode = extractPayloadCode((error as { data?: unknown }).data)
|
||||
if (dataCode !== null) {
|
||||
return dataCode
|
||||
}
|
||||
|
||||
const {response} = (error as { response?: { _data?: unknown; data?: unknown } })
|
||||
const responseCode = extractPayloadCode(response?._data ?? response?.data)
|
||||
if (responseCode !== null) {
|
||||
return responseCode
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './api'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
export * from './upgrade-prompts'
|
||||
|
||||
77
be/apps/dashboard/src/modules/billing/upgrade-prompts.tsx
Normal file
77
be/apps/dashboard/src/modules/billing/upgrade-prompts.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ModalComponent } from '@afilmory/ui'
|
||||
import { Button, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Modal } from '@afilmory/ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { getRequestErrorCode, getRequestStatusCode } from '~/lib/errors'
|
||||
import { ManagedStoragePlansModal } from '~/modules/storage-providers/components/ManagedStoragePlansModal'
|
||||
|
||||
const PLAN_LIMIT_CODE = 40
|
||||
const STORAGE_LIMIT_CODE = 41
|
||||
|
||||
export type BillingUpgradeCategory = 'plan' | 'storage'
|
||||
|
||||
export function resolveBillingUpgradeCategory(error: unknown): BillingUpgradeCategory | null {
|
||||
const code = getRequestErrorCode(error)
|
||||
if (code === PLAN_LIMIT_CODE) {
|
||||
return 'plan'
|
||||
}
|
||||
if (code === STORAGE_LIMIT_CODE) {
|
||||
return 'storage'
|
||||
}
|
||||
|
||||
const status = getRequestStatusCode(error)
|
||||
if (status === 402) {
|
||||
return 'plan'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function presentBillingUpgradeModal(category: BillingUpgradeCategory) {
|
||||
if (category === 'storage') {
|
||||
Modal.present(ManagedStoragePlansModal, {}, { dismissOnOutsideClick: true })
|
||||
return
|
||||
}
|
||||
Modal.present(BillingPlanUpgradeModal, {}, { dismissOnOutsideClick: true })
|
||||
}
|
||||
|
||||
const billingPlanUpgradeKeys = {
|
||||
title: 'plan.upgrade-modal.title',
|
||||
description: 'plan.upgrade-modal.description',
|
||||
actionUpgrade: 'plan.upgrade-modal.action.upgrade',
|
||||
actionLater: 'plan.upgrade-modal.action.later',
|
||||
} as const
|
||||
|
||||
export const BillingPlanUpgradeModal: ModalComponent = ({ dismiss }) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleUpgrade = () => {
|
||||
dismiss?.()
|
||||
navigate('/plan')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-[520px] flex-col gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold leading-none tracking-tight">
|
||||
{t(billingPlanUpgradeKeys.title)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-text-secondary">
|
||||
{t(billingPlanUpgradeKeys.description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-1 gap-2">
|
||||
<Button type="button" variant="ghost" onClick={dismiss}>
|
||||
{t(billingPlanUpgradeKeys.actionLater)}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleUpgrade}>
|
||||
{t(billingPlanUpgradeKeys.actionUpgrade)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
BillingPlanUpgradeModal.contentClassName = 'w-[520px] max-w-[92vw]'
|
||||
@@ -42,26 +42,72 @@ function parseRawPayload(raw: string | null | undefined): unknown | null {
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageFromRaw(raw: string | null | undefined): string | null {
|
||||
const payload = parseRawPayload(raw)
|
||||
if (payload == null) {
|
||||
return null
|
||||
}
|
||||
return normalizeServerMessage(payload)
|
||||
type ServerErrorInfo = {
|
||||
message: string | null
|
||||
code: number | null
|
||||
raw: unknown
|
||||
}
|
||||
|
||||
async function readResponseErrorMessage(response: Response): Promise<string | null> {
|
||||
const extractErrorInfoFromPayload = (payload: unknown): ServerErrorInfo => {
|
||||
const message = normalizeServerMessage(payload)
|
||||
const codeValue = typeof payload === 'object' && payload ? (payload as { code?: unknown }).code : null
|
||||
const code =
|
||||
typeof codeValue === 'number' && Number.isFinite(codeValue)
|
||||
? codeValue
|
||||
: typeof codeValue === 'string'
|
||||
? Number.parseInt(codeValue, 10)
|
||||
: null
|
||||
return {
|
||||
message,
|
||||
code: Number.isFinite(code ?? Number.NaN) ? (code as number) : null,
|
||||
raw: payload,
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorInfoFromRaw(raw: string | null | undefined): ServerErrorInfo {
|
||||
const payload = parseRawPayload(raw)
|
||||
return extractErrorInfoFromPayload(payload ?? raw ?? null)
|
||||
}
|
||||
|
||||
async function readResponseErrorInfo(response: Response): Promise<ServerErrorInfo> {
|
||||
try {
|
||||
const text = await response.text()
|
||||
return extractMessageFromRaw(text)
|
||||
return extractErrorInfoFromRaw(text)
|
||||
} catch {
|
||||
return null
|
||||
return { message: null, code: null, raw: null }
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageFromXhr(xhr: XMLHttpRequest): string | null {
|
||||
function extractErrorInfoFromXhr(xhr: XMLHttpRequest): ServerErrorInfo {
|
||||
const raw = typeof xhr.response === 'string' && xhr.response.length > 0 ? xhr.response : (xhr.responseText ?? '')
|
||||
return extractMessageFromRaw(raw)
|
||||
return extractErrorInfoFromRaw(raw)
|
||||
}
|
||||
|
||||
const createApiError = (
|
||||
info: ServerErrorInfo,
|
||||
fallback: string,
|
||||
status?: number,
|
||||
): Error & {
|
||||
statusCode?: number
|
||||
code?: number
|
||||
data?: unknown
|
||||
response?: { status?: number; _data?: unknown }
|
||||
} => {
|
||||
const error = new Error(info.message ?? fallback) as Error & {
|
||||
statusCode?: number
|
||||
code?: number
|
||||
data?: unknown
|
||||
response?: { status?: number; _data?: unknown }
|
||||
}
|
||||
if (typeof status === 'number' && Number.isFinite(status)) {
|
||||
error.statusCode = status
|
||||
error.response = { status }
|
||||
}
|
||||
if (typeof info.code === 'number') {
|
||||
error.code = info.code
|
||||
}
|
||||
error.data = info.raw
|
||||
return error
|
||||
}
|
||||
|
||||
type RunPhotoSyncOptions = {
|
||||
@@ -111,8 +157,8 @@ export async function runPhotoSync(
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const fallback = `Sync request failed: ${response.status} ${response.statusText}`
|
||||
const serverMessage = await readResponseErrorMessage(response)
|
||||
throw new Error(serverMessage ?? fallback)
|
||||
const errorInfo = await readResponseErrorInfo(response)
|
||||
throw createApiError(errorInfo, fallback, response.status)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
@@ -120,6 +166,7 @@ export async function runPhotoSync(
|
||||
let buffer = ''
|
||||
let finalResult: PhotoSyncResult | null = null
|
||||
let lastErrorMessage: string | null = null
|
||||
let lastErrorCode: number | null = null
|
||||
|
||||
const stageEvent = (rawEvent: string) => {
|
||||
const lines = rawEvent.split(STABLE_NEWLINE)
|
||||
@@ -167,6 +214,15 @@ export async function runPhotoSync(
|
||||
|
||||
if (event.type === 'error') {
|
||||
lastErrorMessage = event.payload.message
|
||||
const payloadCode = (event.payload as { code?: unknown }).code
|
||||
if (typeof payloadCode === 'number' && Number.isFinite(payloadCode)) {
|
||||
lastErrorCode = payloadCode
|
||||
} else if (typeof payloadCode === 'string') {
|
||||
const parsed = Number.parseInt(payloadCode, 10)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
lastErrorCode = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse sync progress event', error)
|
||||
@@ -200,7 +256,7 @@ export async function runPhotoSync(
|
||||
}
|
||||
|
||||
if (lastErrorMessage) {
|
||||
throw new Error(lastErrorMessage)
|
||||
throw createApiError({ message: lastErrorMessage, code: lastErrorCode, raw: null }, lastErrorMessage)
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
@@ -413,7 +469,11 @@ export async function uploadPhotoAssets(
|
||||
const event = camelCaseKeys<PhotoSyncProgressEvent>(parsed)
|
||||
options?.onServerEvent?.(event)
|
||||
if (event.type === 'error') {
|
||||
settle(() => {}, reject, new Error(event.payload.message || 'Server processing failed'))
|
||||
const error = createApiError(
|
||||
extractErrorInfoFromPayload(event.payload),
|
||||
'Server processing failed',
|
||||
)
|
||||
settle(() => {}, reject, error)
|
||||
xhr.abort()
|
||||
return
|
||||
}
|
||||
@@ -436,7 +496,15 @@ export async function uploadPhotoAssets(
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
settle(() => {}, reject, new Error('Network error during upload. Please try again later.'))
|
||||
settle(
|
||||
() => {},
|
||||
reject,
|
||||
createApiError(
|
||||
{ message: 'Network error during upload. Please try again later.', code: null, raw: null },
|
||||
'Network error during upload. Please try again later.',
|
||||
xhr.status,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
xhr.onabort = () => {
|
||||
@@ -444,7 +512,15 @@ export async function uploadPhotoAssets(
|
||||
}
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
settle(() => {}, reject, new Error('Upload timed out. Please try again later.'))
|
||||
settle(
|
||||
() => {},
|
||||
reject,
|
||||
createApiError(
|
||||
{ message: 'Upload timed out. Please try again later.', code: null, raw: null },
|
||||
'Upload timed out. Please try again later.',
|
||||
xhr.status,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
@@ -456,8 +532,8 @@ export async function uploadPhotoAssets(
|
||||
|
||||
const fallbackMessage =
|
||||
xhr.status >= 200 && xhr.status < 300 ? 'Upload response incomplete' : `Upload failed: ${xhr.status}`
|
||||
const serverMessage = extractMessageFromXhr(xhr)
|
||||
settle(() => {}, reject, new Error(serverMessage ?? fallbackMessage))
|
||||
const errorInfo = extractErrorInfoFromXhr(xhr)
|
||||
settle(() => {}, reject, createApiError(errorInfo, fallbackMessage, xhr.status || undefined))
|
||||
}
|
||||
|
||||
xhr.send(formData)
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { StoreApi } from 'zustand'
|
||||
import { useStore } from 'zustand'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
import { presentBillingUpgradeModal, resolveBillingUpgradeCategory } from '~/modules/billing/upgrade-prompts'
|
||||
|
||||
import type { PhotoSyncProgressEvent } from '../../../types'
|
||||
import type { PhotoUploadRequestOptions } from '../upload.types'
|
||||
import type { FileProgressEntry, ProcessingLogEntry, ProcessingState, WorkflowPhase } from './types'
|
||||
@@ -319,6 +321,10 @@ export function createPhotoUploadStore(params: PhotoUploadStoreParams): PhotoUpl
|
||||
const currentFiles = get().files
|
||||
updateEntries(() => createFileEntries(currentFiles))
|
||||
} else {
|
||||
const upgradeCategory = resolveBillingUpgradeCategory(error)
|
||||
if (upgradeCategory) {
|
||||
presentBillingUpgradeModal(upgradeCategory)
|
||||
}
|
||||
const message = getErrorMessage(error, '上传失败,请稍后再试。')
|
||||
set({
|
||||
uploadError: message,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
|
||||
import { usePhotoSyncAutoRunValue, useSetPhotoSyncAutoRun } from '~/atoms/photo-sync'
|
||||
import { useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
import { presentBillingUpgradeModal, resolveBillingUpgradeCategory } from '~/modules/billing/upgrade-prompts'
|
||||
|
||||
import { runPhotoSync } from '../../api'
|
||||
import type { RunPhotoSyncPayload } from '../../types'
|
||||
@@ -80,6 +81,10 @@ export function PhotoSyncActions() {
|
||||
},
|
||||
onError: (error) => {
|
||||
const normalizedError = error instanceof Error ? error : new Error(t(photoSyncActionKeys.toastErrorDescription))
|
||||
const upgradeCategory = resolveBillingUpgradeCategory(error)
|
||||
if (upgradeCategory) {
|
||||
presentBillingUpgradeModal(upgradeCategory)
|
||||
}
|
||||
|
||||
const message = getRequestErrorMessage(error, normalizedError.message)
|
||||
toast.error(t(photoSyncActionKeys.toastErrorTitle), { description: message })
|
||||
|
||||
@@ -549,6 +549,10 @@
|
||||
"plan.toast.missing-portal-account": "Subscription account not found. Please try again later.",
|
||||
"plan.toast.missing-portal-url": "Portal link is unavailable. Please try again later.",
|
||||
"plan.toast.portal-failure": "Unable to open subscription portal. Please try again later.",
|
||||
"plan.upgrade-modal.action.later": "Maybe later",
|
||||
"plan.upgrade-modal.action.upgrade": "Go to plans",
|
||||
"plan.upgrade-modal.description": "Uploads and sync need a higher subscription. Upgrade your plan to continue.",
|
||||
"plan.upgrade-modal.title": "Upgrade required",
|
||||
"schema-form.secret.helper": "For security, provide a new value only when updating.",
|
||||
"schema-form.secret.hide": "Hide",
|
||||
"schema-form.secret.show": "Show",
|
||||
|
||||
@@ -497,6 +497,10 @@
|
||||
"plan.toast.missing-portal-account": "找不到订阅账户,请稍后再试。",
|
||||
"plan.toast.missing-portal-url": "Creem 未返回订阅管理地址,请稍后再试。",
|
||||
"plan.toast.portal-failure": "无法打开订阅管理,请稍后再试。",
|
||||
"plan.upgrade-modal.action.later": "稍后再说",
|
||||
"plan.upgrade-modal.action.upgrade": "去升级",
|
||||
"plan.upgrade-modal.description": "当前上传或同步已触达套餐上限,请前往订阅页面升级后继续。",
|
||||
"plan.upgrade-modal.title": "升级订阅以继续",
|
||||
"schema-form.secret.helper": "出于安全考虑,仅在更新时填写新的值。",
|
||||
"schema-form.secret.hide": "隐藏",
|
||||
"schema-form.secret.show": "显示",
|
||||
|
||||
Reference in New Issue
Block a user