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:
hailin 2026-02-27 22:19:56 -08:00
parent 1d1c60e2a2
commit 55cfc96464
52 changed files with 2018 additions and 10 deletions

View File

@ -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 幂等消费追踪
// ============================================================================

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -7,3 +7,4 @@ export * from './user.controller';
export * from './health.controller';
export * from './admin.controller';
export * from './internal.controller';
export * from './capability.controller';

View File

@ -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(', ')}`,
);
}
}
}

View File

@ -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(

View File

@ -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 {}

View File

@ -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);
}
}
}

View File

@ -1 +1,2 @@
export * from './outbox.scheduler';
export * from './capability-expiry.scheduler';

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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 }>;
}

View File

@ -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';

View File

@ -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]: '查看记录',
};

View File

@ -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';

View File

@ -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 {}

View File

@ -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 };
}
}

View File

@ -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';

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITY_KEY = 'requiredCapability';
export const RequireCapability = (capability: string) =>
SetMetadata(CAPABILITY_KEY, capability);

View File

@ -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;
}
}
}

View File

@ -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: '每页数量' })

View File

@ -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 {}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITY_KEY = 'requiredCapability';
export const RequireCapability = (capability: string) =>
SetMetadata(CAPABILITY_KEY, capability);

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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 {}

View File

@ -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),
);
}
}

View File

@ -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 {

View File

@ -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}`);
}
}
}

View File

@ -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 {}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITY_KEY = 'requiredCapability';
export const RequireCapability = (capability: string) =>
SetMetadata(CAPABILITY_KEY, capability);

View File

@ -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;
}
}
}

View File

@ -36,7 +36,7 @@ export class JwtAuthGuard implements CanActivate {
request.user = {
userId: payload.sub,
accountSequence: payload.accountSequence,
accountSequence: payload.accountSequence || payload.sub,
};
return true;

View File

@ -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) {

View File

@ -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

View File

@ -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,

View File

@ -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) {

View File

@ -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;

View File

@ -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 {}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITY_KEY = 'requiredCapability';
export const RequireCapability = (capability: string) =>
SetMetadata(CAPABILITY_KEY, capability);

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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,
});
}

View File

@ -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';
}

View File

@ -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);

View File

@ -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';

View File

@ -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();
}
}
}

View File

@ -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);
}

View File

@ -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();
}
});