Przeglądaj źródła

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

liuq 3 tygodni temu
rodzic
commit
b560054e8d

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

@@ -7,7 +7,7 @@ import logging
 import json
 import json
 from typing import List, Optional
 from typing import List, Optional
 from datetime import datetime
 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 fastapi.responses import StreamingResponse
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from sqlalchemy import desc, or_, func
 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.user import User
 from app.models.mapping import AppUserMapping
 from app.models.mapping import AppUserMapping
 from app.models.app_category import AppCategory
 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 (
 from app.schemas.application import (
     ApplicationCreate,
     ApplicationCreate,
     ApplicationUpdate,
     ApplicationUpdate,
@@ -1432,6 +1432,7 @@ def get_all_users_m2m(
 
 
 @router.post("/mapping/sync", response_model=MappingResponse, summary="同步映射 (M2M)")
 @router.post("/mapping/sync", response_model=MappingResponse, summary="同步映射 (M2M)")
 def sync_mapping(
 def sync_mapping(
+    request: Request,
     *,
     *,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
     sync_in: UserSyncRequest,
     sync_in: UserSyncRequest,
@@ -1444,8 +1445,8 @@ def sync_mapping(
     - DELETE: 仅删除应用与用户的映射关系,不删除用户。
     - DELETE: 仅删除应用与用户的映射关系,不删除用户。
     需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)。
     需要应用访问令牌 (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
     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_name = sync_in.name if sync_in.name else None
     in_english_name = sync_in.english_name if sync_in.english_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})")
             logger.warning(f"M2M 删除失败: 映射不存在 (User {sync_in.mobile})")
             raise HTTPException(status_code=404, detail="映射关系不存在")
             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)
         # 构造返回数据(删除前快照,将状态置为 False)
         resp_data = MappingResponse(
         resp_data = MappingResponse(
             id=mapping.id,
             id=mapping.id,
@@ -1497,7 +1506,13 @@ def sync_mapping(
             action_type=ActionType.DELETE, 
             action_type=ActionType.DELETE, 
             target_user_id=user.id,
             target_user_id=user.id,
             target_mobile=user.mobile,
             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}")
         logger.info(f"M2M 删除成功: {sync_in.mobile}")
 
 
@@ -1611,8 +1626,7 @@ def sync_mapping(
     new_mapping_created = False
     new_mapping_created = False
     if mapping:
     if mapping:
         # Update existing 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:
         if sync_in.is_active is not None:
             mapping.is_active = sync_in.is_active
             mapping.is_active = sync_in.is_active
         if sync_in.mapped_email is not None:
         if sync_in.mapped_email is not None:
@@ -1640,12 +1654,16 @@ def sync_mapping(
         action_type=ActionType.SYNC_M2M, 
         action_type=ActionType.SYNC_M2M, 
         target_user_id=user.id,
         target_user_id=user.id,
         target_mobile=user.mobile,
         target_mobile=user.mobile,
+        ip_address=get_client_ip(request),
         details={
         details={
             "mapped_key": mapped_key,
             "mapped_key": mapped_key,
             "mapped_email": mapped_email,
             "mapped_email": mapped_email,
             "new_user_created": new_user_created,
             "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})")
     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.core.utils import generate_english_name, get_client_ip
 from app.models.user import User, UserRole
 from app.models.user import User, UserRole
 from app.models.mapping import AppUserMapping
 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.webhook_service import WebhookService
 from app.services.captcha_service import CaptchaService
 from app.services.captcha_service import CaptchaService
+from app.services.sms_service import SmsService
 from app.services.log_service import LogService
 from app.services.log_service import LogService
 from app.schemas.operation_log import ActionType
 from app.schemas.operation_log import ActionType
 
 
@@ -361,13 +362,9 @@ def update_user(
     if "admin_password" in update_data:
     if "admin_password" in update_data:
         del update_data["admin_password"]
         del update_data["admin_password"]
 
 
+    # 删除用户请使用 POST /users/{id}/delete(密码+短信),不再接受通过更新接口软删除
     if "is_deleted" in update_data:
     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:
     if "password" in update_data:
         password = update_data["password"]
         password = update_data["password"]
@@ -464,36 +461,53 @@ def promote_user(
     logger.info(f"提升用户为超管: {user.mobile}")
     logger.info(f"提升用户为超管: {user.mobile}")
     return user
     return user
 
 
-@router.delete("/{user_id}", response_model=UserSchema, summary="删除用户")
+@router.post("/{user_id}/delete", response_model=UserSchema, summary="删除普通用户(软删除)")
 def delete_user(
 def delete_user(
     *,
     *,
     db: Session = Depends(deps.get_db),
     db: Session = Depends(deps.get_db),
     user_id: int,
     user_id: int,
+    body: DeleteUserRequest,
     request: Request,
     request: Request,
     current_user: User = Depends(deps.get_current_active_user),
     current_user: User = Depends(deps.get_current_active_user),
     background_tasks: BackgroundTasks,
     background_tasks: BackgroundTasks,
 ):
 ):
     """
     """
-    软删除用户。仅限超级管理员。
+    软删除普通用户(ORDINARY_USER)。仅限超级管理员。
+    需验证操作者登录密码与发送至操作者手机的短信验证码。不提供用户自助销户。
     """
     """
     if current_user.role != "SUPER_ADMIN":
     if current_user.role != "SUPER_ADMIN":
         raise HTTPException(status_code=403, detail="权限不足")
         raise HTTPException(status_code=403, detail="权限不足")
-    
+
     if user_id == current_user.id:
     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()
     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="用户不存在")
         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.add(user)
     db.commit()
     db.commit()
-    
+
     background_tasks.add_task(WebhookService.trigger_user_event, db, user.id, "DELETE")
     background_tasks.add_task(WebhookService.trigger_user_event, db, user.id, "DELETE")
-    
-    # Log Operation
+
     LogService.create_log(
     LogService.create_log(
         db=db,
         db=db,
         operator_id=current_user.id,
         operator_id=current_user.id,
@@ -503,7 +517,7 @@ def delete_user(
         ip_address=get_client_ip(request),
         ip_address=get_client_ip(request),
         details={"status": "DISABLED"}
         details={"status": "DISABLED"}
     )
     )
-    
+
     logger.info(f"删除用户成功: {user.mobile}")
     logger.info(f"删除用户成功: {user.mobile}")
     return user
     return user
 
 

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

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

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

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

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

@@ -59,9 +59,23 @@ export interface MappingListResponse {
 export interface OperationLog {
 export interface OperationLog {
     id: number
     id: number
     app_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
     target_mobile?: string
     operator_mobile?: string
     operator_mobile?: string
+    ip_address?: string
     details?: any
     details?: any
     created_at: string
     created_at: string
 }
 }

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

@@ -10,7 +10,11 @@ export interface User {
 }
 }
 
 
 export const searchUsers = (keyword: string) => {
 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 {
 export interface UserListResponse {
@@ -22,3 +26,13 @@ export const getUsers = (params: any) => {
   return api.get<UserListResponse>('/users/', { params })
   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="ACTIVE" />
           <el-option label="已禁用" value="DISABLED" />
           <el-option label="已禁用" value="DISABLED" />
         </el-select>
         </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="DEVELOPER" />
           <el-option label="管理员" value="SUPER_ADMIN" />
           <el-option label="管理员" value="SUPER_ADMIN" />
         </el-select>
         </el-select>
@@ -125,6 +126,13 @@
                         >
                         >
                             重置密码
                             重置密码
                         </el-dropdown-item>
                         </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>
                       </el-dropdown-menu>
                     </template>
                     </template>
                 </el-dropdown>
                 </el-dropdown>
@@ -176,6 +184,46 @@
         </template>
         </template>
     </el-dialog>
     </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 -->
     <!-- Admin Verify Dialog for Status/Reset -->
     <el-dialog v-model="verifyDialogVisible" title="安全验证" width="400px">
     <el-dialog v-model="verifyDialogVisible" title="安全验证" width="400px">
         <p>此操作需要验证管理员权限。</p>
         <p>此操作需要验证管理员权限。</p>
@@ -404,13 +452,25 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <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 { ElMessage, FormInstance, FormRules } from 'element-plus'
 import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from '@element-plus/icons-vue'
 import { Refresh, ArrowDown, Search, Plus, List, Upload, EditPen, Warning } from '@element-plus/icons-vue'
 import api from '../utils/request'
 import api from '../utils/request'
 import { getLogs, OperationLog } from '../api/logs'
 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'
 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 {
 interface User {
   id: number
   id: number
   mobile: string
   mobile: string
@@ -784,6 +844,91 @@ const handleBatchResetClick = () => {
     batchResetDialogVisible.value = true
     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 () => {
 const confirmBatchReset = async () => {
     if (!batchAdminPassword.value) {
     if (!batchAdminPassword.value) {
         ElMessage.warning('请输入管理员密码')
         ElMessage.warning('请输入管理员密码')
@@ -879,6 +1024,16 @@ const getActionLabel = (type: string) => {
 
 
 onMounted(() => {
 onMounted(() => {
   fetchUsers()
   fetchUsers()
+  if (authStore.token && !authStore.user) {
+    authStore.fetchUser().catch(() => {})
+  }
+})
+
+onUnmounted(() => {
+  if (deleteSmsTimer) {
+    clearInterval(deleteSmsTimer)
+    deleteSmsTimer = null
+  }
 })
 })
 </script>
 </script>
 
 
@@ -936,4 +1091,25 @@ onMounted(() => {
 .captcha-item {
 .captcha-item {
     margin-bottom: 0;
     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>
 </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-dialog v-model="transferDialogVisible" title="应用转让" width="450px" :close-on-click-modal="false">
       <el-alert title="将应用管理权限完全转让给其他用户,操作后您将失去管理权限。" type="error" :closable="false" style="margin-bottom: 20px;" />
       <el-alert title="将应用管理权限完全转让给其他用户,操作后您将失去管理权限。" type="error" :closable="false" style="margin-bottom: 20px;" />
       <el-form :model="transferForm" label-width="100px">
       <el-form :model="transferForm" label-width="100px">
+        <el-form-item label="当前拥有者">
+          <span>{{ transferOwnerName }}</span>
+        </el-form-item>
         <el-form-item label="目标用户" required>
         <el-form-item label="目标用户" required>
             <el-select
             <el-select
                 v-model="transferForm.targetMobile"
                 v-model="transferForm.targetMobile"
                 filterable
                 filterable
                 remote
                 remote
                 reserve-keyword
                 reserve-keyword
-                placeholder="搜索接收者手机号"
+                placeholder="手机号或姓名搜索(仅开发者/超管)"
                 :remote-method="handleUserSearch"
                 :remote-method="handleUserSearch"
                 :loading="userLoading"
                 :loading="userLoading"
                 style="width: 100%"
                 style="width: 100%"
@@ -221,7 +224,7 @@
                 <el-option
                 <el-option
                     v-for="item in userOptions"
                     v-for="item in userOptions"
                     :key="item.id"
                     :key="item.id"
-                    :label="`${item.mobile} (${item.role})`"
+                    :label="`${item.name || item.mobile} · ${item.mobile} (${item.role})`"
                     :value="item.mobile"
                     :value="item.mobile"
                 />
                 />
             </el-select>
             </el-select>
@@ -262,6 +265,7 @@
                 <el-option label="更新映射" value="UPDATE" />
                 <el-option label="更新映射" value="UPDATE" />
                 <el-option label="批量导入" value="IMPORT" />
                 <el-option label="批量导入" value="IMPORT" />
                 <el-option label="同步用户" value="SYNC" />
                 <el-option label="同步用户" value="SYNC" />
+                <el-option label="M2M 账号同步" value="SYNC_M2M" />
                 <el-option label="应用转让" value="TRANSFER" />
                 <el-option label="应用转让" value="TRANSFER" />
                 <el-option label="查看密钥" value="VIEW_SECRET" />
                 <el-option label="查看密钥" value="VIEW_SECRET" />
                 <el-option label="重置密钥" value="REGENERATE_SECRET" />
                 <el-option label="重置密钥" value="REGENERATE_SECRET" />
@@ -293,6 +297,11 @@
                     {{ scope.row.target_mobile || '-' }}
                     {{ scope.row.target_mobile || '-' }}
                 </template>
                 </template>
             </el-table-column>
             </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">
             <el-table-column prop="details" label="详情" min-width="200">
                 <template #default="scope">
                 <template #default="scope">
                     <el-popover placement="top-start" :width="400" trigger="hover" v-if="scope.row.details">
                     <el-popover placement="top-start" :width="400" trigger="hover" v-if="scope.row.details">
@@ -335,7 +344,7 @@ import {
   getPresetCategories,
   getPresetCategories,
   Application, ApplicationCreate, OperationLog, AppCategory
   Application, ApplicationCreate, OperationLog, AppCategory
 } from '../../api/apps'
 } from '../../api/apps'
-import { searchUsers, User } from '../../api/users'
+import { searchUsers, getUserById, User } from '../../api/users'
 import { sendImportVerificationCode } from '../../api/mapping'
 import { sendImportVerificationCode } from '../../api/mapping'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { ArrowDown, Search } from '@element-plus/icons-vue'
 import { ArrowDown, Search } from '@element-plus/icons-vue'
@@ -643,6 +652,9 @@ const transferForm = reactive({
 const transferDynamicFields = reactive({
 const transferDynamicFields = reactive({
     password: 'transfer_password'
     password: 'transfer_password'
 })
 })
+const transferOwnerName = ref('')
+
+const TRANSFER_TARGET_ROLES = ['DEVELOPER', 'SUPER_ADMIN']
 
 
 // Sync Users
 // Sync Users
 const handleSyncUsers = (row: Application) => {
 const handleSyncUsers = (row: Application) => {
@@ -741,7 +753,7 @@ const handleUserSearch = async (query: string) => {
     userLoading.value = true
     userLoading.value = true
     try {
     try {
         const res = await searchUsers(query)
         const res = await searchUsers(query)
-        userOptions.value = res.data
+        userOptions.value = res.data.filter((u) => TRANSFER_TARGET_ROLES.includes(u.role))
     } catch (e) {
     } catch (e) {
         console.error(e)
         console.error(e)
     } finally {
     } finally {
@@ -749,19 +761,32 @@ const handleUserSearch = async (query: string) => {
     }
     }
 }
 }
 
 
-const handleTransfer = (row: Application) => {
+const handleTransfer = async (row: Application) => {
     currentId.value = row.id
     currentId.value = row.id
     transferForm.targetMobile = ''
     transferForm.targetMobile = ''
     transferForm.verificationCode = ''
     transferForm.verificationCode = ''
     transferForm.password = ''
     transferForm.password = ''
     userOptions.value = []
     userOptions.value = []
-    
+    transferOwnerName.value = '加载中…'
+
     const randomSuffix = Math.random().toString(36).slice(2, 8)
     const randomSuffix = Math.random().toString(36).slice(2, 8)
     transferDynamicFields.password = `pwd_${randomSuffix}`
     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
     transferDialogVisible.value = true
 }
 }
 
 

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

@@ -185,11 +185,13 @@
       <el-tab-pane label="映射账号操作日志" name="logs">
       <el-tab-pane label="映射账号操作日志" name="logs">
           <div class="toolbar">
           <div class="toolbar">
               <el-input v-model="logKeyword" placeholder="搜索目标手机号" style="width: 200px; margin-right: 10px;" clearable @clear="fetchLogs" @keyup.enter="fetchLogs" />
               <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="MANUAL_ADD" />
                   <el-option label="删除" value="DELETE" />
                   <el-option label="删除" value="DELETE" />
                   <el-option label="修改" value="UPDATE" />
                   <el-option label="修改" value="UPDATE" />
                   <el-option label="Excel 导入" value="IMPORT" />
                   <el-option label="Excel 导入" value="IMPORT" />
+                  <el-option label="M2M 账号同步" value="SYNC_M2M" />
+                  <el-option label="控制台同步用户" value="SYNC" />
               </el-select>
               </el-select>
               <el-date-picker 
               <el-date-picker 
                 v-model="logDateRange" 
                 v-model="logDateRange" 
@@ -216,19 +218,44 @@
                   </template>
                   </template>
               </el-table-column>
               </el-table-column>
               <el-table-column prop="operator_mobile" label="操作人手机号" width="140" />
               <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">
               <el-table-column prop="target_mobile" label="目标手机号" width="140">
                   <template #default="scope">
                   <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>
                   </template>
               </el-table-column>
               </el-table-column>
-              <el-table-column label="详情" min-width="200">
+              <el-table-column label="详情" min-width="260">
                   <template #default="scope">
                   <template #default="scope">
                       <div v-if="scope.row.action_type === 'IMPORT'">
                       <div v-if="scope.row.action_type === 'IMPORT'">
                           <el-button type="primary" link @click="showImportDetails(scope.row)">查看导入详情</el-button>
                           <el-button type="primary" link @click="showImportDetails(scope.row)">查看导入详情</el-button>
                       </div>
                       </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">
                       <div v-else class="log-details">
                           <span v-if="scope.row.action_type === 'DELETE'">
                           <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>
                           <span v-else-if="scope.row.action_type === 'UPDATE'">
                           <span v-else-if="scope.row.action_type === 'UPDATE'">
                               变更前: {{ scope.row.details?.old?.mapped_key || '空' }} -> 变更后: {{ scope.row.details?.new?.mapped_key || '空' }}
                               变更前: {{ 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 'MANUAL_ADD': return '手动新增'
         case 'DELETE': return '删除'
         case 'DELETE': return '删除'
         case 'UPDATE': return '修改'
         case 'UPDATE': return '修改'
-        case 'SYNC_M2M': return 'M2M 同步'
+        case 'SYNC_M2M': return 'M2M 账号同步'
+        case 'SYNC': return '控制台同步'
         case 'IMPORT': return 'Excel 导入'
         case 'IMPORT': return 'Excel 导入'
         default: return type
         default: return type
     }
     }
@@ -746,6 +774,7 @@ const getActionTypeTag = (type: string) => {
         case 'DELETE': return 'danger'
         case 'DELETE': return 'danger'
         case 'UPDATE': return 'warning'
         case 'UPDATE': return 'warning'
         case 'SYNC_M2M': return 'primary'
         case 'SYNC_M2M': return 'primary'
+        case 'SYNC': return 'success'
         case 'IMPORT': return 'info'
         case 'IMPORT': return 'info'
         default: return ''
         default: return ''
     }
     }

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

@@ -53,10 +53,10 @@
           <tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
           <tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr>
         </thead>
         </thead>
         <tbody>
         <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>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>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>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>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>
           <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" \
      -H "X-App-Access-Token: YOUR_APP_ACCESS_TOKEN" \
      -d '{
      -d '{
            "mobile": "13800138000",
            "mobile": "13800138000",
+           "mapped_key": "user_1001_updated",
            "sync_action": "DELETE"
            "sync_action": "DELETE"
          }'
          }'
         </pre>
         </pre>