feat(auth): add SMS OTP verification for phone registration and login
- auth-service: add SmsService (Aliyun SMS) + RedisProvider for OTP storage - POST /api/v1/auth/sms/send — send OTP (rate limited 1/min per phone) - POST /api/v1/auth/sms/verify — verify OTP only - POST /api/v1/auth/login/otp — passwordless login with phone + OTP - register endpoint now requires smsCode when registering with phone - Web Admin register page: add OTP input + 60s countdown button for phone mode - Flutter login page: add 验证码登录 tab with phone + OTP flow - SMS enabled via ALIYUN_ACCESS_KEY_ID/SECRET + SMS_ENABLED=true env vars - Falls back to mock mode (logs code) when env vars not set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2773b6265c
commit
71ea80972d
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -23,6 +23,10 @@ export default function RegisterPage() {
|
|||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [smsSending, setSmsSending] = useState(false);
|
||||
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
|
@ -40,6 +44,27 @@ export default function RegisterPage() {
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (!phone.trim()) { setError('请先填写手机号'); return; }
|
||||
setError(null);
|
||||
setSmsSending(true);
|
||||
try {
|
||||
await apiClient('/api/v1/auth/sms/send', { method: 'POST', body: { phone: phone.trim(), purpose: 'register' } });
|
||||
setSmsCooldown(60);
|
||||
if (cooldownRef.current) clearInterval(cooldownRef.current);
|
||||
cooldownRef.current = setInterval(() => {
|
||||
setSmsCooldown((prev) => {
|
||||
if (prev <= 1) { clearInterval(cooldownRef.current!); return 0; }
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||
} finally {
|
||||
setSmsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
|
|
@ -67,7 +92,9 @@ export default function RegisterPage() {
|
|||
if (loginMethod === 'email') {
|
||||
body.email = email;
|
||||
} else {
|
||||
if (!smsCode.trim()) { setError('请输入短信验证码'); setIsLoading(false); return; }
|
||||
body.phone = phone;
|
||||
body.smsCode = smsCode;
|
||||
}
|
||||
|
||||
const data = await apiClient<RegisterResponse>('/api/v1/auth/register', {
|
||||
|
|
@ -149,17 +176,41 @@ export default function RegisterPage() {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('phone')}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
|
||||
placeholder={t('phonePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('phone')}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-input border rounded-md text-sm"
|
||||
placeholder={t('phonePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">短信验证码</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value)}
|
||||
className="flex-1 px-3 py-2 bg-input border rounded-md text-sm"
|
||||
placeholder="请输入 6 位验证码"
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendSms}
|
||||
disabled={smsSending || smsCooldown > 0}
|
||||
className="px-4 py-2 text-sm font-medium bg-secondary text-secondary-foreground rounded-md hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{smsSending ? '发送中...' : smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Full name */}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ class ApiEndpoints {
|
|||
|
||||
// Auth
|
||||
static const String login = '$auth/login';
|
||||
static const String loginOtp = '$auth/login/otp';
|
||||
static const String register = '$auth/register';
|
||||
static const String profile = '$auth/profile';
|
||||
static const String refreshToken = '$auth/refresh';
|
||||
static const String smsSend = '$auth/sms/send';
|
||||
static const String smsVerify = '$auth/sms/verify';
|
||||
|
||||
// Agent
|
||||
static const String tasks = '$agent/tasks';
|
||||
|
|
|
|||
|
|
@ -135,6 +135,44 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Send SMS OTP to the given phone number.
|
||||
Future<void> sendSmsCode(String phone) async {
|
||||
final config = _ref.read(appConfigProvider);
|
||||
final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl));
|
||||
await dio.post(ApiEndpoints.smsSend, data: {'phone': phone, 'purpose': 'login'});
|
||||
}
|
||||
|
||||
/// Login with phone number + OTP (passwordless).
|
||||
Future<bool> loginWithOtp(String phone, String smsCode) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
try {
|
||||
final config = _ref.read(appConfigProvider);
|
||||
final dio = Dio(BaseOptions(baseUrl: config.apiBaseUrl));
|
||||
final response = await dio.post(
|
||||
ApiEndpoints.loginOtp,
|
||||
data: {'phone': phone, 'smsCode': smsCode},
|
||||
);
|
||||
final authResponse = AuthResponse.fromJson(response.data as Map<String, dynamic>);
|
||||
final storage = _ref.read(secureStorageProvider);
|
||||
await storage.write(key: _keyAccessToken, value: authResponse.accessToken);
|
||||
await storage.write(key: _keyRefreshToken, value: authResponse.refreshToken);
|
||||
state = state.copyWith(isAuthenticated: true, isLoading: false, user: authResponse.user);
|
||||
_ref.invalidate(accessTokenProvider);
|
||||
if (authResponse.user.tenantId != null) {
|
||||
_ref.read(currentTenantIdProvider.notifier).state = authResponse.user.tenantId;
|
||||
}
|
||||
_connectNotifications(authResponse.user.tenantId);
|
||||
return true;
|
||||
} on DioException catch (e) {
|
||||
final message = (e.response?.data is Map) ? e.response?.data['message'] : null;
|
||||
state = state.copyWith(isLoading: false, error: message?.toString() ?? '验证码登录失败');
|
||||
return false;
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: ErrorHandler.friendlyMessage(e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> login(String email, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
|
@ -5,6 +6,8 @@ import 'package:go_router/go_router.dart';
|
|||
import '../../../../core/theme/app_colors.dart';
|
||||
import '../../data/providers/auth_provider.dart';
|
||||
|
||||
enum _LoginMode { password, otp }
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
|
|
@ -16,6 +19,56 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _otpController = TextEditingController();
|
||||
|
||||
_LoginMode _mode = _LoginMode.password;
|
||||
int _smsCooldown = 0;
|
||||
bool _smsSending = false;
|
||||
Timer? _cooldownTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_phoneController.dispose();
|
||||
_otpController.dispose();
|
||||
_cooldownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendSmsCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先输入手机号')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _smsSending = true);
|
||||
try {
|
||||
await ref.read(authStateProvider.notifier).sendSmsCode(phone);
|
||||
setState(() => _smsCooldown = 60);
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
setState(() {
|
||||
if (_smsCooldown <= 1) {
|
||||
_smsCooldown = 0;
|
||||
t.cancel();
|
||||
} else {
|
||||
_smsCooldown--;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _smsSending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -33,11 +86,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/icons/logo.svg',
|
||||
width: 96,
|
||||
height: 96,
|
||||
),
|
||||
SvgPicture.asset('assets/icons/logo.svg', width: 96, height: 96),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'iAgent',
|
||||
|
|
@ -51,69 +100,117 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'服务器集群运维智能体',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Login mode toggle
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.surfaceLight),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _modeButton('密码登录', _LoginMode.password)),
|
||||
Expanded(child: _modeButton('验证码登录', _LoginMode.otp)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邮箱',
|
||||
hintText: 'user@example.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (_mode == _LoginMode.password) ...[
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邮箱',
|
||||
hintText: 'user@example.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '请输入邮箱地址';
|
||||
if (!v.contains('@')) return '请输入有效的邮箱地址';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入邮箱地址';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return '请输入有效的邮箱地址';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? '请输入密码' : null,
|
||||
onFieldSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
] else ...[
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '手机号',
|
||||
hintText: '+86 138 0000 0000',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? '请输入手机号' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _otpController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '验证码',
|
||||
hintText: '6 位数字',
|
||||
prefixIcon: Icon(Icons.sms_outlined),
|
||||
counterText: '',
|
||||
),
|
||||
validator: (v) => (v == null || v.isEmpty) ? '请输入验证码' : null,
|
||||
onFieldSubmitted: (_) => _handleOtpLogin(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: OutlinedButton(
|
||||
onPressed: (_smsSending || _smsCooldown > 0) ? null : _sendSmsCode,
|
||||
child: Text(
|
||||
_smsSending
|
||||
? '发送中'
|
||||
: _smsCooldown > 0
|
||||
? '${_smsCooldown}s'
|
||||
: '获取验证码',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
if (authState.error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
color: AppColors.error, size: 18),
|
||||
const Icon(Icons.error_outline, color: AppColors.error, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
authState.error!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.error, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -125,29 +222,25 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: authState.isLoading ? null : _handleLogin,
|
||||
onPressed: authState.isLoading
|
||||
? null
|
||||
: (_mode == _LoginMode.password
|
||||
? _handlePasswordLogin
|
||||
: _handleOtpLogin),
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text(
|
||||
'登录',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
: const Text('登录', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'账号由管理员在后台创建或通过邀请链接注册',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
|
@ -159,23 +252,44 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
Widget _modeButton(String label, _LoginMode mode) {
|
||||
final selected = _mode == mode;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _mode = mode),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.primary : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: selected ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final success = await ref.read(authStateProvider.notifier).login(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
context.go('/dashboard');
|
||||
}
|
||||
if (success && mounted) context.go('/dashboard');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
Future<void> _handleOtpLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
final success = await ref.read(authStateProvider.notifier).loginWithOtp(
|
||||
_phoneController.text.trim(),
|
||||
_otpController.text.trim(),
|
||||
);
|
||||
if (success && mounted) context.go('/dashboard');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@
|
|||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"@it0/common": "workspace:*",
|
||||
"@it0/database": "workspace:*"
|
||||
"@it0/database": "workspace:*",
|
||||
"@alicloud/dysmsapi20170525": "^4.3.1",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"ioredis": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
|
|
|
|||
|
|
@ -105,6 +105,30 @@ export class AuthService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Login by phone number only (OTP already verified by controller).
|
||||
* The user must exist in public.users with a matching phone.
|
||||
*/
|
||||
async loginByPhone(phone: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId: string };
|
||||
}> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT * FROM public.users WHERE phone = $1 AND is_active = true LIMIT 1`, [phone],
|
||||
);
|
||||
if (!rows.length) throw new UnauthorizedException('该手机号未注册');
|
||||
const user = this.mapRow(rows[0]);
|
||||
await this.dataSource.query(
|
||||
`UPDATE public.users SET last_login_at = NOW() WHERE id = $1`, [user.id],
|
||||
);
|
||||
const tokens = this.generateTokens(user);
|
||||
return {
|
||||
...tokens,
|
||||
user: { id: user.id, email: user.email, phone: user.phone, name: user.name, roles: user.roles, tenantId: user.tenantId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user.
|
||||
* If companyName is provided, creates a new tenant with schema provisioning
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import { UserController } from './interfaces/rest/controllers/user.controller';
|
|||
import { SettingsController } from './interfaces/rest/controllers/settings.controller';
|
||||
import { RoleController } from './interfaces/rest/controllers/role.controller';
|
||||
import { PermissionController } from './interfaces/rest/controllers/permission.controller';
|
||||
import { SmsController } from './interfaces/rest/controllers/sms.controller';
|
||||
import { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||
import { RbacGuard } from './infrastructure/guards/rbac.guard';
|
||||
import { AuthService } from './application/services/auth.service';
|
||||
import { UserRepository } from './infrastructure/repositories/user.repository';
|
||||
import { ApiKeyRepository } from './infrastructure/repositories/api-key.repository';
|
||||
import { SmsService } from './infrastructure/sms/sms.service';
|
||||
import { RedisProvider } from './infrastructure/redis/redis.provider';
|
||||
import { User } from './domain/entities/user.entity';
|
||||
import { Role } from './domain/entities/role.entity';
|
||||
import { ApiKey } from './domain/entities/api-key.entity';
|
||||
|
|
@ -34,7 +37,7 @@ import { TenantInvite } from './domain/entities/tenant-invite.entity';
|
|||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController],
|
||||
controllers: [AuthController, TenantController, UserController, SettingsController, RoleController, PermissionController, SmsController],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
RbacGuard,
|
||||
|
|
@ -42,6 +45,8 @@ import { TenantInvite } from './domain/entities/tenant-invite.entity';
|
|||
UserRepository,
|
||||
ApiKeyRepository,
|
||||
TenantProvisioningService,
|
||||
SmsService,
|
||||
RedisProvider,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { Provider } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||
|
||||
export const RedisProvider: Provider = {
|
||||
provide: REDIS_CLIENT,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new Redis({
|
||||
host: config.get('REDIS_HOST', 'redis'),
|
||||
port: config.get<number>('REDIS_PORT', 6379),
|
||||
password: config.get('REDIS_PASSWORD') || undefined,
|
||||
lazyConnect: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
||||
import * as $OpenApi from '@alicloud/openapi-client';
|
||||
import * as $Util from '@alicloud/tea-util';
|
||||
|
||||
export interface SmsSendResult {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
bizId?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private client: Dysmsapi20170525 | null = null;
|
||||
private readonly signName: string;
|
||||
private readonly templateCode: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.signName = this.configService.get('ALIYUN_SMS_SIGN_NAME', '');
|
||||
this.templateCode = this.configService.get('ALIYUN_SMS_TEMPLATE_CODE', '');
|
||||
this.enabled = this.configService.get('SMS_ENABLED') === 'true';
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
||||
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
this.logger.warn('阿里云 SMS 配置缺失,短信功能将使用模拟模式');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = new $OpenApi.Config({
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
endpoint: 'dysmsapi.aliyuncs.com',
|
||||
});
|
||||
this.client = new Dysmsapi20170525(config);
|
||||
this.logger.log('阿里云 SMS 客户端初始化成功');
|
||||
} catch (error) {
|
||||
this.logger.error('阿里云 SMS 客户端初始化失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsSendResult> {
|
||||
const normalized = this.normalizePhone(phoneNumber);
|
||||
this.logger.log(`[SMS] 发送验证码到 ${this.maskPhone(normalized)}`);
|
||||
|
||||
if (!this.enabled || !this.client) {
|
||||
this.logger.warn(`[SMS] 模拟模式: 验证码 ${code} → ${this.maskPhone(normalized)}`);
|
||||
return { success: true, code: 'OK', message: '模拟发送成功' };
|
||||
}
|
||||
|
||||
try {
|
||||
const req = new $Dysmsapi20170525.SendSmsRequest({
|
||||
phoneNumbers: normalized,
|
||||
signName: this.signName,
|
||||
templateCode: this.templateCode,
|
||||
templateParam: JSON.stringify({ code }),
|
||||
});
|
||||
const runtime = new $Util.RuntimeOptions({ connectTimeout: 15000, readTimeout: 15000 });
|
||||
const resp = await this.client.sendSmsWithOptions(req, runtime);
|
||||
const body = resp.body;
|
||||
const result: SmsSendResult = {
|
||||
success: body?.code === 'OK',
|
||||
requestId: body?.requestId,
|
||||
bizId: body?.bizId,
|
||||
code: body?.code,
|
||||
message: body?.message,
|
||||
};
|
||||
if (result.success) {
|
||||
this.logger.log(`[SMS] 发送成功: requestId=${result.requestId}`);
|
||||
} else {
|
||||
this.logger.error(`[SMS] 发送失败: code=${result.code}, msg=${result.message}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[SMS] 发送异常: ${error.message}`);
|
||||
return { success: false, code: error.code || 'UNKNOWN_ERROR', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePhone(phone: string): string {
|
||||
let p = phone.trim();
|
||||
if (p.startsWith('+86')) p = p.slice(3);
|
||||
else if (p.startsWith('86') && p.length === 13) p = p.slice(2);
|
||||
return p;
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length < 7) return phone;
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { Controller, Post, Body, Get, Param, UseGuards, Request } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Get, Param, UseGuards, Request, Inject, BadRequestException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from '../../../application/services/auth.service';
|
||||
import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Controller('api/v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
|
|
@ -21,6 +26,7 @@ export class AuthController {
|
|||
* Register a new enterprise account.
|
||||
* Supports email or phone as the login identifier.
|
||||
* If companyName is provided, creates a new tenant (self-service registration).
|
||||
* Phone registration requires a valid OTP (smsCode).
|
||||
*/
|
||||
@Post('register')
|
||||
async register(
|
||||
|
|
@ -28,11 +34,25 @@ export class AuthController {
|
|||
body: {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
smsCode?: string;
|
||||
password: string;
|
||||
name: string;
|
||||
companyName?: string;
|
||||
},
|
||||
) {
|
||||
// Verify OTP when registering with phone
|
||||
if (body.phone) {
|
||||
const phone = body.phone.trim();
|
||||
if (!body.smsCode) throw new BadRequestException('手机注册需要验证码');
|
||||
const otpKey = `sms:otp:${phone}`;
|
||||
const stored = await this.redis.get(otpKey);
|
||||
if (!stored || stored !== body.smsCode.trim()) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
// Consume the OTP immediately
|
||||
await this.redis.del(otpKey);
|
||||
}
|
||||
|
||||
return this.authService.register(
|
||||
body.password,
|
||||
body.name,
|
||||
|
|
@ -42,6 +62,26 @@ export class AuthController {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with phone number + OTP (passwordless).
|
||||
* POST /api/v1/auth/login/otp
|
||||
*/
|
||||
@Post('login/otp')
|
||||
async loginWithOtp(@Body() body: { phone: string; smsCode: string }) {
|
||||
const phone = (body.phone || '').trim();
|
||||
const code = (body.smsCode || '').trim();
|
||||
if (!phone || !code) throw new BadRequestException('手机号和验证码不能为空');
|
||||
|
||||
const otpKey = `sms:otp:${phone}`;
|
||||
const stored = await this.redis.get(otpKey);
|
||||
if (!stored || stored !== code) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
await this.redis.del(otpKey);
|
||||
|
||||
return this.authService.loginByPhone(phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invitation token (public endpoint).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Inject,
|
||||
BadRequestException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { SmsService } from '../../../infrastructure/sms/sms.service';
|
||||
import { REDIS_CLIENT } from '../../../infrastructure/redis/redis.provider';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const OTP_TTL = 300; // 5 minutes
|
||||
const RATE_LIMIT_TTL = 60; // 1 per minute per phone
|
||||
|
||||
function generateCode(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
@Controller('api/v1/auth/sms')
|
||||
export class SmsController {
|
||||
constructor(
|
||||
private readonly smsService: SmsService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/sms/send
|
||||
* Send OTP to phone. Rate-limited: 1 per minute per phone number.
|
||||
* purpose: 'register' | 'login'
|
||||
*/
|
||||
@Post('send')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async sendOtp(@Body() body: { phone: string; purpose?: string }) {
|
||||
const phone = (body.phone || '').trim();
|
||||
if (!phone) throw new BadRequestException('手机号不能为空');
|
||||
|
||||
const rateKey = `sms:rate:${phone}`;
|
||||
const already = await this.redis.get(rateKey);
|
||||
if (already) {
|
||||
throw new BadRequestException('发送太频繁,请 60 秒后再试');
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const otpKey = `sms:otp:${phone}`;
|
||||
|
||||
// Store OTP first, then send (avoid race condition)
|
||||
await this.redis.set(otpKey, code, 'EX', OTP_TTL);
|
||||
await this.redis.set(rateKey, '1', 'EX', RATE_LIMIT_TTL);
|
||||
|
||||
const result = await this.smsService.sendVerificationCode(phone, code);
|
||||
if (!result.success) {
|
||||
// Clean up if SMS actually failed
|
||||
await this.redis.del(otpKey);
|
||||
await this.redis.del(rateKey);
|
||||
throw new BadRequestException(result.message || '短信发送失败,请稍后重试');
|
||||
}
|
||||
|
||||
return { message: '验证码已发送', expiresIn: OTP_TTL };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/sms/verify
|
||||
* Verify OTP only (does not log in). Returns 200 on success.
|
||||
*/
|
||||
@Post('verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyOtp(@Body() body: { phone: string; code: string }) {
|
||||
const phone = (body.phone || '').trim();
|
||||
const code = (body.code || '').trim();
|
||||
if (!phone || !code) throw new BadRequestException('手机号和验证码不能为空');
|
||||
|
||||
const otpKey = `sms:otp:${phone}`;
|
||||
const stored = await this.redis.get(otpKey);
|
||||
|
||||
if (!stored || stored !== code) {
|
||||
throw new BadRequestException('验证码错误或已过期');
|
||||
}
|
||||
|
||||
return { verified: true };
|
||||
}
|
||||
}
|
||||
299
pnpm-lock.yaml
299
pnpm-lock.yaml
|
|
@ -190,6 +190,15 @@ importers:
|
|||
|
||||
packages/services/auth-service:
|
||||
dependencies:
|
||||
'@alicloud/dysmsapi20170525':
|
||||
specifier: ^4.3.1
|
||||
version: 4.5.0
|
||||
'@alicloud/openapi-client':
|
||||
specifier: ^0.4.15
|
||||
version: 0.4.15
|
||||
'@alicloud/tea-util':
|
||||
specifier: ^1.4.11
|
||||
version: 1.4.11
|
||||
'@it0/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../shared/common
|
||||
|
|
@ -223,6 +232,9 @@ importers:
|
|||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
ioredis:
|
||||
specifier: ^5.3.0
|
||||
version: 5.9.2
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
|
|
@ -834,6 +846,60 @@ importers:
|
|||
|
||||
packages:
|
||||
|
||||
'@alicloud/credentials@2.4.4':
|
||||
resolution: {integrity: sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==}
|
||||
|
||||
'@alicloud/darabonba-array@0.1.2':
|
||||
resolution: {integrity: sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==}
|
||||
|
||||
'@alicloud/darabonba-encode-util@0.0.1':
|
||||
resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==}
|
||||
|
||||
'@alicloud/darabonba-encode-util@0.0.2':
|
||||
resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==}
|
||||
|
||||
'@alicloud/darabonba-map@0.0.1':
|
||||
resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==}
|
||||
|
||||
'@alicloud/darabonba-signature-util@0.0.4':
|
||||
resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==}
|
||||
|
||||
'@alicloud/darabonba-string@1.0.3':
|
||||
resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==}
|
||||
|
||||
'@alicloud/dysmsapi20170525@4.5.0':
|
||||
resolution: {integrity: sha512-nhKdRDLRDhTVxr7VbMbBi6UtJWmVFgwySU2ohkJ1zL7jd98DEGGy8CE/n7W44ZP9+yTBBmLhM8qW1C12kHDEIg==}
|
||||
|
||||
'@alicloud/endpoint-util@0.0.1':
|
||||
resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==}
|
||||
|
||||
'@alicloud/gateway-pop@0.0.6':
|
||||
resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==}
|
||||
|
||||
'@alicloud/gateway-spi@0.0.8':
|
||||
resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==}
|
||||
|
||||
'@alicloud/openapi-client@0.4.15':
|
||||
resolution: {integrity: sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==}
|
||||
|
||||
'@alicloud/openapi-core@1.0.7':
|
||||
resolution: {integrity: sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==}
|
||||
|
||||
'@alicloud/openapi-util@0.3.3':
|
||||
resolution: {integrity: sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==}
|
||||
|
||||
'@alicloud/tea-typescript@1.8.0':
|
||||
resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==}
|
||||
|
||||
'@alicloud/tea-util@1.4.11':
|
||||
resolution: {integrity: sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==}
|
||||
|
||||
'@alicloud/tea-util@1.4.9':
|
||||
resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==}
|
||||
|
||||
'@alicloud/tea-xml@0.0.3':
|
||||
resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==}
|
||||
|
||||
'@angular-devkit/core@17.3.11':
|
||||
resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==}
|
||||
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
|
|
@ -1033,6 +1099,9 @@ packages:
|
|||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
'@darabonba/typescript@1.0.4':
|
||||
resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==}
|
||||
|
||||
'@fidm/asn1@1.0.4':
|
||||
resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1558,12 +1627,18 @@ packages:
|
|||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@12.20.55':
|
||||
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||
|
||||
'@types/node@20.19.33':
|
||||
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
||||
|
||||
'@types/node@22.19.15':
|
||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||
|
||||
'@types/nodemailer@6.4.22':
|
||||
resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==}
|
||||
|
||||
|
|
@ -1607,6 +1682,9 @@ packages:
|
|||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/xml2js@0.4.14':
|
||||
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
|
|
@ -2611,6 +2689,9 @@ packages:
|
|||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpx@2.3.3:
|
||||
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
|
|
@ -2645,6 +2726,9 @@ packages:
|
|||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
inquirer@8.2.6:
|
||||
resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -2970,6 +3054,9 @@ packages:
|
|||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
kitx@2.2.0:
|
||||
resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==}
|
||||
|
||||
kleur@3.0.3:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -3140,6 +3227,12 @@ packages:
|
|||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
moment-timezone@0.5.48:
|
||||
resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==}
|
||||
|
||||
moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
ms@2.0.0:
|
||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||
|
||||
|
|
@ -3584,6 +3677,10 @@ packages:
|
|||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sax@1.5.0:
|
||||
resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
schema-utils@3.3.0:
|
||||
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
|
@ -3669,6 +3766,9 @@ packages:
|
|||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
sm3@1.0.3:
|
||||
resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==}
|
||||
|
||||
snake-case@3.0.4:
|
||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||
|
||||
|
|
@ -4249,6 +4349,14 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml2js@0.6.2:
|
||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
xmlbuilder@11.0.1:
|
||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
xmlbuilder@13.0.2:
|
||||
resolution: {integrity: sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
|
@ -4292,6 +4400,146 @@ packages:
|
|||
|
||||
snapshots:
|
||||
|
||||
'@alicloud/credentials@2.4.4':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
httpx: 2.3.3
|
||||
ini: 1.3.8
|
||||
kitx: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/darabonba-array@0.1.2':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/darabonba-encode-util@0.0.1':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
moment: 2.30.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/darabonba-encode-util@0.0.2':
|
||||
dependencies:
|
||||
moment: 2.30.1
|
||||
|
||||
'@alicloud/darabonba-map@0.0.1':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/darabonba-signature-util@0.0.4':
|
||||
dependencies:
|
||||
'@alicloud/darabonba-encode-util': 0.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/darabonba-string@1.0.3':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/dysmsapi20170525@4.5.0':
|
||||
dependencies:
|
||||
'@alicloud/openapi-core': 1.0.7
|
||||
'@darabonba/typescript': 1.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/endpoint-util@0.0.1':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
kitx: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/gateway-pop@0.0.6':
|
||||
dependencies:
|
||||
'@alicloud/credentials': 2.4.4
|
||||
'@alicloud/darabonba-array': 0.1.2
|
||||
'@alicloud/darabonba-encode-util': 0.0.2
|
||||
'@alicloud/darabonba-map': 0.0.1
|
||||
'@alicloud/darabonba-signature-util': 0.0.4
|
||||
'@alicloud/darabonba-string': 1.0.3
|
||||
'@alicloud/endpoint-util': 0.0.1
|
||||
'@alicloud/gateway-spi': 0.0.8
|
||||
'@alicloud/openapi-util': 0.3.3
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
'@alicloud/tea-util': 1.4.11
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/gateway-spi@0.0.8':
|
||||
dependencies:
|
||||
'@alicloud/credentials': 2.4.4
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/openapi-client@0.4.15':
|
||||
dependencies:
|
||||
'@alicloud/credentials': 2.4.4
|
||||
'@alicloud/gateway-spi': 0.0.8
|
||||
'@alicloud/openapi-util': 0.3.3
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
'@alicloud/tea-util': 1.4.9
|
||||
'@alicloud/tea-xml': 0.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/openapi-core@1.0.7':
|
||||
dependencies:
|
||||
'@alicloud/credentials': 2.4.4
|
||||
'@alicloud/gateway-pop': 0.0.6
|
||||
'@alicloud/gateway-spi': 0.0.8
|
||||
'@darabonba/typescript': 1.0.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/openapi-util@0.3.3':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
'@alicloud/tea-util': 1.4.11
|
||||
kitx: 2.2.0
|
||||
sm3: 1.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/tea-typescript@1.8.0':
|
||||
dependencies:
|
||||
'@types/node': 12.20.55
|
||||
httpx: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/tea-util@1.4.11':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
'@darabonba/typescript': 1.0.4
|
||||
kitx: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/tea-util@1.4.9':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
kitx: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@alicloud/tea-xml@0.0.3':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
'@types/xml2js': 0.4.14
|
||||
xml2js: 0.6.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@angular-devkit/core@17.3.11(chokidar@3.6.0)':
|
||||
dependencies:
|
||||
ajv: 8.12.0
|
||||
|
|
@ -4544,6 +4792,17 @@ snapshots:
|
|||
'@colors/colors@1.5.0':
|
||||
optional: true
|
||||
|
||||
'@darabonba/typescript@1.0.4':
|
||||
dependencies:
|
||||
'@alicloud/tea-typescript': 1.8.0
|
||||
httpx: 2.3.3
|
||||
lodash: 4.17.23
|
||||
moment: 2.30.1
|
||||
moment-timezone: 0.5.48
|
||||
xml2js: 0.6.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@fidm/asn1@1.0.4': {}
|
||||
|
||||
'@fidm/x509@1.2.1':
|
||||
|
|
@ -5178,6 +5437,8 @@ snapshots:
|
|||
'@types/node': 20.19.33
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node@12.20.55': {}
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
|
@ -5186,6 +5447,10 @@ snapshots:
|
|||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.19.15':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/nodemailer@6.4.22':
|
||||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
|
|
@ -5245,6 +5510,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
|
||||
'@types/xml2js@0.4.14':
|
||||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.35':
|
||||
|
|
@ -6365,6 +6634,13 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpx@2.3.3:
|
||||
dependencies:
|
||||
'@types/node': 20.19.33
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
humanize-ms@1.2.1:
|
||||
|
|
@ -6396,6 +6672,8 @@ snapshots:
|
|||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
inquirer@8.2.6:
|
||||
dependencies:
|
||||
ansi-escapes: 4.3.2
|
||||
|
|
@ -6954,6 +7232,10 @@ snapshots:
|
|||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
kitx@2.2.0:
|
||||
dependencies:
|
||||
'@types/node': 22.19.15
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
|
@ -7078,6 +7360,12 @@ snapshots:
|
|||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
moment-timezone@0.5.48:
|
||||
dependencies:
|
||||
moment: 2.30.1
|
||||
|
||||
moment@2.30.1: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
|
@ -7497,6 +7785,8 @@ snapshots:
|
|||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sax@1.5.0: {}
|
||||
|
||||
schema-utils@3.3.0:
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
|
@ -7608,6 +7898,8 @@ snapshots:
|
|||
|
||||
slash@3.0.0: {}
|
||||
|
||||
sm3@1.0.3: {}
|
||||
|
||||
snake-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
|
|
@ -8140,6 +8432,13 @@ snapshots:
|
|||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xml2js@0.6.2:
|
||||
dependencies:
|
||||
sax: 1.5.0
|
||||
xmlbuilder: 11.0.1
|
||||
|
||||
xmlbuilder@11.0.1: {}
|
||||
|
||||
xmlbuilder@13.0.2: {}
|
||||
|
||||
xmlbuilder@15.1.1: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue