From 71ea80972d6e6484860e0477808c9198f3d82b26 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 06:43:27 -0800 Subject: [PATCH] feat(auth): add SMS OTP verification for phone registration and login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/app/(auth)/register/page.tsx | 75 ++++- it0_app/lib/core/config/api_endpoints.dart | 3 + .../auth/data/providers/auth_provider.dart | 38 +++ .../auth/presentation/pages/login_page.dart | 262 ++++++++++----- packages/services/auth-service/package.json | 6 +- .../src/application/services/auth.service.ts | 24 ++ .../services/auth-service/src/auth.module.ts | 7 +- .../infrastructure/redis/redis.provider.ts | 18 ++ .../src/infrastructure/sms/sms.module.ts | 8 + .../src/infrastructure/sms/sms.service.ts | 100 ++++++ .../rest/controllers/auth.controller.ts | 44 ++- .../rest/controllers/sms.controller.ts | 83 +++++ pnpm-lock.yaml | 299 ++++++++++++++++++ 13 files changed, 877 insertions(+), 90 deletions(-) create mode 100644 packages/services/auth-service/src/infrastructure/redis/redis.provider.ts create mode 100644 packages/services/auth-service/src/infrastructure/sms/sms.module.ts create mode 100644 packages/services/auth-service/src/infrastructure/sms/sms.service.ts create mode 100644 packages/services/auth-service/src/interfaces/rest/controllers/sms.controller.ts diff --git a/it0-web-admin/src/app/(auth)/register/page.tsx b/it0-web-admin/src/app/(auth)/register/page.tsx index 35bfa2e..c64f569 100644 --- a/it0-web-admin/src/app/(auth)/register/page.tsx +++ b/it0-web-admin/src/app/(auth)/register/page.tsx @@ -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 | 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('/api/v1/auth/register', { @@ -149,17 +176,41 @@ export default function RegisterPage() { /> ) : ( -
- - setPhone(e.target.value)} - className="w-full px-3 py-2 bg-input border rounded-md text-sm" - placeholder={t('phonePlaceholder')} - required - /> -
+ <> +
+ + setPhone(e.target.value)} + className="w-full px-3 py-2 bg-input border rounded-md text-sm" + placeholder={t('phonePlaceholder')} + required + /> +
+
+ +
+ setSmsCode(e.target.value)} + className="flex-1 px-3 py-2 bg-input border rounded-md text-sm" + placeholder="请输入 6 位验证码" + maxLength={6} + required + /> + +
+
+ )} {/* Full name */} diff --git a/it0_app/lib/core/config/api_endpoints.dart b/it0_app/lib/core/config/api_endpoints.dart index e4f0116..e07aa41 100644 --- a/it0_app/lib/core/config/api_endpoints.dart +++ b/it0_app/lib/core/config/api_endpoints.dart @@ -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'; diff --git a/it0_app/lib/features/auth/data/providers/auth_provider.dart b/it0_app/lib/features/auth/data/providers/auth_provider.dart index 7c0c7ed..a6ed645 100644 --- a/it0_app/lib/features/auth/data/providers/auth_provider.dart +++ b/it0_app/lib/features/auth/data/providers/auth_provider.dart @@ -135,6 +135,44 @@ class AuthNotifier extends StateNotifier { } } + /// Send SMS OTP to the given phone number. + Future 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 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); + 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 login(String email, String password) async { state = state.copyWith(isLoading: true, error: null); diff --git a/it0_app/lib/features/auth/presentation/pages/login_page.dart b/it0_app/lib/features/auth/presentation/pages/login_page.dart index 9888f81..d58e442 100644 --- a/it0_app/lib/features/auth/presentation/pages/login_page.dart +++ b/it0_app/lib/features/auth/presentation/pages/login_page.dart @@ -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 { final _formKey = GlobalKey(); 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 _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 { 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 { 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 { 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 { ); } - Future _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 _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 _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'); } } diff --git a/packages/services/auth-service/package.json b/packages/services/auth-service/package.json index dc79a13..f753d3e 100644 --- a/packages/services/auth-service/package.json +++ b/packages/services/auth-service/package.json @@ -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", diff --git a/packages/services/auth-service/src/application/services/auth.service.ts b/packages/services/auth-service/src/application/services/auth.service.ts index 7761636..e096718 100644 --- a/packages/services/auth-service/src/application/services/auth.service.ts +++ b/packages/services/auth-service/src/application/services/auth.service.ts @@ -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 diff --git a/packages/services/auth-service/src/auth.module.ts b/packages/services/auth-service/src/auth.module.ts index 9bd81fb..e0676d3 100644 --- a/packages/services/auth-service/src/auth.module.ts +++ b/packages/services/auth-service/src/auth.module.ts @@ -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], }) diff --git a/packages/services/auth-service/src/infrastructure/redis/redis.provider.ts b/packages/services/auth-service/src/infrastructure/redis/redis.provider.ts new file mode 100644 index 0000000..d1b84dc --- /dev/null +++ b/packages/services/auth-service/src/infrastructure/redis/redis.provider.ts @@ -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('REDIS_PORT', 6379), + password: config.get('REDIS_PASSWORD') || undefined, + lazyConnect: true, + }); + }, +}; diff --git a/packages/services/auth-service/src/infrastructure/sms/sms.module.ts b/packages/services/auth-service/src/infrastructure/sms/sms.module.ts new file mode 100644 index 0000000..cc282dd --- /dev/null +++ b/packages/services/auth-service/src/infrastructure/sms/sms.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/packages/services/auth-service/src/infrastructure/sms/sms.service.ts b/packages/services/auth-service/src/infrastructure/sms/sms.service.ts new file mode 100644 index 0000000..3d157d7 --- /dev/null +++ b/packages/services/auth-service/src/infrastructure/sms/sms.service.ts @@ -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('ALIYUN_ACCESS_KEY_ID'); + const accessKeySecret = this.configService.get('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 { + 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); + } +} diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts index 2bc40ce..fba9802 100644 --- a/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts +++ b/packages/services/auth-service/src/interfaces/rest/controllers/auth.controller.ts @@ -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). */ diff --git a/packages/services/auth-service/src/interfaces/rest/controllers/sms.controller.ts b/packages/services/auth-service/src/interfaces/rest/controllers/sms.controller.ts new file mode 100644 index 0000000..f8bc508 --- /dev/null +++ b/packages/services/auth-service/src/interfaces/rest/controllers/sms.controller.ts @@ -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 }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52157a2..995e066 100644 --- a/pnpm-lock.yaml +++ b/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: {}