fix: 添加skills
This commit is contained in:
340
.claude/skills/repo-core-di/SKILL.md
Normal file
340
.claude/skills/repo-core-di/SKILL.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
name: repo-core-di
|
||||
description: "@repo/core 依赖注入框架使用指南。当需要创建注入器、注册提供者、使用装饰器(@Injectable, @Inject, @Optional等)、处理生命周期(OnInit, OnDestroy)、或解决循环依赖时使用此技能。适用于:(1) 创建和配置注入器层次结构 (2) 注册各类提供者(Value/Class/Factory/Lazy) (3) 使用参数装饰器控制注入行为 (4) 实现服务生命周期管理 (5) 使用 InjectionToken 和 ForwardRef"
|
||||
---
|
||||
|
||||
# @repo/core 依赖注入框架
|
||||
|
||||
## 核心概念
|
||||
|
||||
@repo/core 提供企业级依赖注入框架,支持层次化注入器、多种提供者类型、生命周期管理。
|
||||
|
||||
### 注入器层次结构
|
||||
|
||||
```
|
||||
Root (根注入器)
|
||||
└── Platform (平台注入器)
|
||||
└── Application (应用注入器)
|
||||
└── Feature (特性注入器)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 创建注入器
|
||||
|
||||
```typescript
|
||||
import { EnvironmentInjector, Injectable, InjectionToken } from '@repo/core'
|
||||
|
||||
// 定义类型安全的令牌
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
|
||||
// 创建根注入器
|
||||
const rootInjector = EnvironmentInjector.createRootInjector([
|
||||
{ provide: API_URL, useValue: 'https://api.example.com' }
|
||||
])
|
||||
|
||||
// 创建应用注入器(继承根注入器)
|
||||
const appInjector = EnvironmentInjector.createApplicationInjector([
|
||||
UserService,
|
||||
{ provide: LoggerService, useClass: LoggerService }
|
||||
])
|
||||
|
||||
// 初始化(执行所有 @OnInit)
|
||||
await appInjector.init()
|
||||
|
||||
// 获取服务
|
||||
const userService = appInjector.get(UserService)
|
||||
```
|
||||
|
||||
### 使用 @Injectable 装饰器
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@repo/core'
|
||||
|
||||
// providedIn: 'root' - 单例,自动注册到根注入器
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class ConfigService {
|
||||
apiUrl = 'https://api.example.com'
|
||||
}
|
||||
|
||||
// providedIn: 'auto' - 不自动注册到根注入器,可在任何注入器中被按需解析
|
||||
// 推荐用于请求级服务
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class RequestScopedService {
|
||||
// 每次请求创建新实例
|
||||
}
|
||||
|
||||
// 使用工厂函数
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
useFactory: (config: ConfigService) => new ApiClient(config.apiUrl),
|
||||
deps: [ConfigService]
|
||||
})
|
||||
class ApiClient {
|
||||
constructor(public baseUrl: string) {}
|
||||
}
|
||||
```
|
||||
|
||||
## 提供者类型
|
||||
|
||||
### 值提供者 (ValueProvider)
|
||||
|
||||
```typescript
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
const CONFIG_TOKEN = new InjectionToken<AppConfig>('CONFIG_TOKEN')
|
||||
|
||||
{ provide: API_URL, useValue: 'https://api.example.com' }
|
||||
{ provide: CONFIG_TOKEN, useValue: { debug: true, timeout: 5000 } }
|
||||
```
|
||||
|
||||
### 类提供者 (ClassProvider)
|
||||
|
||||
```typescript
|
||||
{ provide: UserService, useClass: UserService }
|
||||
{ provide: Logger, useClass: ConsoleLogger } // 接口映射到实现
|
||||
```
|
||||
|
||||
### 工厂提供者 (FactoryProvider)
|
||||
|
||||
```typescript
|
||||
{
|
||||
provide: DatabaseConnection,
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new DatabaseConnection(config.dbUrl)
|
||||
},
|
||||
deps: [ConfigService]
|
||||
}
|
||||
```
|
||||
|
||||
### 别名提供者 (ExistingProvider)
|
||||
|
||||
```typescript
|
||||
const LOGGER_TOKEN = new InjectionToken<Logger>('Logger')
|
||||
|
||||
{ provide: LOGGER_TOKEN, useExisting: LoggerService }
|
||||
```
|
||||
|
||||
### 延迟提供者 (LazyProvider)
|
||||
|
||||
```typescript
|
||||
const LAZY_CONFIG = new InjectionToken<AppConfig>('LAZY_CONFIG')
|
||||
|
||||
// 延迟类 - 首次访问时才实例化
|
||||
{ provide: HeavyService, useLazyClass: HeavyService }
|
||||
|
||||
// 延迟工厂
|
||||
{ provide: LAZY_CONFIG, useLazyFactory: () => loadConfig(), deps: [] }
|
||||
```
|
||||
|
||||
### 多值提供者 (Multi Provider)
|
||||
|
||||
```typescript
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }
|
||||
|
||||
// 获取时返回数组
|
||||
const interceptors = injector.get(HTTP_INTERCEPTORS) // [AuthInterceptor, LoggingInterceptor]
|
||||
```
|
||||
|
||||
## 参数装饰器
|
||||
|
||||
### @Inject - 显式指定注入令牌
|
||||
|
||||
```typescript
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
|
||||
class UserController {
|
||||
constructor(
|
||||
@Inject(UserService) private userService: UserService,
|
||||
@Inject(API_URL) private apiUrl: string
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### @Optional - 可选注入
|
||||
|
||||
```typescript
|
||||
class NotificationService {
|
||||
constructor(
|
||||
@Optional(EmailService) private email?: EmailService // 不存在时为 null
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### @Self - 仅在当前注入器查找
|
||||
|
||||
```typescript
|
||||
class ChildComponent {
|
||||
constructor(
|
||||
@Self(LocalService) private local: LocalService // 不查找父注入器
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### @SkipSelf - 跳过当前注入器
|
||||
|
||||
```typescript
|
||||
class ChildService {
|
||||
constructor(
|
||||
@SkipSelf(ParentService) private parent: ParentService // 从父注入器开始查找
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### @Host - 在宿主注入器查找
|
||||
|
||||
```typescript
|
||||
class DirectiveService {
|
||||
constructor(
|
||||
@Host(HostService) private host: HostService
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## 生命周期管理
|
||||
|
||||
### @OnInit 装饰器
|
||||
|
||||
```typescript
|
||||
import { Injectable, OnInit } from '@repo/core'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class DatabaseService implements OnInit {
|
||||
private connection: Connection
|
||||
|
||||
@OnInit()
|
||||
async onInit() {
|
||||
this.connection = await this.connect()
|
||||
console.log('Database connected')
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
// 连接逻辑
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OnDestroy 接口
|
||||
|
||||
```typescript
|
||||
import { Injectable, OnDestroy } from '@repo/core'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class CacheService implements OnDestroy {
|
||||
async onDestroy() {
|
||||
await this.flush()
|
||||
console.log('Cache flushed')
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁注入器时自动调用
|
||||
await injector.destroy()
|
||||
```
|
||||
|
||||
### APP_INITIALIZER - 应用初始化器
|
||||
|
||||
```typescript
|
||||
import { APP_INITIALIZER, InjectionToken } from '@repo/core'
|
||||
|
||||
const appInjector = EnvironmentInjector.createApplicationInjector([
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useValue: {
|
||||
provide: new InjectionToken('DB_INIT'),
|
||||
deps: [ConfigService],
|
||||
init: async () => {
|
||||
await initDatabase()
|
||||
}
|
||||
},
|
||||
multi: true
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useValue: {
|
||||
provide: new InjectionToken('CACHE_INIT'),
|
||||
deps: [new InjectionToken('DB_INIT')], // 依赖 DB_INIT
|
||||
init: async () => {
|
||||
await initCache()
|
||||
}
|
||||
},
|
||||
multi: true
|
||||
}
|
||||
])
|
||||
|
||||
// 按依赖顺序执行初始化器
|
||||
await appInjector.init()
|
||||
```
|
||||
|
||||
## InjectionToken
|
||||
|
||||
```typescript
|
||||
import { InjectionToken } from '@repo/core'
|
||||
|
||||
// 创建类型安全的令牌
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
const CONFIG = new InjectionToken<AppConfig>('CONFIG')
|
||||
const LOGGER_LEVEL = new InjectionToken<LoggerLevel>('LOGGER_LEVEL')
|
||||
|
||||
// 注册
|
||||
const injector = EnvironmentInjector.createRootInjector([
|
||||
{ provide: API_URL, useValue: 'https://api.example.com' },
|
||||
{ provide: CONFIG, useValue: { debug: true } }
|
||||
])
|
||||
|
||||
// 获取(类型安全)
|
||||
const url: string = injector.get(API_URL)
|
||||
const config: AppConfig = injector.get(CONFIG)
|
||||
```
|
||||
|
||||
## ForwardRef - 解决循环依赖
|
||||
|
||||
```typescript
|
||||
import { Injectable, Inject, forwardRef } from '@repo/core'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class ServiceA {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB
|
||||
) {}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class ServiceB {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => ServiceA)) private serviceA: ServiceA
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
## 便捷工厂函数
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createRootInjector,
|
||||
createPlatformInjector,
|
||||
createApplicationInjector,
|
||||
createFeatureInjector
|
||||
} from '@repo/core'
|
||||
|
||||
// 简化创建
|
||||
const root = createRootInjector([...providers])
|
||||
const platform = createPlatformInjector([...providers])
|
||||
const app = createApplicationInjector([...providers])
|
||||
const feature = createFeatureInjector([...providers], parentInjector)
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 providedIn: 'root'** - 大多数服务应该是单例
|
||||
2. **避免循环依赖** - 使用 ForwardRef 仅作为最后手段
|
||||
3. **使用 InjectionToken** - 为非类类型创建类型安全令牌
|
||||
4. **实现 OnDestroy** - 清理资源(连接、订阅等)
|
||||
5. **使用 APP_INITIALIZER** - 管理复杂的初始化顺序
|
||||
6. **延迟加载** - 对重型服务使用 useLazyClass/useLazyFactory
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 详细 API 参考:见 [references/api.md](references/api.md)
|
||||
- 提供者类型详解:见 [references/providers.md](references/providers.md)
|
||||
- 请求级注入器:见 [references/request-scoped-injector.md](references/request-scoped-injector.md) - HTTP 请求隔离、CURRENT_USER 注入、Controller 生命周期
|
||||
- 错误处理系统:见 [references/errors.md](references/errors.md) - 预定义错误类、ErrorFactory、ErrorSerializer
|
||||
228
.claude/skills/repo-core-di/references/api.md
Normal file
228
.claude/skills/repo-core-di/references/api.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# @repo/core 依赖注入 API 参考
|
||||
|
||||
## EnvironmentInjector
|
||||
|
||||
核心注入器类,管理依赖的注册和解析。
|
||||
|
||||
### 静态方法
|
||||
|
||||
```typescript
|
||||
// 创建根注入器 - 最顶层,无父注入器
|
||||
static createRootInjector(providers: Provider[]): EnvironmentInjector
|
||||
|
||||
// 创建平台注入器 - 继承根注入器
|
||||
static createPlatformInjector(providers: Provider[]): EnvironmentInjector
|
||||
|
||||
// 创建应用注入器 - 继承平台注入器
|
||||
static createApplicationInjector(providers: Provider[]): EnvironmentInjector
|
||||
|
||||
// 创建特性注入器 - 需要指定父注入器
|
||||
static createFeatureInjector(
|
||||
providers: Provider[],
|
||||
parent: Injector
|
||||
): EnvironmentInjector
|
||||
|
||||
// 创建带自动提供者解析的注入器
|
||||
static createWithAutoProviders(
|
||||
providers: Provider[],
|
||||
parent: Injector | null,
|
||||
scope: InjectorScope
|
||||
): EnvironmentInjector
|
||||
```
|
||||
|
||||
### 实例方法
|
||||
|
||||
```typescript
|
||||
// 获取服务实例
|
||||
get<T>(token: InjectionTokenType<T>, defaultValue?: T): T
|
||||
|
||||
// 动态注册提供者
|
||||
set(providers: Provider[]): void
|
||||
|
||||
// 初始化 - 执行所有 @OnInit 和 APP_INITIALIZER
|
||||
async init(): Promise<void>
|
||||
|
||||
// 销毁 - 执行所有 OnDestroy
|
||||
async destroy(): Promise<void>
|
||||
```
|
||||
|
||||
## InjectionToken<T>
|
||||
|
||||
类型安全的注入令牌。
|
||||
|
||||
```typescript
|
||||
class InjectionToken<T> {
|
||||
constructor(description: string)
|
||||
toString(): string
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
const CONFIG = new InjectionToken<AppConfig>('CONFIG')
|
||||
```
|
||||
|
||||
## HostAttributeToken<T>
|
||||
|
||||
宿主属性令牌,用于获取宿主元素属性。
|
||||
|
||||
```typescript
|
||||
class HostAttributeToken<T> {
|
||||
constructor(attributeName: string, defaultValue?: T)
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
const dataAttr = new HostAttributeToken<string>('data-value', 'default')
|
||||
```
|
||||
|
||||
## 装饰器
|
||||
|
||||
### @Injectable(options?)
|
||||
|
||||
标记类为可注入服务。
|
||||
|
||||
```typescript
|
||||
interface InjectableOptions {
|
||||
providedIn?: 'root' | 'platform' | 'application' | 'feature' | 'auto' | null
|
||||
useFactory?: (...args: any[]) => any
|
||||
deps?: any[]
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class MyService {}
|
||||
```
|
||||
|
||||
### @Inject(token)
|
||||
|
||||
显式指定注入令牌。
|
||||
|
||||
```typescript
|
||||
constructor(@Inject(TOKEN) value: string) {}
|
||||
```
|
||||
|
||||
### @Optional(token)
|
||||
|
||||
可选注入,不存在时返回 null。
|
||||
|
||||
```typescript
|
||||
constructor(@Optional(OptionalService) service?: OptionalService) {}
|
||||
```
|
||||
|
||||
### @Self(token)
|
||||
|
||||
仅在当前注入器查找。
|
||||
|
||||
```typescript
|
||||
constructor(@Self(LocalService) service: LocalService) {}
|
||||
```
|
||||
|
||||
### @SkipSelf(token)
|
||||
|
||||
跳过当前注入器,从父注入器开始查找。
|
||||
|
||||
```typescript
|
||||
constructor(@SkipSelf(ParentService) service: ParentService) {}
|
||||
```
|
||||
|
||||
### @Host(token)
|
||||
|
||||
在宿主注入器中查找。
|
||||
|
||||
```typescript
|
||||
constructor(@Host(HostService) service: HostService) {}
|
||||
```
|
||||
|
||||
### @OnInit()
|
||||
|
||||
标记初始化方法。
|
||||
|
||||
```typescript
|
||||
@OnInit()
|
||||
async onInit() {
|
||||
await this.initialize()
|
||||
}
|
||||
```
|
||||
|
||||
## 生命周期接口
|
||||
|
||||
### OnInit
|
||||
|
||||
```typescript
|
||||
interface OnInit {
|
||||
onInit(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
### OnDestroy
|
||||
|
||||
```typescript
|
||||
interface OnDestroy {
|
||||
onDestroy(): Promise<void> | void
|
||||
}
|
||||
```
|
||||
|
||||
## ForwardRef
|
||||
|
||||
解决循环依赖。
|
||||
|
||||
```typescript
|
||||
// 创建前向引用
|
||||
function forwardRef<T>(fn: () => T): ForwardRef<T>
|
||||
|
||||
// 检查是否为前向引用
|
||||
function isForwardRef(ref: any): boolean
|
||||
|
||||
// 解析前向引用
|
||||
function resolveForwardRef<T>(ref: T | ForwardRef<T>): T
|
||||
```
|
||||
|
||||
## 内部标志位
|
||||
|
||||
用于性能优化的位标志。
|
||||
|
||||
```typescript
|
||||
enum InternalInjectFlags {
|
||||
Default = 0,
|
||||
Optional = 1 << 0, // 1
|
||||
SkipSelf = 1 << 1, // 2
|
||||
Self = 1 << 2, // 4
|
||||
Host = 1 << 3, // 8
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function combineInjectFlags(...flags: InternalInjectFlags[]): number
|
||||
function hasFlag(flags: number, flag: InternalInjectFlags): boolean
|
||||
function convertInjectOptionsToFlags(options: InjectOptions): number
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### InjectionTokenType
|
||||
|
||||
```typescript
|
||||
type InjectionTokenType<T> =
|
||||
| InjectionToken<T>
|
||||
| HostAttributeToken<T>
|
||||
| Type<T>
|
||||
| AbstractType<T>
|
||||
| StringToken<T>
|
||||
| SymbolToken<T>
|
||||
| Function
|
||||
| ForwardRef<InjectionTokenType<T>>
|
||||
```
|
||||
|
||||
### InjectOptions
|
||||
|
||||
```typescript
|
||||
interface InjectOptions {
|
||||
optional?: boolean
|
||||
skipSelf?: boolean
|
||||
self?: boolean
|
||||
host?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### InjectorScope
|
||||
|
||||
```typescript
|
||||
type InjectorScope = 'root' | 'platform' | 'application' | 'feature' | 'auto'
|
||||
```
|
||||
371
.claude/skills/repo-core-di/references/controller.md
Normal file
371
.claude/skills/repo-core-di/references/controller.md
Normal file
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: repo-core-controller
|
||||
description: "@repo/core HTTP 控制器装饰器使用指南。当需要创建 REST API 端点、处理 HTTP 请求参数、实现权限控制、或使用 SSE 流式响应时使用此技能。适用于:(1) 使用 @Controller 和 HTTP 方法装饰器(@Get, @Post, @Put, @Delete, @Patch) (2) 使用参数装饰器(@Body, @Query, @Param, @Header) (3) 实现权限和会话验证(@RequirePermissions, @RequireSession) (4) 创建 SSE 端点(@Sse) (5) 添加 API 文档(@ApiDescription)"
|
||||
---
|
||||
|
||||
# @repo/core HTTP 控制器
|
||||
|
||||
## 核心概念
|
||||
|
||||
@repo/core 提供声明式 HTTP 控制器装饰器,支持路由定义、参数绑定、权限控制和 SSE 流式响应。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 创建控制器
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, Post, Body, Param, Injectable } from '@repo/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
@Controller('/api/users')
|
||||
class UserController {
|
||||
constructor(private userService: UserService) {}
|
||||
|
||||
@Get('/list')
|
||||
async getUsers() {
|
||||
return this.userService.findAll()
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return this.userService.findById(id)
|
||||
}
|
||||
|
||||
@Post('/create', z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email()
|
||||
}))
|
||||
async createUser(@Body() data: { name: string; email: string }) {
|
||||
return this.userService.create(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP 方法装饰器
|
||||
|
||||
### @Get(path)
|
||||
|
||||
```typescript
|
||||
@Get('/users')
|
||||
async getUsers() { }
|
||||
|
||||
@Get('/users/:id')
|
||||
async getUser(@Param('id') id: string) { }
|
||||
|
||||
@Get('/search')
|
||||
async search(@Query('q') query: string) { }
|
||||
```
|
||||
|
||||
### @Post(path, schema?, contentType?)
|
||||
|
||||
```typescript
|
||||
// 基本用法
|
||||
@Post('/users')
|
||||
async createUser(@Body() data: CreateUserDto) { }
|
||||
|
||||
// 带 Zod 验证
|
||||
@Post('/users', z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email()
|
||||
}))
|
||||
async createUser(@Body() data: { name: string; email: string }) { }
|
||||
|
||||
// 指定 Content-Type
|
||||
@Post('/upload', schema, 'multipart/form-data')
|
||||
async upload(@Body() data: FormData) { }
|
||||
```
|
||||
|
||||
### @Put(path, schema?)
|
||||
|
||||
```typescript
|
||||
@Put('/users/:id', z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email().optional()
|
||||
}))
|
||||
async updateUser(
|
||||
@Param('id') id: string,
|
||||
@Body() data: Partial<User>
|
||||
) { }
|
||||
```
|
||||
|
||||
### @Delete(path)
|
||||
|
||||
```typescript
|
||||
@Delete('/users/:id')
|
||||
async deleteUser(@Param('id') id: string) { }
|
||||
```
|
||||
|
||||
### @Patch(path, schema?)
|
||||
|
||||
```typescript
|
||||
@Patch('/users/:id/status', z.object({
|
||||
status: z.enum(['active', 'inactive'])
|
||||
}))
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: string
|
||||
) { }
|
||||
```
|
||||
|
||||
### @Sse(path) - Server-Sent Events
|
||||
|
||||
```typescript
|
||||
@Sse('/events')
|
||||
async *streamEvents() {
|
||||
while (true) {
|
||||
yield { data: { timestamp: Date.now() } }
|
||||
await sleep(1000)
|
||||
}
|
||||
}
|
||||
|
||||
@Sse('/notifications/:userId')
|
||||
async *userNotifications(@Param('userId') userId: string) {
|
||||
const stream = this.notificationService.subscribe(userId)
|
||||
for await (const notification of stream) {
|
||||
yield { event: 'notification', data: notification }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参数装饰器
|
||||
|
||||
### @Body(key?, schema?)
|
||||
|
||||
```typescript
|
||||
// 获取整个请求体
|
||||
@Post('/users')
|
||||
async create(@Body() data: CreateUserDto) { }
|
||||
|
||||
// 获取特定字段
|
||||
@Post('/users')
|
||||
async create(@Body('name') name: string) { }
|
||||
|
||||
// 带验证
|
||||
@Post('/users')
|
||||
async create(@Body('email', z.string().email()) email: string) { }
|
||||
```
|
||||
|
||||
### @Query(key?, schema?)
|
||||
|
||||
```typescript
|
||||
// 获取查询参数
|
||||
@Get('/search')
|
||||
async search(@Query('q') query: string) { }
|
||||
|
||||
// 带默认值和验证
|
||||
@Get('/list')
|
||||
async list(
|
||||
@Query('page', z.coerce.number().default(1)) page: number,
|
||||
@Query('limit', z.coerce.number().default(10)) limit: number
|
||||
) { }
|
||||
```
|
||||
|
||||
### @Param(key)
|
||||
|
||||
```typescript
|
||||
@Get('/users/:id')
|
||||
async getUser(@Param('id') id: string) { }
|
||||
|
||||
@Get('/projects/:projectId/tasks/:taskId')
|
||||
async getTask(
|
||||
@Param('projectId') projectId: string,
|
||||
@Param('taskId') taskId: string
|
||||
) { }
|
||||
```
|
||||
|
||||
### @Header(key)
|
||||
|
||||
```typescript
|
||||
@Get('/protected')
|
||||
async protected(@Header('authorization') auth: string) { }
|
||||
|
||||
@Post('/webhook')
|
||||
async webhook(
|
||||
@Header('x-signature') signature: string,
|
||||
@Body() payload: any
|
||||
) { }
|
||||
```
|
||||
|
||||
### @Session()
|
||||
|
||||
```typescript
|
||||
@Get('/profile')
|
||||
async getProfile(@Session() session: UserSession) {
|
||||
return this.userService.findById(session.userId)
|
||||
}
|
||||
```
|
||||
|
||||
### @Req() / @Res()
|
||||
|
||||
```typescript
|
||||
@Get('/download')
|
||||
async download(@Req() req: Request, @Res() res: Response) {
|
||||
// 直接访问原始请求/响应对象
|
||||
res.setHeader('Content-Disposition', 'attachment')
|
||||
return fileStream
|
||||
}
|
||||
```
|
||||
|
||||
### @Headers()
|
||||
|
||||
```typescript
|
||||
@Post('/webhook')
|
||||
async webhook(@Headers() headers: Record<string, string>) {
|
||||
const signature = headers['x-signature']
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 权限控制
|
||||
|
||||
### @RequireSession()
|
||||
|
||||
```typescript
|
||||
@Controller('/api/account')
|
||||
class AccountController {
|
||||
@Get('/profile')
|
||||
@RequireSession()
|
||||
async getProfile(@Session() session: UserSession) {
|
||||
return this.userService.findById(session.userId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @RequirePermissions(permissions, mode?)
|
||||
|
||||
```typescript
|
||||
// 需要单个权限
|
||||
@Delete('/users/:id')
|
||||
@RequirePermissions({ user: ['delete'] })
|
||||
async deleteUser(@Param('id') id: string) { }
|
||||
|
||||
// 需要多个权限 (AND 模式 - 全部满足)
|
||||
@Put('/projects/:id')
|
||||
@RequirePermissions({ project: ['read', 'update'] }, 'AND')
|
||||
async updateProject(@Param('id') id: string) { }
|
||||
|
||||
// OR 模式 - 满足任一即可
|
||||
@Get('/reports')
|
||||
@RequirePermissions({ report: ['read'], admin: ['access'] }, 'OR')
|
||||
async getReports() { }
|
||||
```
|
||||
|
||||
### 权限资源类型
|
||||
|
||||
```typescript
|
||||
// 可用的权限资源和操作
|
||||
const permissions = {
|
||||
activity: ['create', 'read', 'update', 'delete'],
|
||||
project: ['create', 'read', 'list', 'update', 'delete', 'run'],
|
||||
template: ['create', 'read', 'list', 'update', 'delete', 'run'],
|
||||
user: ['read', 'update', 'delete'],
|
||||
admin: ['access', 'manage'],
|
||||
report: ['read', 'export']
|
||||
}
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### @ApiDescription(description, tags?)
|
||||
|
||||
```typescript
|
||||
@Controller('/api/users')
|
||||
class UserController {
|
||||
@Get('/list')
|
||||
@ApiDescription('获取用户列表', ['Users', 'Admin'])
|
||||
async getUsers() { }
|
||||
|
||||
@Post('/create')
|
||||
@ApiDescription('创建新用户', ['Users'])
|
||||
async createUser(@Body() data: CreateUserDto) { }
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Controller, Get, Post, Put, Delete, Sse,
|
||||
Body, Query, Param, Header, Session,
|
||||
RequireSession, RequirePermissions, ApiDescription,
|
||||
Injectable
|
||||
} from '@repo/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['user', 'admin']).default('user')
|
||||
})
|
||||
|
||||
const UpdateUserSchema = CreateUserSchema.partial()
|
||||
|
||||
@Controller('/api/users')
|
||||
class UserController {
|
||||
constructor(
|
||||
private userService: UserService,
|
||||
private notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
@Get('/list')
|
||||
@RequireSession()
|
||||
@ApiDescription('获取用户列表', ['Users'])
|
||||
async list(
|
||||
@Query('page', z.coerce.number().default(1)) page: number,
|
||||
@Query('limit', z.coerce.number().default(20)) limit: number,
|
||||
@Query('search') search?: string
|
||||
) {
|
||||
return this.userService.findAll({ page, limit, search })
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
@RequireSession()
|
||||
@ApiDescription('获取用户详情', ['Users'])
|
||||
async getById(@Param('id') id: string) {
|
||||
return this.userService.findById(id)
|
||||
}
|
||||
|
||||
@Post('/create', CreateUserSchema)
|
||||
@RequirePermissions({ user: ['create'] })
|
||||
@ApiDescription('创建用户', ['Users', 'Admin'])
|
||||
async create(@Body() data: z.infer<typeof CreateUserSchema>) {
|
||||
return this.userService.create(data)
|
||||
}
|
||||
|
||||
@Put('/:id', UpdateUserSchema)
|
||||
@RequirePermissions({ user: ['update'] })
|
||||
@ApiDescription('更新用户', ['Users', 'Admin'])
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() data: z.infer<typeof UpdateUserSchema>
|
||||
) {
|
||||
return this.userService.update(id, data)
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@RequirePermissions({ user: ['delete'] })
|
||||
@ApiDescription('删除用户', ['Users', 'Admin'])
|
||||
async delete(@Param('id') id: string) {
|
||||
return this.userService.delete(id)
|
||||
}
|
||||
|
||||
@Sse('/notifications')
|
||||
@RequireSession()
|
||||
@ApiDescription('用户通知流', ['Users', 'Realtime'])
|
||||
async *notifications(@Session() session: UserSession) {
|
||||
const stream = this.notificationService.subscribe(session.userId)
|
||||
for await (const notification of stream) {
|
||||
yield { event: 'notification', data: notification }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 Zod 验证** - 始终为 POST/PUT/PATCH 请求定义 schema
|
||||
2. **分层权限** - 使用 @RequireSession 和 @RequirePermissions 组合
|
||||
3. **API 文档** - 为所有端点添加 @ApiDescription
|
||||
4. **参数验证** - 使用 @Query 和 @Body 的 schema 参数
|
||||
5. **SSE 清理** - 确保 SSE 生成器正确处理客户端断开
|
||||
6. **控制器单一职责** - 每个控制器处理一个资源类型
|
||||
450
.claude/skills/repo-core-di/references/errors.md
Normal file
450
.claude/skills/repo-core-di/references/errors.md
Normal file
@@ -0,0 +1,450 @@
|
||||
---
|
||||
name: repo-core-errors
|
||||
description: "@repo/core 错误处理系统使用指南。当需要创建自定义错误、处理 HTTP 错误响应、序列化错误信息、或使用错误工厂时使用此技能。适用于:(1) 使用预定义错误类(ValidationError, NotFoundError, UnauthorizedError等) (2) 创建业务逻辑错误 (3) 使用 ErrorFactory 快速创建错误 (4) 序列化错误用于 API 响应 (5) 实现全局错误处理"
|
||||
---
|
||||
|
||||
# @repo/core 错误处理系统
|
||||
|
||||
## 核心概念
|
||||
|
||||
@repo/core 提供完整的错误处理系统,包括预定义错误类、错误工厂、错误序列化和全局错误处理器。
|
||||
|
||||
## 错误类层次结构
|
||||
|
||||
```
|
||||
Error (原生)
|
||||
└── AppError (基础应用错误)
|
||||
├── NoRetryError (不可重试错误)
|
||||
├── HTTP 错误
|
||||
│ ├── ValidationError (400)
|
||||
│ ├── UnauthorizedError (401)
|
||||
│ ├── ForbiddenError (403)
|
||||
│ ├── NotFoundError (404)
|
||||
│ ├── ConflictError (409)
|
||||
│ ├── UnprocessableEntityError (422)
|
||||
│ ├── RateLimitError (429)
|
||||
│ ├── InternalError (500)
|
||||
│ └── ServiceUnavailableError (503)
|
||||
└── 业务错误
|
||||
├── DatabaseError
|
||||
├── ExternalServiceError
|
||||
├── ConfigurationError
|
||||
└── BusinessRuleError
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 抛出错误
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
errorFactory // 注意:使用小写的实例
|
||||
} from '@repo/core'
|
||||
|
||||
// 直接使用错误类
|
||||
throw new NotFoundError('User', '123') // "User with identifier '123' not found"
|
||||
|
||||
// 使用错误工厂实例
|
||||
throw errorFactory.notFound('User', '123')
|
||||
throw errorFactory.validation('Invalid email format', { email: 'Invalid format' })
|
||||
```
|
||||
|
||||
### 处理错误
|
||||
|
||||
```typescript
|
||||
import { ErrorSerializer, ErrorHandler } from '@repo/core'
|
||||
|
||||
try {
|
||||
await userService.findById(id)
|
||||
} catch (error) {
|
||||
// 序列化错误用于 API 响应
|
||||
const serialized = ErrorSerializer.serialize(error)
|
||||
return Response.json(serialized, { status: serialized.statusCode })
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP 错误类
|
||||
|
||||
### ValidationError (400)
|
||||
|
||||
```typescript
|
||||
// 基本用法
|
||||
throw new ValidationError('Invalid input')
|
||||
|
||||
// 带字段错误信息
|
||||
throw new ValidationError('Validation failed', {
|
||||
email: 'Invalid email format',
|
||||
age: 'Must be a positive number'
|
||||
})
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.validation('Email is required', { email: 'Required field' })
|
||||
```
|
||||
|
||||
### UnauthorizedError (401)
|
||||
|
||||
```typescript
|
||||
// 未认证(使用默认消息)
|
||||
throw new UnauthorizedError() // "Authentication required"
|
||||
|
||||
// 自定义消息
|
||||
throw new UnauthorizedError('Token expired')
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.unauthorized('Invalid credentials')
|
||||
```
|
||||
|
||||
### ForbiddenError (403)
|
||||
|
||||
```typescript
|
||||
// 无权限(使用默认消息)
|
||||
throw new ForbiddenError() // "Access forbidden"
|
||||
|
||||
// 自定义消息
|
||||
throw new ForbiddenError('Cannot access this resource')
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.forbidden('Insufficient permissions')
|
||||
```
|
||||
|
||||
### NotFoundError (404)
|
||||
|
||||
```typescript
|
||||
// 基本用法 - 只有资源类型
|
||||
throw new NotFoundError('User') // "User not found"
|
||||
|
||||
// 带资源标识符
|
||||
throw new NotFoundError('User', '123') // "User with identifier '123' not found"
|
||||
|
||||
// 使用工厂 - 推荐
|
||||
throw errorFactory.notFound('User', '123')
|
||||
```
|
||||
|
||||
### ConflictError (409)
|
||||
|
||||
```typescript
|
||||
// 资源冲突
|
||||
throw new ConflictError('Email already exists')
|
||||
|
||||
// 带冲突详情
|
||||
throw new ConflictError('Duplicate entry', {
|
||||
field: 'email',
|
||||
value: 'user@example.com'
|
||||
})
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.conflict('Email already registered', { email: 'user@example.com' })
|
||||
```
|
||||
|
||||
### UnprocessableEntityError (422)
|
||||
|
||||
```typescript
|
||||
// 业务规则验证失败
|
||||
throw new UnprocessableEntityError('Cannot process request', {
|
||||
reason: 'Insufficient balance'
|
||||
})
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.unprocessable('Cannot process request', { reason: 'Insufficient balance' })
|
||||
```
|
||||
|
||||
### RateLimitError (429)
|
||||
|
||||
```typescript
|
||||
// 速率限制(使用默认消息)
|
||||
throw new RateLimitError() // "Rate limit exceeded"
|
||||
|
||||
// 自定义消息和重试时间
|
||||
throw new RateLimitError('Too many requests', 60) // 60秒后重试
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.rateLimit('Too many requests', 60)
|
||||
```
|
||||
|
||||
### InternalError (500)
|
||||
|
||||
```typescript
|
||||
// 内部错误
|
||||
throw new InternalError('Unexpected error occurred')
|
||||
|
||||
// 包装原始错误
|
||||
throw new InternalError('Database operation failed', originalError)
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.internal('Something went wrong', originalError)
|
||||
```
|
||||
|
||||
### ServiceUnavailableError (503)
|
||||
|
||||
```typescript
|
||||
// 服务不可用(使用默认消息)
|
||||
throw new ServiceUnavailableError() // "Service temporarily unavailable"
|
||||
|
||||
// 自定义消息和重试时间
|
||||
throw new ServiceUnavailableError('Service temporarily unavailable', 300)
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.unavailable('Service down for maintenance', 300)
|
||||
```
|
||||
|
||||
## 业务错误类
|
||||
|
||||
### DatabaseError
|
||||
|
||||
```typescript
|
||||
import { DatabaseError } from '@repo/core'
|
||||
|
||||
// 基本用法
|
||||
throw new DatabaseError('Connection failed')
|
||||
|
||||
// 带操作信息
|
||||
throw new DatabaseError('Query timeout', 'SELECT')
|
||||
|
||||
// 带原始错误
|
||||
throw new DatabaseError('Connection failed', 'connect', originalError)
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.database('Query timeout', 'SELECT', originalError)
|
||||
```
|
||||
|
||||
### ExternalServiceError
|
||||
|
||||
```typescript
|
||||
import { ExternalServiceError } from '@repo/core'
|
||||
|
||||
// 基本用法
|
||||
throw new ExternalServiceError('stripe', 'Payment failed')
|
||||
|
||||
// 带原始错误
|
||||
throw new ExternalServiceError('stripe', 'Payment gateway error', gatewayError)
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.externalService('Stripe', 'Payment failed', originalError)
|
||||
```
|
||||
|
||||
### ConfigurationError
|
||||
|
||||
```typescript
|
||||
import { ConfigurationError } from '@repo/core'
|
||||
|
||||
// 基本用法
|
||||
throw new ConfigurationError('Missing required configuration')
|
||||
|
||||
// 带配置键
|
||||
throw new ConfigurationError('Missing required configuration', 'DATABASE_URL')
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.configuration('DATABASE_URL is not set', 'DATABASE_URL')
|
||||
```
|
||||
|
||||
### BusinessRuleError
|
||||
|
||||
```typescript
|
||||
import { BusinessRuleError } from '@repo/core'
|
||||
|
||||
// 基本用法
|
||||
throw new BusinessRuleError('Cannot delete active subscription')
|
||||
|
||||
// 带规则标识
|
||||
throw new BusinessRuleError('Cannot delete active subscription', 'SUBSCRIPTION_ACTIVE')
|
||||
|
||||
// 带额外元数据
|
||||
throw new BusinessRuleError('Insufficient balance', 'BALANCE_CHECK', {
|
||||
required: 100,
|
||||
available: 50
|
||||
})
|
||||
|
||||
// 使用工厂
|
||||
throw errorFactory.businessRule('Insufficient balance for withdrawal', 'BALANCE_CHECK', { amount: 100 })
|
||||
```
|
||||
|
||||
## errorFactory
|
||||
|
||||
错误工厂是一个 `@Injectable()` 服务实例,提供便捷的错误创建方法。
|
||||
|
||||
```typescript
|
||||
import { errorFactory } from '@repo/core'
|
||||
|
||||
// HTTP 错误
|
||||
errorFactory.validation(message, fields?)
|
||||
errorFactory.unauthorized(message?)
|
||||
errorFactory.forbidden(message?)
|
||||
errorFactory.notFound(resource, identifier?)
|
||||
errorFactory.conflict(message, meta?)
|
||||
errorFactory.unprocessable(message, meta?)
|
||||
errorFactory.rateLimit(message?, retryAfter?)
|
||||
errorFactory.internal(message, originalError?)
|
||||
errorFactory.unavailable(message?, retryAfter?)
|
||||
|
||||
// 业务错误
|
||||
errorFactory.database(message, operation?, originalError?)
|
||||
errorFactory.externalService(service, message, originalError?)
|
||||
errorFactory.configuration(message, configKey?)
|
||||
errorFactory.businessRule(message, rule?, meta?)
|
||||
```
|
||||
|
||||
> **注意**: `errorFactory` 是预创建的实例(小写),可以直接导入使用。
|
||||
> 如果需要依赖注入,可以注入 `ErrorFactory` 类。
|
||||
|
||||
## ErrorSerializer
|
||||
|
||||
错误序列化器用于将错误转换为 API 响应格式。
|
||||
|
||||
```typescript
|
||||
import { ErrorSerializer } from '@repo/core'
|
||||
|
||||
// 序列化错误
|
||||
const serialized = ErrorSerializer.serialize(error)
|
||||
// {
|
||||
// message: 'User not found',
|
||||
// name: 'NotFoundError',
|
||||
// type: 'NOT_FOUND',
|
||||
// statusCode: 404,
|
||||
// response: { userId: '123' }
|
||||
// }
|
||||
|
||||
// 包含堆栈信息(开发环境)
|
||||
const withStack = ErrorSerializer.serialize(error, true)
|
||||
|
||||
// 提取最深层错误
|
||||
const deepest = ErrorSerializer.extractDeepestError(wrappedError)
|
||||
|
||||
// 获取完整错误描述
|
||||
const description = ErrorSerializer.getFullDescription(error)
|
||||
```
|
||||
|
||||
### SerializedError 结构
|
||||
|
||||
```typescript
|
||||
interface SerializedError {
|
||||
message: string
|
||||
name: string
|
||||
type?: string
|
||||
statusCode?: number
|
||||
response?: any
|
||||
stack?: string
|
||||
cause?: SerializedError
|
||||
originalError?: any
|
||||
}
|
||||
```
|
||||
|
||||
## ErrorHandler
|
||||
|
||||
全局错误处理器服务。
|
||||
|
||||
```typescript
|
||||
import { ErrorHandler, Injectable } from '@repo/core'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class MyErrorHandler extends ErrorHandler {
|
||||
handle(error: unknown) {
|
||||
// 记录错误
|
||||
console.error('Error occurred:', error)
|
||||
|
||||
// 发送到监控服务
|
||||
this.reportToMonitoring(error)
|
||||
|
||||
// 调用父类处理
|
||||
super.handle(error)
|
||||
}
|
||||
|
||||
private reportToMonitoring(error: unknown) {
|
||||
// Sentry, DataDog 等
|
||||
}
|
||||
}
|
||||
|
||||
// 注册自定义处理器
|
||||
const injector = EnvironmentInjector.createRootInjector([
|
||||
{ provide: ErrorHandler, useClass: MyErrorHandler }
|
||||
])
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Controller, Get, Post, Body, Param,
|
||||
Injectable,
|
||||
NotFoundError, ValidationError, ForbiddenError,
|
||||
errorFactory, ErrorSerializer
|
||||
} from '@repo/core'
|
||||
|
||||
@Controller('/api/orders')
|
||||
class OrderController {
|
||||
constructor(
|
||||
private orderService: OrderService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@Get('/:id')
|
||||
async getOrder(@Param('id') id: string) {
|
||||
const order = await this.orderService.findById(id)
|
||||
if (!order) {
|
||||
throw errorFactory.notFound('Order', id)
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
@Post('/create')
|
||||
async createOrder(@Body() data: CreateOrderDto) {
|
||||
// 验证
|
||||
if (!data.items?.length) {
|
||||
throw new ValidationError('Order must have at least one item', {
|
||||
items: 'At least one item required'
|
||||
})
|
||||
}
|
||||
|
||||
// 业务规则检查
|
||||
const user = await this.userService.findById(data.userId)
|
||||
if (!user) {
|
||||
throw errorFactory.notFound('User', data.userId)
|
||||
}
|
||||
|
||||
if (user.status === 'suspended') {
|
||||
throw new ForbiddenError('Suspended users cannot create orders')
|
||||
}
|
||||
|
||||
// 检查库存
|
||||
for (const item of data.items) {
|
||||
const available = await this.checkInventory(item.productId)
|
||||
if (available < item.quantity) {
|
||||
throw errorFactory.businessRule(
|
||||
`Insufficient inventory for product ${item.productId}`,
|
||||
'INVENTORY_CHECK'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return this.orderService.create(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 全局错误处理中间件
|
||||
async function errorMiddleware(ctx: Context, next: Next) {
|
||||
try {
|
||||
await next()
|
||||
} catch (error) {
|
||||
const serialized = ErrorSerializer.serialize(
|
||||
error,
|
||||
process.env.NODE_ENV === 'development'
|
||||
)
|
||||
|
||||
ctx.status = serialized.statusCode || 500
|
||||
ctx.body = serialized
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用具体错误类** - 不要只用通用 Error,使用语义化错误类
|
||||
2. **提供上下文** - 错误消息应包含足够的调试信息
|
||||
3. **使用 errorFactory** - 保持错误创建的一致性
|
||||
4. **序列化响应** - 使用 ErrorSerializer 确保 API 响应格式统一
|
||||
5. **区分环境** - 生产环境不暴露堆栈信息
|
||||
6. **错误链** - 使用 originalError 参数保留原始错误上下文
|
||||
7. **自定义处理器** - 集成监控和日志系统
|
||||
|
||||
291
.claude/skills/repo-core-di/references/providers.md
Normal file
291
.claude/skills/repo-core-di/references/providers.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# @repo/core 提供者类型详解
|
||||
|
||||
## 提供者类型概览
|
||||
|
||||
| 类型 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| ValueProvider | 提供静态值 | 配置、常量 |
|
||||
| ClassProvider | 提供类实例 | 服务类 |
|
||||
| FactoryProvider | 通过工厂函数创建 | 需要动态配置的服务 |
|
||||
| ExistingProvider | 别名映射 | 接口到实现的映射 |
|
||||
| ConstructorProvider | 类本身作为令牌 | 简化注册 |
|
||||
| LazyClassProvider | 延迟实例化类 | 重型服务 |
|
||||
| LazyFactoryProvider | 延迟调用工厂 | 按需加载配置 |
|
||||
|
||||
## ValueProvider
|
||||
|
||||
直接提供值,不进行实例化。
|
||||
|
||||
```typescript
|
||||
interface ValueProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useValue: T
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例 - 始终使用 InjectionToken 确保类型安全
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
const CONFIG_TOKEN = new InjectionToken<AppConfig>('CONFIG_TOKEN')
|
||||
const VERSION = new InjectionToken<string>('VERSION')
|
||||
|
||||
{ provide: API_URL, useValue: 'https://api.example.com' }
|
||||
{ provide: CONFIG_TOKEN, useValue: { debug: true, timeout: 5000 } }
|
||||
{ provide: VERSION, useValue: '1.0.0' }
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 配置值
|
||||
- 常量
|
||||
- 已创建的实例
|
||||
- 简单对象
|
||||
|
||||
## ClassProvider
|
||||
|
||||
通过构造函数创建实例。
|
||||
|
||||
```typescript
|
||||
interface ClassProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useClass: Type<T>
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例
|
||||
{ provide: UserService, useClass: UserService }
|
||||
{ provide: Logger, useClass: ConsoleLogger }
|
||||
{ provide: IAuthService, useClass: JwtAuthService }
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 服务类
|
||||
- 接口到实现的映射
|
||||
- 替换默认实现
|
||||
|
||||
## FactoryProvider
|
||||
|
||||
通过工厂函数创建实例。
|
||||
|
||||
```typescript
|
||||
interface FactoryProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useFactory: (...deps: any[]) => T
|
||||
deps?: InjectionTokenType<any>[]
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例
|
||||
{
|
||||
provide: DatabaseConnection,
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new DatabaseConnection({
|
||||
host: config.dbHost,
|
||||
port: config.dbPort,
|
||||
database: config.dbName
|
||||
})
|
||||
},
|
||||
deps: [ConfigService]
|
||||
}
|
||||
|
||||
// 异步工厂
|
||||
{
|
||||
provide: RemoteConfig,
|
||||
useFactory: async (http: HttpClient) => {
|
||||
const config = await http.get('/config')
|
||||
return new RemoteConfig(config)
|
||||
},
|
||||
deps: [HttpClient]
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 需要复杂初始化逻辑
|
||||
- 依赖运行时配置
|
||||
- 条件创建实例
|
||||
- 异步初始化
|
||||
|
||||
## ExistingProvider
|
||||
|
||||
创建到另一个令牌的别名。
|
||||
|
||||
```typescript
|
||||
interface ExistingProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useExisting: InjectionTokenType<T>
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例 - 使用 InjectionToken 确保类型安全
|
||||
const LOGGER_TOKEN = new InjectionToken<Logger>('Logger')
|
||||
|
||||
{ provide: LOGGER_TOKEN, useExisting: LoggerService }
|
||||
{ provide: AbstractLogger, useExisting: ConsoleLogger }
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 为同一服务提供多个令牌
|
||||
- 向后兼容
|
||||
- 接口别名
|
||||
|
||||
## ConstructorProvider
|
||||
|
||||
类本身作为令牌和实现。
|
||||
|
||||
```typescript
|
||||
interface ConstructorProvider<T> {
|
||||
provide: Type<T>
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例
|
||||
{ provide: UserService }
|
||||
// 等价于
|
||||
{ provide: UserService, useClass: UserService }
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 简化注册
|
||||
- 类名即令牌
|
||||
|
||||
## LazyClassProvider
|
||||
|
||||
延迟实例化,首次访问时才创建。
|
||||
|
||||
```typescript
|
||||
interface LazyClassProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useLazyClass: Type<T>
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例
|
||||
{ provide: HeavyAnalyticsService, useLazyClass: HeavyAnalyticsService }
|
||||
{ provide: ReportGenerator, useLazyClass: ReportGenerator }
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 重型服务(大量初始化开销)
|
||||
- 可能不会使用的服务
|
||||
- 优化启动时间
|
||||
|
||||
## LazyFactoryProvider
|
||||
|
||||
延迟调用工厂函数。
|
||||
|
||||
```typescript
|
||||
interface LazyFactoryProvider<T> {
|
||||
provide: InjectionTokenType<T>
|
||||
useLazyFactory: (...deps: any[]) => T
|
||||
deps?: InjectionTokenType<any>[]
|
||||
multi?: boolean
|
||||
}
|
||||
|
||||
// 示例 - 使用 InjectionToken 确保类型安全
|
||||
const LAZY_CONFIG = new InjectionToken<AppConfig>('LAZY_CONFIG')
|
||||
|
||||
{
|
||||
provide: LAZY_CONFIG,
|
||||
useLazyFactory: async () => {
|
||||
const response = await fetch('/api/config')
|
||||
return response.json()
|
||||
},
|
||||
deps: []
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 按需加载配置
|
||||
- 延迟网络请求
|
||||
- 条件初始化
|
||||
|
||||
## Multi Provider
|
||||
|
||||
允许多个提供者注册到同一令牌。
|
||||
|
||||
```typescript
|
||||
// 注册多个拦截器
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true }
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }
|
||||
|
||||
// 获取时返回数组
|
||||
const interceptors = injector.get(HTTP_INTERCEPTORS)
|
||||
// [AuthInterceptor实例, LoggingInterceptor实例, ErrorInterceptor实例]
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 插件系统
|
||||
- 中间件/拦截器
|
||||
- 事件处理器
|
||||
- 验证器集合
|
||||
|
||||
## 提供者选择决策树
|
||||
|
||||
```
|
||||
需要提供什么?
|
||||
├── 静态值/配置 → ValueProvider
|
||||
├── 类实例
|
||||
│ ├── 简单实例化 → ClassProvider 或 ConstructorProvider
|
||||
│ ├── 需要复杂初始化 → FactoryProvider
|
||||
│ ├── 延迟加载 → LazyClassProvider
|
||||
│ └── 别名/映射 → ExistingProvider
|
||||
├── 动态值
|
||||
│ ├── 同步创建 → FactoryProvider
|
||||
│ └── 延迟创建 → LazyFactoryProvider
|
||||
└── 多个实现 → 任意类型 + multi: true
|
||||
```
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 环境配置
|
||||
|
||||
```typescript
|
||||
import { InjectionToken } from '@repo/core'
|
||||
|
||||
// 定义类型安全的令牌
|
||||
const ENV = new InjectionToken<string>('ENV')
|
||||
const API_URL = new InjectionToken<string>('API_URL')
|
||||
|
||||
const providers = [
|
||||
{ provide: ENV, useValue: process.env.NODE_ENV },
|
||||
{
|
||||
provide: API_URL,
|
||||
useFactory: (env: string) => {
|
||||
return env === 'production'
|
||||
? 'https://api.prod.com'
|
||||
: 'https://api.dev.com'
|
||||
},
|
||||
deps: [ENV]
|
||||
}
|
||||
]
|
||||
|
||||
// 获取时类型安全
|
||||
const apiUrl: string = injector.get(API_URL)
|
||||
```
|
||||
|
||||
### 条件提供者
|
||||
|
||||
```typescript
|
||||
const providers = [
|
||||
process.env.USE_MOCK
|
||||
? { provide: UserService, useClass: MockUserService }
|
||||
: { provide: UserService, useClass: RealUserService }
|
||||
]
|
||||
```
|
||||
|
||||
### 工厂链
|
||||
|
||||
```typescript
|
||||
const providers = [
|
||||
{ provide: ConfigService, useClass: ConfigService },
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useFactory: (config: ConfigService) => new DatabaseService(config),
|
||||
deps: [ConfigService]
|
||||
},
|
||||
{
|
||||
provide: UserRepository,
|
||||
useFactory: (db: DatabaseService) => new UserRepository(db),
|
||||
deps: [DatabaseService]
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,364 @@
|
||||
# 请求级注入器 (Request-Scoped Injector)
|
||||
|
||||
## 核心概念
|
||||
|
||||
在 HTTP 请求处理中,每个请求需要独立的依赖注入上下文,以便:
|
||||
1. 隔离请求间的状态
|
||||
2. 注入请求特定的数据(当前用户、请求上下文等)
|
||||
3. 请求结束后自动清理资源
|
||||
|
||||
## 架构模式
|
||||
|
||||
### 完整注入器层次结构
|
||||
|
||||
```
|
||||
Root Injector (scope: 'root', 全局单例)
|
||||
│
|
||||
├── 全局服务 (ConfigService, DatabaseService...)
|
||||
│
|
||||
└── Platform Injector (scope: 'platform', 全局单例, 可选)
|
||||
│
|
||||
└── Application Injector (scope: 'application', 可选)
|
||||
│
|
||||
└── Feature/Request Injector (scope: 'feature', 请求级)
|
||||
│
|
||||
├── 请求上下文 (BETTER_AUTH_CONTEXT)
|
||||
├── 当前用户 (CURRENT_USER)
|
||||
├── Feature Providers
|
||||
└── Controller 实例
|
||||
```
|
||||
|
||||
### 简化模式(常用)
|
||||
|
||||
```
|
||||
Root Injector (应用级单例)
|
||||
│
|
||||
├── 全局服务 (ConfigService, DatabaseService...)
|
||||
│
|
||||
└── Request Injector (请求级,生命周期 = 请求)
|
||||
│
|
||||
├── 请求上下文 (BETTER_AUTH_CONTEXT)
|
||||
├── 当前用户 (CURRENT_USER)
|
||||
├── Feature Providers
|
||||
└── Controller 实例
|
||||
```
|
||||
|
||||
## 实现模式
|
||||
|
||||
### 1. 定义请求级令牌
|
||||
|
||||
```typescript
|
||||
import { InjectionToken } from '@repo/core'
|
||||
|
||||
// 请求上下文令牌
|
||||
const BETTER_AUTH_CONTEXT = new InjectionToken<AuthContext>('BETTER_AUTH_CONTEXT')
|
||||
|
||||
// 当前用户令牌
|
||||
const CURRENT_USER = new InjectionToken<User | undefined>('CURRENT_USER')
|
||||
|
||||
// 请求对象令牌
|
||||
const REQUEST = new InjectionToken<Request>('REQUEST')
|
||||
|
||||
// 响应对象令牌
|
||||
const RESPONSE = new InjectionToken<Response>('RESPONSE')
|
||||
```
|
||||
|
||||
### 2. 创建请求级注入器
|
||||
|
||||
```typescript
|
||||
import { createInjector, root, FEATURE_PROVIDERS } from '@repo/core'
|
||||
|
||||
function createEndpointHandler(ControllerClass, methodName) {
|
||||
return async (ctx: RequestContext) => {
|
||||
// 获取全局注册的 feature providers
|
||||
const providers = root.get(FEATURE_PROVIDERS, [])
|
||||
|
||||
// 创建请求级注入器
|
||||
const reqInjector = createInjector([
|
||||
// 注入请求上下文
|
||||
{ provide: BETTER_AUTH_CONTEXT, useValue: ctx.context },
|
||||
|
||||
// 注入当前用户(延迟加载)
|
||||
{
|
||||
provide: CURRENT_USER,
|
||||
useFactory: async () => {
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
return session?.user
|
||||
}
|
||||
},
|
||||
|
||||
// 注入原始请求/响应
|
||||
{ provide: REQUEST, useValue: ctx.request },
|
||||
{ provide: RESPONSE, useValue: ctx.response },
|
||||
|
||||
// 展开 feature providers
|
||||
...providers.flat(),
|
||||
], root, 'feature') // parent = root, scope = 'feature'
|
||||
|
||||
// 从请求级注入器获取 Controller 实例
|
||||
const instance = reqInjector.get(ControllerClass)
|
||||
|
||||
// 执行方法
|
||||
const result = await instance[methodName](...args)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 在服务中使用请求级依赖
|
||||
|
||||
```typescript
|
||||
import { Injectable, Inject } from '@repo/core'
|
||||
|
||||
// 注意:providedIn: 'auto' 表示不自动注册到根注入器
|
||||
// 由请求级注入器在运行时创建实例
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class UserService {
|
||||
constructor(
|
||||
// CURRENT_USER 可能为 undefined,使用 optional: true 避免注入失败
|
||||
@Inject(CURRENT_USER, { optional: true }) private currentUser: User | undefined,
|
||||
@Inject(BETTER_AUTH_CONTEXT) private authContext: AuthContext
|
||||
) {}
|
||||
|
||||
async getCurrentUserProfile() {
|
||||
if (!this.currentUser) {
|
||||
throw new UnauthorizedError('Not logged in')
|
||||
}
|
||||
return this.userRepository.findById(this.currentUser.id)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **重要**:
|
||||
> - `providedIn: 'auto'` - 推荐用于请求级服务,可在任何注入器中被自动解析
|
||||
> - `providedIn: null` - 不会被自动解析,必须手动注册
|
||||
> - `providedIn: 'feature'` - 只能在 `scope: 'feature'` 的注入器中被解析
|
||||
|
||||
### 4. Controller 使用请求级服务
|
||||
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
@Controller('/api/users')
|
||||
class UserController {
|
||||
constructor(
|
||||
private userService: UserService, // 请求级实例
|
||||
@Inject(CURRENT_USER, { optional: true }) private currentUser: User | undefined
|
||||
) {}
|
||||
|
||||
@Get('/me')
|
||||
@RequireSession()
|
||||
async getProfile() {
|
||||
return this.userService.getCurrentUserProfile()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## FEATURE_PROVIDERS 机制
|
||||
|
||||
用于在应用启动时注册需要在每个请求中可用的提供者。
|
||||
|
||||
```typescript
|
||||
import { FEATURE_PROVIDERS, root } from '@repo/core'
|
||||
|
||||
// 注册 feature providers
|
||||
root.set([
|
||||
{
|
||||
provide: FEATURE_PROVIDERS,
|
||||
useValue: [
|
||||
{ provide: UserRepository, useClass: UserRepository },
|
||||
{ provide: OrderRepository, useClass: OrderRepository },
|
||||
],
|
||||
multi: true
|
||||
}
|
||||
])
|
||||
|
||||
// 在请求处理时,这些 providers 会被注入到请求级注入器
|
||||
```
|
||||
|
||||
## 生命周期对比
|
||||
|
||||
| 作用域 | 生命周期 | 示例 |
|
||||
|--------|----------|------|
|
||||
| root | 应用启动 → 应用关闭 | ConfigService, DatabasePool |
|
||||
| platform | 应用启动 → 应用关闭 | 跨应用共享服务 |
|
||||
| application | 应用启动 → 应用关闭 | 应用级服务 |
|
||||
| feature (请求级) | 请求开始 → 请求结束 | Controller, 请求上下文 |
|
||||
|
||||
### 注入器生命周期钩子
|
||||
|
||||
```typescript
|
||||
// OnDestroy - 注入器销毁时调用
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class ResourceService {
|
||||
private connection: Connection
|
||||
|
||||
async onDestroy() {
|
||||
// 清理资源
|
||||
await this.connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁注入器会自动调用所有实例的 onDestroy
|
||||
await reqInjector.destroy()
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - 销毁后的注入器不能再获取服务,会抛出"注入器已销毁"错误
|
||||
> - 重复销毁注入器是安全的(幂等操作)
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 明确服务作用域
|
||||
|
||||
```typescript
|
||||
// 全局单例 - 无状态或共享状态
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class ConfigService { }
|
||||
|
||||
// 请求级 - 依赖请求上下文,使用 'auto' 不自动注册
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class RequestScopedService {
|
||||
constructor(@Inject(CURRENT_USER, { optional: true }) private user: User | undefined) { }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 避免在单例中注入请求级依赖
|
||||
|
||||
```typescript
|
||||
// ⚠️ 问题:单例服务注入请求级依赖
|
||||
// 如果使用 optional: true,不会报错,但 user 始终为 null
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class ProblematicService {
|
||||
constructor(@Inject(CURRENT_USER, { optional: true }) private user: User | null) { }
|
||||
// user 永远是 null,因为根注入器没有 CURRENT_USER
|
||||
}
|
||||
|
||||
// ❌ 错误:单例服务注入必需的请求级依赖会抛出错误
|
||||
@Injectable({ providedIn: 'root' })
|
||||
class BadService {
|
||||
constructor(@Inject(CURRENT_USER) private user: User) { } // 抛出错误!
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 'auto' 作用域,让服务在请求级注入器中实例化
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class GoodService {
|
||||
constructor(@Inject(CURRENT_USER, { optional: true }) private user: User | undefined) { }
|
||||
}
|
||||
```
|
||||
|
||||
> **行为说明**:
|
||||
> - `providedIn: 'root'` + `optional: true` 的请求级依赖 → 依赖值为 `null`,不报错
|
||||
> - `providedIn: 'root'` + 必需的请求级依赖 → 抛出错误
|
||||
> - `providedIn: 'auto'` → 在请求级注入器中实例化,可正确获取请求级依赖
|
||||
|
||||
### 3. 使用工厂延迟加载
|
||||
|
||||
```typescript
|
||||
// 延迟加载当前用户,避免不必要的数据库查询
|
||||
{
|
||||
provide: CURRENT_USER,
|
||||
useFactory: async () => {
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
return session?.user
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 请求隔离保证
|
||||
|
||||
每个请求创建独立的注入器实例,确保:
|
||||
- 请求 A 的用户数据不会泄露到请求 B
|
||||
- 并发请求互不干扰
|
||||
- 请求结束后资源自动释放
|
||||
|
||||
```typescript
|
||||
// 请求 1
|
||||
const reqInjector1 = createInjector([...], root, 'feature')
|
||||
const user1 = reqInjector1.get(CURRENT_USER) // User A
|
||||
|
||||
// 请求 2 (并发)
|
||||
const reqInjector2 = createInjector([...], root, 'feature')
|
||||
const user2 = reqInjector2.get(CURRENT_USER) // User B
|
||||
|
||||
// user1 !== user2,完全隔离
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Injectable, Inject, Controller, Get, Post, Body,
|
||||
InjectionToken, createInjector, root, FEATURE_PROVIDERS,
|
||||
RequireSession
|
||||
} from '@repo/core'
|
||||
|
||||
// 1. 定义令牌
|
||||
const AUTH_CONTEXT = new InjectionToken<AuthContext>('AUTH_CONTEXT')
|
||||
const CURRENT_USER = new InjectionToken<User | undefined>('CURRENT_USER')
|
||||
|
||||
// 2. 请求级服务
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
class OrderService {
|
||||
constructor(
|
||||
@Inject(CURRENT_USER, { optional: true }) private currentUser: User | undefined,
|
||||
private orderRepository: OrderRepository
|
||||
) {}
|
||||
|
||||
async getMyOrders() {
|
||||
if (!this.currentUser) throw new UnauthorizedError()
|
||||
return this.orderRepository.findByUserId(this.currentUser.id)
|
||||
}
|
||||
|
||||
async createOrder(data: CreateOrderDto) {
|
||||
if (!this.currentUser) throw new UnauthorizedError()
|
||||
return this.orderRepository.create({
|
||||
...data,
|
||||
userId: this.currentUser.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Controller
|
||||
@Injectable({ providedIn: 'auto' })
|
||||
@Controller('/api/orders')
|
||||
class OrderController {
|
||||
constructor(private orderService: OrderService) {}
|
||||
|
||||
@Get('/my')
|
||||
@RequireSession()
|
||||
async getMyOrders() {
|
||||
return this.orderService.getMyOrders()
|
||||
}
|
||||
|
||||
@Post('/create')
|
||||
@RequireSession()
|
||||
async createOrder(@Body() data: CreateOrderDto) {
|
||||
return this.orderService.createOrder(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 请求处理器
|
||||
function handleRequest(ControllerClass, methodName) {
|
||||
return async (ctx) => {
|
||||
const providers = root.get(FEATURE_PROVIDERS, [])
|
||||
|
||||
const reqInjector = createInjector([
|
||||
{ provide: AUTH_CONTEXT, useValue: ctx.context },
|
||||
{
|
||||
provide: CURRENT_USER,
|
||||
useFactory: async () => {
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
return session?.user
|
||||
}
|
||||
},
|
||||
...providers.flat(),
|
||||
], root, 'feature')
|
||||
|
||||
const controller = reqInjector.get(ControllerClass)
|
||||
const args = await injectParameters(ctx)
|
||||
|
||||
return controller[methodName](...args)
|
||||
}
|
||||
}
|
||||
```
|
||||
280
.claude/skills/repo-sdk/SKILL.md
Normal file
280
.claude/skills/repo-sdk/SKILL.md
Normal file
@@ -0,0 +1,280 @@
|
||||
---
|
||||
name: repo-sdk
|
||||
description: "@repo/sdk 使用指南。当需要调用 Loomart 平台 API、实现项目管理、模板运行、AI 生成、文件处理、社交互动等功能时使用此技能。适用于:(1) 服务端实现 Controller 接口 (2) 客户端调用 API (3) 理解接口组合使用场景 (4) 数据验证和类型定义"
|
||||
---
|
||||
|
||||
# @repo/sdk 使用指南
|
||||
|
||||
## 架构概述
|
||||
|
||||
SDK 采用**三层分离架构**:
|
||||
|
||||
```
|
||||
@repo/sdk
|
||||
├── controllers/ # 26 个业务控制器(API 契约定义)
|
||||
├── schemas/ # Zod 数据验证 Schema
|
||||
├── types/ # TypeScript 类型定义
|
||||
└── client/ # 客户端集成(Better Auth 插件 + API 生成器)
|
||||
```
|
||||
|
||||
**核心依赖**:
|
||||
- `@repo/core`:装饰器系统(@Controller, @Post, @Get, @Body, @Query 等)
|
||||
- `zod`:数据验证
|
||||
- `reflect-metadata`:元数据反射
|
||||
|
||||
## 推荐调用方式:root.get (DI 风格)
|
||||
|
||||
**强烈推荐使用 `root.get` 方式**,保证完整的类型安全:
|
||||
|
||||
```typescript
|
||||
import { root } from '@repo/core'
|
||||
import { ProjectController, TemplateController, AigcController } from '@repo/sdk'
|
||||
import type { CreateProjectInput, Project } from '@repo/sdk'
|
||||
|
||||
// 获取控制器(完整类型推导)
|
||||
const projectCtrl = root.get(ProjectController)
|
||||
const templateCtrl = root.get(TemplateController)
|
||||
const aigcCtrl = root.get(AigcController)
|
||||
|
||||
// 调用方法(参数和返回值都有类型提示)
|
||||
const projects = await projectCtrl.list({ page: 1, limit: 10 })
|
||||
const project = await projectCtrl.create({ title: '新项目', content: {} })
|
||||
```
|
||||
|
||||
### 其他调用方式
|
||||
|
||||
<details>
|
||||
<summary>Better Auth 插件风格</summary>
|
||||
|
||||
```typescript
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { createSkerClientPlugin } from '@repo/sdk'
|
||||
|
||||
const auth = createAuthClient({
|
||||
baseURL: 'http://localhost:3000',
|
||||
plugins: [createSkerClientPlugin()]
|
||||
})
|
||||
|
||||
const projects = await auth.sker.project.list({ page: 1, limit: 10 })
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>API 函数风格</summary>
|
||||
|
||||
```typescript
|
||||
import { generateApiFunctions, client } from '@repo/sdk'
|
||||
|
||||
client.setConfig({
|
||||
baseUrl: 'http://localhost:3000',
|
||||
auth: async () => localStorage.getItem('token')
|
||||
})
|
||||
|
||||
const apis = generateApiFunctions()
|
||||
const projects = await apis.postLoomartProjectList({ page: 1, limit: 10 })
|
||||
```
|
||||
</details>
|
||||
|
||||
## 功能模块导航
|
||||
|
||||
| 模块 | 控制器 | 主要功能 |
|
||||
|------|--------|----------|
|
||||
| 项目管理 | ProjectController | 创建、列表、更新、删除、转移项目 |
|
||||
| 模板管理 | TemplateController | 创建、列表、运行模板工作流 |
|
||||
| 模板生成 | TemplateGenerationController | 生成记录管理 |
|
||||
| 社交互动 | TemplateSocialController | 点赞、收藏、评论、分享 |
|
||||
| AI 生成 | AigcController | 图像/视频生成任务 |
|
||||
| 聊天 | ChatController | AI 对话 |
|
||||
| 文件处理 | FileController | 上传、压缩、格式转换 |
|
||||
| 分类标签 | CategoryController, TagController | 分类和标签管理 |
|
||||
| 消息系统 | MessageController | 用户消息通知 |
|
||||
| 公告 | AnnouncementController | 系统公告 |
|
||||
| 权限 | PermissionController, RoleController | 权限和角色管理 |
|
||||
| 支付 | AlipayController, AdminBalanceController | 支付和余额管理 |
|
||||
|
||||
## 核心组合使用场景
|
||||
|
||||
### 场景 1:模板运行完整流程
|
||||
|
||||
```typescript
|
||||
// 1. 获取模板详情
|
||||
const template = await templateCtrl.get(templateId)
|
||||
|
||||
// 2. 运行模板(返回 generationId)
|
||||
const { generationId } = await templateCtrl.run({
|
||||
templateId,
|
||||
formData: { prompt: '生成一只猫' }
|
||||
})
|
||||
|
||||
// 3. 轮询任务状态
|
||||
let status = await aigcCtrl.getTaskStatus(taskId)
|
||||
while (status.status === 'PENDING' || status.status === 'PROCESSING') {
|
||||
await sleep(2000)
|
||||
status = await aigcCtrl.getTaskStatus(taskId)
|
||||
}
|
||||
|
||||
// 4. 处理生成结果(可选:转换格式)
|
||||
if (status.result_url) {
|
||||
const webp = await fileCtrl.convertToWebp({
|
||||
videoUrl: status.result_url,
|
||||
quality: 80,
|
||||
loop: true
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:模板社交互动
|
||||
|
||||
```typescript
|
||||
// 1. 获取模板列表(带社交状态)
|
||||
const { templates } = await templateCtrl.list({ page: 1, limit: 20 })
|
||||
|
||||
// 2. 批量检查点赞/收藏状态
|
||||
const likedStatus = await socialCtrl.checkLikedBatch({
|
||||
templateIds: templates.map(t => t.id)
|
||||
})
|
||||
|
||||
// 3. 点赞操作
|
||||
await socialCtrl.like({ templateId })
|
||||
|
||||
// 4. 获取评论
|
||||
const comments = await socialCtrl.getComments({ templateId, page: 1 })
|
||||
|
||||
// 5. 发表评论
|
||||
await socialCtrl.createComment({
|
||||
templateId,
|
||||
content: '很棒的模板!'
|
||||
})
|
||||
```
|
||||
|
||||
### 场景 3:项目管理与标签
|
||||
|
||||
```typescript
|
||||
// 1. 创建项目
|
||||
const project = await projectCtrl.create({
|
||||
title: '我的项目',
|
||||
content: { /* 项目数据 */ }
|
||||
})
|
||||
|
||||
// 2. 创建用户标签
|
||||
const tag = await projectTagCtrl.create({ name: '收藏夹' })
|
||||
|
||||
// 3. 给项目添加标签
|
||||
await projectTagCtrl.addToProject({
|
||||
projectId: project.id,
|
||||
tagId: tag.id
|
||||
})
|
||||
|
||||
// 4. 按标签筛选项目
|
||||
const { projects } = await projectCtrl.list({
|
||||
page: 1,
|
||||
tagId: tag.id
|
||||
})
|
||||
```
|
||||
|
||||
## 参考文档
|
||||
|
||||
- **控制器 API 详情**:查阅 [references/controllers.md](references/controllers.md)
|
||||
- **组合使用场景**:查阅 [references/workflows.md](references/workflows.md)
|
||||
- **客户端集成指南**:查阅 [references/client-usage.md](references/client-usage.md)
|
||||
|
||||
## 权限装饰器
|
||||
|
||||
```typescript
|
||||
// 需要登录
|
||||
@RequireSession()
|
||||
|
||||
// 需要特定权限(AND 模式)
|
||||
@RequirePermissions({ project: ['create', 'update'] })
|
||||
|
||||
// 需要任一权限(OR 模式)
|
||||
@RequirePermissions({ project: ['run'], template: ['run'] }, 'OR')
|
||||
```
|
||||
|
||||
## 复用 Zod Schema 和类型(推荐)
|
||||
|
||||
**强烈推荐复用 SDK 导出的 Schema 和类型**,确保前后端数据一致性:
|
||||
|
||||
### 导入类型
|
||||
|
||||
```typescript
|
||||
// 从 @repo/sdk 导入类型(推荐)
|
||||
import type {
|
||||
// 项目
|
||||
Project, CreateProjectInput, ListProjectsInput, ListProjectsResult,
|
||||
// 模板
|
||||
TemplateDetail, RunTemplateInput, ListTemplatesInput,
|
||||
// AI 生成
|
||||
AigcModel, SubmitTaskBody, GetTaskStatusResult,
|
||||
// 消息
|
||||
Message, ListMessagesInput, UnreadCountResult
|
||||
} from '@repo/sdk'
|
||||
```
|
||||
|
||||
### 复用 Zod Schema 验证
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CreateProjectSchema,
|
||||
ListProjectsSchema,
|
||||
RunTemplateSchema,
|
||||
SubmitTaskBodySchema
|
||||
} from '@repo/sdk'
|
||||
|
||||
// 验证用户输入
|
||||
function validateInput(data: unknown) {
|
||||
return CreateProjectSchema.parse(data) // 抛出错误或返回类型安全的数据
|
||||
}
|
||||
|
||||
// 安全验证(不抛错)
|
||||
function safeValidate(data: unknown) {
|
||||
const result = CreateProjectSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.error('验证失败:', result.error.flatten())
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
```
|
||||
|
||||
### 服务端实现复用类型
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
import type { Project, CreateProjectInput, ListProjectsInput, ListProjectsResult } from '@repo/sdk'
|
||||
|
||||
@Injectable()
|
||||
export class ProjectControllerImpl implements ProjectController {
|
||||
// 参数和返回值类型直接复用 SDK 导出的类型
|
||||
async create(data: CreateProjectInput): Promise<Project> {
|
||||
// 实现...
|
||||
}
|
||||
|
||||
async list(body: ListProjectsInput): Promise<ListProjectsResult> {
|
||||
// 实现...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 前端表单复用 Schema
|
||||
|
||||
```typescript
|
||||
import { CreateProjectSchema } from '@repo/sdk'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
type FormData = z.infer<typeof CreateProjectSchema>
|
||||
|
||||
function CreateProjectForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(CreateProjectSchema)
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const projectCtrl = root.get(ProjectController)
|
||||
await projectCtrl.create(data) // 类型完全匹配
|
||||
}
|
||||
}
|
||||
```
|
||||
619
.claude/skills/repo-sdk/references/client-usage.md
Normal file
619
.claude/skills/repo-sdk/references/client-usage.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# 客户端使用指南
|
||||
|
||||
本文档详细介绍 @repo/sdk 的客户端调用方式及其配置方法。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [推荐方式:root.get (DI 风格)](#1-推荐方式rootget-di-风格)
|
||||
2. [复用 Schema 和类型](#2-复用-schema-和类型)
|
||||
3. [Better Auth 插件风格](#3-better-auth-插件风格)
|
||||
4. [API 函数风格](#4-api-函数风格)
|
||||
5. [服务端实现](#5-服务端实现)
|
||||
6. [错误处理](#6-错误处理)
|
||||
|
||||
---
|
||||
|
||||
## 1. 推荐方式:root.get (DI 风格)
|
||||
|
||||
**强烈推荐使用此方式**,保证完整的类型安全和最佳开发体验。
|
||||
|
||||
### 基本用法
|
||||
|
||||
```typescript
|
||||
import { root } from '@repo/core'
|
||||
import {
|
||||
ProjectController,
|
||||
TemplateController,
|
||||
TemplateSocialController,
|
||||
AigcController,
|
||||
FileController
|
||||
} from '@repo/sdk'
|
||||
import type { CreateProjectInput, Project, ListProjectsResult } from '@repo/sdk'
|
||||
|
||||
// 获取控制器实例(完整类型推导)
|
||||
const projectCtrl = root.get(ProjectController)
|
||||
const templateCtrl = root.get(TemplateController)
|
||||
const socialCtrl = root.get(TemplateSocialController)
|
||||
const aigcCtrl = root.get(AigcController)
|
||||
const fileCtrl = root.get(FileController)
|
||||
|
||||
// 调用方法(参数和返回值都有完整类型提示)
|
||||
const projects: ListProjectsResult = await projectCtrl.list({ page: 1, limit: 10 })
|
||||
const project: Project = await projectCtrl.create({ title: '新项目', content: {} })
|
||||
const template = await templateCtrl.get('template-id')
|
||||
await socialCtrl.like({ templateId: 'xxx' })
|
||||
```
|
||||
|
||||
### 优势
|
||||
|
||||
1. **完整类型安全**:参数和返回值都有精确的类型推导
|
||||
2. **IDE 支持**:自动补全、跳转定义、重构支持
|
||||
3. **可测试性**:可以轻松 mock Controller 进行单元测试
|
||||
4. **前后端一致**:服务端和客户端使用相同的接口定义
|
||||
5. **灵活性**:可以自定义代理实现(如添加缓存、日志等)
|
||||
|
||||
### 前置条件
|
||||
|
||||
需要先初始化客户端插件(注册 HTTP 代理):
|
||||
|
||||
```typescript
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { createSkerClientPlugin } from '@repo/sdk'
|
||||
|
||||
// 初始化(会自动将 Controller 替换为 HTTP 代理)
|
||||
const auth = createAuthClient({
|
||||
baseURL: 'http://localhost:3000',
|
||||
plugins: [createSkerClientPlugin()]
|
||||
})
|
||||
```
|
||||
|
||||
### 自定义代理(高级)
|
||||
|
||||
```typescript
|
||||
import { root } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
import type { ListProjectsInput, ListProjectsResult } from '@repo/sdk'
|
||||
|
||||
// 注册带缓存的自定义实现
|
||||
root.set([{
|
||||
provide: ProjectController,
|
||||
useFactory: () => ({
|
||||
list: async (input: ListProjectsInput): Promise<ListProjectsResult> => {
|
||||
const cacheKey = `projects-${JSON.stringify(input)}`
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const result = await originalProxy.list(input)
|
||||
cache.set(cacheKey, result)
|
||||
return result
|
||||
},
|
||||
// ... 其他方法
|
||||
})
|
||||
}])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 复用 Schema 和类型
|
||||
|
||||
**强烈推荐复用 SDK 导出的 Zod Schema 和 TypeScript 类型**,确保前后端数据一致性。
|
||||
|
||||
### 导入类型(推荐)
|
||||
|
||||
```typescript
|
||||
// 始终从 @repo/sdk 导入类型,不要自己定义
|
||||
import type {
|
||||
// 项目相关
|
||||
Project,
|
||||
CreateProjectInput,
|
||||
ListProjectsInput,
|
||||
ListProjectsResult,
|
||||
UpdateProjectInput,
|
||||
TransferProjectInput,
|
||||
|
||||
// 模板相关
|
||||
TemplateDetail,
|
||||
CreateTemplateInput,
|
||||
RunTemplateInput,
|
||||
ListTemplatesInput,
|
||||
ListTemplatesResult,
|
||||
|
||||
// AI 生成
|
||||
AigcModel,
|
||||
SubmitTaskBody,
|
||||
SubmitTaskResult,
|
||||
GetTaskStatusResult,
|
||||
|
||||
// 社交
|
||||
LikeTemplateInput,
|
||||
CreateCommentInput,
|
||||
TemplateComment,
|
||||
GetCommentsResponse,
|
||||
|
||||
// 消息
|
||||
Message,
|
||||
ListMessagesInput,
|
||||
UnreadCountResult,
|
||||
|
||||
// 文件
|
||||
FileUploadResponse,
|
||||
VideoCompressRequest,
|
||||
ConvertToWebpRequest
|
||||
} from '@repo/sdk'
|
||||
```
|
||||
|
||||
### 复用 Zod Schema 验证
|
||||
|
||||
```typescript
|
||||
import {
|
||||
CreateProjectSchema,
|
||||
ListProjectsSchema,
|
||||
RunTemplateSchema,
|
||||
SubmitTaskBodySchema,
|
||||
CreateCommentSchema
|
||||
} from '@repo/sdk'
|
||||
|
||||
// 验证用户输入(抛出错误)
|
||||
function validateInput(data: unknown) {
|
||||
return CreateProjectSchema.parse(data)
|
||||
}
|
||||
|
||||
// 安全验证(不抛错,返回结果对象)
|
||||
function safeValidate(data: unknown) {
|
||||
const result = CreateProjectSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
console.error('验证失败:', result.error.flatten())
|
||||
return null
|
||||
}
|
||||
return result.data // 类型安全的数据
|
||||
}
|
||||
|
||||
// 部分验证(用于更新操作)
|
||||
const PartialProjectSchema = CreateProjectSchema.partial()
|
||||
function validatePartialUpdate(data: unknown) {
|
||||
return PartialProjectSchema.parse(data)
|
||||
}
|
||||
```
|
||||
|
||||
### 前端表单集成
|
||||
|
||||
```typescript
|
||||
import { CreateProjectSchema } from '@repo/sdk'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { root } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
|
||||
// 从 Schema 推导表单类型
|
||||
type FormData = z.infer<typeof CreateProjectSchema>
|
||||
|
||||
function CreateProjectForm() {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(CreateProjectSchema) // 复用 SDK Schema
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const projectCtrl = root.get(ProjectController)
|
||||
const project = await projectCtrl.create(data) // 类型完全匹配
|
||||
console.log('创建成功:', project.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<input {...register('title')} />
|
||||
{errors.title && <span>{errors.title.message}</span>}
|
||||
{/* ... */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 服务端实现复用类型
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
// 直接复用 SDK 导出的类型
|
||||
import type {
|
||||
Project,
|
||||
CreateProjectInput,
|
||||
ListProjectsInput,
|
||||
ListProjectsResult,
|
||||
UpdateProjectInput
|
||||
} from '@repo/sdk'
|
||||
|
||||
@Injectable()
|
||||
export class ProjectControllerImpl implements ProjectController {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly auth: AuthService
|
||||
) {}
|
||||
|
||||
// 参数和返回值类型直接复用 SDK 类型
|
||||
async create(data: CreateProjectInput): Promise<Project> {
|
||||
const userId = this.auth.getCurrentUserId()
|
||||
return this.db.project.create({
|
||||
data: { ...data, userId }
|
||||
})
|
||||
}
|
||||
|
||||
async list(body: ListProjectsInput): Promise<ListProjectsResult> {
|
||||
// 实现...
|
||||
}
|
||||
|
||||
async update(body: UpdateProjectInput): Promise<Project> {
|
||||
// 实现...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Better Auth 插件风格
|
||||
|
||||
**适用场景**:已使用 Better Auth 进行身份认证的项目
|
||||
|
||||
### 安装和配置
|
||||
|
||||
```typescript
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { createSkerClientPlugin } from '@repo/sdk'
|
||||
|
||||
// 创建带 Sker 插件的 Auth 客户端
|
||||
const auth = createAuthClient({
|
||||
baseURL: 'http://localhost:3000',
|
||||
plugins: [createSkerClientPlugin()]
|
||||
})
|
||||
|
||||
export { auth }
|
||||
```
|
||||
|
||||
### 调用方式
|
||||
|
||||
```typescript
|
||||
// 项目管理
|
||||
const projects = await auth.sker.project.list({ page: 1, limit: 10 })
|
||||
const project = await auth.sker.project.create({ title: '新项目', content: {} })
|
||||
const detail = await auth.sker.project.get('project-id')
|
||||
|
||||
// 模板管理
|
||||
const templates = await auth.sker.template.list({ page: 1 })
|
||||
const { generationId } = await auth.sker.template.run({ templateId: 'xxx', formData: {} })
|
||||
|
||||
// 社交功能
|
||||
await auth.sker.templateSocial.like({ templateId: 'xxx' })
|
||||
const comments = await auth.sker.templateSocial.getComments({ templateId: 'xxx' })
|
||||
|
||||
// AI 生成
|
||||
const models = await auth.sker.aigc.getModels('image')
|
||||
const { task_id } = await auth.sker.aigc.submitTask({ model_name: 'xxx', prompt: 'xxx' })
|
||||
|
||||
// 文件上传
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const { data: fileUrl } = await auth.sker.file.uploadS3(formData)
|
||||
```
|
||||
|
||||
### 插件工作原理
|
||||
|
||||
`createSkerClientPlugin()` 做了以下事情:
|
||||
|
||||
1. **注册 HTTP 代理**:将所有 Controller 替换为 HTTP 调用代理
|
||||
2. **提供 Better Auth 风格 API**:通过 `auth.sker.xxx` 访问
|
||||
3. **自动处理认证**:使用 Better Auth 的 session 进行认证
|
||||
4. **生成路径映射**:自动从 Controller 元数据生成路由
|
||||
|
||||
```typescript
|
||||
// 插件内部实现简化版
|
||||
export function createSkerClientPlugin(): BetterAuthClientPlugin {
|
||||
return {
|
||||
id: 'sker',
|
||||
getActions: ($fetch) => {
|
||||
// 注册代理到 DI 容器
|
||||
registerControllerProxies($fetch)
|
||||
// 返回 Better Auth 风格的 actions
|
||||
return buildBetterAuthActions(controllers, $fetch)
|
||||
},
|
||||
pathMethods: generatePathMethods(controllers)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 函数风格
|
||||
|
||||
**推荐场景**:不使用 Better Auth、需要独立的 API 客户端
|
||||
|
||||
### 配置客户端
|
||||
|
||||
```typescript
|
||||
import { client, generateApiFunctions } from '@repo/sdk'
|
||||
|
||||
// 配置基础 URL 和认证
|
||||
client.setConfig({
|
||||
baseUrl: 'http://localhost:3000',
|
||||
auth: async () => {
|
||||
// 返回认证 token
|
||||
return localStorage.getItem('token') || ''
|
||||
}
|
||||
})
|
||||
|
||||
// 生成所有 API 函数
|
||||
const apis = generateApiFunctions()
|
||||
```
|
||||
|
||||
### 调用方式
|
||||
|
||||
函数名格式:`{httpMethod}{Path}`(驼峰命名)
|
||||
|
||||
```typescript
|
||||
// POST /loomart/project/list -> postLoomartProjectList
|
||||
const projects = await apis.postLoomartProjectList({ page: 1, limit: 10 })
|
||||
|
||||
// GET /loomart/template/get -> getLoomartTemplateGet
|
||||
const template = await apis.getLoomartTemplateGet({ id: 'template-id' })
|
||||
|
||||
// POST /loomart/template/run -> postLoomartTemplateRun
|
||||
const { generationId } = await apis.postLoomartTemplateRun({
|
||||
templateId: 'xxx',
|
||||
formData: {}
|
||||
})
|
||||
|
||||
// GET /loomart/aigc/models -> getLoomartAigcModels
|
||||
const models = await apis.getLoomartAigcModels({ category: 'image' })
|
||||
|
||||
// POST /loomart/file/upload-s3 -> postLoomartFileUploadS3
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await apis.postLoomartFileUploadS3(formData)
|
||||
```
|
||||
|
||||
### 函数名映射规则
|
||||
|
||||
| 路由 | HTTP 方法 | 函数名 |
|
||||
|------|-----------|--------|
|
||||
| /loomart/project/create | POST | postLoomartProjectCreate |
|
||||
| /loomart/project/list | POST | postLoomartProjectList |
|
||||
| /loomart/project/get | GET | getLoomartProjectGet |
|
||||
| /loomart/template/run | POST | postLoomartTemplateRun |
|
||||
| /loomart/aigc/task/submit | POST | postLoomartAigcTaskSubmit |
|
||||
| /loomart/aigc/task/status | GET | getLoomartAigcTaskStatus |
|
||||
| /loomart/file/upload-s3 | POST | postLoomartFileUploadS3 |
|
||||
|
||||
### 动态认证
|
||||
|
||||
```typescript
|
||||
client.setConfig({
|
||||
baseUrl: 'http://localhost:3000',
|
||||
auth: async (authContext) => {
|
||||
// 可以根据上下文动态获取 token
|
||||
if (authContext?.refreshToken) {
|
||||
// 刷新 token
|
||||
const newToken = await refreshAccessToken(authContext.refreshToken)
|
||||
return newToken
|
||||
}
|
||||
return localStorage.getItem('token') || ''
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 服务端实现
|
||||
|
||||
**场景**:在 Hono/Express 等后端框架中实现 Controller
|
||||
|
||||
### 注册控制器实现
|
||||
|
||||
```typescript
|
||||
import { root } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
import { ProjectControllerImpl } from './impl/project.controller.impl'
|
||||
|
||||
// 注册实现类
|
||||
root.set([
|
||||
{
|
||||
provide: ProjectController,
|
||||
useClass: ProjectControllerImpl
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
### 实现类示例
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@repo/core'
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
import type {
|
||||
Project,
|
||||
CreateProjectInput,
|
||||
ListProjectsInput,
|
||||
ListProjectsResult
|
||||
} from '@repo/sdk'
|
||||
|
||||
@Injectable()
|
||||
export class ProjectControllerImpl implements ProjectController {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly auth: AuthService
|
||||
) {}
|
||||
|
||||
async create(data: CreateProjectInput): Promise<Project> {
|
||||
const userId = this.auth.getCurrentUserId()
|
||||
|
||||
const project = await this.db.project.create({
|
||||
data: {
|
||||
...data,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
return project
|
||||
}
|
||||
|
||||
async list(body: ListProjectsInput): Promise<ListProjectsResult> {
|
||||
const userId = this.auth.getCurrentUserId()
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
this.db.project.findMany({
|
||||
where: { userId, isDeleted: false },
|
||||
skip: ((body.page || 1) - 1) * (body.limit || 20),
|
||||
take: body.limit || 20,
|
||||
orderBy: { [body.sortBy || 'createdAt']: body.sortOrder || 'desc' }
|
||||
}),
|
||||
this.db.project.count({ where: { userId, isDeleted: false } })
|
||||
])
|
||||
|
||||
return {
|
||||
projects,
|
||||
total,
|
||||
page: body.page || 1,
|
||||
limit: body.limit || 20
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Project> {
|
||||
const project = await this.db.project.findUnique({ where: { id } })
|
||||
if (!project) throw new Error('Project not found')
|
||||
return project
|
||||
}
|
||||
|
||||
async update(body: UpdateProjectInput): Promise<Project> {
|
||||
return this.db.project.update({
|
||||
where: { id: body.id },
|
||||
data: body
|
||||
})
|
||||
}
|
||||
|
||||
async delete(body: { id: string }): Promise<{ message: string }> {
|
||||
await this.db.project.update({
|
||||
where: { id: body.id },
|
||||
data: { isDeleted: true }
|
||||
})
|
||||
return { message: 'Project deleted' }
|
||||
}
|
||||
|
||||
async transfer(body: TransferProjectInput): Promise<TransferProjectResult> {
|
||||
// 实现转移逻辑...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 与 Hono 集成
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono'
|
||||
import { root, CONTROLLES } from '@repo/core'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// 自动注册所有控制器路由
|
||||
const controllers = root.get(CONTROLLES, [])
|
||||
for (const controller of controllers) {
|
||||
// 使用 @repo/core 的路由注册逻辑
|
||||
registerControllerRoutes(app, controller)
|
||||
}
|
||||
|
||||
export default app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 错误处理
|
||||
|
||||
### 客户端错误处理
|
||||
|
||||
```typescript
|
||||
import { ProjectController } from '@repo/sdk'
|
||||
import { root } from '@repo/core'
|
||||
|
||||
const projectCtrl = root.get(ProjectController)
|
||||
|
||||
try {
|
||||
const project = await projectCtrl.get('non-existent-id')
|
||||
} catch (error) {
|
||||
if (error.message.includes('API error')) {
|
||||
// API 返回的错误
|
||||
console.error('API 错误:', error.message)
|
||||
} else {
|
||||
// 网络或其他错误
|
||||
console.error('请求失败:', error)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 统一错误处理
|
||||
|
||||
```typescript
|
||||
// 创建带错误处理的包装器
|
||||
function withErrorHandling<T extends (...args: any[]) => Promise<any>>(fn: T): T {
|
||||
return (async (...args: Parameters<T>) => {
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (error: any) {
|
||||
// 统一错误处理
|
||||
if (error.message.includes('401')) {
|
||||
// 未认证,跳转登录
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
if (error.message.includes('403')) {
|
||||
// 无权限
|
||||
throw new Error('您没有权限执行此操作')
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
throw new Error('资源不存在')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}) as T
|
||||
}
|
||||
|
||||
// 使用
|
||||
const safeList = withErrorHandling(projectCtrl.list.bind(projectCtrl))
|
||||
const projects = await safeList({ page: 1 })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践总结
|
||||
|
||||
### 推荐做法
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 调用 API | `root.get(Controller)` - 完整类型安全 |
|
||||
| 定义类型 | 从 `@repo/sdk` 导入,不要自己定义 |
|
||||
| 验证数据 | 复用 SDK 导出的 Zod Schema |
|
||||
| 表单验证 | 使用 `zodResolver(Schema)` |
|
||||
| 服务端实现 | `implements Controller` + 复用类型 |
|
||||
|
||||
### 避免做法
|
||||
|
||||
```typescript
|
||||
// ❌ 不要自己定义类型
|
||||
interface MyProject {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// ✅ 从 SDK 导入类型
|
||||
import type { Project } from '@repo/sdk'
|
||||
|
||||
// ❌ 不要自己写验证逻辑
|
||||
if (!data.title || data.title.length < 1) {
|
||||
throw new Error('标题不能为空')
|
||||
}
|
||||
|
||||
// ✅ 复用 SDK Schema
|
||||
import { CreateProjectSchema } from '@repo/sdk'
|
||||
CreateProjectSchema.parse(data)
|
||||
|
||||
// ❌ 不要使用 any 类型
|
||||
const projectCtrl: any = root.get(ProjectController)
|
||||
|
||||
// ✅ 让 TypeScript 自动推导
|
||||
const projectCtrl = root.get(ProjectController) // 类型自动推导
|
||||
```
|
||||
691
.claude/skills/repo-sdk/references/controllers.md
Normal file
691
.claude/skills/repo-sdk/references/controllers.md
Normal file
@@ -0,0 +1,691 @@
|
||||
# 控制器 API 参考
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目管理](#项目管理)
|
||||
2. [模板管理](#模板管理)
|
||||
3. [模板生成记录](#模板生成记录)
|
||||
4. [社交互动](#社交互动)
|
||||
5. [AI 生成](#ai-生成)
|
||||
6. [聊天](#聊天)
|
||||
7. [文件处理](#文件处理)
|
||||
8. [分类管理](#分类管理)
|
||||
9. [标签管理](#标签管理)
|
||||
10. [项目标签](#项目标签)
|
||||
11. [消息系统](#消息系统)
|
||||
12. [公告系统](#公告系统)
|
||||
13. [权限管理](#权限管理)
|
||||
14. [角色管理](#角色管理)
|
||||
15. [支付相关](#支付相关)
|
||||
|
||||
---
|
||||
|
||||
## 项目管理
|
||||
|
||||
**控制器**: `ProjectController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /project/create | 创建新项目 | project:create |
|
||||
| POST | /project/list | 获取用户项目列表 | project:list |
|
||||
| GET | /project/get | 获取单个项目 | project:read |
|
||||
| POST | /project/update | 更新项目信息 | project:update |
|
||||
| POST | /project/delete | 删除项目 | project:delete |
|
||||
| POST | /project/transfer | 转移/复制项目所有权 | project:update OR project:create |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
// 创建项目
|
||||
interface CreateProjectInput {
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
description?: string;
|
||||
descriptionEn?: string;
|
||||
content: any;
|
||||
sourceTemplateId?: string;
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
interface ListProjectsInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
tagId?: string;
|
||||
search?: string;
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'title';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// 项目响应
|
||||
interface Project {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
description?: string;
|
||||
descriptionEn?: string;
|
||||
resultUrl?: string;
|
||||
content: any;
|
||||
sourceTemplateId?: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags?: UserTagBasic[];
|
||||
}
|
||||
|
||||
// 转移项目
|
||||
interface TransferProjectInput {
|
||||
projectId: string;
|
||||
targetUserId: string;
|
||||
mode: 'transfer' | 'copy';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模板管理
|
||||
|
||||
**控制器**: `TemplateController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /template/create | 创建新模板 | - |
|
||||
| POST | /template/list | 获取模板列表 | - |
|
||||
| GET | /template/get | 获取单个模板 | - |
|
||||
| POST | /template/update | 更新模板 | - |
|
||||
| POST | /template/delete | 删除模板 | - |
|
||||
| POST | /template/run | 运行模板工作流 | - |
|
||||
| POST | /template/rerun | 根据生成记录重新运行 | - |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
// 模板详情
|
||||
interface TemplateDetail {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
titleEn: string;
|
||||
description: string;
|
||||
descriptionEn: string;
|
||||
coverImageUrl: string;
|
||||
previewUrl: string;
|
||||
watermarkedPreviewUrl?: string;
|
||||
webpPreviewUrl?: string;
|
||||
webpHighPreviewUrl?: string;
|
||||
content: any;
|
||||
formSchema?: any;
|
||||
sortOrder: number;
|
||||
viewCount: number;
|
||||
useCount: number;
|
||||
likeCount: number;
|
||||
favoriteCount: number;
|
||||
shareCount: number;
|
||||
commentCount: number;
|
||||
costPrice?: number;
|
||||
price?: number;
|
||||
aspectRatio: string;
|
||||
status: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isLiked?: boolean;
|
||||
isFavorited?: boolean;
|
||||
}
|
||||
|
||||
// 运行模板
|
||||
interface RunTemplateInput {
|
||||
templateId: string;
|
||||
formData?: Record<string, any>;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
interface ListTemplatesInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
categoryId?: string;
|
||||
tagId?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
sortBy?: 'sortOrder' | 'likeCount' | 'useCount' | 'viewCount' | 'createdAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模板生成记录
|
||||
|
||||
**控制器**: `TemplateGenerationController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /template-generation/list | 获取生成记录列表 | template:run |
|
||||
| GET | /template-generation/get | 获取单个记录 | template:run |
|
||||
| POST | /template-generation/update | 更新记录 | template:run |
|
||||
| POST | /template-generation/delete | 删除记录 | template:run |
|
||||
| POST | /template-generation/batch-delete | 批量删除 | template:run |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface TemplateGeneration {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
projectId?: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
formData?: any;
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ListTemplateGenerationsInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
templateId?: string;
|
||||
projectId?: string;
|
||||
status?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 社交互动
|
||||
|
||||
**控制器**: `TemplateSocialController`
|
||||
**路由前缀**: `/loomart/template`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /like | 点赞模板 | template:run |
|
||||
| POST | /unlike | 取消点赞 | template:run |
|
||||
| GET | /check-liked | 检查是否已点赞 | template:run |
|
||||
| POST | /check-liked-batch | 批量检查点赞状态 | template:run |
|
||||
| POST | /favorite | 收藏模板 | template:run |
|
||||
| POST | /unfavorite | 取消收藏 | template:run |
|
||||
| GET | /check-favorited | 检查是否已收藏 | template:run |
|
||||
| POST | /check-favorited-batch | 批量检查收藏状态 | template:run |
|
||||
| POST | /favorites | 获取用户收藏列表 | template:run |
|
||||
| POST | /likes | 获取用户喜欢列表 | template:run |
|
||||
| POST | /share | 转发模板 | template:run |
|
||||
| POST | /comment/create | 创建评论 | template:run |
|
||||
| POST | /comment/delete | 删除评论 | template:run |
|
||||
| GET | /comments | 获取评论列表 | template:run |
|
||||
| GET | /comment/replies | 获取评论回复 | template:run |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
// 点赞/收藏
|
||||
interface LikeTemplateInput { templateId: string; }
|
||||
interface FavoriteTemplateInput { templateId: string; }
|
||||
|
||||
// 批量检查
|
||||
interface CheckLikedBatchInput { templateIds: string[]; }
|
||||
interface CheckLikedBatchResponse {
|
||||
results: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// 评论
|
||||
interface CreateCommentInput {
|
||||
templateId: string;
|
||||
content: string;
|
||||
parentId?: string; // 回复评论时使用
|
||||
}
|
||||
|
||||
interface TemplateComment {
|
||||
id: string;
|
||||
userId: string;
|
||||
templateId: string;
|
||||
content: string;
|
||||
parentId?: string;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
user?: { id: string; name: string; image?: string; };
|
||||
replies?: TemplateComment[];
|
||||
replyCount?: number;
|
||||
}
|
||||
|
||||
// 获取评论
|
||||
interface GetCommentsInput {
|
||||
templateId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 生成
|
||||
|
||||
**控制器**: `AigcController`
|
||||
**路由前缀**: `/loomart/aigc`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /models | 获取支持的模型列表 | project:run OR template:run |
|
||||
| POST | /task/submit | 提交图像/视频生成任务 | project:run OR template:run |
|
||||
| GET | /task/status | 获取任务状态 | project:run OR template:run |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
// 模型信息
|
||||
interface AigcModel {
|
||||
model_name: string;
|
||||
description: string;
|
||||
description_en: string;
|
||||
supported_ar: string[]; // 支持的宽高比
|
||||
supported_count: string[]; // 支持的生成数量
|
||||
supported_duration: string[]; // 支持的时长(视频)
|
||||
supported_resolution: string[];
|
||||
mode: string; // 'image' | 'video'
|
||||
model_provider: string;
|
||||
tags: string[];
|
||||
tags_en: string[];
|
||||
provider: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// 提交任务
|
||||
interface SubmitTaskBody {
|
||||
mode?: string;
|
||||
model_name: string;
|
||||
prompt: string;
|
||||
aspect_ratio?: string;
|
||||
webhook_flag?: boolean;
|
||||
watermark?: boolean;
|
||||
extra?: string;
|
||||
img_url?: string;
|
||||
img_list?: any[];
|
||||
duration?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
// 任务状态
|
||||
interface GetTaskStatusResult {
|
||||
task_id: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
progress?: number;
|
||||
result_url?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 聊天
|
||||
|
||||
**控制器**: `ChatController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /chat | 发送聊天消息 | project:run OR template:run |
|
||||
| GET | /chat/models | 获取模型列表 | project:run OR template:run |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface ChatRequest {
|
||||
prompt: string;
|
||||
model_name: string;
|
||||
img_list?: string[];
|
||||
video_url?: string[];
|
||||
stream?: boolean;
|
||||
tools?: string;
|
||||
tool_choice?: string;
|
||||
}
|
||||
|
||||
interface ChatResponse {
|
||||
data: string;
|
||||
status: boolean;
|
||||
msg: string;
|
||||
usage?: any;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件处理
|
||||
|
||||
**控制器**: `FileController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 | 内容类型 |
|
||||
|------|------|------|------|---------|
|
||||
| POST | /file/upload-s3 | 上传文件到S3 | RequireSession | multipart/form-data |
|
||||
| POST | /file/compress-video | 压缩视频 | project:run OR template:run | - |
|
||||
| POST | /file/convert-to-ani | 转换为ANI格式 | project:run OR template:run | - |
|
||||
| POST | /file/convert-to-webp | 转换为WebP格式 | project:run OR template:run | - |
|
||||
| POST | /file/convert-to-watermark | 添加水印 | project:run OR template:run | - |
|
||||
| POST | /file/upload-and-convert-ani | 上传并转换ANI | project:run OR template:run | multipart/form-data |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
// 文件上传响应
|
||||
interface FileUploadResponse {
|
||||
status: boolean;
|
||||
msg: string;
|
||||
data: string; // 文件URL
|
||||
}
|
||||
|
||||
// 视频压缩
|
||||
interface VideoCompressRequest {
|
||||
videoUrl: string;
|
||||
quality: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
// 转换为 WebP
|
||||
interface ConvertToWebpRequest {
|
||||
videoUrl: string;
|
||||
compressionLevel: number; // 0-6
|
||||
quality: number; // 0-100
|
||||
loop: boolean;
|
||||
resolution: string;
|
||||
fps: number;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
// 添加水印
|
||||
interface ConvertToWatermarkRequest {
|
||||
videoUrl: string;
|
||||
watermarkUrl: string;
|
||||
position?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'center';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 分类管理
|
||||
|
||||
**控制器**: `CategoryController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /category/create | 创建分类 | category:create |
|
||||
| POST | /category/list | 获取分类列表 | - |
|
||||
| GET | /category/get | 获取单个分类 | - |
|
||||
| GET | /category/get-by-name | 按名称获取分类 | - |
|
||||
| GET | /category/get-by-id | 按ID获取分类及模板 | - |
|
||||
| POST | /category/list-with-tags | 获取分类及标签列表 | - |
|
||||
| POST | /category/update | 更新分类 | category:update |
|
||||
| POST | /category/delete | 删除分类 | category:delete |
|
||||
| POST | /category/batch-update-sort-order | 批量更新排序 | category:update |
|
||||
| POST | /category/batch-update-tag-sort-order | 批量更新标签排序 | category:update |
|
||||
| GET | /category/stats | 获取分类统计 | category:read |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
description?: string;
|
||||
descriptionEn?: string;
|
||||
coverImageUrl?: string;
|
||||
sortOrder: number;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags?: Tag[];
|
||||
templates?: TemplateDetail[];
|
||||
}
|
||||
|
||||
interface CreateCategoryInput {
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
description?: string;
|
||||
descriptionEn?: string;
|
||||
coverImageUrl?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 标签管理
|
||||
|
||||
**控制器**: `TagController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /tags/create | 创建标签 | tag:create |
|
||||
| POST | /tags/list | 获取标签列表 | tag:list |
|
||||
| GET | /tags/get | 获取单个标签 | tag:read |
|
||||
| GET | /tags/get-by-name | 按名称获取标签 | tag:read |
|
||||
| GET | /tags/get-by-category-tag-id | 按CategoryTag ID获取 | - |
|
||||
| POST | /tags/update | 更新标签 | tag:update |
|
||||
| POST | /tags/delete | 删除标签 | tag:delete |
|
||||
| POST | /tags/update-category-tag | 更新分类标签关联 | tag:update |
|
||||
| POST | /tags/batch-update-sort-order | 批量更新排序 | tag:update |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
description?: string;
|
||||
sortOrder: number;
|
||||
isDeleted: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface CreateTagInput {
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目标签
|
||||
|
||||
**控制器**: `ProjectTagController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /project-tags/create | 创建用户标签 | - |
|
||||
| GET | /project-tags/list | 获取标签列表 | - |
|
||||
| POST | /project-tags/update | 更新标签 | - |
|
||||
| POST | /project-tags/delete | 删除标签 | - |
|
||||
| POST | /project-tags/add-to-project | 添加标签到项目 | - |
|
||||
| POST | /project-tags/remove-from-project | 从项目移除标签 | - |
|
||||
| POST | /project-tags/set-project-tags | 设置项目标签 | - |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface UserTag {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface CreateUserTagInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface SetProjectTagsInput {
|
||||
projectId: string;
|
||||
tagIds: string[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 消息系统
|
||||
|
||||
**控制器**: `MessageController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /messages/list | 获取消息列表 | RequireSession |
|
||||
| GET | /messages/get | 获取单条消息 | RequireSession |
|
||||
| POST | /messages/mark-read | 标记消息已读 | RequireSession |
|
||||
| POST | /messages/batch-mark-read | 批量标记已读 | RequireSession |
|
||||
| POST | /messages/delete | 删除消息 | RequireSession |
|
||||
| GET | /messages/unread-count | 获取未读数量 | RequireSession |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: 'SYSTEM' | 'ACTIVITY' | 'BILLING' | 'MARKETING';
|
||||
title: string;
|
||||
content: string;
|
||||
data?: any;
|
||||
link?: string;
|
||||
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
|
||||
expiresAt?: Date;
|
||||
isRead: boolean;
|
||||
readAt?: Date;
|
||||
isDeleted: boolean;
|
||||
deletedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface ListMessagesInput {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
type?: 'SYSTEM' | 'ACTIVITY' | 'BILLING' | 'MARKETING';
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
interface UnreadCountResult {
|
||||
total: number;
|
||||
byType: {
|
||||
SYSTEM: number;
|
||||
ACTIVITY: number;
|
||||
BILLING: number;
|
||||
MARKETING: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 公告系统
|
||||
|
||||
**控制器**: `AnnouncementController`
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| POST | /announcements/create | 创建公告 | announcement:create |
|
||||
| POST | /announcements/list | 获取公告列表 | - |
|
||||
| GET | /announcements/get | 获取单条公告 | - |
|
||||
| POST | /announcements/update | 更新公告 | announcement:update |
|
||||
| POST | /announcements/delete | 删除公告 | announcement:delete |
|
||||
| POST | /announcements/mark-read | 标记已读 | RequireSession |
|
||||
| GET | /announcements/unread-count | 获取未读数量 | RequireSession |
|
||||
|
||||
---
|
||||
|
||||
## 权限管理
|
||||
|
||||
**控制器**: `PermissionController`
|
||||
**路由前缀**: `/loomart/admin/permissions`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /permissions/list | 获取权限列表 | user:permissions |
|
||||
| POST | /permissions/create | 创建权限 | user:permissions |
|
||||
| PATCH | /permissions/get | 更新权限 | user:permissions |
|
||||
| DELETE | /permissions/delete | 删除权限 | user:permissions |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface Permission {
|
||||
id: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 角色管理
|
||||
|
||||
**控制器**: `RoleController`
|
||||
**路由前缀**: `/loomart/admin`
|
||||
|
||||
| 方法 | 路由 | 功能 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | /roles/list | 获取角色列表 | role:list |
|
||||
| POST | /roles/create | 创建角色 | role:create |
|
||||
| PATCH | /roles/update | 更新角色基本信息 | role:update |
|
||||
| PUT | /roles/update/permissions | 更新角色权限 | role:update |
|
||||
| DELETE | /roles/delete | 删除角色 | role:delete |
|
||||
|
||||
### 类型定义
|
||||
|
||||
```typescript
|
||||
interface RoleWithPermissions {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
scope: string;
|
||||
organizationId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
permissions: Permission[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 支付相关
|
||||
|
||||
### AlipayController
|
||||
|
||||
**路由前缀**: `/loomart`
|
||||
|
||||
| 方法 | 路由 | 功能 |
|
||||
|------|------|------|
|
||||
| POST | /alipay/app-pay | 支付宝APP支付下单 |
|
||||
| POST | /alipay/auth-info | 获取支付宝授权字符串 |
|
||||
| POST | /alipay/webhook | 支付宝异步通知回调 |
|
||||
| POST | /credits/pre-recharge | 积分预充值 |
|
||||
|
||||
### AdminBalanceController
|
||||
|
||||
**路由前缀**: `/loomart/admin-balance`
|
||||
|
||||
| 方法 | 路由 | 功能 |
|
||||
|------|------|------|
|
||||
| POST | /add-user-balance | 管理员给用户充值 |
|
||||
| POST | /deduct-user-balance | 管理员扣除用户余额 |
|
||||
| POST | /get-user-balance | 查询用户当前余额 |
|
||||
1010
.claude/skills/repo-sdk/references/workflows.md
Normal file
1010
.claude/skills/repo-sdk/references/workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
8
bun.lock
8
bun.lock
@@ -13,8 +13,8 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@repo/core": "1.0.3",
|
||||
"@repo/sdk": "1.0.14",
|
||||
"@repo/core": "1.0.4",
|
||||
"@repo/sdk": "1.0.15",
|
||||
"@shopify/flash-list": "^2.0.0",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
@@ -655,9 +655,9 @@
|
||||
|
||||
"@react-navigation/routers": ["@react-navigation/routers@7.5.3", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg=="],
|
||||
|
||||
"@repo/core": ["@repo/core@1.0.3", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fcore/-/1.0.3/core-1.0.3.tgz", {}, "sha512-lO7rk3hsrtoyewZu7cgwlFqjjhGBx+lw4wxkehfvTsbTWm/tKChq1t6SC+XXNJj/YVwnLp6AH8BOsvR4r1nxyg=="],
|
||||
"@repo/core": ["@repo/core@1.0.4", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fcore/-/1.0.4/core-1.0.4.tgz", {}, "sha512-uht306Xupvs637Ww4EPbCTHw9Gi0g0xbDZpnZHX0Eldw4xGfwAgW1SqC65qNxT2Wi2qkFeBvSBNdP6fB6CuxQg=="],
|
||||
|
||||
"@repo/sdk": ["@repo/sdk@1.0.14", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fsdk/-/1.0.14/sdk-1.0.14.tgz", { "dependencies": { "@repo/core": "1.0.3", "reflect-metadata": "^0.2.1", "zod": "^4.2.1" } }, "sha512-J/j3kRL83X4TtGadjLUGX9ueiROStZ0hM+zCPkKtgk5bMUR8K98HefbWzt1PV8jKBp9n+heckyHWHcL3nKDPwA=="],
|
||||
"@repo/sdk": ["@repo/sdk@1.0.15", "https://gitea.bowongai.com/api/packages/bowong/npm/%40repo%2Fsdk/-/1.0.15/sdk-1.0.15.tgz", { "dependencies": { "@repo/core": "1.0.4", "reflect-metadata": "^0.2.1", "zod": "^4.2.1" } }, "sha512-QZtMwBQ/SM5m4qFMv63kCktdYhEzpzOrx1HWoQaOgLyV/vnyff5ktRNqmTVkHlPGGZmqlOjJHnpbqV6iQSeoYA=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
|
||||
373
components/message/MessageCard.test.tsx
Normal file
373
components/message/MessageCard.test.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent } from '@testing-library/react-native'
|
||||
|
||||
import { MessageCard, getMessageIcon, getMessageConfig, formatRelativeTime } from './MessageCard'
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'zh-CN' },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('MessageCard Utilities', () => {
|
||||
describe('getMessageIcon', () => {
|
||||
it('should return celebration icon for TEMPLATE_GENERATION_SUCCESS', () => {
|
||||
expect(getMessageIcon('TEMPLATE_GENERATION_SUCCESS')).toBe('🎉')
|
||||
})
|
||||
|
||||
it('should return error icon for TEMPLATE_GENERATION_FAILED', () => {
|
||||
expect(getMessageIcon('TEMPLATE_GENERATION_FAILED')).toBe('❌')
|
||||
})
|
||||
|
||||
it('should return thumbs up icon for TEMPLATE_LIKED', () => {
|
||||
expect(getMessageIcon('TEMPLATE_LIKED')).toBe('👍')
|
||||
})
|
||||
|
||||
it('should return star icon for TEMPLATE_FAVORITED', () => {
|
||||
expect(getMessageIcon('TEMPLATE_FAVORITED')).toBe('⭐')
|
||||
})
|
||||
|
||||
it('should return comment icon for TEMPLATE_COMMENTED', () => {
|
||||
expect(getMessageIcon('TEMPLATE_COMMENTED')).toBe('💬')
|
||||
})
|
||||
|
||||
it('should return comment icon for COMMENT_REPLIED', () => {
|
||||
expect(getMessageIcon('COMMENT_REPLIED')).toBe('💬')
|
||||
})
|
||||
|
||||
it('should return money icon for CREDITS_DEDUCTED', () => {
|
||||
expect(getMessageIcon('CREDITS_DEDUCTED')).toBe('💰')
|
||||
})
|
||||
|
||||
it('should return money icon for CREDITS_REFUNDED', () => {
|
||||
expect(getMessageIcon('CREDITS_REFUNDED')).toBe('💰')
|
||||
})
|
||||
|
||||
it('should return money icon for CREDITS_RECHARGED', () => {
|
||||
expect(getMessageIcon('CREDITS_RECHARGED')).toBe('💰')
|
||||
})
|
||||
|
||||
it('should return announcement icon for ANNOUNCEMENT', () => {
|
||||
expect(getMessageIcon('ANNOUNCEMENT')).toBe('📢')
|
||||
})
|
||||
|
||||
it('should return default icon for unknown subType', () => {
|
||||
expect(getMessageIcon('UNKNOWN_TYPE')).toBe('📩')
|
||||
})
|
||||
|
||||
it('should return default icon for undefined subType', () => {
|
||||
expect(getMessageIcon(undefined)).toBe('📩')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMessageConfig', () => {
|
||||
it('should return showPreview true for TEMPLATE_GENERATION_SUCCESS', () => {
|
||||
const config = getMessageConfig('TEMPLATE_GENERATION_SUCCESS')
|
||||
expect(config.showPreview).toBe(true)
|
||||
expect(config.showAvatar).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showAvatar true for TEMPLATE_LIKED', () => {
|
||||
const config = getMessageConfig('TEMPLATE_LIKED')
|
||||
expect(config.showAvatar).toBe(true)
|
||||
expect(config.showPreview).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showAvatar true for TEMPLATE_FAVORITED', () => {
|
||||
const config = getMessageConfig('TEMPLATE_FAVORITED')
|
||||
expect(config.showAvatar).toBe(true)
|
||||
})
|
||||
|
||||
it('should return showAvatar true for TEMPLATE_COMMENTED', () => {
|
||||
const config = getMessageConfig('TEMPLATE_COMMENTED')
|
||||
expect(config.showAvatar).toBe(true)
|
||||
})
|
||||
|
||||
it('should return showQuote true for COMMENT_REPLIED', () => {
|
||||
const config = getMessageConfig('COMMENT_REPLIED')
|
||||
expect(config.showQuote).toBe(true)
|
||||
expect(config.showAvatar).toBe(true)
|
||||
})
|
||||
|
||||
it('should return default config for unknown subType', () => {
|
||||
const config = getMessageConfig('UNKNOWN_TYPE')
|
||||
expect(config.showPreview).toBe(false)
|
||||
expect(config.showAvatar).toBe(false)
|
||||
expect(config.showQuote).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2026-01-29T12:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should return "刚刚" for time within 1 minute', () => {
|
||||
const now = new Date()
|
||||
const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000)
|
||||
expect(formatRelativeTime(thirtySecondsAgo)).toBe('刚刚')
|
||||
})
|
||||
|
||||
it('should return "X分钟前" for time within 1 hour', () => {
|
||||
const now = new Date()
|
||||
const tenMinutesAgo = new Date(now.getTime() - 10 * 60 * 1000)
|
||||
expect(formatRelativeTime(tenMinutesAgo)).toBe('10分钟前')
|
||||
})
|
||||
|
||||
it('should return "X小时前" for time within 24 hours', () => {
|
||||
const now = new Date()
|
||||
const threeHoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000)
|
||||
expect(formatRelativeTime(threeHoursAgo)).toBe('3小时前')
|
||||
})
|
||||
|
||||
it('should return "昨天 HH:MM" for time within 48 hours', () => {
|
||||
const now = new Date()
|
||||
const yesterday = new Date(now.getTime() - 25 * 60 * 60 * 1000)
|
||||
const result = formatRelativeTime(yesterday)
|
||||
expect(result).toMatch(/^昨天 \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('should return formatted date for older times', () => {
|
||||
const now = new Date()
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
|
||||
const result = formatRelativeTime(threeDaysAgo)
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/)
|
||||
})
|
||||
|
||||
it('should handle string date input', () => {
|
||||
const result = formatRelativeTime('2026-01-29T11:50:00Z')
|
||||
expect(result).toBe('10分钟前')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageCard Component', () => {
|
||||
const mockMessage = {
|
||||
id: 'msg-123',
|
||||
type: 'ACTIVITY' as const,
|
||||
title: '收到新点赞',
|
||||
content: '小明 赞了你的作品「风景照」',
|
||||
data: {
|
||||
subType: 'TEMPLATE_LIKED',
|
||||
likerName: '小明',
|
||||
likerAvatar: 'https://example.com/avatar.jpg',
|
||||
templateId: 'tpl-456',
|
||||
templateTitle: '风景照',
|
||||
},
|
||||
isRead: false,
|
||||
createdAt: new Date('2026-01-29T11:50:00Z'),
|
||||
}
|
||||
|
||||
const mockOnPress = jest.fn()
|
||||
const mockOnDelete = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useFakeTimers()
|
||||
jest.setSystemTime(new Date('2026-01-29T12:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render message title', () => {
|
||||
const { getByText } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('收到新点赞')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render message content', () => {
|
||||
const { getByText } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('小明 赞了你的作品「风景照」')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render relative time', () => {
|
||||
const { getByText } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('10分钟前')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should show unread indicator when isRead is false', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('unread-indicator')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not show unread indicator when isRead is true', () => {
|
||||
const readMessage = { ...mockMessage, isRead: true }
|
||||
const { queryByTestId } = render(
|
||||
<MessageCard message={readMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(queryByTestId('unread-indicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('should call onPress when card is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
fireEvent.press(getByTestId('message-card'))
|
||||
expect(mockOnPress).toHaveBeenCalledWith(mockMessage)
|
||||
})
|
||||
|
||||
it('should render message icon based on subType', () => {
|
||||
const { getByText } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByText('👍')).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('Avatar Display', () => {
|
||||
it('should show avatar for TEMPLATE_LIKED messages', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('user-avatar')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not show avatar for CREDITS_RECHARGED messages', () => {
|
||||
const billingMessage = {
|
||||
...mockMessage,
|
||||
type: 'BILLING' as const,
|
||||
data: { subType: 'CREDITS_RECHARGED', credits: 1000 },
|
||||
}
|
||||
const { queryByTestId } = render(
|
||||
<MessageCard message={billingMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(queryByTestId('user-avatar')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Preview Image Display', () => {
|
||||
it('should show preview image for TEMPLATE_GENERATION_SUCCESS', () => {
|
||||
const successMessage = {
|
||||
...mockMessage,
|
||||
type: 'SYSTEM' as const,
|
||||
data: {
|
||||
subType: 'TEMPLATE_GENERATION_SUCCESS',
|
||||
webpPreviewUrl: 'https://example.com/preview.webp',
|
||||
},
|
||||
}
|
||||
const { getByTestId } = render(
|
||||
<MessageCard message={successMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('preview-image')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not show preview image when webpPreviewUrl is missing', () => {
|
||||
const successMessage = {
|
||||
...mockMessage,
|
||||
type: 'SYSTEM' as const,
|
||||
data: { subType: 'TEMPLATE_GENERATION_SUCCESS' },
|
||||
}
|
||||
const { queryByTestId } = render(
|
||||
<MessageCard message={successMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(queryByTestId('preview-image')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Quote Display', () => {
|
||||
it('should show quote for COMMENT_REPLIED messages', () => {
|
||||
const replyMessage = {
|
||||
...mockMessage,
|
||||
data: {
|
||||
subType: 'COMMENT_REPLIED',
|
||||
originalComment: '太棒了!',
|
||||
likerAvatar: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
}
|
||||
const { getByTestId, getByText } = render(
|
||||
<MessageCard message={replyMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(getByTestId('quote-container')).toBeTruthy()
|
||||
expect(getByText('太棒了!')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Swipe to Delete', () => {
|
||||
it('should call onDelete when delete action is triggered', () => {
|
||||
const { getByTestId } = render(
|
||||
<MessageCard
|
||||
message={mockMessage}
|
||||
onPress={mockOnPress}
|
||||
onDelete={mockOnDelete}
|
||||
/>
|
||||
)
|
||||
// Simulate swipe delete action
|
||||
const deleteButton = getByTestId('delete-button')
|
||||
fireEvent.press(deleteButton)
|
||||
expect(mockOnDelete).toHaveBeenCalledWith(mockMessage.id)
|
||||
})
|
||||
|
||||
it('should not render delete button when onDelete is not provided', () => {
|
||||
const { queryByTestId } = render(
|
||||
<MessageCard message={mockMessage} onPress={mockOnPress} />
|
||||
)
|
||||
expect(queryByTestId('delete-button')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageCard Types', () => {
|
||||
it('should accept Message type with all required fields', () => {
|
||||
const message = {
|
||||
id: 'msg-123',
|
||||
type: 'SYSTEM' as const,
|
||||
title: 'Test Title',
|
||||
content: 'Test Content',
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
expect(message.id).toBeDefined()
|
||||
expect(message.type).toBeDefined()
|
||||
expect(message.title).toBeDefined()
|
||||
expect(message.content).toBeDefined()
|
||||
expect(message.isRead).toBeDefined()
|
||||
expect(message.createdAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('should accept Message type with optional data field', () => {
|
||||
const message = {
|
||||
id: 'msg-123',
|
||||
type: 'ACTIVITY' as const,
|
||||
title: 'Test Title',
|
||||
content: 'Test Content',
|
||||
data: {
|
||||
subType: 'TEMPLATE_LIKED',
|
||||
likerName: '小明',
|
||||
},
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
expect(message.data).toBeDefined()
|
||||
expect(message.data?.subType).toBe('TEMPLATE_LIKED')
|
||||
})
|
||||
|
||||
it('should handle all message types', () => {
|
||||
const types = ['SYSTEM', 'ACTIVITY', 'BILLING', 'MARKETING'] as const
|
||||
types.forEach((type) => {
|
||||
const message = {
|
||||
id: 'msg-123',
|
||||
type,
|
||||
title: 'Test',
|
||||
content: 'Test',
|
||||
isRead: false,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
expect(message.type).toBe(type)
|
||||
})
|
||||
})
|
||||
})
|
||||
293
components/message/MessageCard.tsx
Normal file
293
components/message/MessageCard.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Image,
|
||||
} from 'react-native'
|
||||
|
||||
export interface MessageData {
|
||||
subType?: string
|
||||
templateId?: string
|
||||
templateTitle?: string
|
||||
generationId?: string
|
||||
resultUrl?: string[]
|
||||
webpPreviewUrl?: string
|
||||
likerId?: string
|
||||
likerName?: string
|
||||
likerAvatar?: string
|
||||
commentId?: string
|
||||
credits?: number
|
||||
balanceAfter?: number
|
||||
originalComment?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
type: 'SYSTEM' | 'ACTIVITY' | 'BILLING' | 'MARKETING'
|
||||
title: string
|
||||
content: string
|
||||
data?: MessageData
|
||||
link?: string
|
||||
priority?: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW'
|
||||
isRead: boolean
|
||||
readAt?: string
|
||||
createdAt: Date | string
|
||||
}
|
||||
|
||||
interface MessageCardProps {
|
||||
message: Message
|
||||
onPress: (message: Message) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
const MESSAGE_TYPE_CONFIG: Record<string, {
|
||||
icon: string
|
||||
showPreview: boolean
|
||||
showAvatar: boolean
|
||||
showQuote: boolean
|
||||
}> = {
|
||||
TEMPLATE_GENERATION_SUCCESS: { icon: '🎉', showPreview: true, showAvatar: false, showQuote: false },
|
||||
TEMPLATE_GENERATION_FAILED: { icon: '❌', showPreview: false, showAvatar: false, showQuote: false },
|
||||
TEMPLATE_LIKED: { icon: '👍', showPreview: false, showAvatar: true, showQuote: false },
|
||||
TEMPLATE_FAVORITED: { icon: '⭐', showPreview: false, showAvatar: true, showQuote: false },
|
||||
TEMPLATE_COMMENTED: { icon: '💬', showPreview: false, showAvatar: true, showQuote: false },
|
||||
COMMENT_REPLIED: { icon: '💬', showPreview: false, showAvatar: true, showQuote: true },
|
||||
CREDITS_DEDUCTED: { icon: '💰', showPreview: false, showAvatar: false, showQuote: false },
|
||||
CREDITS_REFUNDED: { icon: '💰', showPreview: false, showAvatar: false, showQuote: false },
|
||||
CREDITS_RECHARGED: { icon: '💰', showPreview: false, showAvatar: false, showQuote: false },
|
||||
ANNOUNCEMENT: { icon: '📢', showPreview: false, showAvatar: false, showQuote: false },
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG = { icon: '📩', showPreview: false, showAvatar: false, showQuote: false }
|
||||
|
||||
export const getMessageIcon = (subType?: string): string => {
|
||||
if (!subType) return DEFAULT_CONFIG.icon
|
||||
return MESSAGE_TYPE_CONFIG[subType]?.icon ?? DEFAULT_CONFIG.icon
|
||||
}
|
||||
|
||||
export const getMessageConfig = (subType?: string) => {
|
||||
if (!subType) return DEFAULT_CONFIG
|
||||
return MESSAGE_TYPE_CONFIG[subType] ?? DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
export const formatRelativeTime = (date: Date | string): string => {
|
||||
const now = new Date()
|
||||
const targetDate = typeof date === 'string' ? new Date(date) : date
|
||||
const diffMs = now.getTime() - targetDate.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}分钟前`
|
||||
}
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}小时前`
|
||||
}
|
||||
|
||||
if (diffDays < 2) {
|
||||
const hours = String(targetDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(targetDate.getMinutes()).padStart(2, '0')
|
||||
return `昨天 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
const year = targetDate.getFullYear()
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(targetDate.getDate()).padStart(2, '0')
|
||||
const hours = String(targetDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(targetDate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
export const MessageCard: React.FC<MessageCardProps> = ({
|
||||
message,
|
||||
onPress,
|
||||
onDelete,
|
||||
}) => {
|
||||
const config = getMessageConfig(message.data?.subType)
|
||||
const icon = getMessageIcon(message.data?.subType)
|
||||
const showAvatar = config.showAvatar && message.data?.likerAvatar
|
||||
const showPreview = config.showPreview && message.data?.webpPreviewUrl
|
||||
const showQuote = config.showQuote && message.data?.originalComment
|
||||
|
||||
const handlePress = () => {
|
||||
onPress(message)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (onDelete) {
|
||||
onDelete(message.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID="message-card"
|
||||
style={styles.container}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{/* Unread Indicator */}
|
||||
{!message.isRead && (
|
||||
<View testID="unread-indicator" style={styles.unreadIndicatorContainer}>
|
||||
<View style={styles.unreadIndicator} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.contentWrapper}>
|
||||
{/* Left: Icon or Avatar */}
|
||||
<View style={styles.leftSection}>
|
||||
{showAvatar ? (
|
||||
<Image
|
||||
testID="user-avatar"
|
||||
source={{ uri: message.data?.likerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.icon}>{icon}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Middle: Content */}
|
||||
<View style={styles.middleSection}>
|
||||
<Text style={styles.title}>{message.title}</Text>
|
||||
<Text style={styles.content} numberOfLines={2}>
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
{/* Preview Image */}
|
||||
{showPreview && (
|
||||
<Image
|
||||
testID="preview-image"
|
||||
source={{ uri: message.data?.webpPreviewUrl }}
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quote */}
|
||||
{showQuote && (
|
||||
<View testID="quote-container" style={styles.quoteContainer}>
|
||||
<Text style={styles.quoteText} numberOfLines={2}>
|
||||
{message.data?.originalComment}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.time}>
|
||||
{formatRelativeTime(message.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Right: Delete Button (if onDelete provided) */}
|
||||
{onDelete && (
|
||||
<Pressable
|
||||
testID="delete-button"
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Text style={styles.deleteText}>删除</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#16181B',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 4,
|
||||
position: 'relative',
|
||||
},
|
||||
unreadIndicatorContainer: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderWidth: 4,
|
||||
borderColor: '#090A0B',
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
unreadIndicator: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#00FF66',
|
||||
},
|
||||
contentWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
leftSection: {
|
||||
marginRight: 12,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 24,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
},
|
||||
middleSection: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
content: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 13,
|
||||
marginBottom: 8,
|
||||
lineHeight: 18,
|
||||
},
|
||||
previewImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
quoteContainer: {
|
||||
backgroundColor: '#26292E',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
quoteText: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 12,
|
||||
},
|
||||
time: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 11,
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
deleteText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
},
|
||||
})
|
||||
|
||||
export default MessageCard
|
||||
104
components/message/SwipeToDelete.test.tsx
Normal file
104
components/message/SwipeToDelete.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent } from '@testing-library/react-native'
|
||||
|
||||
import { SwipeToDelete } from './SwipeToDelete'
|
||||
|
||||
describe('SwipeToDelete Component', () => {
|
||||
const mockOnDelete = jest.fn()
|
||||
const mockChildren = <></>
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
expect(getByTestId('swipe-container')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render delete button', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
expect(getByTestId('swipe-delete-button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should call onDelete when delete button is pressed', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
fireEvent.press(getByTestId('swipe-delete-button'))
|
||||
expect(mockOnDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render with custom delete text', () => {
|
||||
const { getByText } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete} deleteText="移除">
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
expect(getByText('移除')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render default delete text when not provided', () => {
|
||||
const { getByText } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
expect(getByText('删除')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should be disabled when disabled prop is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete} disabled>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
fireEvent.press(getByTestId('swipe-delete-button'))
|
||||
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply custom style to container', () => {
|
||||
const customStyle = { marginTop: 10 }
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete} style={customStyle}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
const container = getByTestId('swipe-container')
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SwipeToDelete Accessibility', () => {
|
||||
const mockOnDelete = jest.fn()
|
||||
|
||||
it('should have accessible delete button', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
const deleteButton = getByTestId('swipe-delete-button')
|
||||
expect(deleteButton.props.accessibilityRole).toBe('button')
|
||||
})
|
||||
|
||||
it('should have accessibility label on delete button', () => {
|
||||
const { getByTestId } = render(
|
||||
<SwipeToDelete onDelete={mockOnDelete}>
|
||||
<></>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
const deleteButton = getByTestId('swipe-delete-button')
|
||||
expect(deleteButton.props.accessibilityLabel).toBe('删除')
|
||||
})
|
||||
})
|
||||
@@ -5,29 +5,51 @@ jest.mock('react-native-css-interop', () => ({
|
||||
|
||||
// Mock react-native BEFORE any other imports to avoid flow type issues
|
||||
jest.mock('react-native', () => {
|
||||
const React = require('react')
|
||||
const mockReact = require('react')
|
||||
|
||||
// Helper to create mock components that render as actual React elements
|
||||
const mockCreateMockComponent = (mockName) => {
|
||||
const MockComponent = mockReact.forwardRef(({ children, ...mockProps }, mockRef) => {
|
||||
return mockReact.createElement(mockName, { ...mockProps, ref: mockRef }, children)
|
||||
})
|
||||
MockComponent.displayName = mockName
|
||||
return MockComponent
|
||||
}
|
||||
|
||||
// Create Pressable with onPress support
|
||||
const MockPressable = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => {
|
||||
return mockReact.createElement('Pressable', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children)
|
||||
})
|
||||
MockPressable.displayName = 'Pressable'
|
||||
|
||||
// Create TouchableOpacity with onPress support
|
||||
const MockTouchableOpacity = mockReact.forwardRef(({ children, onPress, ...mockProps }, mockRef) => {
|
||||
return mockReact.createElement('TouchableOpacity', { ...mockProps, ref: mockRef, onPress, onClick: onPress }, children)
|
||||
})
|
||||
MockTouchableOpacity.displayName = 'TouchableOpacity'
|
||||
|
||||
return {
|
||||
RefreshControl: 'RefreshControl',
|
||||
ScrollView: 'ScrollView',
|
||||
FlatList: 'FlatList',
|
||||
View: 'View',
|
||||
Text: 'Text',
|
||||
Image: 'Image',
|
||||
Pressable: 'Pressable',
|
||||
TouchableOpacity: 'TouchableOpacity',
|
||||
TextInput: 'TextInput',
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
RefreshControl: mockCreateMockComponent('RefreshControl'),
|
||||
ScrollView: mockCreateMockComponent('ScrollView'),
|
||||
FlatList: mockCreateMockComponent('FlatList'),
|
||||
View: mockCreateMockComponent('View'),
|
||||
Text: mockCreateMockComponent('Text'),
|
||||
Image: mockCreateMockComponent('Image'),
|
||||
Pressable: MockPressable,
|
||||
TouchableOpacity: MockTouchableOpacity,
|
||||
TextInput: mockCreateMockComponent('TextInput'),
|
||||
ActivityIndicator: mockCreateMockComponent('ActivityIndicator'),
|
||||
Platform: {
|
||||
OS: 'web',
|
||||
select: (obj) => obj.web || obj.default,
|
||||
select: (mockObj) => mockObj.web || mockObj.default,
|
||||
},
|
||||
Animated: {
|
||||
View: 'Animated.View',
|
||||
Text: 'Animated.Text',
|
||||
Image: 'Animated.Image',
|
||||
View: mockCreateMockComponent('Animated.View'),
|
||||
Text: mockCreateMockComponent('Animated.Text'),
|
||||
Image: mockCreateMockComponent('Animated.Image'),
|
||||
Value: class MockValue {
|
||||
constructor(v) { this._value = v }
|
||||
setValue(v) { this._value = v }
|
||||
constructor(mockV) { this._value = mockV }
|
||||
setValue(mockV) { this._value = mockV }
|
||||
__getValue() { return this._value }
|
||||
interpolate() { return this }
|
||||
},
|
||||
@@ -38,11 +60,10 @@ jest.mock('react-native', () => {
|
||||
PanResponder: {
|
||||
create: () => ({ panHandlers: {} }),
|
||||
},
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
StyleSheet: { create: (styles) => styles },
|
||||
StyleSheet: { create: (mockStyles) => mockStyles },
|
||||
Dimensions: { get: () => ({ width: 375, height: 812 }) },
|
||||
StatusBar: 'StatusBar',
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
StatusBar: mockCreateMockComponent('StatusBar'),
|
||||
SafeAreaView: mockCreateMockComponent('SafeAreaView'),
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
NativeModules: {
|
||||
DevMenu: {},
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@repo/core": "1.0.3",
|
||||
"@repo/sdk": "1.0.14",
|
||||
"@repo/core": "1.0.4",
|
||||
"@repo/sdk": "1.0.15",
|
||||
"@shopify/flash-list": "^2.0.0",
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.5.3",
|
||||
|
||||
Reference in New Issue
Block a user