Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

242
api/infra/container.ts Normal file
View File

@@ -0,0 +1,242 @@
export enum ServiceLifetime {
Singleton = 'singleton',
Transient = 'transient',
Scoped = 'scoped'
}
export interface ServiceDescriptor<T = any> {
name: string
factory: () => T | Promise<T>
lifetime: ServiceLifetime
dependencies?: string[]
onDispose?: (instance: T) => void | Promise<void>
}
export type ServiceFactory<T> = () => T | Promise<T>
export class ServiceContainer {
private descriptors = new Map<string, ServiceDescriptor>()
private singletonInstances = new Map<string, unknown>()
private scopedInstances = new Map<string, Map<string, unknown>>()
private resolutionStack: string[] = []
private disposed = false
register<T>(name: string, factory: ServiceFactory<T>): void
register<T>(descriptor: ServiceDescriptor<T>): void
register<T>(nameOrDescriptor: string | ServiceDescriptor<T>, factory?: ServiceFactory<T>): void {
this.ensureNotDisposed()
if (typeof nameOrDescriptor === 'string') {
const descriptor: ServiceDescriptor<T> = {
name: nameOrDescriptor,
factory: factory!,
lifetime: ServiceLifetime.Singleton
}
this.descriptors.set(nameOrDescriptor, descriptor)
} else {
this.descriptors.set(nameOrDescriptor.name, nameOrDescriptor)
}
}
async get<T>(name: string): Promise<T> {
this.ensureNotDisposed()
return this.resolveInternal<T>(name, null)
}
getSync<T>(name: string): T {
this.ensureNotDisposed()
const descriptor = this.descriptors.get(name)
if (!descriptor) {
throw new Error(`Service '${name}' not registered`)
}
if (descriptor.lifetime === ServiceLifetime.Singleton) {
if (this.singletonInstances.has(name)) {
return this.singletonInstances.get(name) as T
}
}
const result = descriptor.factory()
if (result instanceof Promise) {
throw new Error(
`Service '${name}' has an async factory but getSync() was called. Use get() instead.`
)
}
if (descriptor.lifetime === ServiceLifetime.Singleton) {
this.singletonInstances.set(name, result)
}
return result as T
}
createScope(scopeId: string): ServiceScope {
this.ensureNotDisposed()
return new ServiceScope(this, scopeId)
}
has(name: string): boolean {
return this.descriptors.has(name)
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
for (const [name, instance] of this.singletonInstances) {
const descriptor = this.descriptors.get(name)
if (descriptor?.onDispose) {
try {
await descriptor.onDispose(instance)
} catch (error) {
console.error(`Error disposing service '${name}':`, error)
}
}
}
for (const [, scopeMap] of this.scopedInstances) {
for (const [name, instance] of scopeMap) {
const descriptor = this.descriptors.get(name)
if (descriptor?.onDispose) {
try {
await descriptor.onDispose(instance)
} catch (error) {
console.error(`Error disposing scoped service '${name}':`, error)
}
}
}
}
this.singletonInstances.clear()
this.scopedInstances.clear()
this.descriptors.clear()
this.resolutionStack = []
this.disposed = true
}
clear(): void {
this.singletonInstances.clear()
this.scopedInstances.clear()
this.descriptors.clear()
this.resolutionStack = []
}
isDisposed(): boolean {
return this.disposed
}
private async resolveInternal<T>(name: string, scopeId: string | null): Promise<T> {
if (this.resolutionStack.includes(name)) {
const cycle = [...this.resolutionStack, name].join(' -> ')
throw new Error(`Circular dependency detected: ${cycle}`)
}
const descriptor = this.descriptors.get(name)
if (!descriptor) {
throw new Error(`Service '${name}' not registered`)
}
if (descriptor.lifetime === ServiceLifetime.Singleton) {
if (this.singletonInstances.has(name)) {
return this.singletonInstances.get(name) as T
}
this.resolutionStack.push(name)
try {
const instance = await descriptor.factory()
this.singletonInstances.set(name, instance)
return instance as T
} finally {
this.resolutionStack.pop()
}
}
if (descriptor.lifetime === ServiceLifetime.Scoped) {
if (!scopeId) {
throw new Error(
`Scoped service '${name}' cannot be resolved outside of a scope. Use createScope() first.`
)
}
let scopeMap = this.scopedInstances.get(scopeId)
if (!scopeMap) {
scopeMap = new Map()
this.scopedInstances.set(scopeId, scopeMap)
}
if (scopeMap.has(name)) {
return scopeMap.get(name) as T
}
this.resolutionStack.push(name)
try {
const instance = await descriptor.factory()
scopeMap.set(name, instance)
return instance as T
} finally {
this.resolutionStack.pop()
}
}
this.resolutionStack.push(name)
try {
const instance = await descriptor.factory()
return instance as T
} finally {
this.resolutionStack.pop()
}
}
private ensureNotDisposed(): void {
if (this.disposed) {
throw new Error('ServiceContainer has been disposed')
}
}
}
export class ServiceScope {
private container: ServiceContainer
private scopeId: string
private disposed = false
constructor(container: ServiceContainer, scopeId: string) {
this.container = container
this.scopeId = scopeId
}
async get<T>(name: string): Promise<T> {
if (this.disposed) {
throw new Error('ServiceScope has been disposed')
}
return this.container['resolveInternal']<T>(name, this.scopeId)
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
const scopeMap = this.container['scopedInstances'].get(this.scopeId)
if (scopeMap) {
for (const [name, instance] of scopeMap) {
const descriptor = this.container['descriptors'].get(name)
if (descriptor?.onDispose) {
try {
await descriptor.onDispose(instance)
} catch (error) {
console.error(`Error disposing scoped service '${name}':`, error)
}
}
}
this.container['scopedInstances'].delete(this.scopeId)
}
this.disposed = true
}
isDisposed(): boolean {
return this.disposed
}
}

41
api/infra/createModule.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Router } from 'express'
import type { ServiceContainer } from './container.js'
import type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
import type { ApiModuleConfig, ModuleEndpoints } from '../../shared/modules/types.js'
export interface CreateModuleOptions<TEndpoints extends ModuleEndpoints> {
routes: (container: ServiceContainer) => Router | Promise<Router>
lifecycle?: ModuleLifecycle
services?: (container: ServiceContainer) => void | Promise<void>
}
export function createApiModule<
TId extends string,
TEndpoints extends ModuleEndpoints
>(
config: ApiModuleConfig<TId, TEndpoints>,
options: CreateModuleOptions<TEndpoints>
): ApiModule {
const metadata: ModuleMetadata = {
id: config.id,
name: config.name,
version: config.version,
basePath: config.basePath,
order: config.order,
dependencies: config.dependencies,
}
const lifecycle: ModuleLifecycle | undefined = options.lifecycle
? options.lifecycle
: options.services
? {
onLoad: options.services,
}
: undefined
return {
metadata,
lifecycle,
createRouter: options.routes,
}
}

5
api/infra/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { ServiceContainer } from './container.js'
export type { ServiceFactory } from './container.js'
export { loadModules } from './moduleLoader.js'
export { ModuleManager } from './moduleManager.js'
export type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'

24
api/infra/moduleLoader.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Application } from 'express'
import type { ServiceContainer } from './container.js'
import type { ApiModule } from './types.js'
import { ModuleManager } from './moduleManager.js'
export async function loadModules(
app: Application,
container: ServiceContainer,
modules: ApiModule[]
): Promise<ModuleManager> {
const manager = new ModuleManager(container)
for (const module of modules) {
await manager.register(module)
}
for (const module of manager.getAllModules()) {
await manager.activate(module.metadata.id)
const router = await module.createRouter(container)
app.use('/api' + module.metadata.basePath, router)
}
return manager
}

View File

@@ -0,0 +1,76 @@
import { ApiModule, ModuleMetadata } from './types.js'
import { ServiceContainer } from './container.js'
export class ModuleManager {
private modules = new Map<string, ApiModule>()
private activeModules = new Set<string>()
private container: ServiceContainer
constructor(container: ServiceContainer) {
this.container = container
}
async register(module: ApiModule): Promise<void> {
const { id, dependencies = [] } = module.metadata
for (const dep of dependencies) {
if (!this.modules.has(dep)) {
throw new Error(`Module '${id}' depends on '${dep}' which is not registered`)
}
}
this.modules.set(id, module)
if (module.lifecycle?.onLoad) {
await module.lifecycle.onLoad(this.container)
}
}
async activate(id: string): Promise<void> {
const module = this.modules.get(id)
if (!module) {
throw new Error(`Module '${id}' not found`)
}
if (this.activeModules.has(id)) {
return
}
const { dependencies = [] } = module.metadata
for (const dep of dependencies) {
await this.activate(dep)
}
if (module.lifecycle?.onActivate) {
await module.lifecycle.onActivate(this.container)
}
this.activeModules.add(id)
}
async deactivate(id: string): Promise<void> {
const module = this.modules.get(id)
if (!module) return
if (!this.activeModules.has(id)) return
if (module.lifecycle?.onDeactivate) {
await module.lifecycle.onDeactivate(this.container)
}
this.activeModules.delete(id)
}
getModule(id: string): ApiModule | undefined {
return this.modules.get(id)
}
getAllModules(): ApiModule[] {
return Array.from(this.modules.values())
.sort((a, b) => (a.metadata.order || 0) - (b.metadata.order || 0))
}
getActiveModules(): string[] {
return Array.from(this.activeModules)
}
}

View File

@@ -0,0 +1,113 @@
import { readdirSync, statSync, existsSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import type { ApiModule } from './types.js'
import type { ModuleDefinition } from '../../shared/modules/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
function getSharedModulesPath(): string | null {
const possiblePaths = [
join(__dirname, '../../shared/modules'),
join(__dirname, '../../../shared/modules'),
join((process as unknown as { resourcesPath?: string })?.resourcesPath || '', 'shared/modules'),
]
for (const p of possiblePaths) {
if (existsSync(p)) {
return p
}
}
return null
}
export interface SharedModuleDefinition {
id: string
name: string
backend?: {
enabled?: boolean
}
}
function needsBackendImplementation(moduleDef: SharedModuleDefinition): boolean {
return moduleDef.backend?.enabled !== false
}
async function loadModuleDefinitions(): Promise<SharedModuleDefinition[]> {
const modules: SharedModuleDefinition[] = []
const sharedModulesPath = getSharedModulesPath()
if (!sharedModulesPath) {
return modules
}
const entries = readdirSync(sharedModulesPath)
for (const entry of entries) {
const entryPath = join(sharedModulesPath, entry)
const stat = statSync(entryPath)
if (!stat.isDirectory()) {
continue
}
try {
const moduleExports = await import(`../../shared/modules/${entry}/index.js`)
for (const key of Object.keys(moduleExports)) {
if (key.endsWith('_MODULE')) {
const moduleDef = moduleExports[key] as ModuleDefinition
if (moduleDef && moduleDef.id) {
modules.push({
id: moduleDef.id,
name: moduleDef.name,
backend: moduleDef.backend,
})
}
}
}
} catch {
// 模块加载失败,跳过
}
}
return modules
}
export async function validateModuleConsistency(apiModules: ApiModule[]): Promise<void> {
const sharedModules = await loadModuleDefinitions()
if (sharedModules.length === 0) {
console.log('[ModuleValidator] Skipping validation (shared modules not found, likely packaged mode)')
return
}
const apiModuleIds = new Set(apiModules.map((m) => m.metadata.id))
const errors: string[] = []
for (const sharedModule of sharedModules) {
const needsBackend = needsBackendImplementation(sharedModule)
const hasApiModule = apiModuleIds.has(sharedModule.id)
if (needsBackend && !hasApiModule) {
errors.push(
`Module '${sharedModule.id}' is defined in shared but not registered in API modules`
)
}
if (!needsBackend && hasApiModule) {
errors.push(
`Module '${sharedModule.id}' has backend disabled but is registered in API modules`
)
}
}
if (errors.length > 0) {
throw new Error(`Module consistency validation failed:\n - ${errors.join('\n - ')}`)
}
console.log(
`[ModuleValidator] ✓ Module consistency validated: ${sharedModules.length} shared, ${apiModules.length} API`
)
}

31
api/infra/types.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { Router, Application } from 'express'
import type { ServiceContainer } from './container.js'
export interface ModuleMetadata {
id: string
name: string
version: string
basePath: string
dependencies?: string[]
order?: number
}
export interface ModuleLifecycle {
onLoad?(container: ServiceContainer): void | Promise<void>
onUnload?(container: ServiceContainer): void | Promise<void>
onActivate?(container: ServiceContainer): void | Promise<void>
onDeactivate?(container: ServiceContainer): void | Promise<void>
}
export interface ApiModule {
metadata: ModuleMetadata
lifecycle?: ModuleLifecycle
createRouter: (container: ServiceContainer) => Router | Promise<Router>
}
export interface LegacyApiModule {
name: string
basePath: string
init?: (app: Application, container: ServiceContainer) => void | Promise<void>
createRouter: (container: ServiceContainer) => Router | Promise<Router>
}