feat(capability): 实现用户能力权限控制系统(Capability-based Permission)
借鉴 Stripe Capability 模型,实现 13 项细粒度用户功能权限控制:
LOGIN, TRADING, C2C, TRANSFER_IN/OUT, P2P_SEND/RECEIVE,
MINING_CLAIM, KYC, PROFILE_EDIT, VIEW_ASSET/TEAM/RECORDS
## 架构设计
- auth-service 为能力数据唯一写入点(DB + Redis DB14 缓存)
- 下游服务通过独立 ioredis 客户端直连 Redis DB14 检查能力(~1ms)
- 默认全部开启(fail-open):无缓存/Redis 故障 = 允许通行
- Guard 执行顺序:JwtAuthGuard → CapabilityGuard
## Phase 1: auth-service 核心
- Prisma Schema: UserCapability + CapabilityLog 两张表
- Domain: Capability 枚举, CapabilityMap 类型, Repository 接口
- Infrastructure: PrismaCapabilityRepository(含 $transaction 原子操作)
- Application: CapabilityService(Redis 缓存优先 → DB fallback → 写回 Redis TTL 1h)
- Scheduler: 每 60 秒扫描到期限制自动恢复(Redis 分布式锁防重复)
- API: GET /auth/user/capabilities (JWT), Internal CRUD API (服务间)
- 登录/refreshToken 均增加 LOGIN 能力检查
## Phase 2: 下游 CapabilityGuard
- trading-service: 14 个端点标注(TRADING/C2C/TRANSFER/P2P_SEND/VIEW_ASSET)
- contribution-service: 3 个端点标注(VIEW_RECORDS/VIEW_TEAM)
- mining-service: Guard 注册 + JwtAuthGuard accountSequence 兼容修复
- auth-service: KYC 端点标注(controller 级别 UseGuards)
## Phase 3: mining-admin-service
- CapabilityAdminService: 代理 auth-service internal API + 本地 AuditLog
- CapabilityController: Admin CRUD + 批量设置 + 变更日志查询
## Phase 4: mining-admin-web
- capability-management.tsx: 分组 Switch 开关 + 禁用 Dialog(原因+到期时间)+ 变更日志分页
- React Query hooks: useCapabilities/useSetCapability/useCapabilityLogs
- 用户详情页新增"权限管理"Tab
## Phase 5: mining-app (Flutter)
- CapabilityMap 数据模型 + ForbiddenException 异常类
- api_client.dart: 403 响应适配 ExceptionFilter 包装格式
- capabilitiesProvider: 登录后获取能力列表,fail-open 降级
## 审计修复
- CRITICAL: users.api.ts capability 方法移入 usersApi 对象内部
- P0: Flutter 403 解析路径适配 {error:{code,message}} 实际格式
- P0: 批量接口 operatorId 提升到 body 顶层匹配 auth-service 契约
- P1: mining-service JwtAuthGuard accountSequence fallback payload.sub
- P1: refreshCache 加 try/catch 防止 Redis 故障导致 500
- P1: processExpiredRestrictions 改用 upsertWithLog 事务方法
- P1: C2C upload-proof 补加 @RequireCapability('C2C')
- HIGH: internal.controller.ts 新增 capability 枚举校验
- HIGH: admin capability.controller.ts adminId fallback + query params 类型修复
- MEDIUM: setCapability 改用 $transaction 保证 upsert+log 原子性
## 部署注意
- 需运行: cd auth-service && npx prisma migrate dev --name add_user_capabilities
- 需配置: mining-admin-service .env AUTH_SERVICE_URL=http://auth-service:3010
## 待后续处理(P2)
- P2P_RECEIVE 需在业务逻辑层检查(收款方无主动请求)
- MINING_CLAIM/PROFILE_EDIT 待对应端点实现后标注
- getCapabilities 返回 Map 转 Array 丢失 reason/expiresAt 详细字段
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1d1c60e2a2
commit
55cfc96464
|
|
@ -285,6 +285,44 @@ enum OutboxStatus {
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 用户能力控制 (Capability-based permissions)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model UserCapability {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence")
|
||||||
|
capability String // LOGIN, TRADING, C2C, TRANSFER_IN, TRANSFER_OUT, P2P_SEND, P2P_RECEIVE, MINING_CLAIM, KYC, PROFILE_EDIT, VIEW_ASSET, VIEW_TEAM, VIEW_RECORDS
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
reason String? // 禁用原因
|
||||||
|
disabledBy String? @map("disabled_by") // 操作人
|
||||||
|
disabledAt DateTime? @map("disabled_at")
|
||||||
|
expiresAt DateTime? @map("expires_at") // null=永久
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([accountSequence, capability])
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("user_capabilities")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CapabilityLog {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence")
|
||||||
|
capability String
|
||||||
|
action String // DISABLE, ENABLE, EXPIRE
|
||||||
|
reason String?
|
||||||
|
operatorId String? @map("operator_id")
|
||||||
|
previousValue Boolean @map("previous_value")
|
||||||
|
newValue Boolean @map("new_value")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([accountSequence, createdAt(sort: Desc)])
|
||||||
|
@@map("capability_logs")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CDC 幂等消费追踪
|
// CDC 幂等消费追踪
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import {
|
||||||
HealthController,
|
HealthController,
|
||||||
AdminController,
|
AdminController,
|
||||||
InternalController,
|
InternalController,
|
||||||
|
CapabilityController,
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
import { ApplicationModule } from '@/application';
|
import { ApplicationModule } from '@/application';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -39,7 +41,8 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
HealthController,
|
HealthController,
|
||||||
AdminController,
|
AdminController,
|
||||||
InternalController,
|
InternalController,
|
||||||
|
CapabilityController,
|
||||||
],
|
],
|
||||||
providers: [JwtAuthGuard],
|
providers: [JwtAuthGuard, CapabilityGuard],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { CapabilityService } from '@/application/services/capability.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户端能力权限 API
|
||||||
|
*/
|
||||||
|
@Controller('auth/user')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class CapabilityController {
|
||||||
|
constructor(private readonly capabilityService: CapabilityService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的能力权限列表
|
||||||
|
* mining-app 登录后调用此接口获取能力状态
|
||||||
|
*/
|
||||||
|
@Get('capabilities')
|
||||||
|
async getCapabilities(
|
||||||
|
@CurrentUser('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilities(accountSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,4 @@ export * from './user.controller';
|
||||||
export * from './health.controller';
|
export * from './health.controller';
|
||||||
export * from './admin.controller';
|
export * from './admin.controller';
|
||||||
export * from './internal.controller';
|
export * from './internal.controller';
|
||||||
|
export * from './capability.controller';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { Controller, Get, Param, NotFoundException, Logger } from '@nestjs/common';
|
import { Controller, Get, Put, Param, Body, Query, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { CapabilityService } from '@/application/services/capability.service';
|
||||||
|
import { Capability, ALL_CAPABILITIES } from '@/domain/value-objects/capability.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内部 API - 供 2.0 服务间调用,不需要 JWT 认证
|
* 内部 API - 供 2.0 服务间调用,不需要 JWT 认证
|
||||||
|
|
@ -8,7 +10,10 @@ import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.servic
|
||||||
export class InternalController {
|
export class InternalController {
|
||||||
private readonly logger = new Logger(InternalController.name);
|
private readonly logger = new Logger(InternalController.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 accountSequence 获取用户的 Kava 地址
|
* 根据 accountSequence 获取用户的 Kava 地址
|
||||||
|
|
@ -47,4 +52,98 @@ export class InternalController {
|
||||||
|
|
||||||
return { kavaAddress: walletAddress.address };
|
return { kavaAddress: walletAddress.address };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 能力权限管理 (供 mining-admin-service 调用)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户能力权限列表
|
||||||
|
*/
|
||||||
|
@Get('capabilities/:accountSequence')
|
||||||
|
async getUserCapabilities(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilities(accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户单个能力
|
||||||
|
*/
|
||||||
|
@Put('capabilities/:accountSequence')
|
||||||
|
async setCapability(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() body: {
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.validateCapability(body.capability);
|
||||||
|
return this.capabilityService.setCapability({
|
||||||
|
accountSequence,
|
||||||
|
capability: body.capability as Capability,
|
||||||
|
enabled: body.enabled,
|
||||||
|
reason: body.reason,
|
||||||
|
operatorId: body.operatorId,
|
||||||
|
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置用户能力
|
||||||
|
*/
|
||||||
|
@Put('capabilities/:accountSequence/bulk')
|
||||||
|
async bulkSetCapabilities(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() body: {
|
||||||
|
capabilities: Array<{
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}>;
|
||||||
|
operatorId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
for (const c of body.capabilities) {
|
||||||
|
this.validateCapability(c.capability);
|
||||||
|
}
|
||||||
|
return this.capabilityService.setCapabilities({
|
||||||
|
accountSequence,
|
||||||
|
capabilities: body.capabilities.map((c) => ({
|
||||||
|
capability: c.capability as Capability,
|
||||||
|
enabled: c.enabled,
|
||||||
|
reason: c.reason,
|
||||||
|
expiresAt: c.expiresAt ? new Date(c.expiresAt) : undefined,
|
||||||
|
})),
|
||||||
|
operatorId: body.operatorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户能力变更日志
|
||||||
|
*/
|
||||||
|
@Get('capabilities/:accountSequence/logs')
|
||||||
|
async getCapabilityLogs(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilityLogs(
|
||||||
|
accountSequence,
|
||||||
|
parseInt(page || '1', 10),
|
||||||
|
parseInt(pageSize || '20', 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCapability(capability: string): void {
|
||||||
|
if (!ALL_CAPABILITIES.includes(capability as Capability)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`无效的能力类型: ${capability},有效值: ${ALL_CAPABILITIES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { KycService, KycStatusResult } from '@/application/services';
|
import { KycService, KycStatusResult } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
class SubmitKycDto {
|
class SubmitKycDto {
|
||||||
realName: string;
|
realName: string;
|
||||||
|
|
@ -20,7 +22,7 @@ class SubmitKycDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('kyc')
|
@Controller('kyc')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
export class KycController {
|
export class KycController {
|
||||||
constructor(private readonly kycService: KycService) {}
|
constructor(private readonly kycService: KycService) {}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,7 @@ export class KycController {
|
||||||
* POST /kyc/submit
|
* POST /kyc/submit
|
||||||
*/
|
*/
|
||||||
@Post('submit')
|
@Post('submit')
|
||||||
|
@RequireCapability('KYC')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseInterceptors(FilesInterceptor('files', 2))
|
@UseInterceptors(FilesInterceptor('files', 2))
|
||||||
async submitKyc(
|
async submitKyc(
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ import {
|
||||||
UserService,
|
UserService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
|
CapabilityService,
|
||||||
} from './services';
|
} from './services';
|
||||||
import { OutboxScheduler } from './schedulers';
|
import { OutboxScheduler, CapabilityExpiryScheduler } from './schedulers';
|
||||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -39,7 +40,9 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
UserService,
|
UserService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
|
CapabilityService,
|
||||||
OutboxScheduler,
|
OutboxScheduler,
|
||||||
|
CapabilityExpiryScheduler,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -50,6 +53,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
UserService,
|
UserService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
|
CapabilityService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { CapabilityService } from '../services/capability.service';
|
||||||
|
import { RedisService } from '@/infrastructure/redis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityExpiryScheduler {
|
||||||
|
private readonly logger = new Logger(CapabilityExpiryScheduler.name);
|
||||||
|
private readonly LOCK_KEY = 'auth:capability:expiry:lock';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 60 秒检查到期的临时限制并自动恢复
|
||||||
|
*/
|
||||||
|
@Cron('*/60 * * * * *')
|
||||||
|
async processExpiredRestrictions(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
|
||||||
|
if (!lockValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await this.capabilityService.processExpiredRestrictions();
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`已恢复 ${count} 个到期的能力限制`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('处理到期限制失败', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './outbox.scheduler';
|
export * from './outbox.scheduler';
|
||||||
|
export * from './capability-expiry.scheduler';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Injectable, Inject, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
import { Injectable, Inject, UnauthorizedException, ForbiddenException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
UserAggregate,
|
UserAggregate,
|
||||||
Phone,
|
Phone,
|
||||||
AccountSequence,
|
AccountSequence,
|
||||||
|
Capability,
|
||||||
USER_REPOSITORY,
|
USER_REPOSITORY,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
LegacyUserMigratedEvent,
|
LegacyUserMigratedEvent,
|
||||||
} from '@/domain';
|
} from '@/domain';
|
||||||
import { OutboxService } from './outbox.service';
|
import { OutboxService } from './outbox.service';
|
||||||
|
import { CapabilityService } from './capability.service';
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
@ -65,6 +67,7 @@ export class AuthService {
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly outboxService: OutboxService,
|
private readonly outboxService: OutboxService,
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -149,6 +152,16 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
user.recordLoginSuccess(dto.ipAddress);
|
user.recordLoginSuccess(dto.ipAddress);
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
||||||
|
|
@ -200,6 +213,15 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = await user.verifyPassword(password);
|
const isValid = await user.verifyPassword(password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
const result = user.recordLoginFailure();
|
const result = user.recordLoginFailure();
|
||||||
|
|
@ -309,6 +331,15 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('账户不可用');
|
throw new UnauthorizedException('账户不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = this.generateAccessToken(user);
|
const accessToken = this.generateAccessToken(user);
|
||||||
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { RedisService } from '@/infrastructure/redis';
|
||||||
|
import {
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
|
CapabilityRepository,
|
||||||
|
} from '@/domain/repositories/capability.repository.interface';
|
||||||
|
import {
|
||||||
|
Capability,
|
||||||
|
CapabilityMap,
|
||||||
|
defaultCapabilityMap,
|
||||||
|
} from '@/domain/value-objects/capability.vo';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityService {
|
||||||
|
private readonly logger = new Logger(CapabilityService.name);
|
||||||
|
private readonly REDIS_PREFIX = 'cap:';
|
||||||
|
private readonly REDIS_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CAPABILITY_REPOSITORY)
|
||||||
|
private readonly capabilityRepo: CapabilityRepository,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的完整能力映射
|
||||||
|
* Redis 缓存优先 → DB fallback → 写回 Redis
|
||||||
|
* 默认行为:无记录 = 全部开启
|
||||||
|
*/
|
||||||
|
async getCapabilities(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const cached = await this.redis.getJson<CapabilityMap>(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const map = await this.buildCapabilityMap(accountSequence);
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
map,
|
||||||
|
this.REDIS_TTL,
|
||||||
|
);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查单个能力是否开启
|
||||||
|
*/
|
||||||
|
async isCapabilityEnabled(
|
||||||
|
accountSequence: string,
|
||||||
|
capability: Capability,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const map = await this.getCapabilities(accountSequence);
|
||||||
|
return map[capability] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个能力
|
||||||
|
*/
|
||||||
|
async setCapability(params: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: Capability;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<CapabilityMap> {
|
||||||
|
const current = await this.capabilityRepo.findByAccountSequence(params.accountSequence);
|
||||||
|
const existing = current.find((c) => c.capability === params.capability);
|
||||||
|
const previousValue = existing ? existing.enabled : true;
|
||||||
|
|
||||||
|
await this.capabilityRepo.upsertWithLog(
|
||||||
|
{
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: params.capability,
|
||||||
|
enabled: params.enabled,
|
||||||
|
reason: params.reason,
|
||||||
|
disabledBy: params.enabled ? undefined : params.operatorId,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: params.capability,
|
||||||
|
action: params.enabled ? 'ENABLE' : 'DISABLE',
|
||||||
|
reason: params.reason,
|
||||||
|
operatorId: params.operatorId,
|
||||||
|
previousValue,
|
||||||
|
newValue: params.enabled,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.refreshCache(params.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置能力
|
||||||
|
*/
|
||||||
|
async setCapabilities(params: {
|
||||||
|
accountSequence: string;
|
||||||
|
capabilities: Array<{
|
||||||
|
capability: Capability;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}>;
|
||||||
|
operatorId?: string;
|
||||||
|
}): Promise<CapabilityMap> {
|
||||||
|
for (const cap of params.capabilities) {
|
||||||
|
await this.setCapability({
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: cap.capability,
|
||||||
|
enabled: cap.enabled,
|
||||||
|
reason: cap.reason,
|
||||||
|
operatorId: params.operatorId,
|
||||||
|
expiresAt: cap.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.refreshCache(params.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理到期的临时限制(由 cron 调用)
|
||||||
|
*/
|
||||||
|
async processExpiredRestrictions(): Promise<number> {
|
||||||
|
const expired = await this.capabilityRepo.findExpired();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const record of expired) {
|
||||||
|
await this.capabilityRepo.upsertWithLog(
|
||||||
|
{
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
capability: record.capability,
|
||||||
|
enabled: true,
|
||||||
|
reason: '临时限制已到期,自动恢复',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
capability: record.capability,
|
||||||
|
action: 'EXPIRE',
|
||||||
|
reason: '临时限制到期自动恢复',
|
||||||
|
previousValue: false,
|
||||||
|
newValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.refreshCache(record.accountSequence);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询能力变更日志
|
||||||
|
*/
|
||||||
|
async getCapabilityLogs(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
) {
|
||||||
|
return this.capabilityRepo.findLogsByAccountSequence(
|
||||||
|
accountSequence,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildCapabilityMap(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const records = await this.capabilityRepo.findByAccountSequence(accountSequence);
|
||||||
|
const map = defaultCapabilityMap();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.capability in Capability) {
|
||||||
|
// 已过期的限制视为开启
|
||||||
|
if (!record.enabled && record.expiresAt && record.expiresAt <= new Date()) {
|
||||||
|
map[record.capability as Capability] = true;
|
||||||
|
} else {
|
||||||
|
map[record.capability as Capability] = record.enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCache(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const map = await this.buildCapabilityMap(accountSequence);
|
||||||
|
try {
|
||||||
|
await this.redis.setJson(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
map,
|
||||||
|
this.REDIS_TTL,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Redis 缓存刷新失败 (${accountSequence}): ${error?.message}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,3 +6,4 @@ export * from './kyc.service';
|
||||||
export * from './user.service';
|
export * from './user.service';
|
||||||
export * from './outbox.service';
|
export * from './outbox.service';
|
||||||
export * from './admin-sync.service';
|
export * from './admin-sync.service';
|
||||||
|
export * from './capability.service';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
export const CAPABILITY_REPOSITORY = Symbol('CAPABILITY_REPOSITORY');
|
||||||
|
|
||||||
|
export interface UserCapabilityRecord {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
disabledBy: string | null;
|
||||||
|
disabledAt: Date | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityLogRecord {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason: string | null;
|
||||||
|
operatorId: string | null;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityRepository {
|
||||||
|
findByAccountSequence(accountSequence: string): Promise<UserCapabilityRecord[]>;
|
||||||
|
|
||||||
|
upsert(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<UserCapabilityRecord>;
|
||||||
|
|
||||||
|
upsertWithLog(
|
||||||
|
upsertData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
logData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
): Promise<UserCapabilityRecord>;
|
||||||
|
|
||||||
|
findExpired(): Promise<UserCapabilityRecord[]>;
|
||||||
|
|
||||||
|
createLog(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
findLogsByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{ data: CapabilityLogRecord[]; total: number }>;
|
||||||
|
}
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './user.repository.interface';
|
||||||
export * from './synced-legacy-user.repository.interface';
|
export * from './synced-legacy-user.repository.interface';
|
||||||
export * from './refresh-token.repository.interface';
|
export * from './refresh-token.repository.interface';
|
||||||
export * from './sms-verification.repository.interface';
|
export * from './sms-verification.repository.interface';
|
||||||
|
export * from './capability.repository.interface';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* 用户能力权限枚举
|
||||||
|
* 借鉴 Stripe Capability 模型,每项能力可独立开关
|
||||||
|
*/
|
||||||
|
export enum Capability {
|
||||||
|
LOGIN = 'LOGIN',
|
||||||
|
TRADING = 'TRADING',
|
||||||
|
C2C = 'C2C',
|
||||||
|
TRANSFER_IN = 'TRANSFER_IN',
|
||||||
|
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||||
|
P2P_SEND = 'P2P_SEND',
|
||||||
|
P2P_RECEIVE = 'P2P_RECEIVE',
|
||||||
|
MINING_CLAIM = 'MINING_CLAIM',
|
||||||
|
KYC = 'KYC',
|
||||||
|
PROFILE_EDIT = 'PROFILE_EDIT',
|
||||||
|
VIEW_ASSET = 'VIEW_ASSET',
|
||||||
|
VIEW_TEAM = 'VIEW_TEAM',
|
||||||
|
VIEW_RECORDS = 'VIEW_RECORDS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_CAPABILITIES = Object.values(Capability);
|
||||||
|
|
||||||
|
export type CapabilityMap = Record<Capability, boolean>;
|
||||||
|
|
||||||
|
export function defaultCapabilityMap(): CapabilityMap {
|
||||||
|
const map = {} as CapabilityMap;
|
||||||
|
for (const cap of ALL_CAPABILITIES) {
|
||||||
|
map[cap] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAPABILITY_LABELS: Record<Capability, string> = {
|
||||||
|
[Capability.LOGIN]: '登录',
|
||||||
|
[Capability.TRADING]: '交易',
|
||||||
|
[Capability.C2C]: 'C2C交易',
|
||||||
|
[Capability.TRANSFER_IN]: '划入',
|
||||||
|
[Capability.TRANSFER_OUT]: '划出',
|
||||||
|
[Capability.P2P_SEND]: 'P2P转出',
|
||||||
|
[Capability.P2P_RECEIVE]: 'P2P收款',
|
||||||
|
[Capability.MINING_CLAIM]: '挖矿领取',
|
||||||
|
[Capability.KYC]: '实名认证',
|
||||||
|
[Capability.PROFILE_EDIT]: '编辑资料',
|
||||||
|
[Capability.VIEW_ASSET]: '查看资产',
|
||||||
|
[Capability.VIEW_TEAM]: '查看团队',
|
||||||
|
[Capability.VIEW_RECORDS]: '查看记录',
|
||||||
|
};
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './account-sequence.vo';
|
||||||
export * from './phone.vo';
|
export * from './phone.vo';
|
||||||
export * from './password.vo';
|
export * from './password.vo';
|
||||||
export * from './sms-code.vo';
|
export * from './sms-code.vo';
|
||||||
|
export * from './capability.vo';
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
PrismaSyncedLegacyUserRepository,
|
PrismaSyncedLegacyUserRepository,
|
||||||
PrismaRefreshTokenRepository,
|
PrismaRefreshTokenRepository,
|
||||||
PrismaSmsVerificationRepository,
|
PrismaSmsVerificationRepository,
|
||||||
|
PrismaCapabilityRepository,
|
||||||
} from './persistence/repositories';
|
} from './persistence/repositories';
|
||||||
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
|
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
|
||||||
import { KafkaModule, KafkaProducerService } from './kafka';
|
import { KafkaModule, KafkaProducerService } from './kafka';
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
REFRESH_TOKEN_REPOSITORY,
|
REFRESH_TOKEN_REPOSITORY,
|
||||||
SMS_VERIFICATION_REPOSITORY,
|
SMS_VERIFICATION_REPOSITORY,
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
} from '@/domain';
|
} from '@/domain';
|
||||||
import { ApplicationModule } from '@/application/application.module';
|
import { ApplicationModule } from '@/application/application.module';
|
||||||
|
|
||||||
|
|
@ -59,6 +61,10 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
provide: SMS_VERIFICATION_REPOSITORY,
|
provide: SMS_VERIFICATION_REPOSITORY,
|
||||||
useClass: PrismaSmsVerificationRepository,
|
useClass: PrismaSmsVerificationRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CAPABILITY_REPOSITORY,
|
||||||
|
useClass: PrismaCapabilityRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|
@ -68,6 +74,7 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
REFRESH_TOKEN_REPOSITORY,
|
REFRESH_TOKEN_REPOSITORY,
|
||||||
SMS_VERIFICATION_REPOSITORY,
|
SMS_VERIFICATION_REPOSITORY,
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CapabilityRepository,
|
||||||
|
UserCapabilityRecord,
|
||||||
|
CapabilityLogRecord,
|
||||||
|
} from '@/domain/repositories/capability.repository.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaCapabilityRepository implements CapabilityRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findByAccountSequence(accountSequence: string): Promise<UserCapabilityRecord[]> {
|
||||||
|
return this.prisma.userCapability.findMany({
|
||||||
|
where: { accountSequence },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<UserCapabilityRecord> {
|
||||||
|
return this.prisma.userCapability.upsert({
|
||||||
|
where: {
|
||||||
|
accountSequence_capability: {
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
capability: data.capability,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountSequence: data.accountSequence,
|
||||||
|
capability: data.capability,
|
||||||
|
enabled: data.enabled,
|
||||||
|
reason: data.reason,
|
||||||
|
disabledBy: data.disabledBy,
|
||||||
|
disabledAt: data.enabled ? null : new Date(),
|
||||||
|
expiresAt: data.expiresAt,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: data.enabled,
|
||||||
|
reason: data.reason,
|
||||||
|
disabledBy: data.disabledBy,
|
||||||
|
disabledAt: data.enabled ? null : new Date(),
|
||||||
|
expiresAt: data.enabled ? null : data.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertWithLog(
|
||||||
|
upsertData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
logData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
): Promise<UserCapabilityRecord> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const record = await tx.userCapability.upsert({
|
||||||
|
where: {
|
||||||
|
accountSequence_capability: {
|
||||||
|
accountSequence: upsertData.accountSequence,
|
||||||
|
capability: upsertData.capability,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountSequence: upsertData.accountSequence,
|
||||||
|
capability: upsertData.capability,
|
||||||
|
enabled: upsertData.enabled,
|
||||||
|
reason: upsertData.reason,
|
||||||
|
disabledBy: upsertData.disabledBy,
|
||||||
|
disabledAt: upsertData.enabled ? null : new Date(),
|
||||||
|
expiresAt: upsertData.expiresAt,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
enabled: upsertData.enabled,
|
||||||
|
reason: upsertData.reason,
|
||||||
|
disabledBy: upsertData.disabledBy,
|
||||||
|
disabledAt: upsertData.enabled ? null : new Date(),
|
||||||
|
expiresAt: upsertData.enabled ? null : upsertData.expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.capabilityLog.create({ data: logData });
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findExpired(): Promise<UserCapabilityRecord[]> {
|
||||||
|
return this.prisma.userCapability.findMany({
|
||||||
|
where: {
|
||||||
|
enabled: false,
|
||||||
|
expiresAt: { not: null, lte: new Date() },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLog(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.prisma.capabilityLog.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLogsByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{ data: CapabilityLogRecord[]; total: number }> {
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.capabilityLog.findMany({
|
||||||
|
where: { accountSequence },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.capabilityLog.count({ where: { accountSequence } }),
|
||||||
|
]);
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './user.repository';
|
||||||
export * from './synced-legacy-user.repository';
|
export * from './synced-legacy-user.repository';
|
||||||
export * from './refresh-token.repository';
|
export * from './refresh-token.repository';
|
||||||
export * from './sms-verification.repository';
|
export * from './sms-verification.repository';
|
||||||
|
export * from './capability.repository';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITY_KEY = 'requiredCapability';
|
||||||
|
export const RequireCapability = (capability: string) =>
|
||||||
|
SetMetadata(CAPABILITY_KEY, capability);
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { CAPABILITY_KEY } from '../decorators/require-capability.decorator';
|
||||||
|
import { CapabilityService } from '@/application/services/capability.service';
|
||||||
|
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
TRADING: '交易',
|
||||||
|
C2C: 'C2C交易',
|
||||||
|
TRANSFER_IN: '划入',
|
||||||
|
TRANSFER_OUT: '划出',
|
||||||
|
P2P_SEND: 'P2P转出',
|
||||||
|
P2P_RECEIVE: 'P2P收款',
|
||||||
|
MINING_CLAIM: '挖矿领取',
|
||||||
|
KYC: '实名认证',
|
||||||
|
PROFILE_EDIT: '编辑资料',
|
||||||
|
VIEW_ASSET: '查看资产',
|
||||||
|
VIEW_TEAM: '查看团队',
|
||||||
|
VIEW_RECORDS: '查看记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CapabilityGuard - auth-service 内部版本,直接使用 CapabilityService
|
||||||
|
* 必须在 JwtAuthGuard 之后使用,依赖 request.user.accountSequence
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CapabilityGuard.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapability = this.reflector.getAllAndOverride<string>(
|
||||||
|
CAPABILITY_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无能力要求 → 放行
|
||||||
|
if (!requiredCapability) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const accountSequence = request.user?.accountSequence;
|
||||||
|
|
||||||
|
// 未认证(公开端点或认证前) → 放行
|
||||||
|
if (!accountSequence) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
accountSequence,
|
||||||
|
requiredCapability,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
const label = CAPABILITY_LABELS[requiredCapability] || requiredCapability;
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'CAPABILITY_DISABLED',
|
||||||
|
capability: requiredCapability,
|
||||||
|
message: `您的${label}功能已被限制`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// 服务错误 → fail-open
|
||||||
|
this.logger.warn(`能力检查失败 (fail-open): ${accountSequence}`, error?.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import { ContributionStatsResponse } from '../dto/response/contribution-stats.re
|
||||||
import { ContributionRankingResponse, UserRankResponse } from '../dto/response/contribution-ranking.response';
|
import { ContributionRankingResponse, UserRankResponse } from '../dto/response/contribution-ranking.response';
|
||||||
import { GetContributionRecordsRequest } from '../dto/request/get-records.request';
|
import { GetContributionRecordsRequest } from '../dto/request/get-records.request';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
@ApiTags('Contribution')
|
@ApiTags('Contribution')
|
||||||
@Controller('contribution')
|
@Controller('contribution')
|
||||||
|
|
@ -55,6 +56,7 @@ export class ContributionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountSequence/records')
|
@Get('accounts/:accountSequence/records')
|
||||||
|
@RequireCapability('VIEW_RECORDS')
|
||||||
@ApiOperation({ summary: '获取账户算力明细记录' })
|
@ApiOperation({ summary: '获取账户算力明细记录' })
|
||||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
@ApiResponse({ status: 200, type: ContributionRecordsResponse })
|
@ApiResponse({ status: 200, type: ContributionRecordsResponse })
|
||||||
|
|
@ -123,6 +125,7 @@ export class ContributionController {
|
||||||
// ========== 团队树 API ==========
|
// ========== 团队树 API ==========
|
||||||
|
|
||||||
@Get('accounts/:accountSequence/team')
|
@Get('accounts/:accountSequence/team')
|
||||||
|
@RequireCapability('VIEW_TEAM')
|
||||||
@ApiOperation({ summary: '获取账户团队信息' })
|
@ApiOperation({ summary: '获取账户团队信息' })
|
||||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
@ApiResponse({ status: 200, description: '团队信息' })
|
@ApiResponse({ status: 200, description: '团队信息' })
|
||||||
|
|
@ -133,6 +136,7 @@ export class ContributionController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('accounts/:accountSequence/team/direct-referrals')
|
@Get('accounts/:accountSequence/team/direct-referrals')
|
||||||
|
@RequireCapability('VIEW_TEAM')
|
||||||
@ApiOperation({ summary: '获取账户直推列表(用于伞下树懒加载)' })
|
@ApiOperation({ summary: '获取账户直推列表(用于伞下树懒加载)' })
|
||||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量' })
|
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量' })
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { DomainExceptionFilter } from './shared/filters/domain-exception.filter'
|
||||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||||
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||||
// [2026-02-17] 新增:预种 CDC 集成模块(纯新增,与现有 CDC 消费零耦合)
|
// [2026-02-17] 新增:预种 CDC 集成模块(纯新增,与现有 CDC 消费零耦合)
|
||||||
import { PrePlantingCdcModule } from './pre-planting/pre-planting-cdc.module';
|
import { PrePlantingCdcModule } from './pre-planting/pre-planting-cdc.module';
|
||||||
|
|
||||||
|
|
@ -44,6 +45,10 @@ import { PrePlantingCdcModule } from './pre-planting/pre-planting-cdc.module';
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CapabilityGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITY_KEY = 'requiredCapability';
|
||||||
|
export const RequireCapability = (capability: string) =>
|
||||||
|
SetMetadata(CAPABILITY_KEY, capability);
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CAPABILITY_KEY } from '../decorators/require-capability.decorator';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
TRADING: '交易',
|
||||||
|
C2C: 'C2C交易',
|
||||||
|
TRANSFER_IN: '划入',
|
||||||
|
TRANSFER_OUT: '划出',
|
||||||
|
P2P_SEND: 'P2P转出',
|
||||||
|
P2P_RECEIVE: 'P2P收款',
|
||||||
|
MINING_CLAIM: '挖矿领取',
|
||||||
|
KYC: '实名认证',
|
||||||
|
PROFILE_EDIT: '编辑资料',
|
||||||
|
VIEW_ASSET: '查看资产',
|
||||||
|
VIEW_TEAM: '查看团队',
|
||||||
|
VIEW_RECORDS: '查看记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CapabilityGuard - 从 Redis DB 14 (auth-service) 读取用户能力并校验
|
||||||
|
* 必须在 JwtAuthGuard 之后注册,依赖 request.user.accountSequence
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate, OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(CapabilityGuard.name);
|
||||||
|
private capRedis: Redis | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.capRedis = new Redis({
|
||||||
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||||
|
db: 14, // auth-service 的 Redis DB
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: (times) => Math.min(times * 100, 3000),
|
||||||
|
});
|
||||||
|
this.capRedis.connect().catch((err) => {
|
||||||
|
this.logger.warn('Capability Redis 连接失败 (fail-open)', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.capRedis?.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapability = this.reflector.getAllAndOverride<string>(
|
||||||
|
CAPABILITY_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无能力要求 → 放行
|
||||||
|
if (!requiredCapability) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const accountSequence = request.user?.accountSequence;
|
||||||
|
|
||||||
|
// 未认证(公开端点或认证前) → 放行
|
||||||
|
if (!accountSequence) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await this.capRedis?.get(`cap:${accountSequence}`);
|
||||||
|
|
||||||
|
// 无缓存 = 默认全部开启 (fail-open)
|
||||||
|
if (!raw) return true;
|
||||||
|
|
||||||
|
const capabilities = JSON.parse(raw);
|
||||||
|
const isEnabled = capabilities[requiredCapability];
|
||||||
|
|
||||||
|
// 能力键不存在 = 默认开启
|
||||||
|
if (isEnabled === undefined) return true;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
const label = CAPABILITY_LABELS[requiredCapability] || requiredCapability;
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'CAPABILITY_DISABLED',
|
||||||
|
capability: requiredCapability,
|
||||||
|
message: `您的${label}功能已被限制`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// Redis 错误 → fail-open
|
||||||
|
this.logger.warn(`能力检查失败 (fail-open): ${accountSequence}`, error?.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ JWT_SECRET=your-admin-jwt-secret-key
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
AUTH_SERVICE_URL=http://localhost:3010
|
||||||
CONTRIBUTION_SERVICE_URL=http://localhost:3020
|
CONTRIBUTION_SERVICE_URL=http://localhost:3020
|
||||||
MINING_SERVICE_URL=http://localhost:3021
|
MINING_SERVICE_URL=http://localhost:3021
|
||||||
TRADING_SERVICE_URL=http://localhost:3022
|
TRADING_SERVICE_URL=http://localhost:3022
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { VersionController } from './controllers/version.controller';
|
||||||
import { UpgradeVersionController } from './controllers/upgrade-version.controller';
|
import { UpgradeVersionController } from './controllers/upgrade-version.controller';
|
||||||
import { MobileVersionController } from './controllers/mobile-version.controller';
|
import { MobileVersionController } from './controllers/mobile-version.controller';
|
||||||
import { PoolAccountController } from './controllers/pool-account.controller';
|
import { PoolAccountController } from './controllers/pool-account.controller';
|
||||||
|
import { CapabilityController } from './controllers/capability.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -42,6 +43,7 @@ import { PoolAccountController } from './controllers/pool-account.controller';
|
||||||
UpgradeVersionController,
|
UpgradeVersionController,
|
||||||
MobileVersionController,
|
MobileVersionController,
|
||||||
PoolAccountController,
|
PoolAccountController,
|
||||||
|
CapabilityController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Controller, Get, Put, Param, Query, Body, Req } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CapabilityAdminService, SetCapabilityDto } from '../../application/services/capability-admin.service';
|
||||||
|
|
||||||
|
@ApiTags('Capabilities')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('capabilities')
|
||||||
|
export class CapabilityController {
|
||||||
|
constructor(private readonly capabilityAdminService: CapabilityAdminService) {}
|
||||||
|
|
||||||
|
@Get('users/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '查询用户能力列表' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
async getCapabilities(@Param('accountSequence') accountSequence: string) {
|
||||||
|
return this.capabilityAdminService.getCapabilities(accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('users/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '设置用户单个能力' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
async setCapability(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() dto: SetCapabilityDto,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
const adminId = req.admin?.id || req.admin?.username || 'unknown';
|
||||||
|
return this.capabilityAdminService.setCapability(accountSequence, dto, adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('users/:accountSequence/bulk')
|
||||||
|
@ApiOperation({ summary: '批量设置用户能力' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
async setCapabilities(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() body: { capabilities: SetCapabilityDto[] },
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
const adminId = req.admin?.id || req.admin?.username || 'unknown';
|
||||||
|
return this.capabilityAdminService.setCapabilities(
|
||||||
|
accountSequence,
|
||||||
|
body.capabilities,
|
||||||
|
adminId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('users/:accountSequence/logs')
|
||||||
|
@ApiOperation({ summary: '查询能力变更日志' })
|
||||||
|
@ApiParam({ name: 'accountSequence', type: String })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, type: Number })
|
||||||
|
async getCapabilityLogs(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityAdminService.getCapabilityLogs(
|
||||||
|
accountSequence,
|
||||||
|
parseInt(page || '1', 10),
|
||||||
|
parseInt(pageSize || '20', 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { ManualMiningService } from './services/manual-mining.service';
|
||||||
import { PendingContributionsService } from './services/pending-contributions.service';
|
import { PendingContributionsService } from './services/pending-contributions.service';
|
||||||
import { BatchMiningService } from './services/batch-mining.service';
|
import { BatchMiningService } from './services/batch-mining.service';
|
||||||
import { VersionService } from './services/version.service';
|
import { VersionService } from './services/version.service';
|
||||||
|
import { CapabilityAdminService } from './services/capability-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [InfrastructureModule],
|
imports: [InfrastructureModule],
|
||||||
|
|
@ -24,6 +25,7 @@ import { VersionService } from './services/version.service';
|
||||||
PendingContributionsService,
|
PendingContributionsService,
|
||||||
BatchMiningService,
|
BatchMiningService,
|
||||||
VersionService,
|
VersionService,
|
||||||
|
CapabilityAdminService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
|
|
@ -36,6 +38,7 @@ import { VersionService } from './services/version.service';
|
||||||
PendingContributionsService,
|
PendingContributionsService,
|
||||||
BatchMiningService,
|
BatchMiningService,
|
||||||
VersionService,
|
VersionService,
|
||||||
|
CapabilityAdminService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule implements OnModuleInit {
|
export class ApplicationModule implements OnModuleInit {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
const ALL_CAPABILITIES = [
|
||||||
|
'LOGIN', 'TRADING', 'C2C', 'TRANSFER_IN', 'TRANSFER_OUT',
|
||||||
|
'P2P_SEND', 'P2P_RECEIVE', 'MINING_CLAIM', 'KYC',
|
||||||
|
'PROFILE_EDIT', 'VIEW_ASSET', 'VIEW_TEAM', 'VIEW_RECORDS',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface CapabilityItem {
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
disabledAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetCapabilityDto {
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: string; // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityLogItem {
|
||||||
|
id: string;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityAdminService {
|
||||||
|
private readonly logger = new Logger(CapabilityAdminService.name);
|
||||||
|
private readonly authServiceUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.authServiceUrl = this.configService.get<string>(
|
||||||
|
'AUTH_SERVICE_URL',
|
||||||
|
'http://localhost:3010',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户能力列表
|
||||||
|
*/
|
||||||
|
async getCapabilities(accountSequence: string): Promise<CapabilityItem[]> {
|
||||||
|
const url = `${this.authServiceUrl}/api/v2/internal/capabilities/${accountSequence}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`auth-service responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
// 将 { LOGIN: true, C2C: false, ... } 转成详细列表
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果返回的是 map 格式,转换成列表
|
||||||
|
return ALL_CAPABILITIES.map((cap) => ({
|
||||||
|
capability: cap,
|
||||||
|
enabled: data[cap] !== false,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`获取用户能力失败: ${error.message}`);
|
||||||
|
// fallback: 返回全部开启
|
||||||
|
return ALL_CAPABILITIES.map((cap) => ({
|
||||||
|
capability: cap,
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个能力
|
||||||
|
*/
|
||||||
|
async setCapability(
|
||||||
|
accountSequence: string,
|
||||||
|
dto: SetCapabilityDto,
|
||||||
|
adminId: string,
|
||||||
|
): Promise<CapabilityItem[]> {
|
||||||
|
if (!ALL_CAPABILITIES.includes(dto.capability as any)) {
|
||||||
|
throw new BadRequestException(`无效的能力: ${dto.capability}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.authServiceUrl}/api/v2/internal/capabilities/${accountSequence}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
capability: dto.capability,
|
||||||
|
enabled: dto.enabled,
|
||||||
|
reason: dto.reason,
|
||||||
|
operatorId: adminId,
|
||||||
|
expiresAt: dto.expiresAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody = await response.text();
|
||||||
|
throw new Error(`auth-service responded with ${response.status}: ${errBody}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写本地审计日志
|
||||||
|
await this.writeAuditLog(adminId, accountSequence, dto);
|
||||||
|
|
||||||
|
// 返回最新能力列表
|
||||||
|
return this.getCapabilities(accountSequence);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof BadRequestException) throw error;
|
||||||
|
this.logger.error(`设置用户能力失败: ${error.message}`);
|
||||||
|
throw new BadRequestException(`设置用户能力失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置能力
|
||||||
|
*/
|
||||||
|
async setCapabilities(
|
||||||
|
accountSequence: string,
|
||||||
|
items: SetCapabilityDto[],
|
||||||
|
adminId: string,
|
||||||
|
): Promise<CapabilityItem[]> {
|
||||||
|
// 验证所有能力
|
||||||
|
for (const item of items) {
|
||||||
|
if (!ALL_CAPABILITIES.includes(item.capability as any)) {
|
||||||
|
throw new BadRequestException(`无效的能力: ${item.capability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.authServiceUrl}/api/v2/internal/capabilities/${accountSequence}/bulk`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
capabilities: items.map((item) => ({
|
||||||
|
capability: item.capability,
|
||||||
|
enabled: item.enabled,
|
||||||
|
reason: item.reason,
|
||||||
|
expiresAt: item.expiresAt,
|
||||||
|
})),
|
||||||
|
operatorId: adminId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody = await response.text();
|
||||||
|
throw new Error(`auth-service responded with ${response.status}: ${errBody}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写本地审计日志
|
||||||
|
for (const item of items) {
|
||||||
|
await this.writeAuditLog(adminId, accountSequence, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getCapabilities(accountSequence);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof BadRequestException) throw error;
|
||||||
|
this.logger.error(`批量设置用户能力失败: ${error.message}`);
|
||||||
|
throw new BadRequestException(`批量设置用户能力失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取能力变更日志
|
||||||
|
*/
|
||||||
|
async getCapabilityLogs(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<{ data: CapabilityLogItem[]; total: number; page: number; pageSize: number }> {
|
||||||
|
const url = `${this.authServiceUrl}/api/v2/internal/capabilities/${accountSequence}/logs?page=${page}&pageSize=${pageSize}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`auth-service responded with ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
const data = result.data || result;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.data || [],
|
||||||
|
total: data.total || 0,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`获取能力变更日志失败: ${error.message}`);
|
||||||
|
return { data: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写本地审计日志
|
||||||
|
*/
|
||||||
|
private async writeAuditLog(
|
||||||
|
adminId: string,
|
||||||
|
accountSequence: string,
|
||||||
|
dto: SetCapabilityDto,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
adminId,
|
||||||
|
action: dto.enabled ? 'ENABLE' : 'DISABLE',
|
||||||
|
resource: 'CAPABILITY',
|
||||||
|
resourceId: `${accountSequence}:${dto.capability}`,
|
||||||
|
newValue: {
|
||||||
|
capability: dto.capability,
|
||||||
|
enabled: dto.enabled,
|
||||||
|
reason: dto.reason || null,
|
||||||
|
expiresAt: dto.expiresAt || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`写审计日志失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { DomainExceptionFilter } from './shared/filters/domain-exception.filter'
|
||||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||||
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -41,6 +42,10 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: JwtAuthGuard,
|
useClass: JwtAuthGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CapabilityGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITY_KEY = 'requiredCapability';
|
||||||
|
export const RequireCapability = (capability: string) =>
|
||||||
|
SetMetadata(CAPABILITY_KEY, capability);
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CAPABILITY_KEY } from '../decorators/require-capability.decorator';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
TRADING: '交易',
|
||||||
|
C2C: 'C2C交易',
|
||||||
|
TRANSFER_IN: '划入',
|
||||||
|
TRANSFER_OUT: '划出',
|
||||||
|
P2P_SEND: 'P2P转出',
|
||||||
|
P2P_RECEIVE: 'P2P收款',
|
||||||
|
MINING_CLAIM: '挖矿领取',
|
||||||
|
KYC: '实名认证',
|
||||||
|
PROFILE_EDIT: '编辑资料',
|
||||||
|
VIEW_ASSET: '查看资产',
|
||||||
|
VIEW_TEAM: '查看团队',
|
||||||
|
VIEW_RECORDS: '查看记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CapabilityGuard - 从 Redis DB 14 (auth-service) 读取用户能力并校验
|
||||||
|
* 必须在 JwtAuthGuard 之后注册,依赖 request.user.accountSequence
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate, OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(CapabilityGuard.name);
|
||||||
|
private capRedis: Redis | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.capRedis = new Redis({
|
||||||
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||||
|
db: 14, // auth-service 的 Redis DB
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: (times) => Math.min(times * 100, 3000),
|
||||||
|
});
|
||||||
|
this.capRedis.connect().catch((err) => {
|
||||||
|
this.logger.warn('Capability Redis 连接失败 (fail-open)', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.capRedis?.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapability = this.reflector.getAllAndOverride<string>(
|
||||||
|
CAPABILITY_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无能力要求 → 放行
|
||||||
|
if (!requiredCapability) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const accountSequence = request.user?.accountSequence;
|
||||||
|
|
||||||
|
// 未认证(公开端点或认证前) → 放行
|
||||||
|
if (!accountSequence) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await this.capRedis?.get(`cap:${accountSequence}`);
|
||||||
|
|
||||||
|
// 无缓存 = 默认全部开启 (fail-open)
|
||||||
|
if (!raw) return true;
|
||||||
|
|
||||||
|
const capabilities = JSON.parse(raw);
|
||||||
|
const isEnabled = capabilities[requiredCapability];
|
||||||
|
|
||||||
|
// 能力键不存在 = 默认开启
|
||||||
|
if (isEnabled === undefined) return true;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
const label = CAPABILITY_LABELS[requiredCapability] || requiredCapability;
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'CAPABILITY_DISABLED',
|
||||||
|
capability: requiredCapability,
|
||||||
|
message: `您的${label}功能已被限制`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// Redis 错误 → fail-open
|
||||||
|
this.logger.warn(`能力检查失败 (fail-open): ${accountSequence}`, error?.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||||
|
|
||||||
request.user = {
|
request.user = {
|
||||||
userId: payload.sub,
|
userId: payload.sub,
|
||||||
accountSequence: payload.accountSequence,
|
accountSequence: payload.accountSequence || payload.sub,
|
||||||
};
|
};
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
|
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AssetService } from '../../application/services/asset.service';
|
import { AssetService } from '../../application/services/asset.service';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@ApiTags('Asset')
|
@ApiTags('Asset')
|
||||||
|
|
@ -10,6 +11,7 @@ export class AssetController {
|
||||||
constructor(private readonly assetService: AssetService) {}
|
constructor(private readonly assetService: AssetService) {}
|
||||||
|
|
||||||
@Get('my')
|
@Get('my')
|
||||||
|
@RequireCapability('VIEW_ASSET')
|
||||||
@ApiOperation({ summary: '获取我的资产显示' })
|
@ApiOperation({ summary: '获取我的资产显示' })
|
||||||
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||||
async getMyAsset(@Req() req: any, @Query('dailyAllocation') dailyAllocation?: string) {
|
async getMyAsset(@Req() req: any, @Query('dailyAllocation') dailyAllocation?: string) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
C2cOrdersPageResponseDto,
|
C2cOrdersPageResponseDto,
|
||||||
} from '../dto/c2c.dto';
|
} from '../dto/c2c.dto';
|
||||||
import { C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
|
import { C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
@ApiTags('C2C Trading')
|
@ApiTags('C2C Trading')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|
@ -136,6 +137,7 @@ export class C2cController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders')
|
@Post('orders')
|
||||||
|
@RequireCapability('C2C')
|
||||||
@ApiOperation({ summary: '创建C2C订单(发布广告)' })
|
@ApiOperation({ summary: '创建C2C订单(发布广告)' })
|
||||||
@ApiResponse({ status: 201, description: '订单创建成功' })
|
@ApiResponse({ status: 201, description: '订单创建成功' })
|
||||||
async createOrder(
|
async createOrder(
|
||||||
|
|
@ -180,6 +182,7 @@ export class C2cController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders/:orderNo/take')
|
@Post('orders/:orderNo/take')
|
||||||
|
@RequireCapability('C2C')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '接单(吃单)' })
|
@ApiOperation({ summary: '接单(吃单)' })
|
||||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
|
|
@ -228,6 +231,7 @@ export class C2cController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders/:orderNo/confirm-payment')
|
@Post('orders/:orderNo/confirm-payment')
|
||||||
|
@RequireCapability('C2C')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '确认付款(买方操作)' })
|
@ApiOperation({ summary: '确认付款(买方操作)' })
|
||||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
|
|
@ -246,6 +250,7 @@ export class C2cController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders/:orderNo/confirm-received')
|
@Post('orders/:orderNo/confirm-received')
|
||||||
|
@RequireCapability('C2C')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '确认收款(卖方操作)' })
|
@ApiOperation({ summary: '确认收款(卖方操作)' })
|
||||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
|
|
@ -264,6 +269,7 @@ export class C2cController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders/:orderNo/upload-proof')
|
@Post('orders/:orderNo/upload-proof')
|
||||||
|
@RequireCapability('C2C')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Controller, Get, Post, Body, Query, Param, Req, Headers, BadRequestExce
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery, ApiParam } from '@nestjs/swagger';
|
||||||
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
import { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||||
|
|
||||||
class P2pTransferDto {
|
class P2pTransferDto {
|
||||||
|
|
@ -32,6 +33,7 @@ export class P2pTransferController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('transfer')
|
@Post('transfer')
|
||||||
|
@RequireCapability('P2P_SEND')
|
||||||
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
||||||
async transfer(
|
async transfer(
|
||||||
@Body() dto: P2pTransferDto,
|
@Body() dto: P2pTransferDto,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { OrderService } from '../../application/services/order.service';
|
||||||
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
||||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||||
import { OrderType } from '../../domain/aggregates/order.aggregate';
|
import { OrderType } from '../../domain/aggregates/order.aggregate';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
class CreateOrderDto {
|
class CreateOrderDto {
|
||||||
@IsIn(['BUY', 'SELL'])
|
@IsIn(['BUY', 'SELL'])
|
||||||
|
|
@ -56,6 +57,7 @@ export class TradingController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders')
|
@Post('orders')
|
||||||
|
@RequireCapability('TRADING')
|
||||||
@ApiOperation({ summary: '创建订单' })
|
@ApiOperation({ summary: '创建订单' })
|
||||||
async createOrder(@Body() dto: CreateOrderDto, @Req() req: any) {
|
async createOrder(@Body() dto: CreateOrderDto, @Req() req: any) {
|
||||||
const accountSequence = req.user?.accountSequence;
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
|
@ -72,6 +74,7 @@ export class TradingController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('orders/:orderNo/cancel')
|
@Post('orders/:orderNo/cancel')
|
||||||
|
@RequireCapability('TRADING')
|
||||||
@ApiOperation({ summary: '取消订单' })
|
@ApiOperation({ summary: '取消订单' })
|
||||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
async cancelOrder(@Param('orderNo') orderNo: string, @Req() req: any) {
|
async cancelOrder(@Param('orderNo') orderNo: string, @Req() req: any) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Controller, Get, Post, Param, Query, Body, Req, UnauthorizedException }
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
import { TransferService } from '../../application/services/transfer.service';
|
import { TransferService } from '../../application/services/transfer.service';
|
||||||
|
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
class TransferDto {
|
class TransferDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -15,6 +16,7 @@ export class TransferController {
|
||||||
constructor(private readonly transferService: TransferService) {}
|
constructor(private readonly transferService: TransferService) {}
|
||||||
|
|
||||||
@Post('in')
|
@Post('in')
|
||||||
|
@RequireCapability('TRANSFER_IN')
|
||||||
@ApiOperation({ summary: '从挖矿账户划入积分股' })
|
@ApiOperation({ summary: '从挖矿账户划入积分股' })
|
||||||
async transferIn(@Body() dto: TransferDto, @Req() req: any) {
|
async transferIn(@Body() dto: TransferDto, @Req() req: any) {
|
||||||
const accountSequence = req.user?.accountSequence;
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
|
@ -26,6 +28,7 @@ export class TransferController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('out')
|
@Post('out')
|
||||||
|
@RequireCapability('TRANSFER_OUT')
|
||||||
@ApiOperation({ summary: '划出积分股到挖矿账户' })
|
@ApiOperation({ summary: '划出积分股到挖矿账户' })
|
||||||
async transferOut(@Body() dto: TransferDto, @Req() req: any) {
|
async transferOut(@Body() dto: TransferDto, @Req() req: any) {
|
||||||
const accountSequence = req.user?.accountSequence;
|
const accountSequence = req.user?.accountSequence;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { DomainExceptionFilter } from './shared/filters/domain-exception.filter'
|
||||||
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './shared/interceptors/transform.interceptor';
|
||||||
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -29,6 +30,7 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: CapabilityGuard },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITY_KEY = 'requiredCapability';
|
||||||
|
export const RequireCapability = (capability: string) =>
|
||||||
|
SetMetadata(CAPABILITY_KEY, capability);
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CAPABILITY_KEY } from '../decorators/require-capability.decorator';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
TRADING: '交易',
|
||||||
|
C2C: 'C2C交易',
|
||||||
|
TRANSFER_IN: '划入',
|
||||||
|
TRANSFER_OUT: '划出',
|
||||||
|
P2P_SEND: 'P2P转出',
|
||||||
|
P2P_RECEIVE: 'P2P收款',
|
||||||
|
MINING_CLAIM: '挖矿领取',
|
||||||
|
KYC: '实名认证',
|
||||||
|
PROFILE_EDIT: '编辑资料',
|
||||||
|
VIEW_ASSET: '查看资产',
|
||||||
|
VIEW_TEAM: '查看团队',
|
||||||
|
VIEW_RECORDS: '查看记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CapabilityGuard - 从 Redis DB 14 (auth-service) 读取用户能力并校验
|
||||||
|
* 必须在 JwtAuthGuard 之后注册,依赖 request.user.accountSequence
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate, OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(CapabilityGuard.name);
|
||||||
|
private capRedis: Redis | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.capRedis = new Redis({
|
||||||
|
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
|
||||||
|
port: this.configService.get<number>('REDIS_PORT', 6379),
|
||||||
|
password: this.configService.get<string>('REDIS_PASSWORD'),
|
||||||
|
db: 14, // auth-service 的 Redis DB
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
retryStrategy: (times) => Math.min(times * 100, 3000),
|
||||||
|
});
|
||||||
|
this.capRedis.connect().catch((err) => {
|
||||||
|
this.logger.warn('Capability Redis 连接失败 (fail-open)', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.capRedis?.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapability = this.reflector.getAllAndOverride<string>(
|
||||||
|
CAPABILITY_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无能力要求 → 放行
|
||||||
|
if (!requiredCapability) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const accountSequence = request.user?.accountSequence;
|
||||||
|
|
||||||
|
// 未认证(公开端点或认证前) → 放行
|
||||||
|
if (!accountSequence) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await this.capRedis?.get(`cap:${accountSequence}`);
|
||||||
|
|
||||||
|
// 无缓存 = 默认全部开启 (fail-open)
|
||||||
|
if (!raw) return true;
|
||||||
|
|
||||||
|
const capabilities = JSON.parse(raw);
|
||||||
|
const isEnabled = capabilities[requiredCapability];
|
||||||
|
|
||||||
|
// 能力键不存在 = 默认开启
|
||||||
|
if (isEnabled === undefined) return true;
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
const label = CAPABILITY_LABELS[requiredCapability] || requiredCapability;
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'CAPABILITY_DISABLED',
|
||||||
|
capability: requiredCapability,
|
||||||
|
message: `您的${label}功能已被限制`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ForbiddenException) throw error;
|
||||||
|
// Redis 错误 → fail-open
|
||||||
|
this.logger.warn(`能力检查失败 (fail-open): ${accountSequence}`, error?.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,8 @@ import { ReferralTree } from '@/features/users/components/referral-tree';
|
||||||
import { PlantingLedger } from '@/features/users/components/planting-ledger';
|
import { PlantingLedger } from '@/features/users/components/planting-ledger';
|
||||||
import { WalletLedger } from '@/features/users/components/wallet-ledger';
|
import { WalletLedger } from '@/features/users/components/wallet-ledger';
|
||||||
import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list';
|
import { BatchMiningRecordsList } from '@/features/users/components/batch-mining-records-list';
|
||||||
import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins, Gift } from 'lucide-react';
|
import { CapabilityManagement } from '@/features/users/components/capability-management';
|
||||||
|
import { Users, TreePine, Wallet, Zap, ShoppingCart, Network, Coins, Gift, Shield } from 'lucide-react';
|
||||||
|
|
||||||
function UserDetailSkeleton() {
|
function UserDetailSkeleton() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -354,7 +355,11 @@ export default function UserDetailPage() {
|
||||||
|
|
||||||
{/* Tab 区域 */}
|
{/* Tab 区域 */}
|
||||||
<Tabs defaultValue="contributions">
|
<Tabs defaultValue="contributions">
|
||||||
<TabsList className="grid w-full grid-cols-7">
|
<TabsList className="grid w-full grid-cols-8">
|
||||||
|
<TabsTrigger value="capabilities" className="flex items-center gap-1">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">权限管理</span>
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="contributions" className="flex items-center gap-1">
|
<TabsTrigger value="contributions" className="flex items-center gap-1">
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">算力记录</span>
|
<span className="hidden sm:inline">算力记录</span>
|
||||||
|
|
@ -385,6 +390,10 @@ export default function UserDetailPage() {
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="capabilities" className="mt-4">
|
||||||
|
<CapabilityManagement accountSequence={accountSequence} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="contributions" className="mt-4">
|
<TabsContent value="contributions" className="mt-4">
|
||||||
<ContributionRecordsList accountSequence={accountSequence} />
|
<ContributionRecordsList accountSequence={accountSequence} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,71 @@ export const usersApi = {
|
||||||
summary: result.summary || { totalFee: '0', totalAmount: '0', totalCount: 0 },
|
summary: result.summary || { totalFee: '0', totalAmount: '0', totalCount: 0 },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== Capability 权限管理 API ==========
|
||||||
|
|
||||||
|
getCapabilities: async (accountSequence: string): Promise<CapabilityItem[]> => {
|
||||||
|
const response = await apiClient.get(`/capabilities/users/${accountSequence}`);
|
||||||
|
const data = response.data.data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
setCapability: async (
|
||||||
|
accountSequence: string,
|
||||||
|
dto: { capability: string; enabled: boolean; reason?: string; expiresAt?: string },
|
||||||
|
): Promise<CapabilityItem[]> => {
|
||||||
|
const response = await apiClient.put(`/capabilities/users/${accountSequence}`, dto);
|
||||||
|
const data = response.data.data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
setCapabilities: async (
|
||||||
|
accountSequence: string,
|
||||||
|
capabilities: { capability: string; enabled: boolean; reason?: string; expiresAt?: string }[],
|
||||||
|
): Promise<CapabilityItem[]> => {
|
||||||
|
const response = await apiClient.put(`/capabilities/users/${accountSequence}/bulk`, { capabilities });
|
||||||
|
const data = response.data.data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCapabilityLogs: async (
|
||||||
|
accountSequence: string,
|
||||||
|
params: PaginationParams,
|
||||||
|
): Promise<{ data: CapabilityLogItem[]; total: number; page: number; pageSize: number }> => {
|
||||||
|
const response = await apiClient.get(`/capabilities/users/${accountSequence}/logs`, { params });
|
||||||
|
const result = response.data.data;
|
||||||
|
return {
|
||||||
|
data: result.data || [],
|
||||||
|
total: result.total || 0,
|
||||||
|
page: result.page || 1,
|
||||||
|
pageSize: result.pageSize || 20,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capability 类型
|
||||||
|
export interface CapabilityItem {
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
disabledAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityLogItem {
|
||||||
|
id: string;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// P2P转账记录类型
|
// P2P转账记录类型
|
||||||
export interface P2pTransferRecord {
|
export interface P2pTransferRecord {
|
||||||
transferNo: string;
|
transferNo: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useCapabilities, useSetCapability, useCapabilityLogs } from '../hooks/use-users';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { formatDateTime } from '@/lib/utils/date';
|
||||||
|
import { ChevronLeft, ChevronRight, Shield, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
TRADING: '交易',
|
||||||
|
C2C: 'C2C交易',
|
||||||
|
TRANSFER_IN: '划入',
|
||||||
|
TRANSFER_OUT: '划出',
|
||||||
|
P2P_SEND: 'P2P转出',
|
||||||
|
P2P_RECEIVE: 'P2P收款',
|
||||||
|
MINING_CLAIM: '挖矿领取',
|
||||||
|
KYC: '实名认证',
|
||||||
|
PROFILE_EDIT: '编辑资料',
|
||||||
|
VIEW_ASSET: '查看资产',
|
||||||
|
VIEW_TEAM: '查看团队',
|
||||||
|
VIEW_RECORDS: '查看记录',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CAPABILITY_GROUPS = [
|
||||||
|
{ label: '账户', items: ['LOGIN', 'KYC', 'PROFILE_EDIT'] },
|
||||||
|
{ label: '交易', items: ['TRADING', 'C2C'] },
|
||||||
|
{ label: '转账', items: ['TRANSFER_IN', 'TRANSFER_OUT', 'P2P_SEND', 'P2P_RECEIVE'] },
|
||||||
|
{ label: '挖矿', items: ['MINING_CLAIM'] },
|
||||||
|
{ label: '查看', items: ['VIEW_ASSET', 'VIEW_TEAM', 'VIEW_RECORDS'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CapabilityManagementProps {
|
||||||
|
accountSequence: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CapabilityManagement({ accountSequence }: CapabilityManagementProps) {
|
||||||
|
const { data: capabilities, isLoading } = useCapabilities(accountSequence);
|
||||||
|
const setCapability = useSetCapability(accountSequence);
|
||||||
|
const [logPage, setLogPage] = useState(1);
|
||||||
|
const { data: logsData, isLoading: logsLoading } = useCapabilityLogs(accountSequence, { page: logPage, pageSize: 10 });
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [pendingChange, setPendingChange] = useState<{
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
|
|
||||||
|
const capMap = new Map(
|
||||||
|
(capabilities || []).map((c) => [c.capability, c]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (capability: string, currentEnabled: boolean) => {
|
||||||
|
const newEnabled = !currentEnabled;
|
||||||
|
if (!newEnabled) {
|
||||||
|
// Disabling - show dialog for reason
|
||||||
|
setPendingChange({ capability, enabled: false });
|
||||||
|
setReason('');
|
||||||
|
setExpiresAt('');
|
||||||
|
setDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
// Enabling - directly
|
||||||
|
setCapability.mutate({ capability, enabled: true, reason: '管理员恢复' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDisable = () => {
|
||||||
|
if (!pendingChange) return;
|
||||||
|
setCapability.mutate({
|
||||||
|
capability: pendingChange.capability,
|
||||||
|
enabled: false,
|
||||||
|
reason: reason || undefined,
|
||||||
|
expiresAt: expiresAt || undefined,
|
||||||
|
});
|
||||||
|
setDialogOpen(false);
|
||||||
|
setPendingChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Capability switches */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
功能权限
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{CAPABILITY_GROUPS.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-3">{group.label}</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{group.items.map((cap) => {
|
||||||
|
const item = capMap.get(cap);
|
||||||
|
const enabled = item?.enabled !== false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cap}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||||
|
enabled ? 'bg-background' : 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{CAPABILITY_LABELS[cap] || cap}</span>
|
||||||
|
{!enabled && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
已限制
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!enabled && item?.reason && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">原因: {item.reason}</p>
|
||||||
|
)}
|
||||||
|
{!enabled && item?.expiresAt && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
到期: {formatDateTime(item.expiresAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={() => handleToggle(cap, enabled)}
|
||||||
|
disabled={setCapability.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Change logs */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">变更日志</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>时间</TableHead>
|
||||||
|
<TableHead>功能</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
<TableHead>原因</TableHead>
|
||||||
|
<TableHead>到期时间</TableHead>
|
||||||
|
<TableHead>操作人</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logsLoading ? (
|
||||||
|
[...Array(3)].map((_, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
{[...Array(6)].map((_, j) => (
|
||||||
|
<TableCell key={j}><Skeleton className="h-4 w-full" /></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (logsData?.data || []).length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
|
暂无变更记录
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
(logsData?.data || []).map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="text-sm">{formatDateTime(log.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{CAPABILITY_LABELS[log.capability] || log.capability}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.action === 'DISABLE' ? (
|
||||||
|
<Badge variant="destructive">禁用</Badge>
|
||||||
|
) : log.action === 'ENABLE' ? (
|
||||||
|
<Badge className="bg-green-500">启用</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{log.action}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
|
||||||
|
{log.reason || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{log.expiresAt ? formatDateTime(log.expiresAt) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{log.operatorId || '-'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{(logsData?.total || 0) > 10 && (
|
||||||
|
<div className="flex items-center justify-between p-4 border-t">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
共 {logsData?.total} 条,第 {logPage} / {Math.ceil((logsData?.total || 0) / 10)} 页
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogPage(logPage - 1)}
|
||||||
|
disabled={logPage <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogPage(logPage + 1)}
|
||||||
|
disabled={logPage >= Math.ceil((logsData?.total || 0) / 10)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Disable confirmation dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
确认禁用「{pendingChange ? CAPABILITY_LABELS[pendingChange.capability] || pendingChange.capability : ''}」
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
禁用后用户将无法使用该功能,请填写原因。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reason">禁用原因</Label>
|
||||||
|
<Input
|
||||||
|
id="reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="请输入禁用原因(必填)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="expiresAt">到期时间(可选,留空为永久禁用)</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirmDisable}
|
||||||
|
disabled={!reason.trim() || setCapability.isPending}
|
||||||
|
>
|
||||||
|
确认禁用
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { usersApi } from '../api/users.api';
|
import { usersApi } from '../api/users.api';
|
||||||
import type { PaginationParams } from '@/types/api';
|
import type { PaginationParams } from '@/types/api';
|
||||||
|
|
||||||
|
|
@ -84,3 +84,33 @@ export function useP2pTransfers(params: PaginationParams & { search?: string })
|
||||||
queryFn: () => usersApi.getP2pTransfers(params),
|
queryFn: () => usersApi.getP2pTransfers(params),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Capability 权限管理 Hooks ==========
|
||||||
|
|
||||||
|
export function useCapabilities(accountSequence: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', accountSequence, 'capabilities'],
|
||||||
|
queryFn: () => usersApi.getCapabilities(accountSequence),
|
||||||
|
enabled: !!accountSequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetCapability(accountSequence: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (dto: { capability: string; enabled: boolean; reason?: string; expiresAt?: string }) =>
|
||||||
|
usersApi.setCapability(accountSequence, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', accountSequence, 'capabilities'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', accountSequence, 'capability-logs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCapabilityLogs(accountSequence: string, params: PaginationParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users', accountSequence, 'capability-logs', params],
|
||||||
|
queryFn: () => usersApi.getCapabilityLogs(accountSequence, params),
|
||||||
|
enabled: !!accountSequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,14 @@ class UnauthorizedException implements Exception {
|
||||||
@override
|
@override
|
||||||
String toString() => 'UnauthorizedException: $message';
|
String toString() => 'UnauthorizedException: $message';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ForbiddenException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? capability;
|
||||||
|
final String? code;
|
||||||
|
|
||||||
|
ForbiddenException([this.message = '功能已被限制', this.capability, this.code]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ForbiddenException: $message';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,18 @@ class ApiClient {
|
||||||
if (statusCode == 401) {
|
if (statusCode == 401) {
|
||||||
return UnauthorizedException();
|
return UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
if (statusCode == 403) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
// 后端 ExceptionFilter 统一包装为 { success, error: { code, message }, ... }
|
||||||
|
final error = data is Map ? data['error'] : null;
|
||||||
|
final rawMsg = error is Map ? error['message'] : null;
|
||||||
|
final msg = rawMsg is String
|
||||||
|
? rawMsg
|
||||||
|
: (rawMsg is List && rawMsg.isNotEmpty)
|
||||||
|
? rawMsg[0].toString()
|
||||||
|
: '功能已被限制';
|
||||||
|
return ForbiddenException(msg);
|
||||||
|
}
|
||||||
final messages = e.response?.data?['error']?['message'];
|
final messages = e.response?.data?['error']?['message'];
|
||||||
final message = (messages is List && messages.isNotEmpty) ? messages[0].toString() : '服务器错误';
|
final message = (messages is List && messages.isNotEmpty) ? messages[0].toString() : '服务器错误';
|
||||||
return ServerException(message, statusCode: statusCode);
|
return ServerException(message, statusCode: statusCode);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ class ApiEndpoints {
|
||||||
static const String tradePasswordChange = '/api/v2/auth/trade-password/change';
|
static const String tradePasswordChange = '/api/v2/auth/trade-password/change';
|
||||||
static const String tradePasswordVerify = '/api/v2/auth/trade-password/verify';
|
static const String tradePasswordVerify = '/api/v2/auth/trade-password/verify';
|
||||||
|
|
||||||
|
// Capability endpoints (Auth Service)
|
||||||
|
static const String userCapabilities = '/api/v2/auth/user/capabilities';
|
||||||
|
|
||||||
// Mining Service 2.0 (Kong路由: /api/v2/mining)
|
// Mining Service 2.0 (Kong路由: /api/v2/mining)
|
||||||
static String shareAccount(String accountSequence) =>
|
static String shareAccount(String accountSequence) =>
|
||||||
'/api/v2/mining/accounts/$accountSequence';
|
'/api/v2/mining/accounts/$accountSequence';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/network/api_endpoints.dart';
|
import '../../../core/network/api_endpoints.dart';
|
||||||
import '../../../core/error/exceptions.dart';
|
import '../../../core/error/exceptions.dart';
|
||||||
|
import '../../models/capability_model.dart';
|
||||||
|
|
||||||
class AuthResult {
|
class AuthResult {
|
||||||
final String accessToken;
|
final String accessToken;
|
||||||
|
|
@ -82,6 +83,7 @@ abstract class AuthRemoteDataSource {
|
||||||
Future<void> setTradePassword(String loginPassword, String tradePassword);
|
Future<void> setTradePassword(String loginPassword, String tradePassword);
|
||||||
Future<void> changeTradePassword(String oldTradePassword, String newTradePassword);
|
Future<void> changeTradePassword(String oldTradePassword, String newTradePassword);
|
||||||
Future<bool> verifyTradePassword(String tradePassword);
|
Future<bool> verifyTradePassword(String tradePassword);
|
||||||
|
Future<CapabilityMap> getCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
|
|
@ -258,4 +260,19 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
throw ServerException(e.toString());
|
throw ServerException(e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CapabilityMap> getCapabilities() async {
|
||||||
|
try {
|
||||||
|
final response = await client.get(ApiEndpoints.userCapabilities);
|
||||||
|
final data = response.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return CapabilityMap.fromJson(data);
|
||||||
|
}
|
||||||
|
return CapabilityMap.defaultAll();
|
||||||
|
} catch (e) {
|
||||||
|
// fail-open: 获取失败时默认全部开启
|
||||||
|
return CapabilityMap.defaultAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/// 用户能力权限模型
|
||||||
|
class CapabilityMap {
|
||||||
|
final Map<String, bool> _capabilities;
|
||||||
|
|
||||||
|
CapabilityMap(this._capabilities);
|
||||||
|
|
||||||
|
factory CapabilityMap.fromJson(Map<String, dynamic> json) {
|
||||||
|
final map = <String, bool>{};
|
||||||
|
json.forEach((key, value) {
|
||||||
|
if (value is bool) {
|
||||||
|
map[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return CapabilityMap(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认全部开启
|
||||||
|
factory CapabilityMap.defaultAll() {
|
||||||
|
return CapabilityMap({
|
||||||
|
'LOGIN': true,
|
||||||
|
'TRADING': true,
|
||||||
|
'C2C': true,
|
||||||
|
'TRANSFER_IN': true,
|
||||||
|
'TRANSFER_OUT': true,
|
||||||
|
'P2P_SEND': true,
|
||||||
|
'P2P_RECEIVE': true,
|
||||||
|
'MINING_CLAIM': true,
|
||||||
|
'KYC': true,
|
||||||
|
'PROFILE_EDIT': true,
|
||||||
|
'VIEW_ASSET': true,
|
||||||
|
'VIEW_TEAM': true,
|
||||||
|
'VIEW_RECORDS': true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isEnabled(String capability) => _capabilities[capability] ?? true;
|
||||||
|
|
||||||
|
bool get loginEnabled => isEnabled('LOGIN');
|
||||||
|
bool get tradingEnabled => isEnabled('TRADING');
|
||||||
|
bool get c2cEnabled => isEnabled('C2C');
|
||||||
|
bool get transferInEnabled => isEnabled('TRANSFER_IN');
|
||||||
|
bool get transferOutEnabled => isEnabled('TRANSFER_OUT');
|
||||||
|
bool get p2pSendEnabled => isEnabled('P2P_SEND');
|
||||||
|
bool get p2pReceiveEnabled => isEnabled('P2P_RECEIVE');
|
||||||
|
bool get miningClaimEnabled => isEnabled('MINING_CLAIM');
|
||||||
|
bool get kycEnabled => isEnabled('KYC');
|
||||||
|
bool get profileEditEnabled => isEnabled('PROFILE_EDIT');
|
||||||
|
bool get viewAssetEnabled => isEnabled('VIEW_ASSET');
|
||||||
|
bool get viewTeamEnabled => isEnabled('VIEW_TEAM');
|
||||||
|
bool get viewRecordsEnabled => isEnabled('VIEW_RECORDS');
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => Map<String, dynamic>.from(_capabilities);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../../data/datasources/remote/auth_remote_datasource.dart';
|
import '../../data/datasources/remote/auth_remote_datasource.dart';
|
||||||
|
import '../../data/models/capability_model.dart';
|
||||||
import '../../core/di/injection.dart';
|
import '../../core/di/injection.dart';
|
||||||
|
|
||||||
class UserState {
|
class UserState {
|
||||||
|
|
@ -350,3 +351,18 @@ final tradePasswordStatusProvider = FutureProvider<bool>((ref) async {
|
||||||
final userNotifier = ref.read(userNotifierProvider.notifier);
|
final userNotifier = ref.read(userNotifierProvider.notifier);
|
||||||
return userNotifier.getTradePasswordStatus();
|
return userNotifier.getTradePasswordStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 用户能力权限 Provider
|
||||||
|
/// 登录后获取,用于 UI 层判断功能是否可用
|
||||||
|
final capabilitiesProvider = FutureProvider<CapabilityMap>((ref) async {
|
||||||
|
final isLoggedIn = ref.watch(isLoggedInProvider);
|
||||||
|
if (!isLoggedIn) return CapabilityMap.defaultAll();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final authDataSource = getIt<AuthRemoteDataSource>();
|
||||||
|
return await authDataSource.getCapabilities();
|
||||||
|
} catch (_) {
|
||||||
|
// fail-open: 获取失败时默认全部开启
|
||||||
|
return CapabilityMap.defaultAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue