Bläddra i källkod

删除用户功能以及应用转让修改

liuq 3 veckor sedan
förälder
incheckning
b560054e8d

+ 27 - 9
backend/app/api/v1/endpoints/apps.py

@@ -7,7 +7,7 @@ import logging
 import json
 from typing import List, Optional
 from datetime import datetime
-from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, File, Form, Query
+from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, File, Form, Query, Request
 from fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
 from sqlalchemy import desc, or_, func
@@ -18,7 +18,7 @@ from app.models.application import Application, ProtocolType
 from app.models.user import User
 from app.models.mapping import AppUserMapping
 from app.models.app_category import AppCategory
-from app.core.utils import generate_english_name
+from app.core.utils import generate_english_name, get_client_ip
 from app.schemas.application import (
     ApplicationCreate,
     ApplicationUpdate,
@@ -1432,6 +1432,7 @@ def get_all_users_m2m(
 
 @router.post("/mapping/sync", response_model=MappingResponse, summary="同步映射 (M2M)")
 def sync_mapping(
+    request: Request,
     *,
     db: Session = Depends(deps.get_db),
     sync_in: UserSyncRequest,
@@ -1444,8 +1445,8 @@ def sync_mapping(
     - DELETE: 仅删除应用与用户的映射关系,不删除用户。
     需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)。
     """
-    # Normalize input: treat empty strings as None
-    mapped_key = sync_in.mapped_key if sync_in.mapped_key else None
+    # Normalize input: treat empty strings as None (mobile / mapped_key 已在 Schema 中强制校验)
+    mapped_key = sync_in.mapped_key
     mapped_email = sync_in.mapped_email if sync_in.mapped_email else None
     in_name = sync_in.name if sync_in.name else None
     in_english_name = sync_in.english_name if sync_in.english_name else None
@@ -1473,6 +1474,14 @@ def sync_mapping(
             logger.warning(f"M2M 删除失败: 映射不存在 (User {sync_in.mobile})")
             raise HTTPException(status_code=404, detail="映射关系不存在")
 
+        if mapping.mapped_key is None:
+            raise HTTPException(
+                status_code=400,
+                detail="映射记录缺少外部账号,请在平台侧补全后再删除",
+            )
+        if mapping.mapped_key != mapped_key:
+            raise HTTPException(status_code=400, detail="映射账号与平台记录不一致")
+
         # 构造返回数据(删除前快照,将状态置为 False)
         resp_data = MappingResponse(
             id=mapping.id,
@@ -1497,7 +1506,13 @@ def sync_mapping(
             action_type=ActionType.DELETE, 
             target_user_id=user.id,
             target_mobile=user.mobile,
-            details={"mapped_key": mapping.mapped_key, "action": "M2M_DELETE"}
+            ip_address=get_client_ip(request),
+            details={
+                "mapped_key": mapping.mapped_key,
+                "action": "M2M_DELETE",
+                "source": "M2M",
+                "sync_action": "DELETE",
+            },
         )
         logger.info(f"M2M 删除成功: {sync_in.mobile}")
 
@@ -1611,8 +1626,7 @@ def sync_mapping(
     new_mapping_created = False
     if mapping:
         # Update existing mapping
-        if sync_in.mapped_key is not None:
-            mapping.mapped_key = mapped_key
+        mapping.mapped_key = mapped_key
         if sync_in.is_active is not None:
             mapping.is_active = sync_in.is_active
         if sync_in.mapped_email is not None:
@@ -1640,12 +1654,16 @@ def sync_mapping(
         action_type=ActionType.SYNC_M2M, 
         target_user_id=user.id,
         target_mobile=user.mobile,
+        ip_address=get_client_ip(request),
         details={
             "mapped_key": mapped_key,
             "mapped_email": mapped_email,
             "new_user_created": new_user_created,
-            "new_mapping_created": new_mapping_created
-        }
+            "new_mapping_created": new_mapping_created,
+            "sync_action": "UPSERT",
+            "source": "M2M",
+            "is_active": mapping.is_active,
+        },
     )
     
     logger.info(f"M2M 同步成功: {sync_in.mobile} (Mapping: {mapping.id})")

+ 34 - 20
backend/app/api/v1/endpoints/users.py

@@ -10,9 +10,10 @@ from app.core import security
 from app.core.utils import generate_english_name, get_client_ip
 from app.models.user import User, UserRole
 from app.models.mapping import AppUserMapping
-from app.schemas.user import User as UserSchema, UserCreate, UserUpdate, UserList, PromoteUserRequest, BatchResetEnglishNameRequest
+from app.schemas.user import User as UserSchema, UserCreate, UserUpdate, UserList, PromoteUserRequest, BatchResetEnglishNameRequest, DeleteUserRequest
 from app.services.webhook_service import WebhookService
 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
 
@@ -361,13 +362,9 @@ def update_user(
     if "admin_password" in update_data:
         del update_data["admin_password"]
 
+    # 删除用户请使用 POST /users/{id}/delete(密码+短信),不再接受通过更新接口软删除
     if "is_deleted" in update_data:
-        if current_user.role != "SUPER_ADMIN":
-             del update_data["is_deleted"]
-        elif update_data["is_deleted"] == 1 and user_id == current_user.id:
-             raise HTTPException(status_code=400, detail="超级管理员不能删除自己")
-        elif update_data["is_deleted"] == 1:
-            actions.append((ActionType.DELETE, {}))
+        del update_data["is_deleted"]
 
     if "password" in update_data:
         password = update_data["password"]
@@ -464,36 +461,53 @@ def promote_user(
     logger.info(f"提升用户为超管: {user.mobile}")
     return user
 
-@router.delete("/{user_id}", response_model=UserSchema, summary="删除用户")
+@router.post("/{user_id}/delete", response_model=UserSchema, summary="删除普通用户(软删除)")
 def delete_user(
     *,
     db: Session = Depends(deps.get_db),
     user_id: int,
+    body: DeleteUserRequest,
     request: Request,
     current_user: User = Depends(deps.get_current_active_user),
     background_tasks: BackgroundTasks,
 ):
     """
-    软删除用户。仅限超级管理员。
+    软删除普通用户(ORDINARY_USER)。仅限超级管理员。
+    需验证操作者登录密码与发送至操作者手机的短信验证码。不提供用户自助销户。
     """
     if current_user.role != "SUPER_ADMIN":
         raise HTTPException(status_code=403, detail="权限不足")
-    
+
     if user_id == current_user.id:
-        raise HTTPException(status_code=400, detail="超级管理员不能删除自己")
-        
+        raise HTTPException(status_code=400, detail="不能删除自己")
+
     user = db.query(User).filter(User.id == user_id).first()
-    if not user:
+    if not user or user.is_deleted != 0:
         raise HTTPException(status_code=404, detail="用户不存在")
-        
-    user.is_deleted = 1 # Using Integer 1 for True
-    user.status = "DISABLED" # Also disable login
+
+    # 管理员与开发者账号不可删除(仅允许删除普通用户)
+    if user.role in (UserRole.SUPER_ADMIN, "SUPER_ADMIN"):
+        raise HTTPException(status_code=403, detail="不能删除超级管理员")
+    if user.role in (UserRole.DEVELOPER, "DEVELOPER"):
+        raise HTTPException(status_code=403, detail="不能删除开发者")
+    if user.role not in (UserRole.ORDINARY_USER, "ORDINARY_USER"):
+        raise HTTPException(status_code=400, detail="仅可删除普通用户")
+
+    if not security.verify_password(body.password, current_user.password_hash):
+        logger.warning(f"删除用户失败: 密码错误 (Admin: {current_user.mobile})")
+        raise HTTPException(status_code=403, detail="密码错误")
+
+    if not SmsService.verify_code(current_user.mobile, body.sms_code):
+        logger.warning(f"删除用户失败: 短信验证码错误 (Admin: {current_user.mobile})")
+        raise HTTPException(status_code=400, detail="验证码错误或已过期")
+
+    user.is_deleted = 1
+    user.status = "DISABLED"
     db.add(user)
     db.commit()
-    
+
     background_tasks.add_task(WebhookService.trigger_user_event, db, user.id, "DELETE")
-    
-    # Log Operation
+
     LogService.create_log(
         db=db,
         operator_id=current_user.id,
@@ -503,7 +517,7 @@ def delete_user(
         ip_address=get_client_ip(request),
         details={"status": "DISABLED"}
     )
-    
+
     logger.info(f"删除用户成功: {user.mobile}")
     return user
 

+ 18 - 3
backend/app/schemas/user.py

@@ -1,8 +1,11 @@
 from typing import Optional
-from pydantic import BaseModel, EmailStr
+from pydantic import BaseModel, EmailStr, Field, field_validator
 from datetime import datetime
 from typing import List
 
+# 中国大陆手机号(与 M2M 同步等接口一致)
+_CN_MOBILE_PATTERN = r"^1[3-9]\d{9}$"
+
 class UserBase(BaseModel):
     mobile: str
     name: Optional[str] = None
@@ -26,6 +29,11 @@ class PromoteUserRequest(BaseModel):
     captcha_id: str
     captcha_code: str
 
+class DeleteUserRequest(BaseModel):
+    """删除普通用户(软删除)时校验操作者密码与短信验证码。"""
+    password: str
+    sms_code: str
+
 class BatchResetEnglishNameRequest(BaseModel):
     user_ids: List[int]
     admin_password: str
@@ -41,16 +49,23 @@ class UserUpdate(BaseModel):
     admin_password: Optional[str] = None
 
 class UserSyncRequest(BaseModel):
-    mobile: str
+    mobile: str = Field(..., pattern=_CN_MOBILE_PATTERN, description="用户手机号(平台唯一标识)")
     name: Optional[str] = None
     english_name: Optional[str] = None
     password: Optional[str] = None
     status: Optional[str] = None
-    mapped_key: Optional[str] = None # External User ID
+    mapped_key: str = Field(..., min_length=1, max_length=100, description="外部系统用户 ID(映射账号)")
     mapped_email: Optional[str] = None # External User Email
     is_active: Optional[bool] = None # True=Active, False=Disabled. None=No Change
     sync_action: Optional[str] = "UPSERT"
 
+    @field_validator("mobile", "mapped_key", mode="before")
+    @classmethod
+    def strip_required_str(cls, v):
+        if isinstance(v, str):
+            return v.strip()
+        return v
+
 class UserInDBBase(UserBase):
     id: int
     created_at: datetime

+ 7 - 3
frontend/public/docs/account_sync.md

@@ -25,14 +25,15 @@
 2. **删除模式 (DELETE)**:
    - 仅删除该用户在当前应用下的映射关系。
    - **不删除**平台上的用户账号。
+   - 必须提供 `mapped_key`,且须与平台上该映射记录中的外部账号 **一致**(用于校验)。
 
 ### Request Body
 | Field | Type | Required | Description |
 |---|---|---|---|
-| `mobile` | string | Yes | 用户手机号 (平台唯一标识) |
+| `mobile` | string | Yes | 用户手机号 (平台唯一标识),须为 11 位中国大陆手机号(`1[3-9]` 开头) |
 | `name` | string | Conditional | 用户姓名 (新建用户必填,已有用户不允许修改) |
 | `english_name` | string | Optional | 英文名/拼音 (新建用户可选,如未提供会自动生成;已有用户不允许修改) |
-| `mapped_key` | string | No | 外部系统用户ID (可修改) |
+| `mapped_key` | string | Yes | 外部系统用户 ID(映射账号,1~100 字符;可修改) |
 | `mapped_email` | string | No | 外部系统邮箱 (可修改) |
 | `is_active` | boolean | No | 映射状态 (默认 true,可修改) |
 | `sync_action` | string | No | 操作类型: `UPSERT` (增改, 默认) 或 `DELETE` (删除映射) |
@@ -74,9 +75,11 @@ curl -X POST "{{API_BASE_URL}}/apps/mapping/sync" \
      -H "X-App-Access-Token: eyJhbGci..." \
      -d '{
            "mobile": "13800138000",
+           "mapped_key": "user_1001_updated",
            "sync_action": "DELETE"
          }'
 ```
+(`mapped_key` 须与当前平台上该用户在本应用下的映射账号一致。)
 
 ### Response (200)
 ```json
@@ -91,9 +94,10 @@ curl -X POST "{{API_BASE_URL}}/apps/mapping/sync" \
 
 ## 4. 错误码
 - `400 Bad Request`: 
-  - 参数错误(如新建用户未提供 `name`)
+  - 参数错误(如手机号格式非法、缺少 `mapped_key`、新建用户未提供 `name`)
   - 已有用户尝试修改 `name`、`mobile` 或 `english_name`
   - 姓名/英文名已存在(新建用户时)
   - 映射关系冲突(`mapped_key` 或 `mapped_email` 已被占用)
+  - `DELETE` 时 `mapped_key` 与平台记录不一致,或历史数据缺少外部账号
 - `403 Forbidden`: Token 无效。
 

+ 15 - 1
frontend/src/api/apps.ts

@@ -59,9 +59,23 @@ export interface MappingListResponse {
 export interface OperationLog {
     id: number
     app_id: number
-    action_type: 'MANUAL_ADD' | 'DELETE' | 'UPDATE' | 'IMPORT' | 'TRANSFER' | 'VIEW_SECRET' | 'REGENERATE_SECRET' | 'SYNC'
+    action_type:
+        | 'MANUAL_ADD'
+        | 'DELETE'
+        | 'UPDATE'
+        | 'IMPORT'
+        | 'TRANSFER'
+        | 'VIEW_SECRET'
+        | 'REGENERATE_SECRET'
+        | 'SYNC'
+        | 'SYNC_M2M'
+        | 'DISABLE'
+        | 'ENABLE'
+        | 'RESET_PASSWORD'
+        | 'CHANGE_ROLE'
     target_mobile?: string
     operator_mobile?: string
+    ip_address?: string
     details?: any
     created_at: string
 }

+ 15 - 1
frontend/src/api/users.ts

@@ -10,7 +10,11 @@ export interface User {
 }
 
 export const searchUsers = (keyword: string) => {
-  return api.get<User[]>('/users/search', { params: { keyword } })
+  return api.get<User[]>('/users/search', { params: { q: keyword || undefined, limit: 50 } })
+}
+
+export const getUserById = (id: number) => {
+  return api.get<User>(`/users/${id}`)
 }
 
 export interface UserListResponse {
@@ -22,3 +26,13 @@ export const getUsers = (params: any) => {
   return api.get<UserListResponse>('/users/', { params })
 }
 
+export interface DeleteUserRequest {
+  password: string
+  sms_code: string
+}
+
+/** 软删除普通用户:需操作者密码 + 发至本人手机的短信验证码 */
+export const deleteUserWithVerification = (userId: number, data: DeleteUserRequest) => {
+  return api.post<User>(`/users/${userId}/delete`, data)
+}
+

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

@@ -23,7 +23,8 @@
           <el-option label="正常" value="ACTIVE" />
           <el-option label="已禁用" value="DISABLED" />
         </el-select>
-        <el-select v-model="roleFilter" placeholder="角色筛选" clearable @change="handleSearch" style="width: 120px">
+        <el-select v-model="roleFilter" placeholder="角色筛选" clearable @change="handleSearch" style="width: 140px">
+          <el-option label="普通用户" value="ORDINARY_USER" />
           <el-option label="开发者" value="DEVELOPER" />
           <el-option label="管理员" value="SUPER_ADMIN" />
         </el-select>
@@ -125,6 +126,13 @@
                         >
                             重置密码
                         </el-dropdown-item>
+                        <el-dropdown-item
+                            v-if="isSuperAdmin && scope.row.role === 'ORDINARY_USER'"
+                            class="danger-dropdown-item"
+                            @click="openDeleteUserDialog(scope.row)"
+                        >
+                            删除用户
+                        </el-dropdown-item>
                       </el-dropdown-menu>
                     </template>
                 </el-dropdown>
@@ -176,6 +184,46 @@
         </template>
     </el-dialog>
 
+    <!-- Delete user (ordinary only): password + SMS -->
+    <el-dialog v-model="deleteUserDialogVisible" title="删除用户" width="440px" @closed="resetDeleteUserForm">
+        <p class="delete-hint">
+            将软删除用户 <strong>{{ deleteTarget?.mobile }}</strong>(不可登录)。需验证您的登录密码与本人手机号短信验证码。
+        </p>
+        <p v-if="maskedAdminMobile" class="text-muted">验证码发送至:<strong>{{ maskedAdminMobile }}</strong></p>
+        <el-form label-position="top" style="margin-top: 12px;">
+            <el-form-item label="登录密码" required>
+                <el-input
+                    v-model="deleteUserForm.password"
+                    type="password"
+                    show-password
+                    placeholder="请输入您的登录密码"
+                    autocomplete="new-password"
+                    :name="'del_pwd_' + dynamicPwdField"
+                />
+            </el-form-item>
+            <el-form-item label="短信验证码" required>
+                <div class="sms-row">
+                    <el-input
+                        v-model="deleteUserForm.sms_code"
+                        placeholder="6 位验证码"
+                        maxlength="6"
+                        @keyup.enter="confirmDeleteUser"
+                    />
+                    <el-button
+                        :disabled="deleteSmsCountdown > 0 || !adminMobile"
+                        @click="sendDeleteUserSms"
+                    >
+                        {{ deleteSmsCountdown > 0 ? `${deleteSmsCountdown}s` : '获取验证码' }}
+                    </el-button>
+                </div>
+            </el-form-item>
+        </el-form>
+        <template #footer>
+            <el-button @click="deleteUserDialogVisible = false">取消</el-button>
+            <el-button type="danger" :loading="deletingUser" @click="confirmDeleteUser">确认删除</el-button>
+        </template>
+    </el-dialog>
+
     <!-- Admin Verify Dialog for Status/Reset -->
     <el-dialog v-model="verifyDialogVisible" title="安全验证" width="400px">
         <p>此操作需要验证管理员权限。</p>
@@ -404,13 +452,25 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, reactive } from 'vue'
+import { ref, onMounted, onUnmounted, reactive, computed } from 'vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from '@element-plus/icons-vue'
 import api from '../utils/request'
 import { getLogs, OperationLog } from '../api/logs'
+import { sendSmsCode } from '../api/smsAuth'
+import { deleteUserWithVerification } from '../api/users'
+import { useAuthStore } from '../store/auth'
 import UserImportDialog from '../components/UserImportDialog.vue'
 
+const authStore = useAuthStore()
+const isSuperAdmin = computed(() => authStore.user?.role === 'SUPER_ADMIN')
+const adminMobile = computed(() => (authStore.user?.mobile as string) || '')
+const maskedAdminMobile = computed(() => {
+    const m = adminMobile.value
+    if (!m || m.length < 11) return ''
+    return `${m.slice(0, 3)}****${m.slice(-4)}`
+})
+
 interface User {
   id: number
   mobile: string
@@ -784,6 +844,91 @@ const handleBatchResetClick = () => {
     batchResetDialogVisible.value = true
 }
 
+const deleteUserDialogVisible = ref(false)
+const deleteTarget = ref<User | null>(null)
+const deletingUser = ref(false)
+const deleteUserForm = reactive({
+    password: '',
+    sms_code: ''
+})
+const deleteSmsCountdown = ref(0)
+let deleteSmsTimer: ReturnType<typeof setInterval> | null = null
+
+const resetDeleteUserForm = () => {
+    deleteTarget.value = null
+    deleteUserForm.password = ''
+    deleteUserForm.sms_code = ''
+}
+
+const openDeleteUserDialog = async (user: User) => {
+    if (user.role !== 'ORDINARY_USER') return
+    if (!authStore.user?.mobile) {
+        try {
+            await authStore.fetchUser()
+        } catch {
+            ElMessage.error('无法获取当前账号信息,请重新登录')
+            return
+        }
+    }
+    if (!adminMobile.value) {
+        ElMessage.error('当前账号无手机号,无法发送验证码')
+        return
+    }
+    deleteTarget.value = user
+    deleteUserForm.password = ''
+    deleteUserForm.sms_code = ''
+    refreshDynamicField()
+    deleteUserDialogVisible.value = true
+}
+
+const sendDeleteUserSms = async () => {
+    if (!adminMobile.value) {
+        ElMessage.warning('无法获取手机号')
+        return
+    }
+    try {
+        await sendSmsCode(adminMobile.value, 'pc')
+        ElMessage.success('验证码已发送')
+        deleteSmsCountdown.value = 60
+        if (deleteSmsTimer) clearInterval(deleteSmsTimer)
+        deleteSmsTimer = setInterval(() => {
+            deleteSmsCountdown.value--
+            if (deleteSmsCountdown.value <= 0 && deleteSmsTimer) {
+                clearInterval(deleteSmsTimer)
+                deleteSmsTimer = null
+            }
+        }, 1000)
+    } catch {
+        // interceptor
+    }
+}
+
+const confirmDeleteUser = async () => {
+    if (!deleteTarget.value) return
+    if (!deleteUserForm.password) {
+        ElMessage.warning('请输入登录密码')
+        return
+    }
+    if (!deleteUserForm.sms_code) {
+        ElMessage.warning('请输入短信验证码')
+        return
+    }
+    deletingUser.value = true
+    try {
+        await deleteUserWithVerification(deleteTarget.value.id, {
+            password: deleteUserForm.password,
+            sms_code: deleteUserForm.sms_code
+        })
+        ElMessage.success('用户已删除')
+        deleteUserDialogVisible.value = false
+        fetchUsers()
+    } catch {
+        // interceptor
+    } finally {
+        deletingUser.value = false
+    }
+}
+
 const confirmBatchReset = async () => {
     if (!batchAdminPassword.value) {
         ElMessage.warning('请输入管理员密码')
@@ -879,6 +1024,16 @@ const getActionLabel = (type: string) => {
 
 onMounted(() => {
   fetchUsers()
+  if (authStore.token && !authStore.user) {
+    authStore.fetchUser().catch(() => {})
+  }
+})
+
+onUnmounted(() => {
+  if (deleteSmsTimer) {
+    clearInterval(deleteSmsTimer)
+    deleteSmsTimer = null
+  }
 })
 </script>
 
@@ -936,4 +1091,25 @@ onMounted(() => {
 .captcha-item {
     margin-bottom: 0;
 }
+.delete-hint {
+    margin: 0 0 8px;
+    line-height: 1.5;
+    color: #606266;
+}
+.text-muted {
+    margin: 0 0 8px;
+    font-size: 13px;
+    color: #909399;
+}
+.sms-row {
+    display: flex;
+    gap: 8px;
+    width: 100%;
+}
+.sms-row .el-input {
+    flex: 1;
+}
+:deep(.danger-dropdown-item) {
+    color: #f56c6c;
+}
 </style>

+ 35 - 10
frontend/src/views/apps/AppList.vue

@@ -207,13 +207,16 @@
     <el-dialog v-model="transferDialogVisible" title="应用转让" width="450px" :close-on-click-modal="false">
       <el-alert title="将应用管理权限完全转让给其他用户,操作后您将失去管理权限。" type="error" :closable="false" style="margin-bottom: 20px;" />
       <el-form :model="transferForm" label-width="100px">
+        <el-form-item label="当前拥有者">
+          <span>{{ transferOwnerName }}</span>
+        </el-form-item>
         <el-form-item label="目标用户" required>
             <el-select
                 v-model="transferForm.targetMobile"
                 filterable
                 remote
                 reserve-keyword
-                placeholder="搜索接收者手机号"
+                placeholder="手机号或姓名搜索(仅开发者/超管)"
                 :remote-method="handleUserSearch"
                 :loading="userLoading"
                 style="width: 100%"
@@ -221,7 +224,7 @@
                 <el-option
                     v-for="item in userOptions"
                     :key="item.id"
-                    :label="`${item.mobile} (${item.role})`"
+                    :label="`${item.name || item.mobile} · ${item.mobile} (${item.role})`"
                     :value="item.mobile"
                 />
             </el-select>
@@ -262,6 +265,7 @@
                 <el-option label="更新映射" value="UPDATE" />
                 <el-option label="批量导入" value="IMPORT" />
                 <el-option label="同步用户" value="SYNC" />
+                <el-option label="M2M 账号同步" value="SYNC_M2M" />
                 <el-option label="应用转让" value="TRANSFER" />
                 <el-option label="查看密钥" value="VIEW_SECRET" />
                 <el-option label="重置密钥" value="REGENERATE_SECRET" />
@@ -293,6 +297,11 @@
                     {{ scope.row.target_mobile || '-' }}
                 </template>
             </el-table-column>
+            <el-table-column prop="ip_address" label="来源 IP" width="130">
+                <template #default="scope">
+                    {{ scope.row.ip_address || '-' }}
+                </template>
+            </el-table-column>
             <el-table-column prop="details" label="详情" min-width="200">
                 <template #default="scope">
                     <el-popover placement="top-start" :width="400" trigger="hover" v-if="scope.row.details">
@@ -335,7 +344,7 @@ import {
   getPresetCategories,
   Application, ApplicationCreate, OperationLog, AppCategory
 } from '../../api/apps'
-import { searchUsers, User } from '../../api/users'
+import { searchUsers, getUserById, User } from '../../api/users'
 import { sendImportVerificationCode } from '../../api/mapping'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowDown, Search } from '@element-plus/icons-vue'
@@ -643,6 +652,9 @@ const transferForm = reactive({
 const transferDynamicFields = reactive({
     password: 'transfer_password'
 })
+const transferOwnerName = ref('')
+
+const TRANSFER_TARGET_ROLES = ['DEVELOPER', 'SUPER_ADMIN']
 
 // Sync Users
 const handleSyncUsers = (row: Application) => {
@@ -741,7 +753,7 @@ const handleUserSearch = async (query: string) => {
     userLoading.value = true
     try {
         const res = await searchUsers(query)
-        userOptions.value = res.data
+        userOptions.value = res.data.filter((u) => TRANSFER_TARGET_ROLES.includes(u.role))
     } catch (e) {
         console.error(e)
     } finally {
@@ -749,19 +761,32 @@ const handleUserSearch = async (query: string) => {
     }
 }
 
-const handleTransfer = (row: Application) => {
+const handleTransfer = async (row: Application) => {
     currentId.value = row.id
     transferForm.targetMobile = ''
     transferForm.verificationCode = ''
     transferForm.password = ''
     userOptions.value = []
-    
+    transferOwnerName.value = '加载中…'
+
     const randomSuffix = Math.random().toString(36).slice(2, 8)
     transferDynamicFields.password = `pwd_${randomSuffix}`
-    
-    // Initial load
-    handleUserSearch('')
-    
+
+    try {
+        if (user.value?.id === row.owner_id) {
+            const u = user.value
+            transferOwnerName.value = u?.name?.trim() || u?.mobile || '—'
+        } else {
+            const res = await getUserById(row.owner_id)
+            const n = res.data.name?.trim()
+            transferOwnerName.value = n || res.data.mobile || '—'
+        }
+    } catch {
+        transferOwnerName.value = '—'
+    }
+
+    await handleUserSearch('')
+
     transferDialogVisible.value = true
 }
 

+ 34 - 5
frontend/src/views/apps/MappingImport.vue

@@ -185,11 +185,13 @@
       <el-tab-pane label="映射账号操作日志" name="logs">
           <div class="toolbar">
               <el-input v-model="logKeyword" placeholder="搜索目标手机号" style="width: 200px; margin-right: 10px;" clearable @clear="fetchLogs" @keyup.enter="fetchLogs" />
-              <el-select v-model="logActionType" placeholder="操作类型" clearable @change="fetchLogs" style="width: 150px; margin-right: 10px;">
+              <el-select v-model="logActionType" placeholder="操作类型" clearable @change="fetchLogs" style="width: 170px; margin-right: 10px;">
                   <el-option label="手动新增" value="MANUAL_ADD" />
                   <el-option label="删除" value="DELETE" />
                   <el-option label="修改" value="UPDATE" />
                   <el-option label="Excel 导入" value="IMPORT" />
+                  <el-option label="M2M 账号同步" value="SYNC_M2M" />
+                  <el-option label="控制台同步用户" value="SYNC" />
               </el-select>
               <el-date-picker 
                 v-model="logDateRange" 
@@ -216,19 +218,44 @@
                   </template>
               </el-table-column>
               <el-table-column prop="operator_mobile" label="操作人手机号" width="140" />
+              <el-table-column prop="ip_address" label="来源 IP" width="130">
+                  <template #default="scope">
+                      {{ scope.row.ip_address || '-' }}
+                  </template>
+              </el-table-column>
               <el-table-column prop="target_mobile" label="目标手机号" width="140">
                   <template #default="scope">
-                      {{ scope.row.target_mobile || (scope.row.action_type === 'IMPORT' ? '批量操作' : '-') }}
+                      {{ scope.row.target_mobile || (scope.row.action_type === 'IMPORT' || scope.row.action_type === 'SYNC' ? '批量操作' : '-') }}
                   </template>
               </el-table-column>
-              <el-table-column label="详情" min-width="200">
+              <el-table-column label="详情" min-width="260">
                   <template #default="scope">
                       <div v-if="scope.row.action_type === 'IMPORT'">
                           <el-button type="primary" link @click="showImportDetails(scope.row)">查看导入详情</el-button>
                       </div>
+                      <div v-else-if="scope.row.action_type === 'SYNC_M2M'" class="log-details">
+                          账号同步接口 · 外部账号 {{ scope.row.details?.mapped_key ?? '-' }}
+                          <span v-if="scope.row.details?.mapped_email"> · 邮箱 {{ scope.row.details.mapped_email }}</span>
+                          <span v-if="scope.row.details?.new_user_created"> · 新建用户</span>
+                          <span v-if="scope.row.details?.new_mapping_created"> · 新建映射</span>
+                          <span v-if="scope.row.details && 'is_active' in scope.row.details">
+                              · 映射{{ scope.row.details.is_active ? '启用' : '停用' }}
+                          </span>
+                      </div>
+                      <div v-else-if="scope.row.action_type === 'SYNC'" class="log-details">
+                          控制台同步 · 模式 {{ scope.row.details?.mode ?? '-' }}
+                          · 尝试 {{ scope.row.details?.total_attempted ?? '-' }}
+                          · 成功 {{ scope.row.details?.success ?? '-' }}
+                          · 失败 {{ scope.row.details?.failed ?? '-' }}
+                      </div>
                       <div v-else class="log-details">
                           <span v-if="scope.row.action_type === 'DELETE'">
-                             删除映射 ID: {{ scope.row.details?.mapping_id }}
+                              <template v-if="scope.row.details?.action === 'M2M_DELETE'">
+                                  账号同步接口删除 · 外部账号 {{ scope.row.details?.mapped_key }}
+                              </template>
+                              <template v-else>
+                                  删除映射 ID: {{ scope.row.details?.mapping_id }}
+                              </template>
                           </span>
                           <span v-else-if="scope.row.action_type === 'UPDATE'">
                               变更前: {{ scope.row.details?.old?.mapped_key || '空' }} -> 变更后: {{ scope.row.details?.new?.mapped_key || '空' }}
@@ -734,7 +761,8 @@ const getActionTypeText = (type: string) => {
         case 'MANUAL_ADD': return '手动新增'
         case 'DELETE': return '删除'
         case 'UPDATE': return '修改'
-        case 'SYNC_M2M': return 'M2M 同步'
+        case 'SYNC_M2M': return 'M2M 账号同步'
+        case 'SYNC': return '控制台同步'
         case 'IMPORT': return 'Excel 导入'
         default: return type
     }
@@ -746,6 +774,7 @@ const getActionTypeTag = (type: string) => {
         case 'DELETE': return 'danger'
         case 'UPDATE': return 'warning'
         case 'SYNC_M2M': return 'primary'
+        case 'SYNC': return 'success'
         case 'IMPORT': return 'info'
         default: return ''
     }

+ 3 - 2
frontend/src/views/help/AccountSync.vue

@@ -53,10 +53,10 @@
           <tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
         </thead>
         <tbody>
-          <tr><td><code>mobile</code></td><td>string</td><td><span class="tag-required">是</span></td><td>用户手机号(平台唯一标识)</td></tr>
+          <tr><td><code>mobile</code></td><td>string</td><td><span class="tag-required">是</span></td><td>用户手机号(平台唯一标识),须为 11 位中国大陆手机号(1[3-9] 开头)</td></tr>
           <tr><td><code>name</code></td><td>string</td><td><span class="tag-conditional">条件</span></td><td>姓名(新建用户必填,已有用户不允许修改)</td></tr>
           <tr><td><code>english_name</code></td><td>string</td><td><span class="tag-optional">否</span></td><td>英文名(新建用户可选,如未提供会自动生成;已有用户不允许修改)</td></tr>
-          <tr><td><code>mapped_key</code></td><td>string</td><td><span class="tag-optional">否</span></td><td>外部系统中的用户ID(在该应用下唯一,可修改)</td></tr>
+          <tr><td><code>mapped_key</code></td><td>string</td><td><span class="tag-required">是</span></td><td>外部系统中的用户 ID(映射账号,1~100 字符;在该应用下唯一,可修改)。删除时须与平台记录一致</td></tr>
           <tr><td><code>mapped_email</code></td><td>string</td><td><span class="tag-optional">否</span></td><td>外部系统中的邮箱(在该应用下唯一,可修改)</td></tr>
           <tr><td><code>is_active</code></td><td>boolean</td><td><span class="tag-optional">否</span></td><td>映射关系状态(<code>true</code>启用,<code>false</code>禁用,可修改)</td></tr>
           <tr><td><code>sync_action</code></td><td>string</td><td><span class="tag-optional">否</span></td><td><code>UPSERT</code> (默认) 或 <code>DELETE</code></td></tr>
@@ -105,6 +105,7 @@ curl -X POST "http://your-uap-domain/api/v1/apps/mapping/sync" \
      -H "X-App-Access-Token: YOUR_APP_ACCESS_TOKEN" \
      -d '{
            "mobile": "13800138000",
+           "mapped_key": "user_1001_updated",
            "sync_action": "DELETE"
          }'
         </pre>