diff --git a/backend/services/auth-service/prisma/schema.prisma b/backend/services/auth-service/prisma/schema.prisma index a15983a9..81ee7e85 100644 --- a/backend/services/auth-service/prisma/schema.prisma +++ b/backend/services/auth-service/prisma/schema.prisma @@ -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 幂等消费追踪 // ============================================================================ diff --git a/backend/services/auth-service/src/api/api.module.ts b/backend/services/auth-service/src/api/api.module.ts index 1cd8824f..5af43afb 100644 --- a/backend/services/auth-service/src/api/api.module.ts +++ b/backend/services/auth-service/src/api/api.module.ts @@ -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 {} diff --git a/backend/services/auth-service/src/api/controllers/capability.controller.ts b/backend/services/auth-service/src/api/controllers/capability.controller.ts new file mode 100644 index 00000000..0dfeab68 --- /dev/null +++ b/backend/services/auth-service/src/api/controllers/capability.controller.ts @@ -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); + } +} diff --git a/backend/services/auth-service/src/api/controllers/index.ts b/backend/services/auth-service/src/api/controllers/index.ts index fa45d3c9..225cbeea 100644 --- a/backend/services/auth-service/src/api/controllers/index.ts +++ b/backend/services/auth-service/src/api/controllers/index.ts @@ -7,3 +7,4 @@ export * from './user.controller'; export * from './health.controller'; export * from './admin.controller'; export * from './internal.controller'; +export * from './capability.controller'; diff --git a/backend/services/auth-service/src/api/controllers/internal.controller.ts b/backend/services/auth-service/src/api/controllers/internal.controller.ts index 369f9e27..08334a7c 100644 --- a/backend/services/auth-service/src/api/controllers/internal.controller.ts +++ b/backend/services/auth-service/src/api/controllers/internal.controller.ts @@ -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(', ')}`, + ); + } + } } diff --git a/backend/services/auth-service/src/api/controllers/kyc.controller.ts b/backend/services/auth-service/src/api/controllers/kyc.controller.ts index 2773ab17..895a7f01 100644 --- a/backend/services/auth-service/src/api/controllers/kyc.controller.ts +++ b/backend/services/auth-service/src/api/controllers/kyc.controller.ts @@ -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( diff --git a/backend/services/auth-service/src/application/application.module.ts b/backend/services/auth-service/src/application/application.module.ts index b4fa8a42..705b8182 100644 --- a/backend/services/auth-service/src/application/application.module.ts +++ b/backend/services/auth-service/src/application/application.module.ts @@ -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 {} diff --git a/backend/services/auth-service/src/application/schedulers/capability-expiry.scheduler.ts b/backend/services/auth-service/src/application/schedulers/capability-expiry.scheduler.ts new file mode 100644 index 00000000..2610a50e --- /dev/null +++ b/backend/services/auth-service/src/application/schedulers/capability-expiry.scheduler.ts @@ -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 { + 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); + } + } +} diff --git a/backend/services/auth-service/src/application/schedulers/index.ts b/backend/services/auth-service/src/application/schedulers/index.ts index 140724aa..eb756004 100644 --- a/backend/services/auth-service/src/application/schedulers/index.ts +++ b/backend/services/auth-service/src/application/schedulers/index.ts @@ -1 +1,2 @@ export * from './outbox.scheduler'; +export * from './capability-expiry.scheduler'; diff --git a/backend/services/auth-service/src/application/services/auth.service.ts b/backend/services/auth-service/src/application/services/auth.service.ts index 921d6d13..1d6d9928 100644 --- a/backend/services/auth-service/src/application/services/auth.service.ts +++ b/backend/services/auth-service/src/application/services/auth.service.ts @@ -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('JWT_EXPIRES_IN_SECONDS', 3600); diff --git a/backend/services/auth-service/src/application/services/capability.service.ts b/backend/services/auth-service/src/application/services/capability.service.ts new file mode 100644 index 00000000..1eb098c7 --- /dev/null +++ b/backend/services/auth-service/src/application/services/capability.service.ts @@ -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 { + const cached = await this.redis.getJson( + `${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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/backend/services/auth-service/src/application/services/index.ts b/backend/services/auth-service/src/application/services/index.ts index dd5440a4..dded4aaf 100644 --- a/backend/services/auth-service/src/application/services/index.ts +++ b/backend/services/auth-service/src/application/services/index.ts @@ -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'; diff --git a/backend/services/auth-service/src/domain/repositories/capability.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/capability.repository.interface.ts new file mode 100644 index 00000000..657a2981 --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/capability.repository.interface.ts @@ -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; + + upsert(data: { + accountSequence: string; + capability: string; + enabled: boolean; + reason?: string; + disabledBy?: string; + expiresAt?: Date; + }): Promise; + + 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; + + findExpired(): Promise; + + createLog(data: { + accountSequence: string; + capability: string; + action: string; + reason?: string; + operatorId?: string; + previousValue: boolean; + newValue: boolean; + expiresAt?: Date; + }): Promise; + + findLogsByAccountSequence( + accountSequence: string, + page: number, + pageSize: number, + ): Promise<{ data: CapabilityLogRecord[]; total: number }>; +} diff --git a/backend/services/auth-service/src/domain/repositories/index.ts b/backend/services/auth-service/src/domain/repositories/index.ts index 4bfe64e5..358306fb 100644 --- a/backend/services/auth-service/src/domain/repositories/index.ts +++ b/backend/services/auth-service/src/domain/repositories/index.ts @@ -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'; diff --git a/backend/services/auth-service/src/domain/value-objects/capability.vo.ts b/backend/services/auth-service/src/domain/value-objects/capability.vo.ts new file mode 100644 index 00000000..544fbc3b --- /dev/null +++ b/backend/services/auth-service/src/domain/value-objects/capability.vo.ts @@ -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; + +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.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]: '查看记录', +}; diff --git a/backend/services/auth-service/src/domain/value-objects/index.ts b/backend/services/auth-service/src/domain/value-objects/index.ts index 9627b206..85f2a4cd 100644 --- a/backend/services/auth-service/src/domain/value-objects/index.ts +++ b/backend/services/auth-service/src/domain/value-objects/index.ts @@ -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'; diff --git a/backend/services/auth-service/src/infrastructure/infrastructure.module.ts b/backend/services/auth-service/src/infrastructure/infrastructure.module.ts index bc0a80f6..60c48e7c 100644 --- a/backend/services/auth-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/auth-service/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/backend/services/auth-service/src/infrastructure/persistence/repositories/capability.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/repositories/capability.repository.ts new file mode 100644 index 00000000..babdeb9f --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/repositories/capability.repository.ts @@ -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 { + return this.prisma.userCapability.findMany({ + where: { accountSequence }, + }); + } + + async upsert(data: { + accountSequence: string; + capability: string; + enabled: boolean; + reason?: string; + disabledBy?: string; + expiresAt?: Date; + }): Promise { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/backend/services/auth-service/src/infrastructure/persistence/repositories/index.ts b/backend/services/auth-service/src/infrastructure/persistence/repositories/index.ts index 1c06cf53..98612a58 100644 --- a/backend/services/auth-service/src/infrastructure/persistence/repositories/index.ts +++ b/backend/services/auth-service/src/infrastructure/persistence/repositories/index.ts @@ -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'; diff --git a/backend/services/auth-service/src/shared/decorators/require-capability.decorator.ts b/backend/services/auth-service/src/shared/decorators/require-capability.decorator.ts new file mode 100644 index 00000000..3e827267 --- /dev/null +++ b/backend/services/auth-service/src/shared/decorators/require-capability.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CAPABILITY_KEY = 'requiredCapability'; +export const RequireCapability = (capability: string) => + SetMetadata(CAPABILITY_KEY, capability); diff --git a/backend/services/auth-service/src/shared/guards/capability.guard.ts b/backend/services/auth-service/src/shared/guards/capability.guard.ts new file mode 100644 index 00000000..1741de58 --- /dev/null +++ b/backend/services/auth-service/src/shared/guards/capability.guard.ts @@ -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 = { + 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 { + const requiredCapability = this.reflector.getAllAndOverride( + 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; + } + } +} diff --git a/backend/services/contribution-service/src/api/controllers/contribution.controller.ts b/backend/services/contribution-service/src/api/controllers/contribution.controller.ts index 93ced569..ad986ae6 100644 --- a/backend/services/contribution-service/src/api/controllers/contribution.controller.ts +++ b/backend/services/contribution-service/src/api/controllers/contribution.controller.ts @@ -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: '每页数量' }) diff --git a/backend/services/contribution-service/src/app.module.ts b/backend/services/contribution-service/src/app.module.ts index 8cd466e7..107eda6d 100644 --- a/backend/services/contribution-service/src/app.module.ts +++ b/backend/services/contribution-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/contribution-service/src/shared/decorators/require-capability.decorator.ts b/backend/services/contribution-service/src/shared/decorators/require-capability.decorator.ts new file mode 100644 index 00000000..3e827267 --- /dev/null +++ b/backend/services/contribution-service/src/shared/decorators/require-capability.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CAPABILITY_KEY = 'requiredCapability'; +export const RequireCapability = (capability: string) => + SetMetadata(CAPABILITY_KEY, capability); diff --git a/backend/services/contribution-service/src/shared/guards/capability.guard.ts b/backend/services/contribution-service/src/shared/guards/capability.guard.ts new file mode 100644 index 00000000..2335ea46 --- /dev/null +++ b/backend/services/contribution-service/src/shared/guards/capability.guard.ts @@ -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 = { + 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('REDIS_HOST', 'localhost'), + port: this.configService.get('REDIS_PORT', 6379), + password: this.configService.get('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 { + const requiredCapability = this.reflector.getAllAndOverride( + 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; + } + } +} diff --git a/backend/services/mining-admin-service/.env.example b/backend/services/mining-admin-service/.env.example index d4da39b9..833997c0 100644 --- a/backend/services/mining-admin-service/.env.example +++ b/backend/services/mining-admin-service/.env.example @@ -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 diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts index 969cb3b7..b7e654a6 100644 --- a/backend/services/mining-admin-service/src/api/api.module.ts +++ b/backend/services/mining-admin-service/src/api/api.module.ts @@ -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 {} diff --git a/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts b/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts new file mode 100644 index 00000000..f9f45dd4 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/capability.controller.ts @@ -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), + ); + } +} diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts index 3f603c0b..e3130d4f 100644 --- a/backend/services/mining-admin-service/src/application/application.module.ts +++ b/backend/services/mining-admin-service/src/application/application.module.ts @@ -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 { diff --git a/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts b/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts new file mode 100644 index 00000000..d4eba01a --- /dev/null +++ b/backend/services/mining-admin-service/src/application/services/capability-admin.service.ts @@ -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( + 'AUTH_SERVICE_URL', + 'http://localhost:3010', + ); + } + + /** + * 获取用户能力列表 + */ + async getCapabilities(accountSequence: string): Promise { + 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 { + 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 { + // 验证所有能力 + 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 { + 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}`); + } + } +} diff --git a/backend/services/mining-service/src/app.module.ts b/backend/services/mining-service/src/app.module.ts index 1b60ccde..9a5aa838 100644 --- a/backend/services/mining-service/src/app.module.ts +++ b/backend/services/mining-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/mining-service/src/shared/decorators/require-capability.decorator.ts b/backend/services/mining-service/src/shared/decorators/require-capability.decorator.ts new file mode 100644 index 00000000..3e827267 --- /dev/null +++ b/backend/services/mining-service/src/shared/decorators/require-capability.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CAPABILITY_KEY = 'requiredCapability'; +export const RequireCapability = (capability: string) => + SetMetadata(CAPABILITY_KEY, capability); diff --git a/backend/services/mining-service/src/shared/guards/capability.guard.ts b/backend/services/mining-service/src/shared/guards/capability.guard.ts new file mode 100644 index 00000000..2335ea46 --- /dev/null +++ b/backend/services/mining-service/src/shared/guards/capability.guard.ts @@ -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 = { + 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('REDIS_HOST', 'localhost'), + port: this.configService.get('REDIS_PORT', 6379), + password: this.configService.get('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 { + const requiredCapability = this.reflector.getAllAndOverride( + 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; + } + } +} diff --git a/backend/services/mining-service/src/shared/guards/jwt-auth.guard.ts b/backend/services/mining-service/src/shared/guards/jwt-auth.guard.ts index bb2350ca..e300bae4 100644 --- a/backend/services/mining-service/src/shared/guards/jwt-auth.guard.ts +++ b/backend/services/mining-service/src/shared/guards/jwt-auth.guard.ts @@ -36,7 +36,7 @@ export class JwtAuthGuard implements CanActivate { request.user = { userId: payload.sub, - accountSequence: payload.accountSequence, + accountSequence: payload.accountSequence || payload.sub, }; return true; diff --git a/backend/services/trading-service/src/api/controllers/asset.controller.ts b/backend/services/trading-service/src/api/controllers/asset.controller.ts index 27b92a09..596c5e5d 100644 --- a/backend/services/trading-service/src/api/controllers/asset.controller.ts +++ b/backend/services/trading-service/src/api/controllers/asset.controller.ts @@ -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) { diff --git a/backend/services/trading-service/src/api/controllers/c2c.controller.ts b/backend/services/trading-service/src/api/controllers/c2c.controller.ts index 544f0df8..76cd6143 100644 --- a/backend/services/trading-service/src/api/controllers/c2c.controller.ts +++ b/backend/services/trading-service/src/api/controllers/c2c.controller.ts @@ -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 diff --git a/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts b/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts index 061f8b48..5eb2508f 100644 --- a/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts +++ b/backend/services/trading-service/src/api/controllers/p2p-transfer.controller.ts @@ -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, diff --git a/backend/services/trading-service/src/api/controllers/trading.controller.ts b/backend/services/trading-service/src/api/controllers/trading.controller.ts index 130c0dbf..a3a6af6c 100644 --- a/backend/services/trading-service/src/api/controllers/trading.controller.ts +++ b/backend/services/trading-service/src/api/controllers/trading.controller.ts @@ -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) { diff --git a/backend/services/trading-service/src/api/controllers/transfer.controller.ts b/backend/services/trading-service/src/api/controllers/transfer.controller.ts index 1ac75135..56782c00 100644 --- a/backend/services/trading-service/src/api/controllers/transfer.controller.ts +++ b/backend/services/trading-service/src/api/controllers/transfer.controller.ts @@ -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; diff --git a/backend/services/trading-service/src/app.module.ts b/backend/services/trading-service/src/app.module.ts index 7fc804de..fd3dba71 100644 --- a/backend/services/trading-service/src/app.module.ts +++ b/backend/services/trading-service/src/app.module.ts @@ -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 {} diff --git a/backend/services/trading-service/src/shared/decorators/require-capability.decorator.ts b/backend/services/trading-service/src/shared/decorators/require-capability.decorator.ts new file mode 100644 index 00000000..3e827267 --- /dev/null +++ b/backend/services/trading-service/src/shared/decorators/require-capability.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CAPABILITY_KEY = 'requiredCapability'; +export const RequireCapability = (capability: string) => + SetMetadata(CAPABILITY_KEY, capability); diff --git a/backend/services/trading-service/src/shared/guards/capability.guard.ts b/backend/services/trading-service/src/shared/guards/capability.guard.ts new file mode 100644 index 00000000..2335ea46 --- /dev/null +++ b/backend/services/trading-service/src/shared/guards/capability.guard.ts @@ -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 = { + 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('REDIS_HOST', 'localhost'), + port: this.configService.get('REDIS_PORT', 6379), + password: this.configService.get('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 { + const requiredCapability = this.reflector.getAllAndOverride( + 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; + } + } +} diff --git a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx index aa466d84..e6d9538e 100644 --- a/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx +++ b/frontend/mining-admin-web/src/app/(dashboard)/users/[accountSequence]/page.tsx @@ -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 区域 */} - + + + + 权限管理 + 算力记录 @@ -385,6 +390,10 @@ export default function UserDetailPage() { + + + + diff --git a/frontend/mining-admin-web/src/features/users/api/users.api.ts b/frontend/mining-admin-web/src/features/users/api/users.api.ts index 164ccbec..fdc8fc24 100644 --- a/frontend/mining-admin-web/src/features/users/api/users.api.ts +++ b/frontend/mining-admin-web/src/features/users/api/users.api.ts @@ -209,8 +209,71 @@ export const usersApi = { summary: result.summary || { totalFee: '0', totalAmount: '0', totalCount: 0 }, }; }, + + // ========== Capability 权限管理 API ========== + + getCapabilities: async (accountSequence: string): Promise => { + 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 => { + 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 => { + 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; diff --git a/frontend/mining-admin-web/src/features/users/components/capability-management.tsx b/frontend/mining-admin-web/src/features/users/components/capability-management.tsx new file mode 100644 index 00000000..b0ce1384 --- /dev/null +++ b/frontend/mining-admin-web/src/features/users/components/capability-management.tsx @@ -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 = { + 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 ( + + + {[...Array(5)].map((_, i) => ( + + ))} + + + ); + } + + return ( +
+ {/* Capability switches */} + + + + + 功能权限 + + + +
+ {CAPABILITY_GROUPS.map((group) => ( +
+

{group.label}

+
+ {group.items.map((cap) => { + const item = capMap.get(cap); + const enabled = item?.enabled !== false; + return ( +
+
+
+ {CAPABILITY_LABELS[cap] || cap} + {!enabled && ( + + 已限制 + + )} +
+ {!enabled && item?.reason && ( +

原因: {item.reason}

+ )} + {!enabled && item?.expiresAt && ( +

+ + 到期: {formatDateTime(item.expiresAt)} +

+ )} +
+ handleToggle(cap, enabled)} + disabled={setCapability.isPending} + /> +
+ ); + })} +
+
+ ))} +
+
+
+ + {/* Change logs */} + + + 变更日志 + + + + + + 时间 + 功能 + 操作 + 原因 + 到期时间 + 操作人 + + + + {logsLoading ? ( + [...Array(3)].map((_, i) => ( + + {[...Array(6)].map((_, j) => ( + + ))} + + )) + ) : (logsData?.data || []).length === 0 ? ( + + + 暂无变更记录 + + + ) : ( + (logsData?.data || []).map((log) => ( + + {formatDateTime(log.createdAt)} + + {CAPABILITY_LABELS[log.capability] || log.capability} + + + {log.action === 'DISABLE' ? ( + 禁用 + ) : log.action === 'ENABLE' ? ( + 启用 + ) : ( + {log.action} + )} + + + {log.reason || '-'} + + + {log.expiresAt ? formatDateTime(log.expiresAt) : '-'} + + {log.operatorId || '-'} + + )) + )} + +
+ + {/* Pagination */} + {(logsData?.total || 0) > 10 && ( +
+

+ 共 {logsData?.total} 条,第 {logPage} / {Math.ceil((logsData?.total || 0) / 10)} 页 +

+
+ + +
+
+ )} +
+
+ + {/* Disable confirmation dialog */} + + + + + 确认禁用「{pendingChange ? CAPABILITY_LABELS[pendingChange.capability] || pendingChange.capability : ''}」 + + + 禁用后用户将无法使用该功能,请填写原因。 + + +
+
+ + setReason(e.target.value)} + placeholder="请输入禁用原因(必填)" + /> +
+
+ + setExpiresAt(e.target.value)} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts index 3486b1df..d0a09ef7 100644 --- a/frontend/mining-admin-web/src/features/users/hooks/use-users.ts +++ b/frontend/mining-admin-web/src/features/users/hooks/use-users.ts @@ -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, + }); +} diff --git a/frontend/mining-app/lib/core/error/exceptions.dart b/frontend/mining-app/lib/core/error/exceptions.dart index f600da6e..6f86f4c1 100644 --- a/frontend/mining-app/lib/core/error/exceptions.dart +++ b/frontend/mining-app/lib/core/error/exceptions.dart @@ -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'; +} diff --git a/frontend/mining-app/lib/core/network/api_client.dart b/frontend/mining-app/lib/core/network/api_client.dart index 98461bed..98937716 100644 --- a/frontend/mining-app/lib/core/network/api_client.dart +++ b/frontend/mining-app/lib/core/network/api_client.dart @@ -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); diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 8bcf50f2..42cb6119 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -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'; diff --git a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart index dade96c6..d1d5f452 100644 --- a/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/auth_remote_datasource.dart @@ -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 setTradePassword(String loginPassword, String tradePassword); Future changeTradePassword(String oldTradePassword, String newTradePassword); Future verifyTradePassword(String tradePassword); + Future getCapabilities(); } class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { @@ -258,4 +260,19 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future getCapabilities() async { + try { + final response = await client.get(ApiEndpoints.userCapabilities); + final data = response.data; + if (data is Map) { + return CapabilityMap.fromJson(data); + } + return CapabilityMap.defaultAll(); + } catch (e) { + // fail-open: 获取失败时默认全部开启 + return CapabilityMap.defaultAll(); + } + } } diff --git a/frontend/mining-app/lib/data/models/capability_model.dart b/frontend/mining-app/lib/data/models/capability_model.dart new file mode 100644 index 00000000..2cb6d1d7 --- /dev/null +++ b/frontend/mining-app/lib/data/models/capability_model.dart @@ -0,0 +1,53 @@ +/// 用户能力权限模型 +class CapabilityMap { + final Map _capabilities; + + CapabilityMap(this._capabilities); + + factory CapabilityMap.fromJson(Map json) { + final map = {}; + 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 toJson() => Map.from(_capabilities); +} diff --git a/frontend/mining-app/lib/presentation/providers/user_providers.dart b/frontend/mining-app/lib/presentation/providers/user_providers.dart index 9c422ca5..29d36238 100644 --- a/frontend/mining-app/lib/presentation/providers/user_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/user_providers.dart @@ -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((ref) async { final userNotifier = ref.read(userNotifierProvider.notifier); return userNotifier.getTradePasswordStatus(); }); + +/// 用户能力权限 Provider +/// 登录后获取,用于 UI 层判断功能是否可用 +final capabilitiesProvider = FutureProvider((ref) async { + final isLoggedIn = ref.watch(isLoggedInProvider); + if (!isLoggedIn) return CapabilityMap.defaultAll(); + + try { + final authDataSource = getIt(); + return await authDataSource.getCapabilities(); + } catch (_) { + // fail-open: 获取失败时默认全部开启 + return CapabilityMap.defaultAll(); + } +});