mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
feat(errors): add new error codes for internal server errors and reserved tenant slugs
- Introduced COMMON_INTERNAL_SERVER_ERROR and TENANT_SLUG_RESERVED error codes to enhance error handling. - Updated ERROR_CODE_DESCRIPTORS to include HTTP status and messages for the new error codes. - Refactored various services and controllers to replace references to SuperAdminSettingService with SystemSettingService for improved consistency and clarity. - Removed SuperAdminSettingService and its associated types, consolidating functionality into SystemSettingService. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -5,6 +5,7 @@ export enum ErrorCode {
|
||||
COMMON_NOT_FOUND = 3,
|
||||
COMMON_CONFLICT = 4,
|
||||
COMMON_RATE_LIMITED = 5,
|
||||
COMMON_INTERNAL_SERVER_ERROR = 6,
|
||||
|
||||
// Auth
|
||||
AUTH_UNAUTHORIZED = 10,
|
||||
@@ -16,6 +17,7 @@ export enum ErrorCode {
|
||||
TENANT_NOT_FOUND = 20,
|
||||
TENANT_SUSPENDED = 21,
|
||||
TENANT_INACTIVE = 22,
|
||||
TENANT_SLUG_RESERVED = 23,
|
||||
|
||||
// Image Processing
|
||||
IMAGE_PROCESSING_FAILED = 30,
|
||||
@@ -48,6 +50,10 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
|
||||
httpStatus: 429,
|
||||
message: 'Too many requests',
|
||||
},
|
||||
[ErrorCode.COMMON_INTERNAL_SERVER_ERROR]: {
|
||||
httpStatus: 500,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
[ErrorCode.AUTH_UNAUTHORIZED]: {
|
||||
httpStatus: 401,
|
||||
message: 'Unauthorized',
|
||||
@@ -76,6 +82,10 @@ export const ERROR_CODE_DESCRIPTORS: Record<ErrorCode, ErrorDescriptor> = {
|
||||
httpStatus: 403,
|
||||
message: 'Tenant is not active',
|
||||
},
|
||||
[ErrorCode.TENANT_SLUG_RESERVED]: {
|
||||
httpStatus: 400,
|
||||
message: 'Tenant slug is reserved',
|
||||
},
|
||||
|
||||
[ErrorCode.IMAGE_PROCESSING_FAILED]: {
|
||||
httpStatus: 500,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SETTING_SCHEMAS } from '../setting/setting.constant'
|
||||
import type { SettingEntryInput } from '../setting/setting.service'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import type { SettingKeyType } from '../setting/setting.type'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { getTenantContext } from '../tenant/tenant.context'
|
||||
import { TenantRepository } from '../tenant/tenant.repository'
|
||||
import { TenantService } from '../tenant/tenant.service'
|
||||
@@ -53,13 +53,13 @@ export class AuthRegistrationService {
|
||||
private readonly authProvider: AuthProvider,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly tenantRepository: TenantRepository,
|
||||
private readonly superAdminSettings: SuperAdminSettingService,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly settingService: SettingService,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
) {}
|
||||
|
||||
async registerTenant(input: RegisterTenantInput, headers: Headers): Promise<RegisterTenantResult> {
|
||||
await this.superAdminSettings.ensureRegistrationAllowed()
|
||||
await this.systemSettings.ensureRegistrationAllowed()
|
||||
|
||||
const tenantContext = getTenantContext()
|
||||
const account = this.normalizeAccountInput(input.account)
|
||||
@@ -268,7 +268,7 @@ export class AuthRegistrationService {
|
||||
initialSettings.map((entry) => ({
|
||||
...entry,
|
||||
options: {
|
||||
tenantId,
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
isSensitive: false,
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
|
||||
export interface SocialProviderOptions {
|
||||
clientId: string
|
||||
@@ -22,11 +22,11 @@ export interface AuthModuleOptions {
|
||||
|
||||
@injectable()
|
||||
export class AuthConfig {
|
||||
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
|
||||
constructor(private readonly systemSettings: SystemSettingService) {}
|
||||
|
||||
async getOptions(): Promise<AuthModuleOptions> {
|
||||
const prefix = '/auth'
|
||||
const { socialProviders, baseDomain } = await this.superAdminSettings.getAuthModuleConfig()
|
||||
const { socialProviders, baseDomain } = await this.systemSettings.getAuthModuleConfig()
|
||||
|
||||
return {
|
||||
prefix,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Context } from 'hono'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { RoleBit, Roles } from '../../guards/roles.decorator'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { getTenantContext } from '../tenant/tenant.context'
|
||||
import type { SocialProvidersConfig } from './auth.config'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
@@ -76,7 +76,7 @@ export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthProvider,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
private readonly superAdminSettings: SuperAdminSettingService,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
private readonly registration: AuthRegistrationService,
|
||||
) {}
|
||||
|
||||
@@ -94,7 +94,7 @@ export class AuthController {
|
||||
|
||||
@Get('/social/providers')
|
||||
async getSocialProviders() {
|
||||
const { socialProviders } = await this.superAdminSettings.getAuthModuleConfig()
|
||||
const { socialProviders } = await this.systemSettings.getAuthModuleConfig()
|
||||
return { providers: buildProviderResponse(socialProviders) }
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class AuthController {
|
||||
if (email.length === 0) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '邮箱不能为空' })
|
||||
}
|
||||
const settings = await this.superAdminSettings.getSettings()
|
||||
const settings = await this.systemSettings.getSettings()
|
||||
if (!settings.localProviderEnabled) {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DrizzleProvider } from '../../database/database.provider'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import type { AuthModuleOptions, SocialProviderOptions, SocialProvidersConfig } from './auth.config'
|
||||
import { AuthConfig } from './auth.config'
|
||||
|
||||
@@ -26,7 +26,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly config: AuthConfig,
|
||||
private readonly drizzleProvider: DrizzleProvider,
|
||||
private readonly superAdminSettings: SuperAdminSettingService,
|
||||
private readonly systemSettings: SystemSettingService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -236,7 +236,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.superAdminSettings.ensureRegistrationAllowed()
|
||||
await this.systemSettings.ensureRegistrationAllowed()
|
||||
} catch (error) {
|
||||
if (error instanceof BizException) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
|
||||
@@ -2,24 +2,24 @@ import { Body, Controller, Get, Patch } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { UpdateSuperAdminSettingsDto } from './super-admin.dto'
|
||||
|
||||
@Controller('super-admin/settings')
|
||||
@Roles('superadmin')
|
||||
export class SuperAdminSettingController {
|
||||
constructor(private readonly superAdminSettings: SuperAdminSettingService) {}
|
||||
constructor(private readonly systemSettings: SystemSettingService) {}
|
||||
|
||||
@Get('/')
|
||||
@BypassResponseTransform()
|
||||
async getOverview() {
|
||||
return await this.superAdminSettings.getOverview()
|
||||
return await this.systemSettings.getOverview()
|
||||
}
|
||||
|
||||
@Patch('/')
|
||||
@BypassResponseTransform()
|
||||
async update(@Body() dto: UpdateSuperAdminSettingsDto) {
|
||||
await this.superAdminSettings.updateSettings(dto)
|
||||
return await this.superAdminSettings.getOverview()
|
||||
await this.systemSettings.updateSettings(dto)
|
||||
return await this.systemSettings.getOverview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import type { SocialProvidersConfig } from '../auth/auth.config'
|
||||
import { SUPER_ADMIN_SETTING_DEFINITIONS, SUPER_ADMIN_SETTING_KEYS } from './super-admin-setting.constants'
|
||||
import type {
|
||||
SuperAdminSettingField,
|
||||
SuperAdminSettings,
|
||||
SuperAdminSettingsOverview,
|
||||
SuperAdminSettingsStats,
|
||||
UpdateSuperAdminSettingsInput,
|
||||
} from './super-admin-setting.types'
|
||||
import { SUPER_ADMIN_SETTING_UI_SCHEMA } from './super-admin-setting.ui-schema'
|
||||
import { SystemSettingService } from './system-setting.service'
|
||||
|
||||
@injectable()
|
||||
export class SuperAdminSettingService {
|
||||
constructor(
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
) {}
|
||||
|
||||
async getSettings(): Promise<SuperAdminSettings> {
|
||||
const rawValues = await this.systemSettingService.getMany(SUPER_ADMIN_SETTING_KEYS)
|
||||
|
||||
const allowRegistration = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.allowRegistration.defaultValue,
|
||||
)
|
||||
|
||||
const maxRegistrableUsers = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue,
|
||||
)
|
||||
|
||||
const localProviderEnabled = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.localProviderEnabled.defaultValue,
|
||||
)
|
||||
|
||||
const baseDomainRaw = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue,
|
||||
)
|
||||
|
||||
const baseDomain = baseDomainRaw.trim().toLowerCase()
|
||||
|
||||
const oauthGoogleClientId = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientId.defaultValue,
|
||||
)
|
||||
const oauthGoogleClientSecret = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleClientSecret.defaultValue,
|
||||
)
|
||||
const oauthGoogleRedirectUri = this.normalizeRedirectPath(
|
||||
this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGoogleRedirectUri.defaultValue,
|
||||
),
|
||||
)
|
||||
|
||||
const oauthGithubClientId = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientId.defaultValue,
|
||||
)
|
||||
const oauthGithubClientSecret = this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
|
||||
)
|
||||
const oauthGithubRedirectUri = this.normalizeRedirectPath(
|
||||
this.parseSetting(
|
||||
rawValues[SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.key],
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.schema,
|
||||
SUPER_ADMIN_SETTING_DEFINITIONS.oauthGithubRedirectUri.defaultValue,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
allowRegistration,
|
||||
maxRegistrableUsers,
|
||||
localProviderEnabled,
|
||||
baseDomain,
|
||||
oauthGoogleClientId,
|
||||
oauthGoogleClientSecret,
|
||||
oauthGoogleRedirectUri,
|
||||
oauthGithubClientId,
|
||||
oauthGithubClientSecret,
|
||||
oauthGithubRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(): Promise<SuperAdminSettingsStats> {
|
||||
const settings = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
return this.buildStats(settings, totalUsers)
|
||||
}
|
||||
|
||||
async getOverview(): Promise<SuperAdminSettingsOverview> {
|
||||
const values = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
const stats = this.buildStats(values, totalUsers)
|
||||
return {
|
||||
schema: SUPER_ADMIN_SETTING_UI_SCHEMA,
|
||||
values,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
async updateSettings(patch: UpdateSuperAdminSettingsInput): Promise<SuperAdminSettings> {
|
||||
if (!patch || Object.values(patch).every((value) => value === undefined)) {
|
||||
return await this.getSettings()
|
||||
}
|
||||
|
||||
const current = await this.getSettings()
|
||||
const updates: Array<{ field: SuperAdminSettingField; value: SuperAdminSettings[SuperAdminSettingField] }> = []
|
||||
|
||||
const enqueueUpdate = <K extends SuperAdminSettingField>(field: K, value: SuperAdminSettings[K]) => {
|
||||
updates.push({ field, value })
|
||||
current[field] = value
|
||||
}
|
||||
|
||||
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
|
||||
enqueueUpdate('allowRegistration', patch.allowRegistration)
|
||||
}
|
||||
|
||||
if (patch.localProviderEnabled !== undefined && patch.localProviderEnabled !== current.localProviderEnabled) {
|
||||
enqueueUpdate('localProviderEnabled', patch.localProviderEnabled)
|
||||
}
|
||||
|
||||
if (patch.maxRegistrableUsers !== undefined) {
|
||||
const normalized = patch.maxRegistrableUsers === null ? null : Math.max(0, Math.trunc(patch.maxRegistrableUsers))
|
||||
if (normalized !== current.maxRegistrableUsers) {
|
||||
if (normalized !== null) {
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
if (normalized < totalUsers) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '最大可注册用户数不能小于当前用户总数',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enqueueUpdate('maxRegistrableUsers', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.baseDomain !== undefined) {
|
||||
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
|
||||
if (!sanitized) {
|
||||
enqueueUpdate('baseDomain', SUPER_ADMIN_SETTING_DEFINITIONS.baseDomain.defaultValue)
|
||||
} else if (sanitized !== current.baseDomain) {
|
||||
enqueueUpdate('baseDomain', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
|
||||
if (sanitized !== current.oauthGoogleClientId) {
|
||||
enqueueUpdate('oauthGoogleClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
|
||||
if (sanitized !== current.oauthGoogleClientSecret) {
|
||||
enqueueUpdate('oauthGoogleClientSecret', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleRedirectUri !== undefined) {
|
||||
const sanitized = this.normalizeRedirectPath(patch.oauthGoogleRedirectUri)
|
||||
if (sanitized !== current.oauthGoogleRedirectUri) {
|
||||
enqueueUpdate('oauthGoogleRedirectUri', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
|
||||
if (sanitized !== current.oauthGithubClientId) {
|
||||
enqueueUpdate('oauthGithubClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
|
||||
if (sanitized !== current.oauthGithubClientSecret) {
|
||||
enqueueUpdate('oauthGithubClientSecret', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubRedirectUri !== undefined) {
|
||||
const sanitized = this.normalizeRedirectPath(patch.oauthGithubRedirectUri)
|
||||
if (sanitized !== current.oauthGithubRedirectUri) {
|
||||
enqueueUpdate('oauthGithubRedirectUri', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return current
|
||||
}
|
||||
|
||||
await this.systemSettingService.setMany(
|
||||
updates.map((entry) => {
|
||||
const definition = SUPER_ADMIN_SETTING_DEFINITIONS[entry.field]
|
||||
return {
|
||||
key: definition.key,
|
||||
value: (entry.value ?? null) as SuperAdminSettings[typeof entry.field] | null,
|
||||
options: { isSensitive: definition.isSensitive ?? false },
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
async ensureRegistrationAllowed(): Promise<void> {
|
||||
const settings = await this.getSettings()
|
||||
|
||||
if (!settings.allowRegistration) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '当前已关闭用户注册,请联系管理员获取访问权限。',
|
||||
})
|
||||
}
|
||||
|
||||
if (settings.maxRegistrableUsers === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
if (totalUsers >= settings.maxRegistrableUsers) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '用户注册数量已达到上限,请联系管理员。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
|
||||
const settings = await this.getSettings()
|
||||
return {
|
||||
baseDomain: settings.baseDomain,
|
||||
socialProviders: this.buildSocialProviders(settings),
|
||||
}
|
||||
}
|
||||
|
||||
private parseSetting<T>(raw: unknown, schema: ZodType<T>, defaultValue: T): T {
|
||||
if (raw === null || raw === undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(raw)
|
||||
return parsed.success ? parsed.data : defaultValue
|
||||
}
|
||||
|
||||
private buildSocialProviders(settings: SuperAdminSettings): SocialProvidersConfig {
|
||||
const providers: SocialProvidersConfig = {}
|
||||
|
||||
if (settings.oauthGoogleClientId && settings.oauthGoogleClientSecret) {
|
||||
providers.google = {
|
||||
clientId: settings.oauthGoogleClientId,
|
||||
clientSecret: settings.oauthGoogleClientSecret,
|
||||
redirectPath: settings.oauthGoogleRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.oauthGithubClientId && settings.oauthGithubClientSecret) {
|
||||
providers.github = {
|
||||
clientId: settings.oauthGithubClientId,
|
||||
clientSecret: settings.oauthGithubClientSecret,
|
||||
redirectPath: settings.oauthGithubRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
private normalizeNullableString(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
private normalizeRedirectPath(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ensureLeadingSlash = (input: string): string | null => {
|
||||
if (!input.startsWith('/')) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
const url = new URL(trimmed)
|
||||
const pathWithQuery = `${url.pathname}${url.search ?? ''}`
|
||||
return ensureLeadingSlash(pathWithQuery) ?? null
|
||||
}
|
||||
} catch {
|
||||
// fall through to path handling
|
||||
}
|
||||
|
||||
return ensureLeadingSlash(trimmed)
|
||||
}
|
||||
|
||||
private buildStats(settings: SuperAdminSettings, totalUsers: number): SuperAdminSettingsStats {
|
||||
const remaining =
|
||||
settings.maxRegistrableUsers === null ? null : Math.max(settings.maxRegistrableUsers - totalUsers, 0)
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
registrationsRemaining: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
private async getTotalUserCount(): Promise<number> {
|
||||
const db = this.dbAccessor.get()
|
||||
const [row] = await db.select({ total: sql<number>`count(*)` }).from(authUsers)
|
||||
return typeof row?.total === 'number' ? row.total : Number(row?.total ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { UiSchema } from '../ui-schema/ui-schema.type'
|
||||
import type { SuperAdminSettingField } from './super-admin-setting.constants'
|
||||
|
||||
export interface SuperAdminSettings {
|
||||
allowRegistration: boolean
|
||||
maxRegistrableUsers: number | null
|
||||
localProviderEnabled: boolean
|
||||
baseDomain: string
|
||||
oauthGoogleClientId: string | null
|
||||
oauthGoogleClientSecret: string | null
|
||||
oauthGoogleRedirectUri: string | null
|
||||
oauthGithubClientId: string | null
|
||||
oauthGithubClientSecret: string | null
|
||||
oauthGithubRedirectUri: string | null
|
||||
}
|
||||
|
||||
export type SuperAdminSettingValueMap = {
|
||||
[K in SuperAdminSettingField]: SuperAdminSettings[K]
|
||||
}
|
||||
|
||||
export interface SuperAdminSettingsStats {
|
||||
totalUsers: number
|
||||
registrationsRemaining: number | null
|
||||
}
|
||||
|
||||
export interface SuperAdminSettingsOverview {
|
||||
schema: UiSchema<SuperAdminSettingField>
|
||||
values: SuperAdminSettingValueMap
|
||||
stats: SuperAdminSettingsStats
|
||||
}
|
||||
|
||||
export type UpdateSuperAdminSettingsInput = Partial<SuperAdminSettings>
|
||||
|
||||
export { type SuperAdminSettingField } from './super-admin-setting.constants'
|
||||
@@ -5,7 +5,7 @@ const nonEmptyString = z.string().trim().min(1)
|
||||
const nullableNonEmptyString = nonEmptyString.nullable()
|
||||
const nullableUrl = z.string().trim().url({ message: '必须是有效的 URL' }).nullable()
|
||||
|
||||
export const SUPER_ADMIN_SETTING_DEFINITIONS = {
|
||||
export const SYSTEM_SETTING_DEFINITIONS = {
|
||||
allowRegistration: {
|
||||
key: 'system.registration.allow',
|
||||
schema: z.boolean(),
|
||||
@@ -74,9 +74,7 @@ export const SUPER_ADMIN_SETTING_DEFINITIONS = {
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SuperAdminSettingField = keyof typeof SUPER_ADMIN_SETTING_DEFINITIONS
|
||||
export type SuperAdminSettingKey = (typeof SUPER_ADMIN_SETTING_DEFINITIONS)[SuperAdminSettingField]['key']
|
||||
export type SystemSettingField = keyof typeof SYSTEM_SETTING_DEFINITIONS
|
||||
export type SystemSettingKey = (typeof SYSTEM_SETTING_DEFINITIONS)[SystemSettingField]['key']
|
||||
|
||||
export const SUPER_ADMIN_SETTING_KEYS = Object.values(SUPER_ADMIN_SETTING_DEFINITIONS).map(
|
||||
(definition) => definition.key,
|
||||
)
|
||||
export const SYSTEM_SETTING_KEYS = Object.values(SYSTEM_SETTING_DEFINITIONS).map((definition) => definition.key)
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { SuperAdminSettingService } from './super-admin-setting.service'
|
||||
import { SystemSettingService } from './system-setting.service'
|
||||
import { SystemSettingStore } from './system-setting.store.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
providers: [SystemSettingService, SuperAdminSettingService],
|
||||
providers: [SystemSettingStore, SystemSettingService],
|
||||
exports: [SystemSettingStore, SystemSettingService],
|
||||
})
|
||||
export class SystemSettingModule {}
|
||||
|
||||
@@ -1,90 +1,344 @@
|
||||
import { systemSettings } from '@afilmory/db'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import type { SocialProvidersConfig } from '../auth/auth.config'
|
||||
import { SYSTEM_SETTING_DEFINITIONS, SYSTEM_SETTING_KEYS } from './system-setting.constants'
|
||||
import { SystemSettingStore } from './system-setting.store.service'
|
||||
import type {
|
||||
SystemSettingEntryInput,
|
||||
SystemSettingKey,
|
||||
SystemSettingRecord,
|
||||
SystemSettingSetOptions,
|
||||
SystemSettingField,
|
||||
SystemSettingOverview,
|
||||
SystemSettings,
|
||||
SystemSettingStats,
|
||||
UpdateSystemSettingsInput,
|
||||
} from './system-setting.types'
|
||||
import { SYSTEM_SETTING_UI_SCHEMA } from './system-setting.ui-schema'
|
||||
|
||||
@injectable()
|
||||
export class SystemSettingService {
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
constructor(
|
||||
private readonly systemSettingStore: SystemSettingStore,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
) {}
|
||||
|
||||
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
|
||||
const record = await this.find(key)
|
||||
return record?.value ?? null
|
||||
}
|
||||
async getSettings(): Promise<SystemSettings> {
|
||||
const rawValues = await this.systemSettingStore.getMany(SYSTEM_SETTING_KEYS)
|
||||
|
||||
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
|
||||
if (keys.length === 0) {
|
||||
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
|
||||
}
|
||||
|
||||
const uniqueKeys = Array.from(new Set(keys))
|
||||
const db = this.dbAccessor.get()
|
||||
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
|
||||
|
||||
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
|
||||
return uniqueKeys.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = map.get(key)?.value ?? null
|
||||
return acc
|
||||
},
|
||||
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
|
||||
const allowRegistration = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.allowRegistration.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.allowRegistration.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.allowRegistration.defaultValue,
|
||||
)
|
||||
}
|
||||
|
||||
async set(
|
||||
key: SystemSettingKey,
|
||||
value: SystemSettingRecord['value'],
|
||||
options: SystemSettingSetOptions = {},
|
||||
): Promise<void> {
|
||||
const existing = await this.find(key)
|
||||
const db = this.dbAccessor.get()
|
||||
const maxRegistrableUsers = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.maxRegistrableUsers.defaultValue,
|
||||
)
|
||||
|
||||
const sanitizedValue = value ?? null
|
||||
const isSensitive = options.isSensitive ?? existing?.isSensitive ?? false
|
||||
const description = options.description ?? existing?.description ?? null
|
||||
const now = new Date().toISOString()
|
||||
const localProviderEnabled = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.localProviderEnabled.defaultValue,
|
||||
)
|
||||
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key,
|
||||
value: sanitizedValue,
|
||||
isSensitive,
|
||||
description,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: sanitizedValue,
|
||||
isSensitive,
|
||||
description,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
const baseDomainRaw = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.baseDomain.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.baseDomain.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.baseDomain.defaultValue,
|
||||
)
|
||||
|
||||
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await this.set(entry.key, entry.value ?? null, entry.options ?? {})
|
||||
const baseDomain = baseDomainRaw.trim().toLowerCase()
|
||||
|
||||
const oauthGoogleClientId = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientId.defaultValue,
|
||||
)
|
||||
const oauthGoogleClientSecret = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleClientSecret.defaultValue,
|
||||
)
|
||||
const oauthGoogleRedirectUri = this.normalizeRedirectPath(
|
||||
this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGoogleRedirectUri.defaultValue,
|
||||
),
|
||||
)
|
||||
|
||||
const oauthGithubClientId = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientId.defaultValue,
|
||||
)
|
||||
const oauthGithubClientSecret = this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubClientSecret.defaultValue,
|
||||
)
|
||||
const oauthGithubRedirectUri = this.normalizeRedirectPath(
|
||||
this.parseSetting(
|
||||
rawValues[SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.key],
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.schema,
|
||||
SYSTEM_SETTING_DEFINITIONS.oauthGithubRedirectUri.defaultValue,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
allowRegistration,
|
||||
maxRegistrableUsers,
|
||||
localProviderEnabled,
|
||||
baseDomain,
|
||||
oauthGoogleClientId,
|
||||
oauthGoogleClientSecret,
|
||||
oauthGoogleRedirectUri,
|
||||
oauthGithubClientId,
|
||||
oauthGithubClientSecret,
|
||||
oauthGithubRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: SystemSettingKey): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.delete(systemSettings).where(eq(systemSettings.key, key))
|
||||
async getStats(): Promise<SystemSettingStats> {
|
||||
const settings = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
return this.buildStats(settings, totalUsers)
|
||||
}
|
||||
|
||||
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db.select().from(systemSettings).where(eq(systemSettings.key, key)).limit(1)
|
||||
async getOverview(): Promise<SystemSettingOverview> {
|
||||
const values = await this.getSettings()
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
const stats = this.buildStats(values, totalUsers)
|
||||
return {
|
||||
schema: SYSTEM_SETTING_UI_SCHEMA,
|
||||
values,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
return record ?? null
|
||||
async updateSettings(patch: UpdateSystemSettingsInput): Promise<SystemSettings> {
|
||||
if (!patch || Object.values(patch).every((value) => value === undefined)) {
|
||||
return await this.getSettings()
|
||||
}
|
||||
|
||||
const current = await this.getSettings()
|
||||
const updates: Array<{ field: SystemSettingField; value: SystemSettings[SystemSettingField] }> = []
|
||||
|
||||
const enqueueUpdate = <K extends SystemSettingField>(field: K, value: SystemSettings[K]) => {
|
||||
updates.push({ field, value })
|
||||
current[field] = value
|
||||
}
|
||||
|
||||
if (patch.allowRegistration !== undefined && patch.allowRegistration !== current.allowRegistration) {
|
||||
enqueueUpdate('allowRegistration', patch.allowRegistration)
|
||||
}
|
||||
|
||||
if (patch.localProviderEnabled !== undefined && patch.localProviderEnabled !== current.localProviderEnabled) {
|
||||
enqueueUpdate('localProviderEnabled', patch.localProviderEnabled)
|
||||
}
|
||||
|
||||
if (patch.maxRegistrableUsers !== undefined) {
|
||||
const normalized = patch.maxRegistrableUsers === null ? null : Math.max(0, Math.trunc(patch.maxRegistrableUsers))
|
||||
if (normalized !== current.maxRegistrableUsers) {
|
||||
if (normalized !== null) {
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
if (normalized < totalUsers) {
|
||||
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
|
||||
message: '最大可注册用户数不能小于当前用户总数',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
enqueueUpdate('maxRegistrableUsers', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.baseDomain !== undefined) {
|
||||
const sanitized = patch.baseDomain === null ? null : String(patch.baseDomain).trim().toLowerCase()
|
||||
if (!sanitized) {
|
||||
enqueueUpdate('baseDomain', SYSTEM_SETTING_DEFINITIONS.baseDomain.defaultValue)
|
||||
} else if (sanitized !== current.baseDomain) {
|
||||
enqueueUpdate('baseDomain', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientId)
|
||||
if (sanitized !== current.oauthGoogleClientId) {
|
||||
enqueueUpdate('oauthGoogleClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGoogleClientSecret)
|
||||
if (sanitized !== current.oauthGoogleClientSecret) {
|
||||
enqueueUpdate('oauthGoogleClientSecret', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGoogleRedirectUri !== undefined) {
|
||||
const sanitized = this.normalizeRedirectPath(patch.oauthGoogleRedirectUri)
|
||||
if (sanitized !== current.oauthGoogleRedirectUri) {
|
||||
enqueueUpdate('oauthGoogleRedirectUri', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientId !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientId)
|
||||
if (sanitized !== current.oauthGithubClientId) {
|
||||
enqueueUpdate('oauthGithubClientId', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubClientSecret !== undefined) {
|
||||
const sanitized = this.normalizeNullableString(patch.oauthGithubClientSecret)
|
||||
if (sanitized !== current.oauthGithubClientSecret) {
|
||||
enqueueUpdate('oauthGithubClientSecret', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.oauthGithubRedirectUri !== undefined) {
|
||||
const sanitized = this.normalizeRedirectPath(patch.oauthGithubRedirectUri)
|
||||
if (sanitized !== current.oauthGithubRedirectUri) {
|
||||
enqueueUpdate('oauthGithubRedirectUri', sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return current
|
||||
}
|
||||
|
||||
await this.systemSettingStore.setMany(
|
||||
updates.map((entry) => {
|
||||
const definition = SYSTEM_SETTING_DEFINITIONS[entry.field]
|
||||
return {
|
||||
key: definition.key,
|
||||
value: (entry.value ?? null) as SystemSettings[typeof entry.field] | null,
|
||||
options: { isSensitive: definition.isSensitive ?? false },
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
async ensureRegistrationAllowed(): Promise<void> {
|
||||
const settings = await this.getSettings()
|
||||
|
||||
if (!settings.allowRegistration) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '当前已关闭用户注册,请联系管理员获取访问权限。',
|
||||
})
|
||||
}
|
||||
|
||||
if (settings.maxRegistrableUsers === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const totalUsers = await this.getTotalUserCount()
|
||||
if (totalUsers >= settings.maxRegistrableUsers) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '用户注册数量已达到上限,请联系管理员。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthModuleConfig(): Promise<{ baseDomain: string; socialProviders: SocialProvidersConfig }> {
|
||||
const settings = await this.getSettings()
|
||||
return {
|
||||
baseDomain: settings.baseDomain,
|
||||
socialProviders: this.buildSocialProviders(settings),
|
||||
}
|
||||
}
|
||||
|
||||
private parseSetting<T>(raw: unknown, schema: ZodType<T>, defaultValue: T): T {
|
||||
if (raw === null || raw === undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(raw)
|
||||
return parsed.success ? parsed.data : defaultValue
|
||||
}
|
||||
|
||||
private buildSocialProviders(settings: SystemSettings): SocialProvidersConfig {
|
||||
const providers: SocialProvidersConfig = {}
|
||||
|
||||
if (settings.oauthGoogleClientId && settings.oauthGoogleClientSecret) {
|
||||
providers.google = {
|
||||
clientId: settings.oauthGoogleClientId,
|
||||
clientSecret: settings.oauthGoogleClientSecret,
|
||||
redirectPath: settings.oauthGoogleRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.oauthGithubClientId && settings.oauthGithubClientSecret) {
|
||||
providers.github = {
|
||||
clientId: settings.oauthGithubClientId,
|
||||
clientSecret: settings.oauthGithubClientSecret,
|
||||
redirectPath: settings.oauthGithubRedirectUri,
|
||||
}
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
private normalizeNullableString(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
private normalizeRedirectPath(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ensureLeadingSlash = (input: string): string | null => {
|
||||
if (!input.startsWith('/')) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
try {
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
const url = new URL(trimmed)
|
||||
const pathWithQuery = `${url.pathname}${url.search ?? ''}`
|
||||
return ensureLeadingSlash(pathWithQuery) ?? null
|
||||
}
|
||||
} catch {
|
||||
// fall through to path handling
|
||||
}
|
||||
|
||||
return ensureLeadingSlash(trimmed)
|
||||
}
|
||||
|
||||
private buildStats(settings: SystemSettings, totalUsers: number): SystemSettingStats {
|
||||
const remaining =
|
||||
settings.maxRegistrableUsers === null ? null : Math.max(settings.maxRegistrableUsers - totalUsers, 0)
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
registrationsRemaining: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
private async getTotalUserCount(): Promise<number> {
|
||||
const [row] = await this.dbAccessor
|
||||
.get()
|
||||
.select({ total: sql<number>`count(*)` })
|
||||
.from(authUsers)
|
||||
return typeof row?.total === 'number' ? row.total : Number(row?.total ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { systemSettings } from '@afilmory/db'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import type {
|
||||
SystemSettingEntryInput,
|
||||
SystemSettingKey,
|
||||
SystemSettingRecord,
|
||||
SystemSettingSetOptions,
|
||||
} from './system-setting.store.types'
|
||||
|
||||
@injectable()
|
||||
export class SystemSettingStore {
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async get(key: SystemSettingKey): Promise<SystemSettingRecord['value']> {
|
||||
const record = await this.find(key)
|
||||
return record?.value ?? null
|
||||
}
|
||||
|
||||
async getMany(keys: readonly SystemSettingKey[]): Promise<Record<SystemSettingKey, SystemSettingRecord['value']>> {
|
||||
if (keys.length === 0) {
|
||||
return {} as Record<SystemSettingKey, SystemSettingRecord['value']>
|
||||
}
|
||||
|
||||
const uniqueKeys = Array.from(new Set(keys))
|
||||
const db = this.dbAccessor.get()
|
||||
const records = await db.select().from(systemSettings).where(inArray(systemSettings.key, uniqueKeys))
|
||||
|
||||
const map = new Map<SystemSettingKey, SystemSettingRecord>(records.map((record) => [record.key, record]))
|
||||
return uniqueKeys.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = map.get(key)?.value ?? null
|
||||
return acc
|
||||
},
|
||||
Object.create(null) as Record<SystemSettingKey, SystemSettingRecord['value']>,
|
||||
)
|
||||
}
|
||||
|
||||
async set(
|
||||
key: SystemSettingKey,
|
||||
value: SystemSettingRecord['value'],
|
||||
options: SystemSettingSetOptions = {},
|
||||
): Promise<void> {
|
||||
const existing = await this.find(key)
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
const sanitizedValue = value ?? null
|
||||
const isSensitive = options.isSensitive ?? existing?.isSensitive ?? false
|
||||
const description = options.description ?? existing?.description ?? null
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key,
|
||||
value: sanitizedValue,
|
||||
isSensitive,
|
||||
description,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: sanitizedValue,
|
||||
isSensitive,
|
||||
description,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async setMany(entries: readonly SystemSettingEntryInput[]): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
await this.set(entry.key, entry.value ?? null, entry.options ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: SystemSettingKey): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
await db.delete(systemSettings).where(eq(systemSettings.key, key))
|
||||
}
|
||||
|
||||
private async find(key: SystemSettingKey): Promise<SystemSettingRecord | null> {
|
||||
const db = this.dbAccessor.get()
|
||||
const [record] = await db.select().from(systemSettings).where(eq(systemSettings.key, key)).limit(1)
|
||||
|
||||
return record ?? null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { systemSettings } from '@afilmory/db'
|
||||
|
||||
export type SystemSettingRecord = typeof systemSettings.$inferSelect
|
||||
export type SystemSettingKey = SystemSettingRecord['key']
|
||||
|
||||
export type SystemSettingGetManyResult = Record<SystemSettingKey, SystemSettingRecord['value']>
|
||||
|
||||
export interface SystemSettingSetOptions {
|
||||
readonly isSensitive?: boolean
|
||||
readonly description?: string | null
|
||||
}
|
||||
|
||||
export interface SystemSettingEntryInput {
|
||||
readonly key: SystemSettingKey
|
||||
readonly value: SystemSettingRecord['value']
|
||||
readonly options?: SystemSettingSetOptions
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
import type { systemSettings } from '@afilmory/db'
|
||||
import type { UiSchema } from '../ui-schema/ui-schema.type'
|
||||
import type { SystemSettingField } from './system-setting.constants'
|
||||
|
||||
export type SystemSettingRecord = typeof systemSettings.$inferSelect
|
||||
export type SystemSettingKey = SystemSettingRecord['key']
|
||||
|
||||
export type SystemSettingGetManyResult = Record<SystemSettingKey, SystemSettingRecord['value']>
|
||||
|
||||
export interface SystemSettingSetOptions {
|
||||
readonly isSensitive?: boolean
|
||||
readonly description?: string | null
|
||||
export interface SystemSettings {
|
||||
allowRegistration: boolean
|
||||
maxRegistrableUsers: number | null
|
||||
localProviderEnabled: boolean
|
||||
baseDomain: string
|
||||
oauthGoogleClientId: string | null
|
||||
oauthGoogleClientSecret: string | null
|
||||
oauthGoogleRedirectUri: string | null
|
||||
oauthGithubClientId: string | null
|
||||
oauthGithubClientSecret: string | null
|
||||
oauthGithubRedirectUri: string | null
|
||||
}
|
||||
|
||||
export interface SystemSettingEntryInput {
|
||||
readonly key: SystemSettingKey
|
||||
readonly value: SystemSettingRecord['value']
|
||||
readonly options?: SystemSettingSetOptions
|
||||
export type SystemSettingValueMap = {
|
||||
[K in SystemSettingField]: SystemSettings[K]
|
||||
}
|
||||
|
||||
export interface SystemSettingStats {
|
||||
totalUsers: number
|
||||
registrationsRemaining: number | null
|
||||
}
|
||||
|
||||
export interface SystemSettingOverview {
|
||||
schema: UiSchema<SystemSettingField>
|
||||
values: SystemSettingValueMap
|
||||
stats: SystemSettingStats
|
||||
}
|
||||
|
||||
export type UpdateSystemSettingsInput = Partial<SystemSettings>
|
||||
|
||||
export { type SystemSettingField } from './system-setting.constants'
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { UiNode, UiSchema } from '../ui-schema/ui-schema.type'
|
||||
import type { SuperAdminSettingField } from './super-admin-setting.constants'
|
||||
import type { SystemSettingField } from './system-setting.constants'
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION = '1.1.0'
|
||||
export const SYSTEM_SETTING_UI_SCHEMA_VERSION = '1.1.0'
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
|
||||
version: SUPER_ADMIN_SETTING_UI_SCHEMA_VERSION,
|
||||
title: '超级管理员设置',
|
||||
export const SYSTEM_SETTING_UI_SCHEMA: UiSchema<SystemSettingField> = {
|
||||
version: SYSTEM_SETTING_UI_SCHEMA_VERSION,
|
||||
title: '系统设置',
|
||||
description: '管理整个平台的注册入口、登录策略与第三方 OAuth 配置。',
|
||||
sections: [
|
||||
{
|
||||
@@ -162,8 +162,8 @@ export const SUPER_ADMIN_SETTING_UI_SCHEMA: UiSchema<SuperAdminSettingField> = {
|
||||
],
|
||||
}
|
||||
|
||||
function collectKeys(nodes: ReadonlyArray<UiNode<SuperAdminSettingField>>): SuperAdminSettingField[] {
|
||||
const keys: SuperAdminSettingField[] = []
|
||||
function collectKeys(nodes: ReadonlyArray<UiNode<SystemSettingField>>): SystemSettingField[] {
|
||||
const keys: SystemSettingField[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
@@ -177,6 +177,6 @@ function collectKeys(nodes: ReadonlyArray<UiNode<SuperAdminSettingField>>): Supe
|
||||
return keys
|
||||
}
|
||||
|
||||
export const SUPER_ADMIN_SETTING_UI_SCHEMA_KEYS = Array.from(
|
||||
new Set(collectKeys(SUPER_ADMIN_SETTING_UI_SCHEMA.sections)),
|
||||
) as SuperAdminSettingField[]
|
||||
export const SYSTEM_SETTING_UI_SCHEMA_KEYS = Array.from(
|
||||
new Set(collectKeys(SYSTEM_SETTING_UI_SCHEMA.sections)),
|
||||
) as SystemSettingField[]
|
||||
@@ -1,12 +1,12 @@
|
||||
import { HttpContext } from '@afilmory/framework'
|
||||
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import type { Context } from 'hono'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { logger } from '../../helpers/logger.helper'
|
||||
import { OnboardingService } from '../onboarding/onboarding.service'
|
||||
import { SuperAdminSettingService } from '../system-setting/super-admin-setting.service'
|
||||
import { SystemSettingService } from '../system-setting/system-setting.service'
|
||||
import { TenantService } from './tenant.service'
|
||||
import type { TenantContext } from './tenant.types'
|
||||
|
||||
@@ -26,7 +26,7 @@ export class TenantContextResolver {
|
||||
constructor(
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
private readonly superAdminSettingService: SuperAdminSettingService,
|
||||
private readonly systemSettingService: SystemSettingService,
|
||||
) {}
|
||||
|
||||
async resolve(context: Context, options: TenantResolutionOptions = {}): Promise<TenantContext | null> {
|
||||
@@ -66,11 +66,6 @@ export class TenantContextResolver {
|
||||
derivedSlug = this.extractSlugFromHost(host, baseDomain)
|
||||
}
|
||||
|
||||
if (derivedSlug && isTenantSlugReserved(derivedSlug)) {
|
||||
this.log.verbose(`Host ${host} matched reserved slug ${derivedSlug}, skipping tenant resolution.`)
|
||||
return null
|
||||
}
|
||||
|
||||
const tenantContext = await this.tenantService.resolve(
|
||||
{
|
||||
tenantId,
|
||||
@@ -112,7 +107,7 @@ export class TenantContextResolver {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return 'localhost'
|
||||
}
|
||||
const settings = await this.superAdminSettingService.getSettings()
|
||||
const settings = await this.systemSettingService.getSettings()
|
||||
return settings.baseDomain || DEFAULT_BASE_DOMAIN
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { generateId, tenants } from '@afilmory/db'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@@ -42,7 +43,9 @@ export class TenantRepository {
|
||||
|
||||
return await this.findById(tenantId).then((aggregate) => {
|
||||
if (!aggregate) {
|
||||
throw new Error('Failed to create tenant')
|
||||
throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, {
|
||||
message: 'Failed to create tenant',
|
||||
})
|
||||
}
|
||||
return aggregate
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isTenantSlugReserved } from '@afilmory/utils'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@@ -9,7 +10,22 @@ export class TenantService {
|
||||
constructor(private readonly repository: TenantRepository) {}
|
||||
|
||||
async createTenant(payload: { name: string; slug: string }): Promise<TenantAggregate> {
|
||||
return await this.repository.createTenant(payload)
|
||||
const normalizedSlug = this.normalizeSlug(payload.slug)
|
||||
|
||||
if (!normalizedSlug) {
|
||||
throw new BizException(ErrorCode.COMMON_VALIDATION, {
|
||||
message: 'Tenant slug is required',
|
||||
})
|
||||
}
|
||||
|
||||
if (isTenantSlugReserved(normalizedSlug)) {
|
||||
throw new BizException(ErrorCode.TENANT_SLUG_RESERVED)
|
||||
}
|
||||
|
||||
return await this.repository.createTenant({
|
||||
...payload,
|
||||
slug: normalizedSlug,
|
||||
})
|
||||
}
|
||||
async resolve(input: TenantResolutionInput, noThrow: boolean): Promise<TenantContext | null>
|
||||
async resolve(input: TenantResolutionInput): Promise<TenantContext>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Navigate, NavLink, Outlet } from 'react-router'
|
||||
import { useAuthUserValue, useIsSuperAdmin } from '~/atoms/auth'
|
||||
import { usePageRedirect } from '~/hooks/usePageRedirect'
|
||||
|
||||
const navigationTabs = [{ label: '系统设置', path: '/superadmin/settings' }] as const
|
||||
const navigationTabs = [{ label: 'System Settings', path: '/superadmin/settings' }] as const
|
||||
|
||||
export function Component() {
|
||||
const { logout } = usePageRedirect()
|
||||
@@ -37,7 +37,7 @@ export function Component() {
|
||||
<div className="flex h-screen flex-col">
|
||||
<nav className="border-border/50 bg-background-tertiary shrink-0 border-b px-6 py-3">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-text text-base font-semibold">Afilmory · Superadmin</div>
|
||||
<div className="text-text text-base font-semibold">Afilmory · System Settings</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{navigationTabs.map((tab) => (
|
||||
|
||||
@@ -12,8 +12,8 @@ export function Component() {
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-text text-2xl font-semibold">超级管理员设置</h1>
|
||||
<p className="text-text-secondary text-sm">管理整个平台的注册策略与本地登录渠道,仅对超级管理员开放。</p>
|
||||
<h1 className="text-text text-2xl font-semibold">系统设置</h1>
|
||||
<p className="text-text-secondary text-sm">管理整个平台的注册策略与本地登录渠道,由超级管理员统一维护。</p>
|
||||
</header>
|
||||
|
||||
<SuperAdminSettingsForm />
|
||||
|
||||
Reference in New Issue
Block a user