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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 用户能力控制 (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 幂等消费追踪
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import {
|
|||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
CapabilityController,
|
||||
} from './controllers';
|
||||
import { ApplicationModule } from '@/application';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -39,7 +41,8 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
|||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
CapabilityController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
providers: [JwtAuthGuard, CapabilityGuard],
|
||||
})
|
||||
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 './admin.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 { CapabilityService } from '@/application/services/capability.service';
|
||||
import { Capability, ALL_CAPABILITIES } from '@/domain/value-objects/capability.vo';
|
||||
|
||||
/**
|
||||
* 内部 API - 供 2.0 服务间调用,不需要 JWT 认证
|
||||
|
|
@ -8,7 +10,10 @@ import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.servic
|
|||
export class InternalController {
|
||||
private readonly logger = new Logger(InternalController.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly capabilityService: CapabilityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 根据 accountSequence 获取用户的 Kava 地址
|
||||
|
|
@ -47,4 +52,98 @@ export class InternalController {
|
|||
|
||||
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 { KycService, KycStatusResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
|
||||
|
||||
class SubmitKycDto {
|
||||
realName: string;
|
||||
|
|
@ -20,7 +22,7 @@ class SubmitKycDto {
|
|||
}
|
||||
|
||||
@Controller('kyc')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||
export class KycController {
|
||||
constructor(private readonly kycService: KycService) {}
|
||||
|
||||
|
|
@ -41,6 +43,7 @@ export class KycController {
|
|||
* POST /kyc/submit
|
||||
*/
|
||||
@Post('submit')
|
||||
@RequireCapability('KYC')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FilesInterceptor('files', 2))
|
||||
async submitKyc(
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
UserService,
|
||||
OutboxService,
|
||||
AdminSyncService,
|
||||
CapabilityService,
|
||||
} from './services';
|
||||
import { OutboxScheduler } from './schedulers';
|
||||
import { OutboxScheduler, CapabilityExpiryScheduler } from './schedulers';
|
||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||
|
||||
@Module({
|
||||
|
|
@ -39,7 +40,9 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
UserService,
|
||||
OutboxService,
|
||||
AdminSyncService,
|
||||
CapabilityService,
|
||||
OutboxScheduler,
|
||||
CapabilityExpiryScheduler,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
|
|
@ -50,6 +53,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
UserService,
|
||||
AdminSyncService,
|
||||
OutboxService,
|
||||
CapabilityService,
|
||||
],
|
||||
})
|
||||
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 './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 { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
UserAggregate,
|
||||
Phone,
|
||||
AccountSequence,
|
||||
Capability,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
LegacyUserMigratedEvent,
|
||||
} from '@/domain';
|
||||
import { OutboxService } from './outbox.service';
|
||||
import { CapabilityService } from './capability.service';
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
|
|
@ -65,6 +67,7 @@ export class AuthService {
|
|||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly outboxService: OutboxService,
|
||||
private readonly capabilityService: CapabilityService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -149,6 +152,16 @@ export class AuthService {
|
|||
}
|
||||
throw new UnauthorizedException('账户已被禁用');
|
||||
}
|
||||
|
||||
// 检查 LOGIN 能力是否被限制
|
||||
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||
user.accountSequence.value,
|
||||
Capability.LOGIN,
|
||||
);
|
||||
if (!loginEnabled) {
|
||||
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||
}
|
||||
|
||||
user.recordLoginSuccess(dto.ipAddress);
|
||||
await this.userRepository.save(user);
|
||||
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
||||
|
|
@ -200,6 +213,15 @@ export class AuthService {
|
|||
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);
|
||||
if (!isValid) {
|
||||
const result = user.recordLoginFailure();
|
||||
|
|
@ -309,6 +331,15 @@ export class AuthService {
|
|||
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 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 './outbox.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 './refresh-token.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 './password.vo';
|
||||
export * from './sms-code.vo';
|
||||
export * from './capability.vo';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
PrismaSyncedLegacyUserRepository,
|
||||
PrismaRefreshTokenRepository,
|
||||
PrismaSmsVerificationRepository,
|
||||
PrismaCapabilityRepository,
|
||||
} from './persistence/repositories';
|
||||
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
|
||||
import { KafkaModule, KafkaProducerService } from './kafka';
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
CAPABILITY_REPOSITORY,
|
||||
} from '@/domain';
|
||||
import { ApplicationModule } from '@/application/application.module';
|
||||
|
||||
|
|
@ -59,6 +61,10 @@ import { ApplicationModule } from '@/application/application.module';
|
|||
provide: SMS_VERIFICATION_REPOSITORY,
|
||||
useClass: PrismaSmsVerificationRepository,
|
||||
},
|
||||
{
|
||||
provide: CAPABILITY_REPOSITORY,
|
||||
useClass: PrismaCapabilityRepository,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
PrismaModule,
|
||||
|
|
@ -68,6 +74,7 @@ import { ApplicationModule } from '@/application/application.module';
|
|||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
REFRESH_TOKEN_REPOSITORY,
|
||||
SMS_VERIFICATION_REPOSITORY,
|
||||
CAPABILITY_REPOSITORY,
|
||||
],
|
||||
})
|
||||
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 './refresh-token.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 { GetContributionRecordsRequest } from '../dto/request/get-records.request';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
|
||||
@ApiTags('Contribution')
|
||||
@Controller('contribution')
|
||||
|
|
@ -55,6 +56,7 @@ export class ContributionController {
|
|||
}
|
||||
|
||||
@Get('accounts/:accountSequence/records')
|
||||
@RequireCapability('VIEW_RECORDS')
|
||||
@ApiOperation({ summary: '获取账户算力明细记录' })
|
||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||
@ApiResponse({ status: 200, type: ContributionRecordsResponse })
|
||||
|
|
@ -123,6 +125,7 @@ export class ContributionController {
|
|||
// ========== 团队树 API ==========
|
||||
|
||||
@Get('accounts/:accountSequence/team')
|
||||
@RequireCapability('VIEW_TEAM')
|
||||
@ApiOperation({ summary: '获取账户团队信息' })
|
||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||
@ApiResponse({ status: 200, description: '团队信息' })
|
||||
|
|
@ -133,6 +136,7 @@ export class ContributionController {
|
|||
}
|
||||
|
||||
@Get('accounts/:accountSequence/team/direct-referrals')
|
||||
@RequireCapability('VIEW_TEAM')
|
||||
@ApiOperation({ summary: '获取账户直推列表(用于伞下树懒加载)' })
|
||||
@ApiParam({ name: 'accountSequence', 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 { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||
// [2026-02-17] 新增:预种 CDC 集成模块(纯新增,与现有 CDC 消费零耦合)
|
||||
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,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: CapabilityGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
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
|
||||
|
||||
# Services
|
||||
AUTH_SERVICE_URL=http://localhost:3010
|
||||
CONTRIBUTION_SERVICE_URL=http://localhost:3020
|
||||
MINING_SERVICE_URL=http://localhost:3021
|
||||
TRADING_SERVICE_URL=http://localhost:3022
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { VersionController } from './controllers/version.controller';
|
|||
import { UpgradeVersionController } from './controllers/upgrade-version.controller';
|
||||
import { MobileVersionController } from './controllers/mobile-version.controller';
|
||||
import { PoolAccountController } from './controllers/pool-account.controller';
|
||||
import { CapabilityController } from './controllers/capability.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -42,6 +43,7 @@ import { PoolAccountController } from './controllers/pool-account.controller';
|
|||
UpgradeVersionController,
|
||||
MobileVersionController,
|
||||
PoolAccountController,
|
||||
CapabilityController,
|
||||
],
|
||||
})
|
||||
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 { BatchMiningService } from './services/batch-mining.service';
|
||||
import { VersionService } from './services/version.service';
|
||||
import { CapabilityAdminService } from './services/capability-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
|
|
@ -24,6 +25,7 @@ import { VersionService } from './services/version.service';
|
|||
PendingContributionsService,
|
||||
BatchMiningService,
|
||||
VersionService,
|
||||
CapabilityAdminService,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
|
|
@ -36,6 +38,7 @@ import { VersionService } from './services/version.service';
|
|||
PendingContributionsService,
|
||||
BatchMiningService,
|
||||
VersionService,
|
||||
CapabilityAdminService,
|
||||
],
|
||||
})
|
||||
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 { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -41,6 +42,10 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
|||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: CapabilityGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
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 = {
|
||||
userId: payload.sub,
|
||||
accountSequence: payload.accountSequence,
|
||||
accountSequence: payload.accountSequence || payload.sub,
|
||||
};
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Controller, Get, Param, Query, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AssetService } from '../../application/services/asset.service';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Asset')
|
||||
|
|
@ -10,6 +11,7 @@ export class AssetController {
|
|||
constructor(private readonly assetService: AssetService) {}
|
||||
|
||||
@Get('my')
|
||||
@RequireCapability('VIEW_ASSET')
|
||||
@ApiOperation({ summary: '获取我的资产显示' })
|
||||
@ApiQuery({ name: 'dailyAllocation', required: false, type: String, description: '每日分配量(可选)' })
|
||||
async getMyAsset(@Req() req: any, @Query('dailyAllocation') dailyAllocation?: string) {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
C2cOrdersPageResponseDto,
|
||||
} from '../dto/c2c.dto';
|
||||
import { C2cOrderEntity } from '../../infrastructure/persistence/repositories/c2c-order.repository';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
|
||||
@ApiTags('C2C Trading')
|
||||
@ApiBearerAuth()
|
||||
|
|
@ -136,6 +137,7 @@ export class C2cController {
|
|||
}
|
||||
|
||||
@Post('orders')
|
||||
@RequireCapability('C2C')
|
||||
@ApiOperation({ summary: '创建C2C订单(发布广告)' })
|
||||
@ApiResponse({ status: 201, description: '订单创建成功' })
|
||||
async createOrder(
|
||||
|
|
@ -180,6 +182,7 @@ export class C2cController {
|
|||
}
|
||||
|
||||
@Post('orders/:orderNo/take')
|
||||
@RequireCapability('C2C')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '接单(吃单)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
|
|
@ -228,6 +231,7 @@ export class C2cController {
|
|||
}
|
||||
|
||||
@Post('orders/:orderNo/confirm-payment')
|
||||
@RequireCapability('C2C')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '确认付款(买方操作)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
|
|
@ -246,6 +250,7 @@ export class C2cController {
|
|||
}
|
||||
|
||||
@Post('orders/:orderNo/confirm-received')
|
||||
@RequireCapability('C2C')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '确认收款(卖方操作)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
|
|
@ -264,6 +269,7 @@ export class C2cController {
|
|||
}
|
||||
|
||||
@Post('orders/:orderNo/upload-proof')
|
||||
@RequireCapability('C2C')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
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 { IsString, IsOptional, Length, Matches } from 'class-validator';
|
||||
import { P2pTransferService } from '../../application/services/p2p-transfer.service';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
class P2pTransferDto {
|
||||
|
|
@ -32,6 +33,7 @@ export class P2pTransferController {
|
|||
}
|
||||
|
||||
@Post('transfer')
|
||||
@RequireCapability('P2P_SEND')
|
||||
@ApiOperation({ summary: 'P2P转账(积分值)' })
|
||||
async transfer(
|
||||
@Body() dto: P2pTransferDto,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { OrderService } from '../../application/services/order.service';
|
|||
import { OrderRepository } from '../../infrastructure/persistence/repositories/order.repository';
|
||||
import { TradingAccountRepository } from '../../infrastructure/persistence/repositories/trading-account.repository';
|
||||
import { OrderType } from '../../domain/aggregates/order.aggregate';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
|
||||
class CreateOrderDto {
|
||||
@IsIn(['BUY', 'SELL'])
|
||||
|
|
@ -56,6 +57,7 @@ export class TradingController {
|
|||
}
|
||||
|
||||
@Post('orders')
|
||||
@RequireCapability('TRADING')
|
||||
@ApiOperation({ summary: '创建订单' })
|
||||
async createOrder(@Body() dto: CreateOrderDto, @Req() req: any) {
|
||||
const accountSequence = req.user?.accountSequence;
|
||||
|
|
@ -72,6 +74,7 @@ export class TradingController {
|
|||
}
|
||||
|
||||
@Post('orders/:orderNo/cancel')
|
||||
@RequireCapability('TRADING')
|
||||
@ApiOperation({ summary: '取消订单' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
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 { IsString } from 'class-validator';
|
||||
import { TransferService } from '../../application/services/transfer.service';
|
||||
import { RequireCapability } from '../../shared/decorators/require-capability.decorator';
|
||||
|
||||
class TransferDto {
|
||||
@IsString()
|
||||
|
|
@ -15,6 +16,7 @@ export class TransferController {
|
|||
constructor(private readonly transferService: TransferService) {}
|
||||
|
||||
@Post('in')
|
||||
@RequireCapability('TRANSFER_IN')
|
||||
@ApiOperation({ summary: '从挖矿账户划入积分股' })
|
||||
async transferIn(@Body() dto: TransferDto, @Req() req: any) {
|
||||
const accountSequence = req.user?.accountSequence;
|
||||
|
|
@ -26,6 +28,7 @@ export class TransferController {
|
|||
}
|
||||
|
||||
@Post('out')
|
||||
@RequireCapability('TRANSFER_OUT')
|
||||
@ApiOperation({ summary: '划出积分股到挖矿账户' })
|
||||
async transferOut(@Body() dto: TransferDto, @Req() req: any) {
|
||||
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 { LoggingInterceptor } from './shared/interceptors/logging.interceptor';
|
||||
import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
||||
import { CapabilityGuard } from './shared/guards/capability.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -29,6 +30,7 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
|||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: CapabilityGuard },
|
||||
],
|
||||
})
|
||||
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 { WalletLedger } from '@/features/users/components/wallet-ledger';
|
||||
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() {
|
||||
return (
|
||||
|
|
@ -354,7 +355,11 @@ export default function UserDetailPage() {
|
|||
|
||||
{/* Tab 区域 */}
|
||||
<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">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">算力记录</span>
|
||||
|
|
@ -385,6 +390,10 @@ export default function UserDetailPage() {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="capabilities" className="mt-4">
|
||||
<CapabilityManagement accountSequence={accountSequence} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contributions" className="mt-4">
|
||||
<ContributionRecordsList accountSequence={accountSequence} />
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -209,8 +209,71 @@ export const usersApi = {
|
|||
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转账记录类型
|
||||
export interface P2pTransferRecord {
|
||||
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 type { PaginationParams } from '@/types/api';
|
||||
|
||||
|
|
@ -84,3 +84,33 @@ export function useP2pTransfers(params: PaginationParams & { search?: string })
|
|||
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
|
||||
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) {
|
||||
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 message = (messages is List && messages.isNotEmpty) ? messages[0].toString() : '服务器错误';
|
||||
return ServerException(message, statusCode: statusCode);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ class ApiEndpoints {
|
|||
static const String tradePasswordChange = '/api/v2/auth/trade-password/change';
|
||||
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)
|
||||
static String shareAccount(String accountSequence) =>
|
||||
'/api/v2/mining/accounts/$accountSequence';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../../core/error/exceptions.dart';
|
||||
import '../../models/capability_model.dart';
|
||||
|
||||
class AuthResult {
|
||||
final String accessToken;
|
||||
|
|
@ -82,6 +83,7 @@ abstract class AuthRemoteDataSource {
|
|||
Future<void> setTradePassword(String loginPassword, String tradePassword);
|
||||
Future<void> changeTradePassword(String oldTradePassword, String newTradePassword);
|
||||
Future<bool> verifyTradePassword(String tradePassword);
|
||||
Future<CapabilityMap> getCapabilities();
|
||||
}
|
||||
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
|
|
@ -258,4 +260,19 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
|||
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:shared_preferences/shared_preferences.dart';
|
||||
import '../../data/datasources/remote/auth_remote_datasource.dart';
|
||||
import '../../data/models/capability_model.dart';
|
||||
import '../../core/di/injection.dart';
|
||||
|
||||
class UserState {
|
||||
|
|
@ -350,3 +351,18 @@ final tradePasswordStatusProvider = FutureProvider<bool>((ref) async {
|
|||
final userNotifier = ref.read(userNotifierProvider.notifier);
|
||||
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