liuq 3 months ago
parent
commit
8ada900b5a

+ 20 - 0
backend/app/api/v1/endpoints/apps.py

@@ -765,6 +765,7 @@ def sync_mapping(
 
     # 1. Find User or Create
     user = db.query(User).filter(User.mobile == sync_in.mobile).first()
+    new_user_created = False
     if not user:
         # Auto create user
         password = security.generate_alphanumeric_password(8) # Random password letters+digits
@@ -777,6 +778,7 @@ def sync_mapping(
         db.add(user)
         db.commit()
         db.refresh(user)
+        new_user_created = True
 
     # 2. Handle Mapping
     mapping = db.query(AppUserMapping).filter(
@@ -802,6 +804,7 @@ def sync_mapping(
         if email_exists:
              raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapped_email} 已被使用")
 
+    new_mapping_created = False
     if mapping:
         # Update existing mapping
         if sync_in.mapped_key is not None:
@@ -812,6 +815,7 @@ def sync_mapping(
             mapping.mapped_email = mapped_email
     else:
         # Create new mapping
+        new_mapping_created = True
         mapping = AppUserMapping(
             app_id=current_app.id,
             user_id=user.id,
@@ -824,6 +828,22 @@ def sync_mapping(
     db.commit()
     db.refresh(mapping)
 
+    # LOGGING
+    LogService.create_log(
+        db=db, 
+        app_id=current_app.id, 
+        operator_id=current_app.owner_id, 
+        action_type=ActionType.SYNC_M2M, 
+        target_user_id=user.id,
+        target_mobile=user.mobile,
+        details={
+            "mapped_key": mapped_key,
+            "mapped_email": mapped_email,
+            "new_user_created": new_user_created,
+            "new_mapping_created": new_mapping_created
+        }
+    )
+
     return MappingResponse(
         id=mapping.id,
         app_id=mapping.app_id,

+ 1 - 0
backend/app/schemas/operation_log.py

@@ -15,6 +15,7 @@ class ActionType(str, Enum):
     TRANSFER = "TRANSFER"
     VIEW_SECRET = "VIEW_SECRET"
     REGENERATE_SECRET = "REGENERATE_SECRET"
+    SYNC_M2M = "SYNC_M2M"
 
 class OperationLogBase(BaseModel):
     app_id: Optional[int] = None

+ 92 - 2
frontend/src/views/ResetPassword.vue

@@ -39,7 +39,12 @@
            <el-input v-model="form.mobile" disabled prefix-icon="Iphone" />
         </el-form-item>
         <el-form-item>
-          <el-input v-model="form.sms_code" placeholder="收到的短信验证码" prefix-icon="Message" />
+          <div style="display: flex; width: 100%; gap: 10px;">
+            <el-input v-model="form.sms_code" placeholder="收到的短信验证码" prefix-icon="Message" style="flex: 1" />
+            <el-button type="primary" plain :disabled="countdown > 0" @click="openCaptchaDialog">
+              {{ countdown > 0 ? `${countdown}s` : '重新发送' }}
+            </el-button>
+          </div>
         </el-form-item>
         <el-form-item>
           <el-input v-model="form.new_password" type="password" placeholder="新密码" prefix-icon="Lock" show-password />
@@ -54,12 +59,32 @@
         </div>
       </el-form>
 
+      <!-- Resend Captcha Dialog -->
+      <el-dialog v-model="captchaDialogVisible" title="安全验证" width="360px" append-to-body>
+        <el-form :model="captchaForm" label-width="0">
+           <el-form-item class="captcha-item">
+            <el-input v-model="captchaForm.code" placeholder="图形验证码" style="width: 60%" />
+            <div class="captcha-img" @click="fetchResendCaptcha" v-if="resendCaptchaImage">
+              <img :src="resendCaptchaImage" alt="captcha" />
+            </div>
+          </el-form-item>
+        </el-form>
+        <template #footer>
+          <span class="dialog-footer">
+            <el-button @click="captchaDialogVisible = false">取消</el-button>
+            <el-button type="primary" @click="handleResendSmsConfirm" :loading="resendLoading">
+              确认发送
+            </el-button>
+          </span>
+        </template>
+      </el-dialog>
+
     </el-card>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { getCaptcha, sendSms, resetPassword } from '../api/public'
 import { ElMessage } from 'element-plus'
@@ -69,6 +94,17 @@ const activeStep = ref(0)
 const loading = ref(false)
 const captchaImage = ref('')
 
+// Resend Logic State
+const countdown = ref(0)
+let timer: any = null
+const captchaDialogVisible = ref(false)
+const resendCaptchaImage = ref('')
+const resendCaptchaId = ref('')
+const resendLoading = ref(false)
+const captchaForm = reactive({
+  code: ''
+})
+
 const form = reactive({
   mobile: '',
   captcha_id: '',
@@ -88,6 +124,55 @@ const fetchCaptcha = async () => {
   }
 }
 
+const startCountdown = () => {
+  countdown.value = 60
+  if (timer) clearInterval(timer)
+  timer = setInterval(() => {
+    countdown.value--
+    if (countdown.value <= 0) {
+      clearInterval(timer)
+    }
+  }, 1000)
+}
+
+const openCaptchaDialog = () => {
+  captchaForm.code = ''
+  fetchResendCaptcha()
+  captchaDialogVisible.value = true
+}
+
+const fetchResendCaptcha = async () => {
+   try {
+    const res = await getCaptcha()
+    resendCaptchaImage.value = res.data.image
+    resendCaptchaId.value = res.data.captcha_id
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+const handleResendSmsConfirm = async () => {
+  if (!captchaForm.code) {
+    ElMessage.warning('请输入图形验证码')
+    return
+  }
+  resendLoading.value = true
+  try {
+    await sendSms({
+      mobile: form.mobile,
+      captcha_id: resendCaptchaId.value,
+      captcha_code: captchaForm.code
+    })
+    ElMessage.success('验证码已发送')
+    captchaDialogVisible.value = false
+    startCountdown()
+  } catch (e) {
+    fetchResendCaptcha()
+  } finally {
+    resendLoading.value = false
+  }
+}
+
 const handleSendSms = async () => {
   if (!form.mobile || !form.captcha_code) {
     ElMessage.warning('请输入手机号和图形验证码')
@@ -102,6 +187,7 @@ const handleSendSms = async () => {
     })
     ElMessage.success('验证码已发送 (模拟环境: 请检查后端日志)')
     activeStep.value = 1
+    startCountdown()
   } catch (e) {
     fetchCaptcha() // Refresh captcha on fail
   } finally {
@@ -133,6 +219,10 @@ const handleReset = async () => {
 onMounted(() => {
   fetchCaptcha()
 })
+
+onUnmounted(() => {
+  if (timer) clearInterval(timer)
+})
 </script>
 
 <style scoped>

+ 2 - 1
frontend/src/views/UserList.vue

@@ -655,7 +655,8 @@ const getActionLabel = (type: string) => {
         'DISABLE': '禁用',
         'ENABLE': '启用',
         'RESET_PASSWORD': '重置密码',
-        'CHANGE_ROLE': '变更角色'
+        'CHANGE_ROLE': '变更角色',
+        'SYNC_M2M': 'M2M 同步'
     }
     return map[type] || type
 }

+ 1 - 0
frontend/src/views/apps/AppList.vue

@@ -622,6 +622,7 @@ const formatActionType = (type: string) => {
         'TRANSFER': '应用转让',
         'VIEW_SECRET': '查看密钥',
         'REGENERATE_SECRET': '重置密钥',
+        'SYNC_M2M': 'M2M 同步',
         'DISABLE': '禁用',
         'ENABLE': '启用',
         'RESET_PASSWORD': '重置密码',

+ 2 - 0
frontend/src/views/apps/MappingImport.vue

@@ -734,6 +734,7 @@ const getActionTypeText = (type: string) => {
         case 'MANUAL_ADD': return '手动新增'
         case 'DELETE': return '删除'
         case 'UPDATE': return '修改'
+        case 'SYNC_M2M': return 'M2M 同步'
         case 'IMPORT': return 'Excel 导入'
         default: return type
     }
@@ -744,6 +745,7 @@ const getActionTypeTag = (type: string) => {
         case 'MANUAL_ADD': return 'success'
         case 'DELETE': return 'danger'
         case 'UPDATE': return 'warning'
+        case 'SYNC_M2M': return 'primary'
         case 'IMPORT': return 'info'
         default: return ''
     }