diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 5420efe..e2ffdba 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -41,6 +41,7 @@ urlpatterns = [ # ── Super Admin: Anomaly Detection ── path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'), path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'), + path('admin/test-sms', views.admin_test_sms_view, name='admin_test_sms'), path('admin/teams//auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'), path('admin/teams//apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 024dce0..922d718 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -1733,6 +1733,21 @@ def admin_test_feishu_view(request): return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST) +@api_view(['POST']) +@permission_classes([IsSuperAdmin]) +def admin_test_sms_view(request): + """POST /api/v1/admin/test-sms — Send a test SMS alert.""" + mobile = request.data.get('mobile', '').strip() + if not mobile: + return Response({'error': '请输入手机号'}, status=status.HTTP_400_BAD_REQUEST) + + from utils.alert_service import send_sms_test + success, message = send_sms_test(mobile) + if success: + return Response({'message': message}) + return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST) + + @api_view(['POST']) @permission_classes([IsSuperAdmin]) def admin_team_auto_learn_view(request, team_id): diff --git a/backend/config/settings.py b/backend/config/settings.py index 71531d6..adc8b0f 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -42,6 +42,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', # Third party 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', 'corsheaders', # Local apps 'apps.accounts', @@ -151,7 +152,8 @@ REST_FRAMEWORK = { SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), - 'ROTATE_REFRESH_TOKENS': False, + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, 'AUTH_HEADER_TYPES': ('Bearer',), } diff --git a/backend/utils/alert_service.py b/backend/utils/alert_service.py index d0c84c8..9be7de0 100644 --- a/backend/utils/alert_service.py +++ b/backend/utils/alert_service.py @@ -311,6 +311,77 @@ def send_sms_alert(anomaly): logger.error('SMS alert error for %s: %s', mobile, e) +def send_sms_test(mobile): + """发送短信测试到指定手机号。Returns (success, message)。""" + from django.conf import settings as django_settings + + access_key = django_settings.ALIYUN_SMS_ACCESS_KEY + access_secret = django_settings.ALIYUN_SMS_ACCESS_SECRET + sign_name = django_settings.ALIYUN_SMS_SIGN_NAME + template_code = django_settings.ALIYUN_SMS_TEMPLATE_CODE + + if not all([access_key, access_secret, template_code]): + return False, '阿里云短信密钥未配置(ALIYUN_SMS_ACCESS_KEY / ALIYUN_SMS_ACCESS_SECRET)' + + template_param = json.dumps({ + 'team_name': '测试团队', + 'rule_name': '告警测试', + 'username': '测试用户', + 'city': '测试城市', + 'auto_action': '仅测试', + }, ensure_ascii=False) + + import hashlib + import hmac + import base64 + import urllib.parse + import uuid + from datetime import datetime + + def _percent_encode(s): + return urllib.parse.quote(s, safe='', encoding='utf-8') + + try: + params = { + 'AccessKeyId': access_key, + 'Action': 'SendSms', + 'Format': 'JSON', + 'PhoneNumbers': mobile, + 'RegionId': 'cn-hangzhou', + 'SignName': sign_name, + 'SignatureMethod': 'HMAC-SHA1', + 'SignatureNonce': str(uuid.uuid4()), + 'SignatureVersion': '1.0', + 'TemplateCode': template_code, + 'TemplateParam': template_param, + 'Timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'), + 'Version': '2017-05-25', + } + + sorted_params = sorted(params.items()) + query_string = '&'.join(f'{_percent_encode(k)}={_percent_encode(v)}' for k, v in sorted_params) + string_to_sign = f'GET&{_percent_encode("/")}&{_percent_encode(query_string)}' + + sign_key = (access_secret + '&').encode('utf-8') + signature = base64.b64encode( + hmac.new(sign_key, string_to_sign.encode('utf-8'), hashlib.sha1).digest() + ).decode('utf-8') + + params['Signature'] = signature + + resp = requests.get( + 'https://dysmsapi.aliyuncs.com/', + params=params, + timeout=10, + ) + data = resp.json() + if data.get('Code') == 'OK': + return True, '测试短信已发送' + return False, f'发送失败: {data.get("Message", data.get("Code", "未知错误"))}' + except Exception as e: + return False, str(e) + + def send_feishu_test(mobile): """发送测试消息到指定手机号。Returns (success, message)。""" try: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 982f115..1b9251a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -67,6 +67,9 @@ api.interceptors.response.use( refresh: refreshToken, }); localStorage.setItem('access_token', data.access); + if (data.refresh) { + localStorage.setItem('refresh_token', data.refresh); + } originalRequest.headers.Authorization = `Bearer ${data.access}`; return api(originalRequest); } catch { @@ -96,7 +99,7 @@ export const authApi = { api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }), refreshToken: (refresh: string) => - api.post<{ access: string }>('/auth/token/refresh', { refresh }), + api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }), getMe: () => api.get('/auth/me'), @@ -280,6 +283,9 @@ export const adminApi = { testFeishu: (mobile: string) => api.post<{ message: string }>('/admin/test-feishu', { mobile }), + testSms: (mobile: string) => + api.post<{ message: string }>('/admin/test-sms', { mobile }), + teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) => api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>( `/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 5cc27b2..2b797c1 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -33,6 +33,7 @@ export function SettingsPage() { alert_cooldown_seconds: 1800, }); const [testingFeishu, setTestingFeishu] = useState(false); + const [testingSms, setTestingSms] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -87,6 +88,20 @@ export function SettingsPage() { } }; + const handleTestSms = async () => { + const mobiles = settings.sms_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean); + if (mobiles.length === 0) { showToast('请先填写短信告警手机号'); return; } + setTestingSms(true); + try { + await adminApi.testSms(mobiles[0]); + showToast('测试短信已发送'); + } catch (err: any) { + showToast(err.response?.data?.error || '发送失败'); + } finally { + setTestingSms(false); + } + }; + if (loading) { return (
@@ -330,12 +345,23 @@ export function SettingsPage() {
- setSettings({ ...settings, sms_alert_mobiles: e.target.value })} - placeholder="多个手机号用逗号分隔,如 13800138000,13900139000" - /> +
+ setSettings({ ...settings, sms_alert_mobiles: e.target.value })} + placeholder="多个手机号用逗号分隔,如 13800138000,13900139000" + style={{ flex: 1 }} + /> + +
diff --git a/web/src/store/auth.ts b/web/src/store/auth.ts index 2da4bcf..bd4f750 100644 --- a/web/src/store/auth.ts +++ b/web/src/store/auth.ts @@ -75,7 +75,12 @@ export const useAuthStore = create((set, get) => ({ if (!refresh) throw new Error('No refresh token'); const { data } = await authApi.refreshToken(refresh); localStorage.setItem('access_token', data.access); - set({ accessToken: data.access }); + if (data.refresh) { + localStorage.setItem('refresh_token', data.refresh); + set({ accessToken: data.access, refreshToken: data.refresh }); + } else { + set({ accessToken: data.access }); + } }, fetchUserInfo: async () => {