feat(admin): SMS 管理后台 — admin 端点 + 用户管理增强 + SMS 日志页

Phase 8: admin-web 后台管理增强

## 后端 (auth-service)
- 新增 AdminSmsController (JWT 保护):
  · GET  /admin/sms/logs          — 按手机号查询 SMS 发送日志
  · GET  /admin/sms/logs/user/:id — 按用户 ID 查询其 SMS 日志
  · POST /admin/sms/unlock/:id    — 手动解锁账号(清除 loginFailCount + lockedUntil)
  · 手机号脱敏: 138****5678 格式
- auth.module.ts 注册 AdminSmsController

## 前端 (admin-web)
- UserManagementPage 增强:
  · 新增状态列: 正常(绿) / 已冻结(红) / 已锁定(黄)
  · 手机号自动掩码显示
  · 冻结/解冻按钮根据状态切换
  · 锁定用户显示"解锁"按钮
- 新增 SMS 日志查看页面 (SmsLogPage):
  · 按手机号搜索 SMS 发送记录
  · 类型、状态 badge 展示
  · 路由: /users/sms-logs
- AdminLayout 侧边栏新增 "SMS 日志" 导航项
- i18n 补充 (zh-CN/en-US/ja-JP):
  · 用户状态: user_active, user_frozen, user_locked, user_unlock
  · SMS 日志: 17 个新 key (sms_log_*, sms_type_*, sms_status_*, nav_users_sms_logs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 20:34:15 -08:00
parent c29067eee7
commit e59c0d0527
7 changed files with 445 additions and 16 deletions

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { SmsLogPage } from '@/views/users/SmsLogPage';
export default function SmsLogs() {
return <SmsLogPage />;
}

View File

@ -195,6 +195,28 @@ const translations: Record<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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': '発行者管理',

View File

@ -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') },
],
},
{

View File

@ -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<SmsLogsResponse>(
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<string, { bg: string; color: string; label: string }> = {
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 (
<span style={{
padding: '2px 8px',
borderRadius: 'var(--radius-full)',
background: `${m.bg}20`,
color: m.bg,
font: 'var(--text-label-sm)',
fontWeight: 600,
}}>{m.label}</span>
);
};
const statusBadge = (status: string) => {
const isSuccess = status === 'SUCCESS' || status === 'SENT';
return (
<span style={{
padding: '2px 8px',
borderRadius: 'var(--radius-full)',
background: isSuccess ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: isSuccess ? 'var(--color-success)' : 'var(--color-error)',
font: 'var(--text-label-sm)',
fontWeight: 600,
}}>{isSuccess ? t('sms_status_sent') : t('sms_status_failed')}</span>
);
};
return (
<div>
<h1 style={{ font: 'var(--text-h1)', marginBottom: 24 }}>{t('sms_log_title')}</h1>
{/* Search */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
<input
placeholder={t('sms_search_placeholder')}
value={phone}
onChange={e => 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)',
}}
/>
<button
onClick={handleSearch}
style={{
padding: '8px 20px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-primary)',
color: 'white',
cursor: 'pointer',
font: 'var(--text-body)',
}}
>{t('search')}</button>
</div>
{/* Hint when no search */}
{!searchPhone && (
<div style={loadingBox}>{t('sms_search_hint')}</div>
)}
{/* Table */}
{searchPhone && (error ? (
<div style={loadingBox}>Error: {error.message}</div>
) : isLoading ? (
<div style={loadingBox}>Loading...</div>
) : (
<div style={{
background: 'var(--color-surface)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
{[t('sms_log_phone'), t('sms_log_type'), t('sms_log_status'), t('sms_log_provider'), t('sms_log_time')].map(h => (
<th key={h} style={{
font: 'var(--text-label-sm)',
color: 'var(--color-text-tertiary)',
padding: '12px 14px',
textAlign: 'left',
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={5} style={{ ...loadingBox, padding: '24px 14px' }}>{t('sms_no_logs')}</td>
</tr>
) : logs.map(log => (
<tr key={log.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{log.phone}</td>
<td style={{ padding: '12px 14px' }}>{typeBadge(log.type)}</td>
<td style={{ padding: '12px 14px' }}>{statusBadge(log.status)}</td>
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.provider}</td>
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
};

View File

@ -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<void>('POST', '', {
invalidateKeys: ['/api/v1/admin/users'],
});
const unlockMutation = useApiMutation<void>('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 <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-error-light)', color: 'var(--color-error)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_frozen')}</span>;
}
if (isLocked(user)) {
return <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-warning-light)', color: 'var(--color-warning)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_locked')}</span>;
}
return <span style={{ padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--color-success-light)', color: 'var(--color-success)', font: 'var(--text-label-sm)', fontWeight: 600 }}>{t('user_active')}</span>;
};
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 = () => {
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
{[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 => (
<th key={h} style={{
font: 'var(--text-label-sm)',
color: 'var(--color-text-tertiary)',
@ -133,8 +158,9 @@ export const UserManagementPage: React.FC = () => {
{users.map(user => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ font: 'var(--text-body-sm)', fontFamily: 'var(--font-family-mono)', padding: '12px 14px', color: 'var(--color-text-tertiary)' }}>{user.id}</td>
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.phone}</td>
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{maskPhone(user.phone)}</td>
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.email}</td>
<td style={{ padding: '12px 14px' }}>{statusBadge(user)}</td>
<td style={{ padding: '12px 14px' }}>{kycBadge(user.kycLevel)}</td>
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{user.couponCount}</td>
<td style={{ font: 'var(--text-label-sm)', color: 'var(--color-primary)', padding: '12px 14px' }}>{formatCurrency(user.totalTraded)}</td>
@ -154,7 +180,7 @@ export const UserManagementPage: React.FC = () => {
}
</td>
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{user.createdAt}</td>
<td style={{ padding: '12px 14px' }}>
<td style={{ padding: '12px 14px', whiteSpace: 'nowrap' }}>
<button style={{
padding: '4px 12px',
border: '1px solid var(--color-border)',
@ -164,18 +190,49 @@ export const UserManagementPage: React.FC = () => {
font: 'var(--text-caption)',
color: 'var(--color-primary)',
}}>{t('details')}</button>
<button
onClick={() => freezeMutation.mutate({ userId: user.id })}
style={{
marginLeft: 6,
padding: '4px 12px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-error)',
cursor: 'pointer',
font: 'var(--text-caption)',
color: 'white',
}}>{t('user_freeze')}</button>
{user.status === 'FROZEN' ? (
<button
onClick={() => freezeMutation.mutate({ userId: user.id })}
style={{
marginLeft: 6,
padding: '4px 12px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-success)',
cursor: 'pointer',
font: 'var(--text-caption)',
color: 'white',
}}>{t('user_unfreeze')}</button>
) : (
<button
onClick={() => freezeMutation.mutate({ userId: user.id })}
style={{
marginLeft: 6,
padding: '4px 12px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-error)',
cursor: 'pointer',
font: 'var(--text-caption)',
color: 'white',
}}>{t('user_freeze')}</button>
)}
{isLocked(user) && (
<button
onClick={() => unlockMutation.mutate(undefined, {
onSuccess: () => {},
})}
style={{
marginLeft: 6,
padding: '4px 12px',
border: '1px solid var(--color-warning)',
borderRadius: 'var(--radius-sm)',
background: 'none',
cursor: 'pointer',
font: 'var(--text-caption)',
color: 'var(--color-warning)',
}}>{t('user_unlock')}</button>
)}
</td>
</tr>
))}