mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
fix: resolve storage provider auto-switching issue when switching between managed and custom storage (#202)
When users switched from Tencent COS to managed storage and saved, the UI would incorrectly show Tencent COS as the active provider on reload, and uploads would fail because the wrong storage backend was being used. Root cause: The `getActiveStorageProvider` method had a fallback behavior that automatically set and persisted the first provider as active when only one provider existed. This caused the managed storage selection to be overwritten. Changes: - Remove auto-persist fallback in getActiveStorageProvider (setting.service.ts) - Add proper validation for managed storage subscription (storage-setting.service.ts) - Reorder storage resolution to check managed storage first (photo-storage.service.ts) - Add getActivePlanSummaryForTenant with Creem subscription validation (storage-plan.service.ts) - Update dashboard UI to handle provider state correctly (StorageProvidersManager.tsx) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -133,13 +133,9 @@ export class SettingService {
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
// Fallback: if there is exactly one provider, automatically set it active and persist the setting
|
||||
// Fallback: if there is exactly one provider, treat it as active without mutating settings.
|
||||
if (providers.length === 1) {
|
||||
const only = providers[0]
|
||||
// Persist synchronously; ignore schema sensitivity (it's non-sensitive)
|
||||
const setOptions = options ? { ...options, isSensitive: false } : { isSensitive: false }
|
||||
await this.set('builder.storage.activeProvider', only.id, setOptions)
|
||||
return only
|
||||
return providers[0]
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { BillingModule } from 'core/modules/platform/billing/billing.module'
|
||||
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { StorageSettingController } from './storage-setting.controller'
|
||||
import { StorageSettingService } from './storage-setting.service'
|
||||
|
||||
@Module({
|
||||
imports: [SettingModule],
|
||||
imports: [SettingModule, BillingModule],
|
||||
controllers: [StorageSettingController],
|
||||
providers: [StorageSettingService],
|
||||
})
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { StoragePlanService } from 'core/modules/platform/billing/storage-plan.service'
|
||||
import { getTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { getUiSchemaTranslator } from '../../ui/ui-schema/ui-schema.i18n'
|
||||
import type { SettingEntryInput } from '../setting/setting.service'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import { parseStorageProviders } from '../setting/storage-provider.utils'
|
||||
import { createStorageProviderFormSchema } from './storage-provider.ui-schema'
|
||||
|
||||
type StorageSettingKey = 'builder.storage.providers' | 'builder.storage.activeProvider' | 'photo.storage.secureAccess'
|
||||
|
||||
@injectable()
|
||||
export class StorageSettingService {
|
||||
constructor(private readonly settingService: SettingService) {}
|
||||
constructor(
|
||||
private readonly settingService: SettingService,
|
||||
private readonly storagePlanService: StoragePlanService,
|
||||
) {}
|
||||
|
||||
async getUiSchema() {
|
||||
const schema = await this.settingService.getUiSchema()
|
||||
@@ -34,7 +41,54 @@ export class StorageSettingService {
|
||||
}
|
||||
|
||||
async setMany(entries: readonly SettingEntryInput[]): Promise<void> {
|
||||
await this.settingService.setMany(entries)
|
||||
const normalized = [...entries]
|
||||
const providersEntry = normalized.find((entry) => entry.key === 'builder.storage.providers')
|
||||
const activeEntryIndex = normalized.findIndex((entry) => entry.key === 'builder.storage.activeProvider')
|
||||
const activeEntry = activeEntryIndex !== -1 ? normalized[activeEntryIndex] : null
|
||||
const activeRaw = activeEntry ? String(activeEntry.value ?? '').trim() : ''
|
||||
const activeId = activeRaw.length > 0 ? activeRaw : null
|
||||
|
||||
if (activeId === 'managed') {
|
||||
const tenantId = this.resolveTenantId(normalized)
|
||||
if (!tenantId) {
|
||||
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
|
||||
}
|
||||
const plan = await this.storagePlanService.getActivePlanSummaryForTenant(tenantId)
|
||||
if (!plan) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '托管存储订阅无效或已过期,无法设为活动存储。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (providersEntry) {
|
||||
const providers = parseStorageProviders(String(providersEntry.value ?? ''))
|
||||
|
||||
if (!activeId && providers.length === 1) {
|
||||
const only = providers[0]
|
||||
const nextActiveEntry: SettingEntryInput = {
|
||||
key: 'builder.storage.activeProvider',
|
||||
value: only.id,
|
||||
options: activeEntry?.options,
|
||||
}
|
||||
if (activeEntryIndex !== -1) {
|
||||
normalized[activeEntryIndex] = nextActiveEntry
|
||||
} else {
|
||||
normalized.push(nextActiveEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.settingService.setMany(normalized)
|
||||
}
|
||||
|
||||
private resolveTenantId(entries: readonly SettingEntryInput[]): string | null {
|
||||
const entryWithTenant = entries.find((entry) => entry.options?.tenantId)
|
||||
if (entryWithTenant?.options?.tenantId) {
|
||||
return entryWithTenant.options.tenantId
|
||||
}
|
||||
const tenant = getTenantContext()
|
||||
return tenant?.tenant.id ?? null
|
||||
}
|
||||
|
||||
async delete(key: StorageSettingKey): Promise<void> {
|
||||
|
||||
@@ -60,7 +60,6 @@ export class PhotoStorageService {
|
||||
return { builderConfig: overrides.builderConfig, storageConfig }
|
||||
}
|
||||
|
||||
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
|
||||
if (activeProviderId === MANAGED_ACTIVE_PROVIDER_ID) {
|
||||
const managedConfig = await this.tryResolveManagedStorageConfig(tenantId)
|
||||
if (managedConfig) {
|
||||
@@ -71,6 +70,7 @@ export class PhotoStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
|
||||
if (!activeProvider) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: 'Active storage provider is not configured. Configure storage settings before running sync.',
|
||||
@@ -87,7 +87,7 @@ export class PhotoStorageService {
|
||||
|
||||
private async tryResolveManagedStorageConfig(tenantId: string): Promise<ManagedStorageConfig | null> {
|
||||
const [plan, provider] = await Promise.all([
|
||||
this.storagePlanService.getPlanSummaryForTenant(tenantId),
|
||||
this.storagePlanService.getActivePlanSummaryForTenant(tenantId),
|
||||
this.systemSettingService.getManagedStorageProvider(),
|
||||
])
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { tenants } from '@afilmory/db'
|
||||
import { authUsers, creemSubscriptions, tenants } from '@afilmory/db'
|
||||
import { createLogger } from '@afilmory/framework'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { and, desc, eq, inArray, or } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { BillingPlanService } from './billing-plan.service'
|
||||
@@ -25,6 +27,7 @@ export interface StorageQuotaSummary {
|
||||
|
||||
@injectable()
|
||||
export class StoragePlanService {
|
||||
private readonly logger = createLogger('StoragePlanService')
|
||||
constructor(
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
@@ -103,6 +106,26 @@ export class StoragePlanService {
|
||||
return plan
|
||||
}
|
||||
|
||||
async getActivePlanSummaryForTenant(tenantId: string): Promise<StoragePlanSummary | null> {
|
||||
const plan = await this.getPlanSummaryForTenant(tenantId)
|
||||
if (!plan || plan.isActive === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const productId = plan.payment?.creemProductId ?? null
|
||||
if (!productId) {
|
||||
return plan
|
||||
}
|
||||
|
||||
const subscription = await this.resolveLatestSubscriptionForTenant(tenantId, productId)
|
||||
if (!subscription) {
|
||||
return plan
|
||||
}
|
||||
|
||||
const state = this.resolveSubscriptionState(subscription)
|
||||
return state === 'inactive' ? null : plan
|
||||
}
|
||||
|
||||
async getOverviewForCurrentTenant(): Promise<StoragePlanOverview> {
|
||||
const tenant = requireTenantContext()
|
||||
const [plans, currentPlan, providerKey] = await Promise.all([
|
||||
@@ -148,6 +171,73 @@ export class StoragePlanService {
|
||||
return planId && planId.length > 0 ? planId : null
|
||||
}
|
||||
|
||||
private async resolveLatestSubscriptionForTenant(tenantId: string, productId: string) {
|
||||
const db = this.dbAccessor.get()
|
||||
const users = await db
|
||||
.select({ id: authUsers.id, creemCustomerId: authUsers.creemCustomerId })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.tenantId, tenantId))
|
||||
|
||||
const userIds = users.map((user) => user.id).filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
const customerIds = users
|
||||
.map((user) => user.creemCustomerId)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
if (userIds.length === 0 && customerIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const conditions: SQL[] = []
|
||||
if (userIds.length > 0) {
|
||||
conditions.push(inArray(creemSubscriptions.referenceId, userIds))
|
||||
}
|
||||
if (customerIds.length > 0) {
|
||||
conditions.push(inArray(creemSubscriptions.creemCustomerId, customerIds))
|
||||
}
|
||||
|
||||
const where =
|
||||
conditions.length === 1 ? conditions[0] : and(or(...conditions), eq(creemSubscriptions.productId, productId))
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(creemSubscriptions)
|
||||
.where(conditions.length === 1 ? and(where, eq(creemSubscriptions.productId, productId)) : where)
|
||||
.orderBy(desc(creemSubscriptions.updatedAt))
|
||||
.limit(1)
|
||||
|
||||
return record ?? null
|
||||
}
|
||||
|
||||
private resolveSubscriptionState(
|
||||
subscription: typeof creemSubscriptions.$inferSelect,
|
||||
): 'active' | 'inactive' | 'unknown' {
|
||||
const now = Date.now()
|
||||
const status = subscription.status?.toLowerCase() ?? null
|
||||
const periodEndRaw = subscription.periodEnd
|
||||
const periodEnd = periodEndRaw ? new Date(periodEndRaw).getTime() : null
|
||||
const hasValidPeriodEnd = periodEnd !== null && !Number.isNaN(periodEnd)
|
||||
|
||||
if (hasValidPeriodEnd && periodEnd <= now) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
const activeStatuses = new Set(['active', 'trialing', 'paid'])
|
||||
if (status && activeStatuses.has(status)) {
|
||||
return 'active'
|
||||
}
|
||||
|
||||
if (subscription.cancelAtPeriodEnd && hasValidPeriodEnd && periodEnd > now) {
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const inactiveStatuses = new Set(['canceled', 'cancelled', 'expired', 'past_due', 'unpaid'])
|
||||
if (status && inactiveStatuses.has(status)) {
|
||||
return 'inactive'
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
private async getPlanCatalog(): Promise<Record<string, StoragePlanDefinition>> {
|
||||
const config = await this.systemSettingService.getStoragePlanCatalog()
|
||||
const merged: StoragePlanCatalog = { ...DEFAULT_STORAGE_PLAN_CATALOG, ...config }
|
||||
|
||||
@@ -5,11 +5,13 @@ import { m } from 'motion/react'
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSetPhotoSyncAutoRun } from '~/atoms/photo-sync'
|
||||
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
|
||||
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { useBlock } from '~/hooks/useBlock'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
import { useManagedStoragePlansQuery } from '~/modules/storage-plans'
|
||||
|
||||
import { MANAGED_STORAGE_ACTIVE_ID, storageProvidersI18nKeys } from '../constants'
|
||||
@@ -187,6 +189,10 @@ export function StorageProvidersManager() {
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = getRequestErrorMessage(error, t('errors.request.generic'))
|
||||
toast.error(t(storageProvidersI18nKeys.status.error, { reason: message }))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user