diff --git a/backend/services/auth-service/src/auth.module.ts b/backend/services/auth-service/src/auth.module.ts index d08ffc7..44a44b8 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -37,6 +37,7 @@ import { EventPublisherService } from './application/services/event-publisher.se // Interface controllers import { AuthController } from './interface/http/controllers/auth.controller'; +import { AdminSmsController } from './interface/http/controllers/admin-sms.controller'; @Module({ imports: [ @@ -47,7 +48,7 @@ import { AuthController } from './interface/http/controllers/auth.controller'; signOptions: { expiresIn: process.env.JWT_ACCESS_EXPIRY || '15m' }, }), ], - controllers: [AuthController], + controllers: [AuthController, AdminSmsController], providers: [ // Infrastructure -> Domain port binding { provide: USER_REPOSITORY, useClass: UserRepository }, diff --git a/backend/services/auth-service/src/interface/http/controllers/admin-sms.controller.ts b/backend/services/auth-service/src/interface/http/controllers/admin-sms.controller.ts new file mode 100644 index 0000000..e25be58 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/controllers/admin-sms.controller.ts @@ -0,0 +1,131 @@ +import { + Controller, + Get, + Post, + Param, + Query, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { SmsService } from '../../../application/services/sms.service'; +import { Inject } from '@nestjs/common'; +import { + SMS_LOG_REPOSITORY, + ISmsLogRepository, +} from '../../../domain/repositories/sms-log.repository.interface'; +import { + SMS_VERIFICATION_REPOSITORY, + ISmsVerificationRepository, +} from '../../../domain/repositories/sms-verification.repository.interface'; +import { + USER_REPOSITORY, + IUserRepository, +} from '../../../domain/repositories/user.repository.interface'; + +@ApiTags('Admin - SMS') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('admin/sms') +export class AdminSmsController { + constructor( + @Inject(SMS_LOG_REPOSITORY) + private readonly smsLogRepo: ISmsLogRepository, + @Inject(SMS_VERIFICATION_REPOSITORY) + private readonly smsVerificationRepo: ISmsVerificationRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + ) {} + + /* ── SMS 发送日志 ── */ + + @Get('logs') + @ApiOperation({ summary: '查询 SMS 发送日志' }) + @ApiQuery({ name: 'phone', required: false }) + @ApiQuery({ name: 'limit', required: false }) + @ApiQuery({ name: 'offset', required: false }) + @ApiResponse({ status: 200, description: 'SMS 日志列表' }) + async getSmsLogs( + @Query('phone') phone?: string, + @Query('limit') limit = 20, + @Query('offset') offset = 0, + ) { + const logs = phone + ? await this.smsLogRepo.findByPhone(phone, { limit: +limit, offset: +offset }) + : []; + // Mask phone in response: 138****8000 + const masked = logs.map((log) => ({ + ...log, + phone: this.maskPhone(log.phone), + })); + return { + code: 0, + data: { items: masked, total: masked.length }, + message: 'ok', + }; + } + + /* ── 用户 SMS 日志 ── */ + + @Get('logs/user/:userId') + @ApiOperation({ summary: '查询指定用户的 SMS 日志' }) + @ApiResponse({ status: 200, description: '用户 SMS 日志列表' }) + async getUserSmsLogs( + @Param('userId') userId: string, + @Query('limit') limit = 20, + @Query('offset') offset = 0, + ) { + // 获取用户手机号 + const user = await this.userRepo.findById(userId); + if (!user || !user.phone) { + return { code: 0, data: { items: [], total: 0 }, message: 'ok' }; + } + const logs = await this.smsLogRepo.findByPhone(user.phone, { + limit: +limit, + offset: +offset, + }); + const masked = logs.map((log) => ({ + ...log, + phone: this.maskPhone(log.phone), + })); + return { + code: 0, + data: { items: masked, total: masked.length }, + message: 'ok', + }; + } + + /* ── 账号解锁 ── */ + + @Post('unlock/:userId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '手动解锁用户账号 (清除登录失败计数和锁定时间)' }) + @ApiResponse({ status: 200, description: '解锁成功' }) + async unlockUser(@Param('userId') userId: string) { + const user = await this.userRepo.findById(userId); + if (!user) { + return { code: 1, data: null, message: '用户不存在' }; + } + user.loginFailCount = 0; + user.lockedUntil = null; + await this.userRepo.save(user); + return { + code: 0, + data: null, + message: '账号已解锁', + }; + } + + /* ── Private helpers ── */ + + private maskPhone(phone: string): string { + if (!phone) return ''; + // +8613812345678 → +86 138****5678 + const raw = phone.startsWith('+86') ? phone.slice(3) : phone; + if (raw.length < 7) return phone; + return (phone.startsWith('+86') ? '+86 ' : '') + + raw.slice(0, 3) + '****' + raw.slice(-4); + } +} diff --git a/frontend/admin-web/src/app/(admin)/users/sms-logs/page.tsx b/frontend/admin-web/src/app/(admin)/users/sms-logs/page.tsx new file mode 100644 index 0000000..ee00b76 --- /dev/null +++ b/frontend/admin-web/src/app/(admin)/users/sms-logs/page.tsx @@ -0,0 +1,5 @@ +import { SmsLogPage } from '@/views/users/SmsLogPage'; + +export default function SmsLogs() { + return ; +} diff --git a/frontend/admin-web/src/i18n/locales.ts b/frontend/admin-web/src/i18n/locales.ts index fdc6bca..51c74b2 100644 --- a/frontend/admin-web/src/i18n/locales.ts +++ b/frontend/admin-web/src/i18n/locales.ts @@ -195,6 +195,28 @@ const translations: Record> = { 'user_coupon_count': '持券数', 'user_total_traded': '交易额', 'user_risk_tags': '风险标签', + 'user_active': '正常', + 'user_frozen': '已冻结', + 'user_locked': '已锁定', + 'user_unlock': '解锁', + + // ── SMS Log ── + 'nav_users_sms_logs': 'SMS 日志', + 'sms_log_title': 'SMS 发送日志', + 'sms_search_placeholder': '输入手机号搜索...', + 'sms_search_hint': '请输入手机号搜索 SMS 发送记录', + 'sms_log_phone': '手机号', + 'sms_log_type': '类型', + 'sms_log_status': '状态', + 'sms_log_provider': '通道', + 'sms_log_time': '发送时间', + 'sms_type_register': '注册', + 'sms_type_login': '登录', + 'sms_type_reset': '重置密码', + 'sms_type_change_phone': '换绑手机', + 'sms_status_sent': '已发送', + 'sms_status_failed': '发送失败', + 'sms_no_logs': '暂无记录', // ── Issuer Management ── 'issuer_management_title': '发行方管理', @@ -905,6 +927,28 @@ const translations: Record> = { 'user_coupon_count': 'Coupons', 'user_total_traded': 'Traded', 'user_risk_tags': 'Risk Tags', + 'user_active': 'Active', + 'user_frozen': 'Frozen', + 'user_locked': 'Locked', + 'user_unlock': 'Unlock', + + // ── SMS Log ── + 'nav_users_sms_logs': 'SMS Logs', + 'sms_log_title': 'SMS Delivery Logs', + 'sms_search_placeholder': 'Search by phone number...', + 'sms_search_hint': 'Enter a phone number to search SMS delivery records', + 'sms_log_phone': 'Phone', + 'sms_log_type': 'Type', + 'sms_log_status': 'Status', + 'sms_log_provider': 'Provider', + 'sms_log_time': 'Sent At', + 'sms_type_register': 'Register', + 'sms_type_login': 'Login', + 'sms_type_reset': 'Reset Password', + 'sms_type_change_phone': 'Change Phone', + 'sms_status_sent': 'Sent', + 'sms_status_failed': 'Failed', + 'sms_no_logs': 'No records found', // ── Issuer Management ── 'issuer_management_title': 'Issuer Management', @@ -1615,6 +1659,28 @@ const translations: Record> = { 'user_coupon_count': 'クーポン数', 'user_total_traded': '取引額', 'user_risk_tags': 'リスクタグ', + 'user_active': '正常', + 'user_frozen': '凍結中', + 'user_locked': 'ロック中', + 'user_unlock': 'ロック解除', + + // ── SMS Log ── + 'nav_users_sms_logs': 'SMS ログ', + 'sms_log_title': 'SMS 送信ログ', + 'sms_search_placeholder': '電話番号で検索...', + 'sms_search_hint': '電話番号を入力して SMS 送信記録を検索してください', + 'sms_log_phone': '電話番号', + 'sms_log_type': 'タイプ', + 'sms_log_status': 'ステータス', + 'sms_log_provider': 'プロバイダ', + 'sms_log_time': '送信日時', + 'sms_type_register': '登録', + 'sms_type_login': 'ログイン', + 'sms_type_reset': 'パスワードリセット', + 'sms_type_change_phone': '電話番号変更', + 'sms_status_sent': '送信済み', + 'sms_status_failed': '送信失敗', + 'sms_no_logs': '記録なし', // ── Issuer Management ── 'issuer_management_title': '発行者管理', diff --git a/frontend/admin-web/src/layouts/AdminLayout.tsx b/frontend/admin-web/src/layouts/AdminLayout.tsx index 55e2c60..7678d5b 100644 --- a/frontend/admin-web/src/layouts/AdminLayout.tsx +++ b/frontend/admin-web/src/layouts/AdminLayout.tsx @@ -35,6 +35,7 @@ const navItems: NavItem[] = [ children: [ { key: 'users/list', icon: '', label: t('nav_users_list') }, { key: 'users/kyc', icon: '', label: t('nav_users_kyc'), badge: 5 }, + { key: 'users/sms-logs', icon: '', label: t('nav_users_sms_logs') }, ], }, { diff --git a/frontend/admin-web/src/views/users/SmsLogPage.tsx b/frontend/admin-web/src/views/users/SmsLogPage.tsx new file mode 100644 index 0000000..c6a46bf --- /dev/null +++ b/frontend/admin-web/src/views/users/SmsLogPage.tsx @@ -0,0 +1,168 @@ +'use client'; + +import React, { useState } from 'react'; +import { t } from '@/i18n/locales'; +import { useApi } from '@/lib/use-api'; + +/** + * SMS 发送日志查看页面 + * + * 管理员可按手机号搜索 SMS 发送记录 + */ + +interface SmsLogItem { + id: string; + phone: string; + type: string; + status: string; + provider: string; + createdAt: string; +} + +interface SmsLogsResponse { + items: SmsLogItem[]; + total: number; +} + +const loadingBox: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)', +}; + +export const SmsLogPage: React.FC = () => { + const [phone, setPhone] = useState(''); + const [searchPhone, setSearchPhone] = useState(''); + const [limit] = useState(20); + const [offset] = useState(0); + + const { data, isLoading, error } = useApi( + searchPhone ? '/api/v1/admin/sms/logs' : null, + { params: { phone: searchPhone, limit, offset } }, + ); + + const logs = data?.items ?? []; + + const handleSearch = () => { + setSearchPhone(phone.trim()); + }; + + const typeBadge = (type: string) => { + const map: Record = { + REGISTER: { bg: 'var(--color-primary)', color: 'white', label: t('sms_type_register') }, + LOGIN: { bg: 'var(--color-info)', color: 'white', label: t('sms_type_login') }, + RESET_PASSWORD: { bg: 'var(--color-warning)', color: 'white', label: t('sms_type_reset') }, + CHANGE_PHONE: { bg: 'var(--color-success)', color: 'white', label: t('sms_type_change_phone') }, + }; + const m = map[type] ?? { bg: 'var(--color-gray-400)', color: 'white', label: type }; + return ( + {m.label} + ); + }; + + const statusBadge = (status: string) => { + const isSuccess = status === 'SUCCESS' || status === 'SENT'; + return ( + {isSuccess ? t('sms_status_sent') : t('sms_status_failed')} + ); + }; + + return ( +
+

{t('sms_log_title')}

+ + {/* Search */} +
+ setPhone(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + style={{ + flex: 1, + maxWidth: 360, + height: 40, + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + padding: '0 16px', + font: 'var(--text-body)', + }} + /> + +
+ + {/* Hint when no search */} + {!searchPhone && ( +
{t('sms_search_hint')}
+ )} + + {/* Table */} + {searchPhone && (error ? ( +
Error: {error.message}
+ ) : isLoading ? ( +
Loading...
+ ) : ( +
+ + + + {[t('sms_log_phone'), t('sms_log_type'), t('sms_log_status'), t('sms_log_provider'), t('sms_log_time')].map(h => ( + + ))} + + + + {logs.length === 0 ? ( + + + + ) : logs.map(log => ( + + + + + + + + ))} + +
{h}
{t('sms_no_logs')}
{log.phone}{typeBadge(log.type)}{statusBadge(log.status)}{log.provider}{log.createdAt}
+
+ ))} +
+ ); +}; diff --git a/frontend/admin-web/src/views/users/UserManagementPage.tsx b/frontend/admin-web/src/views/users/UserManagementPage.tsx index a82f9ed..ede9af0 100644 --- a/frontend/admin-web/src/views/users/UserManagementPage.tsx +++ b/frontend/admin-web/src/views/users/UserManagementPage.tsx @@ -19,6 +19,9 @@ interface User { totalTraded: number; riskTags: string[]; createdAt: string; + status?: 'ACTIVE' | 'FROZEN'; + loginFailCount?: number; + lockedUntil?: string | null; } interface UsersResponse { @@ -44,9 +47,31 @@ export const UserManagementPage: React.FC = () => { const freezeMutation = useApiMutation('POST', '', { invalidateKeys: ['/api/v1/admin/users'], }); + const unlockMutation = useApiMutation('POST', '', { + invalidateKeys: ['/api/v1/admin/users'], + }); const users = data?.items ?? []; + const maskPhone = (phone: string) => { + if (!phone) return '-'; + const raw = phone.startsWith('+86') ? phone.slice(3) : phone; + if (raw.length < 7) return phone; + return (phone.startsWith('+86') ? '+86 ' : '') + raw.slice(0, 3) + '****' + raw.slice(-4); + }; + + const isLocked = (user: User) => user.lockedUntil && new Date(user.lockedUntil) > new Date(); + + const statusBadge = (user: User) => { + if (user.status === 'FROZEN') { + return {t('user_frozen')}; + } + if (isLocked(user)) { + return {t('user_locked')}; + } + return {t('user_active')}; + }; + const kycBadge = (level: number) => { const colors = ['var(--color-gray-400)', 'var(--color-info)', 'var(--color-primary)', 'var(--color-success)']; return ( @@ -119,7 +144,7 @@ export const UserManagementPage: React.FC = () => { - {[t('user_id'), t('user_phone'), t('user_email'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => ( + {[t('user_id'), t('user_phone'), t('user_email'), t('user_status'), t('user_kyc_level'), t('user_coupon_count'), t('user_total_traded'), t('user_risk_tags'), t('user_created_at'), t('actions')].map(h => ( - + + @@ -154,7 +180,7 @@ export const UserManagementPage: React.FC = () => { } - ))}
{ {users.map(user => (
{user.id}{user.phone}{maskPhone(user.phone)} {user.email}{statusBadge(user)} {kycBadge(user.kycLevel)} {user.couponCount} {formatCurrency(user.totalTraded)} {user.createdAt} + - + {user.status === 'FROZEN' ? ( + + ) : ( + + )} + {isLocked(user) && ( + + )}