liuq 3 месяцев назад
Родитель
Сommit
8109dacfdb

+ 15 - 3
backend/app/api/v1/endpoints/backup.py

@@ -11,7 +11,8 @@ from app.schemas.backup import (
     BackupSettingsUpdate, 
     BackupRecordList,
     RestorePreviewResponse,
-    RestoreRequest
+    RestoreRequest,
+    SendSmsRequest
 )
 from app.models.backup import BackupRecord as BackupRecordModel
 from app.services.backup_service import BackupService
@@ -122,6 +123,18 @@ def preview_restore(
          
     return BackupService.preview_restore(db, id, type)
 
+@router.post("/send-sms")
+def send_restore_sms(
+    req: SendSmsRequest,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+    
+    BackupService.send_restore_sms(req.captcha_id, req.captcha_code, current_user)
+    return {"message": "短信验证码已发送"}
+
 @router.post("/{id}/restore")
 def restore_backup(
     id: int,
@@ -139,6 +152,5 @@ def restore_backup(
         request.restore_type, 
         request.field_mapping,
         request.password,
-        request.captcha_id,
-        request.captcha_code
+        request.sms_code
     )

+ 5 - 2
backend/app/schemas/backup.py

@@ -43,8 +43,11 @@ class RestorePreviewResponse(BaseModel):
     db_columns: List[str]
 
 class RestoreRequest(BaseModel):
-    restore_type: str  # "APPLICATIONS", "USERS", "MAPPINGS"
-    field_mapping: Dict[str, str] # csv_header -> db_column
+    restore_type: str
+    field_mapping: Dict[str, str]
     password: str
+    sms_code: str # Changed from captcha_id/code to sms_code
+
+class SendSmsRequest(BaseModel):
     captcha_id: str
     captcha_code: str

+ 54 - 9
backend/app/services/backup_service.py

@@ -7,7 +7,7 @@ import csv
 from datetime import datetime
 from typing import List, Dict, Any
 from sqlalchemy.orm import Session
-from sqlalchemy import inspect
+from sqlalchemy import inspect, Boolean, Integer
 from fastapi import HTTPException
 from app.core.database import SessionLocal
 from app.models.application import Application
@@ -17,6 +17,7 @@ from app.models.backup import BackupRecord, BackupType, BackupSettings
 from app.core.scheduler import scheduler
 from app.core.security import verify_password
 from app.services.captcha_service import CaptchaService
+from app.services.sms_service import SmsService
 from app.services.log_service import LogService
 from app.schemas.operation_log import ActionType
 from apscheduler.triggers.cron import CronTrigger
@@ -225,6 +226,20 @@ class BackupService:
             raise HTTPException(status_code=400, detail="Invalid backup file format")
 
         return {"csv_headers": csv_headers, "db_columns": db_columns}
+    
+    @staticmethod
+    def send_restore_sms(captcha_id: str, captcha_code: str, user: User):
+        # 1. Verify Captcha
+        if not CaptchaService.verify_captcha(captcha_id, captcha_code):
+             raise HTTPException(status_code=400, detail="图形验证码错误")
+        
+        # 2. Send SMS to current user
+        try:
+            SmsService.send_code(user.mobile)
+        except Exception as e:
+            if hasattr(e, "detail"):
+                raise e
+            raise HTTPException(status_code=400, detail="发送短信失败")
 
     @staticmethod
     def restore_data(
@@ -234,15 +249,16 @@ class BackupService:
         restore_type: str, 
         field_mapping: Dict[str, str],
         password: str,
-        captcha_id: str,
-        captcha_code: str
+        sms_code: str
     ):
         # 1. Verification
-        if not CaptchaService.verify_captcha(captcha_id, captcha_code):
-             raise HTTPException(status_code=400, detail="验证码错误")
-        
+        # Verify Password
         if not verify_password(password, current_user.password_hash):
              raise HTTPException(status_code=400, detail="密码错误")
+        
+        # Verify SMS Code
+        if not SmsService.verify_code(current_user.mobile, sms_code):
+             raise HTTPException(status_code=400, detail="短信验证码错误或已过期")
 
         backup = db.query(BackupRecord).filter(BackupRecord.id == backup_id).first()
         if not backup or not os.path.exists(backup.file_path):
@@ -268,6 +284,10 @@ class BackupService:
         # 3. Process Restore
         restored_count = 0
         try:
+            # Get columns and types for type conversion
+            mapper = inspect(model)
+            columns = mapper.columns
+
             with zipfile.ZipFile(backup.file_path, 'r') as zipf:
                 for filename in target_files:
                     if filename not in zipf.namelist():
@@ -286,9 +306,34 @@ class BackupService:
                             for csv_col, db_col in field_mapping.items():
                                 if csv_col in row and db_col: # if db_col is not empty/none
                                     val = row[csv_col]
-                                    # Handle special conversions if needed (e.g. boolean, nulls)
-                                    if val == "":
-                                        val = None
+                                    
+                                    # Type Conversion
+                                    if db_col in columns:
+                                        col_type = columns[db_col].type
+                                        
+                                        # Handle Boolean
+                                        if isinstance(col_type, Boolean):
+                                            if str(val).lower() in ('true', '1', 't', 'yes'):
+                                                val = True
+                                            elif str(val).lower() in ('false', '0', 'f', 'no'):
+                                                val = False
+                                            else:
+                                                if val == "": val = None
+                                        
+                                        # Handle Integer
+                                        elif isinstance(col_type, Integer):
+                                            if val == "":
+                                                val = None
+                                            else:
+                                                try:
+                                                    val = int(val)
+                                                except ValueError:
+                                                    pass # Keep as is or ignore
+
+                                        # Handle Empty Strings for others
+                                        elif val == "":
+                                            val = None
+                                            
                                     data[db_col] = val
                             
                             # Upsert Logic

+ 9 - 2
frontend/src/api/backup.ts

@@ -38,8 +38,7 @@ export interface RestoreRequest {
   restore_type: 'APPLICATIONS' | 'USERS' | 'MAPPINGS';
   field_mapping: Record<string, string>;
   password: string;
-  captcha_id: string;
-  captcha_code: string;
+  sms_code: string;
 }
 
 export const getBackups = (params?: BackupQueryParams) => {
@@ -84,6 +83,14 @@ export const previewRestore = (id: number, type: string) => {
   });
 };
 
+export const sendRestoreSms = (data: { captcha_id: string; captcha_code: string }) => {
+  return request({
+    url: '/backups/send-sms',
+    method: 'post',
+    data
+  });
+};
+
 export const restoreBackup = (id: number, data: RestoreRequest) => {
   return request({
     url: `/backups/${id}/restore`,

+ 72 - 14
frontend/src/views/admin/maintenance/DataRestore.vue

@@ -109,18 +109,43 @@
          style="margin-bottom: 20px"
        />
        
-       <el-form :model="confirmForm" label-width="80px">
+       <el-form :model="confirmForm" label-width="100px" ref="formRef">
           <el-form-item label="登录密码">
-             <el-input v-model="confirmForm.password" type="password" show-password placeholder="请输入当前登录密码" />
+             <!-- Fake fields to trick browser -->
+             <input style="display:none" type="text" name="fakeusernameremembered"/>
+             <input style="display:none" type="password" name="fakepasswordremembered"/>
+
+             <el-input 
+                v-model="confirmForm.password" 
+                type="password" 
+                show-password 
+                placeholder="请输入当前登录密码" 
+                autocomplete="new-password"
+                name="new-password-field-random"
+             />
           </el-form-item>
+          
           <el-form-item label="验证码">
              <div class="captcha-row">
-               <el-input v-model="confirmForm.captcha_code" placeholder="验证码" style="width: 150px" />
+               <el-input v-model="confirmForm.captcha_code" placeholder="图形验证码" style="width: 150px" />
                <div class="captcha-img" @click="refreshCaptcha" v-if="captchaImage">
                   <img :src="captchaImage" alt="captcha" />
                </div>
              </div>
           </el-form-item>
+
+          <el-form-item label="短信验证码">
+             <div class="captcha-row">
+                <el-input v-model="confirmForm.sms_code" placeholder="短信验证码" style="width: 150px" />
+                <el-button 
+                    type="primary" 
+                    :disabled="smsCooldown > 0 || !confirmForm.captcha_code" 
+                    @click="sendSms"
+                >
+                    {{ smsCooldown > 0 ? `${smsCooldown}s 后重发` : '发送短信' }}
+                </el-button>
+             </div>
+          </el-form-item>
        </el-form>
 
        <template #footer>
@@ -133,12 +158,13 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, computed, watch } from 'vue'
+import { ref, reactive, onMounted, computed, watch, onUnmounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import { 
   getBackups, 
   previewRestore,
   restoreBackup,
+  sendRestoreSms,
   type BackupRecord 
 } from '../../../api/backup'
 import api from '../../../utils/request'
@@ -163,9 +189,12 @@ const restoring = ref(false)
 const confirmForm = reactive({
   password: '',
   captcha_id: '',
-  captcha_code: ''
+  captcha_code: '',
+  sms_code: ''
 })
 const captchaImage = ref('')
+const smsCooldown = ref(0)
+let timer: any = null
 
 // --- Methods: Step 1 ---
 const loadBackups = async () => {
@@ -212,7 +241,6 @@ const loadPreview = async () => {
       mappingData.value = csvHeaders.value.map(header => {
          // Try exact match
          let match = dbColumns.value.find(col => col === header)
-         // Try simple normalization (e.g. secret -> app_secret?) - Optional
          return {
             csv: header,
             db: match || ''
@@ -244,13 +272,44 @@ const refreshCaptcha = async () => {
 
 const openConfirmDialog = () => {
    confirmForm.password = ''
+   confirmForm.sms_code = ''
    refreshCaptcha()
    confirmVisible.value = true
 }
 
+const sendSms = async () => {
+    if (!confirmForm.captcha_code) {
+        ElMessage.warning('请先输入图形验证码')
+        return
+    }
+    
+    try {
+        await sendRestoreSms({
+            captcha_id: confirmForm.captcha_id,
+            captcha_code: confirmForm.captcha_code
+        })
+        ElMessage.success('短信验证码已发送')
+        
+        // Start Cooldown
+        smsCooldown.value = 60
+        timer = setInterval(() => {
+            smsCooldown.value--
+            if (smsCooldown.value <= 0) {
+                clearInterval(timer)
+            }
+        }, 1000)
+        
+    } catch (e: any) {
+        // Refresh captcha on failure as it's one-time use
+        refreshCaptcha() 
+        const msg = e.response?.data?.detail || '发送短信失败'
+        ElMessage.error(msg)
+    }
+}
+
 const handleRestore = async () => {
-    if (!confirmForm.password || !confirmForm.captcha_code) {
-        ElMessage.warning('请输入密码和验证码')
+    if (!confirmForm.password || !confirmForm.sms_code) {
+        ElMessage.warning('请输入密码和短信验证码')
         return
     }
     
@@ -268,19 +327,15 @@ const handleRestore = async () => {
             restore_type: restoreType.value as any,
             field_mapping,
             password: confirmForm.password,
-            captcha_id: confirmForm.captcha_id,
-            captcha_code: confirmForm.captcha_code
+            sms_code: confirmForm.sms_code
         })
         
         ElMessage.success(res.data.message || '还原成功')
         confirmVisible.value = false
         activeStep.value = 0
     } catch (e: any) {
-        // Handle error message
         const msg = e.response?.data?.detail || '还原失败'
         ElMessage.error(msg)
-        // Refresh captcha on failure
-        refreshCaptcha()
     } finally {
         restoring.value = false
     }
@@ -304,6 +359,10 @@ const formatDate = (dateStr: string) => {
 onMounted(() => {
    loadBackups()
 })
+
+onUnmounted(() => {
+    if (timer) clearInterval(timer)
+})
 </script>
 
 <style scoped>
@@ -351,4 +410,3 @@ onMounted(() => {
     height: 100%;
 }
 </style>
-