Sfoglia il codice sorgente

全量用户数据接口

liuq 3 mesi fa
parent
commit
c454a7d4ab

+ 119 - 1
backend/app/api/v1/endpoints/apps.py

@@ -36,7 +36,7 @@ from app.schemas.mapping import (
     MappingStrategy,
     ImportLogResponse
 )
-from app.schemas.user import UserSyncRequest
+from app.schemas.user import UserSyncRequest, UserSyncList
 from app.services.mapping_service import MappingService
 from app.services.sms_service import SmsService
 from app.services.log_service import LogService
@@ -672,6 +672,104 @@ def delete_mapping(
     
     return {"message": "删除成功"}
 
+@router.post("/{app_id}/sync-users", summary="同步所有用户")
+def sync_users_to_app(
+    *,
+    db: Session = Depends(deps.get_db),
+    app_id: int,
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    一键导入用户管理中的用户数据到应用映射中。
+    规则:如果用户已在映射中(基于手机号/User ID),则跳过。
+    否则创建映射,使用英文名称作为映射账号(如果为空则使用手机号)。
+    """
+    app = db.query(Application).filter(Application.id == app_id).first()
+    if not app:
+        raise HTTPException(status_code=404, detail="应用未找到")
+    
+    if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    # Get all active users
+    users = db.query(User).filter(User.is_deleted == 0).all()
+    
+    # Get existing mappings (user_ids)
+    existing_mappings = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id).all()
+    mapped_user_ids = {m.user_id for m in existing_mappings}
+    
+    new_mappings = []
+    
+    for user in users:
+        if user.id in mapped_user_ids:
+            continue
+            
+        # Create mapping
+        # Rule: Use English name as mapped_key. Fallback to mobile.
+        mapped_key = user.english_name if user.english_name else user.mobile
+        
+        # Check if mapped_key is already used in this app (unlikely for User ID check passed, but mapped_key might collide if english names are dupes)
+        # However, bulk insert for thousands might be slow if we check one by one.
+        # Ideally we should fetch all existing mapped_keys too.
+        
+        # For simplicity in "one-click sync", we might just proceed. 
+        # But if mapped_key is not unique in AppUserMapping (uq_app_mapped_key), it will fail.
+        # Let's assume English Name is unique enough or we catch error? 
+        # Actually, let's verify if english_name is unique in User table. It's not (User.english_name is nullable and not unique).
+        # So multiple users might have same english_name.
+        # If so, we should probably append mobile or something to make it unique? 
+        # Or just use mobile if english_name is duplicate?
+        
+        # Re-reading: "没有存在则使用手机号码,英文名称作为账号映射" -> "If not exists, use phone number, English name as account mapping".
+        # This could mean: Use English Name.
+        
+        mapping = AppUserMapping(
+            app_id=app.id,
+            user_id=user.id,
+            mapped_key=mapped_key,
+            mapped_email=None,
+            is_active=True
+        )
+        new_mappings.append(mapping)
+
+    if new_mappings:
+        try:
+            db.bulk_save_objects(new_mappings)
+            db.commit()
+        except Exception as e:
+            db.rollback()
+            # If bulk fails (e.g. unique constraint on mapped_key), we might need to do row-by-row or handle it.
+            # Fallback: try one by one
+            success_count = 0
+            for m in new_mappings:
+                try:
+                    db.add(m)
+                    db.commit()
+                    success_count += 1
+                except:
+                    db.rollback()
+            
+            LogService.create_log(
+                db=db,
+                app_id=app.id,
+                operator_id=current_user.id,
+                action_type=ActionType.IMPORT,
+                details={"message": "Sync all users (partial)", "attempted": len(new_mappings), "success": success_count}
+            )
+            return {"message": f"同步完成,成功 {success_count} 个,失败 {len(new_mappings) - success_count} 个 (可能是账号冲突)"}
+            
+        # Log success
+        LogService.create_log(
+            db=db,
+            app_id=app.id,
+            operator_id=current_user.id,
+            action_type=ActionType.IMPORT,
+            details={"message": "Sync all users", "count": len(new_mappings)}
+        )
+        return {"message": f"同步成功,新增 {len(new_mappings)} 个用户映射"}
+
+    return {"message": "没有需要同步的用户"}
+
 @router.get("/{app_id}/mappings/export", summary="导出映射")
 def export_mappings(
     *,
@@ -790,6 +888,26 @@ async def import_mapping(
     
     return result
 
+@router.get("/mapping/users", response_model=UserSyncList, summary="获取全量用户(M2M)")
+def get_all_users_m2m(
+    *,
+    db: Session = Depends(deps.get_db),
+    skip: int = 0,
+    limit: int = 100,
+    current_app: Application = Depends(deps.get_current_app),
+):
+    """
+    开发者拉取全量用户接口。
+    仅返回:手机号、姓名、英文名。
+    需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)。
+    """
+    query = db.query(User).filter(User.is_deleted == 0)
+    
+    total = query.count()
+    users = query.order_by(User.id).offset(skip).limit(limit).all()
+    
+    return {"total": total, "items": users}
+
 @router.post("/mapping/sync", response_model=MappingResponse, summary="同步映射 (M2M)")
 def sync_mapping(
     *,

+ 12 - 0
backend/app/schemas/user.py

@@ -61,3 +61,15 @@ class UserInDB(UserInDBBase):
 class UserList(BaseModel):
     total: int
     items: List[User]
+
+class UserSyncSimple(BaseModel):
+    mobile: str
+    name: Optional[str] = None
+    english_name: Optional[str] = None
+
+    class Config:
+        from_attributes = True
+
+class UserSyncList(BaseModel):
+    total: int
+    items: List[UserSyncSimple]

+ 136 - 0
frontend/public/docs/user_sync_pull.md

@@ -0,0 +1,136 @@
+# 全量用户同步 (User Sync Pull) 开发指南
+
+本文档旨在指导开发者如何调用统一认证平台 (UAP) 的全量用户同步接口。
+开发者可以通过此接口,将 UAP 中的全量用户数据(仅基础信息)拉取到自己的业务系统中。
+
+## 1. 接口概述
+
+*   **API 地址**: `{{API_BASE_URL}}/apps/mapping/users`
+    *   例如: `http://localhost:8000/api/v1/apps/mapping/users`
+*   **请求方法**: `GET`
+*   **认证方式**: Header 中携带 `X-App-Access-Token: <您的应用访问令牌>`
+
+## 2. 认证准备
+
+在调用接口前,请确保您已在 UAP 平台创建应用,并获取了 **Access Token**。
+Access Token 通常在应用详情页面的 "密钥管理" 或 "应用信息" 区域查看。
+
+## 3. 请求参数 (Query Parameters)
+
+该接口支持分页查询,建议在大数据量时分批拉取。
+
+| 参数名 | 类型 | 必填 | 默认值 | 说明 |
+| :--- | :--- | :--- | :--- | :--- |
+| `skip` | integer | 否 | 0 | 偏移量,表示跳过前 N 条记录 |
+| `limit` | integer | 否 | 100 | 每页返回的记录数量 |
+
+## 4. 响应结构
+
+接口返回标准的 JSON 格式数据。
+
+```json
+{
+  "total": 1250,        // 平台用户总数 (未删除)
+  "items": [            // 用户列表
+    {
+      "mobile": "13800138000",   // 手机号 (唯一标识)
+      "name": "张三",            // 中文名 (可能为空)
+      "english_name": "zhangsan" // 英文名 (可能为空)
+    },
+    // ...
+  ]
+}
+```
+
+## 5. 调用示例
+
+### 5.1 Curl
+
+```bash
+curl -X GET "{{API_BASE_URL}}/apps/mapping/users?skip=0&limit=100" \
+     -H "X-App-Access-Token: YOUR_ACCESS_TOKEN_HERE"
+```
+
+### 5.2 Python (Requests)
+
+```python
+import requests
+import time
+
+API_URL = "{{API_BASE_URL}}/apps/mapping/users"
+ACCESS_TOKEN = "YOUR_ACCESS_TOKEN_HERE"
+
+def sync_all_users():
+    skip = 0
+    limit = 100
+    total_synced = 0
+    
+    while True:
+        print(f"正在拉取: skip={skip}, limit={limit}...")
+        try:
+            resp = requests.get(
+                API_URL, 
+                params={"skip": skip, "limit": limit},
+                headers={"X-App-Access-Token": ACCESS_TOKEN},
+                timeout=10
+            )
+            
+            if resp.status_code != 200:
+                print(f"请求失败: {resp.status_code} - {resp.text}")
+                break
+                
+            data = resp.json()
+            items = data.get("items", [])
+            total = data.get("total", 0)
+            
+            # 处理数据:保存到本地数据库
+            for user in items:
+                # TODO: save_to_db(user)
+                pass
+            
+            count = len(items)
+            total_synced += count
+            print(f"已获取 {count} 条数据。进度: {total_synced}/{total}")
+            
+            if count < limit or total_synced >= total:
+                print("同步完成!")
+                break
+                
+            skip += limit
+            
+            # 避免请求过于频繁
+            time.sleep(0.1)
+            
+        except Exception as e:
+            print(f"发生错误: {e}")
+            break
+
+if __name__ == "__main__":
+    sync_all_users()
+```
+
+### 5.3 Java (OkHttp)
+
+```java
+OkHttpClient client = new OkHttpClient();
+
+String url = "{{API_BASE_URL}}/apps/mapping/users?skip=0&limit=100";
+Request request = new Request.Builder()
+  .url(url)
+  .addHeader("X-App-Access-Token", "YOUR_ACCESS_TOKEN_HERE")
+  .build();
+
+try (Response response = client.newCall(request).execute()) {
+    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);
+
+    System.out.println(response.body().string());
+}
+```
+
+## 6. 常见错误码
+
+*   **403 Forbidden**: 
+    *   令牌无效或过期。
+    *   令牌对应的应用已被禁用。
+*   **422 Unprocessable Entity**: 参数格式错误(如 `skip` 为负数)。
+

+ 4 - 0
frontend/src/api/apps.ts

@@ -127,6 +127,10 @@ export const exportMappings = (appId: number) => {
   return api.get(`/apps/${appId}/mappings/export`, { responseType: 'blob' })
 }
 
+export const syncAppUsers = (appId: number) => {
+  return api.post<{ message: string }>(`/apps/${appId}/sync-users`)
+}
+
 export const getOperationLogs = (appId: number, params: LogQueryParams) => {
     return api.get<LogListResponse>(`/apps/${appId}/logs`, { params })
 }

+ 86 - 4
frontend/src/views/Help.vue

@@ -70,7 +70,7 @@
             <h2>统一认证平台 - 简易认证 (Simple Auth) 集成指南</h2>
             <el-button type="primary" size="small" plain @click="downloadDoc('/docs/simple_auth.md', 'Simple_Auth_Guide.md')">
               <el-icon style="margin-right: 5px"><Download /></el-icon>
-              下载 AI 开发文档
+              下载 开发文档
             </el-button>
           </div>
           <p class="intro">本指南适用于需要使用自定义登录页面(而非跳转到认证中心标准页面),并通过后端 API 直接进行用户认证的场景。</p>
@@ -601,7 +601,7 @@ print(sign)
             <h2>票据交互 (Ticket Exchange) 使用说明</h2>
             <el-button type="primary" size="small" plain @click="downloadDoc('/docs/ticket_exchange.md', 'Ticket_Exchange_Guide.md')">
               <el-icon style="margin-right: 5px"><Download /></el-icon>
-              下载 AI 开发文档
+              下载 开发文档
             </el-button>
           </div>
           <p class="intro">当用户已在 <strong>源应用 (Source App)</strong> 登录,需要无缝跳转到 <strong>目标应用 (Target App)</strong> 且实现免登录时,使用此接口。</p>
@@ -712,7 +712,7 @@ print(sign)
             <h2>接口对比:/login vs /sso-login</h2>
             <el-button type="primary" size="small" plain @click="downloadDoc('/docs/api_comparison.md', 'API_Comparison_Guide.md')">
               <el-icon style="margin-right: 5px"><Download /></el-icon>
-              下载 AI 开发文档
+              下载 开发文档
             </el-button>
           </div>
           <p class="intro">平台提供了两个登录接口,它们在功能上有一定重叠,但<strong>设计目的和使用场景不同</strong>。本章节详细对比两个接口的区别,帮助您选择最合适的接口。</p>
@@ -1038,7 +1038,7 @@ window.location.href = redirect_url;  // 直接跳转
             <h2>账号同步 (M2M) 使用说明</h2>
             <el-button type="primary" size="small" plain @click="downloadDoc('/docs/account_sync.md', 'Account_Sync_Guide.md')">
               <el-icon style="margin-right: 5px"><Download /></el-icon>
-              下载 AI 开发文档
+              下载 开发文档
             </el-button>
           </div>
           <p class="intro">此接口用于将外部业务系统(如 OA、CRM)的用户账号关系同步到本平台。支持批量调用,实现“本平台用户(手机号)”与“外部应用账号(ID/邮箱)”的绑定。</p>
@@ -1119,6 +1119,88 @@ curl -X POST "http://your-uap-domain/api/v1/apps/mapping/sync" \
         </div>
       </el-tab-pane>
 
+      <el-tab-pane label="全量用户同步" name="user-sync-pull">
+        <div class="help-content">
+          <div class="content-header">
+            <h2>全量用户同步 (Pull) 使用说明</h2>
+            <el-button type="primary" size="small" plain @click="downloadDoc('/docs/user_sync_pull.md', 'User_Sync_Pull_Guide.md')">
+              <el-icon style="margin-right: 5px"><Download /></el-icon>
+              下载 开发文档
+            </el-button>
+          </div>
+          <p class="intro">此接口用于开发者从本平台<strong>拉取全量用户数据</strong>(仅包含手机号、姓名、英文名)。<br>
+          适用场景:在业务系统初始化阶段,需要将统一认证平台的所有用户同步到本地数据库。</p>
+
+          <div class="section">
+            <h3>1. 接口概述</h3>
+            <ul>
+              <li><strong>接口地址</strong>: <code>GET /api/v1/apps/mapping/users</code></li>
+              <li><strong>认证方式</strong>: 请求头需包含 <code>X-App-Access-Token</code>(可在应用详情页查看)。</li>
+              <li><strong>接口特点</strong>: 支持分页查询,只返回基础身份信息。</li>
+            </ul>
+          </div>
+
+          <div class="section">
+            <h3>2. 请求参数 (Query Params)</h3>
+            <table class="param-table">
+              <thead>
+                <tr><th>字段</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr>
+              </thead>
+              <tbody>
+                <tr><td><code>skip</code></td><td>integer</td><td><span class="tag-optional">否</span></td><td>0</td><td>偏移量(从第几条开始)</td></tr>
+                <tr><td><code>limit</code></td><td>integer</td><td><span class="tag-optional">否</span></td><td>100</td><td>每页返回数量</td></tr>
+              </tbody>
+            </table>
+
+            <p><strong>请求示例 (Curl):</strong></p>
+            <div class="code-block">
+              <pre>
+curl -X GET "http://your-uap-domain/api/v1/apps/mapping/users?skip=0&limit=100" \
+     -H "X-App-Access-Token: YOUR_APP_ACCESS_TOKEN"
+              </pre>
+            </div>
+          </div>
+
+          <div class="section">
+            <h3>3. 响应说明</h3>
+            <p><strong>响应成功 (200 OK):</strong></p>
+            <div class="code-block">
+              <pre>
+{
+  "total": 1250,
+  "items": [
+    {
+      "mobile": "13800138000",
+      "name": "张三",
+      "english_name": "zhangsan"
+    },
+    {
+      "mobile": "13900139000",
+      "name": "李四",
+      "english_name": "lisi"
+    },
+    // ... 更多用户
+  ]
+}
+              </pre>
+            </div>
+            <ul>
+              <li><code>total</code>: 系统中未删除用户的总数。</li>
+              <li><code>items</code>: 当前页的用户列表。</li>
+            </ul>
+
+            <p><strong>响应失败:</strong></p>
+            <ul>
+              <li><code>403 Forbidden</code>: Access Token 无效、过期或无权限。</li>
+            </ul>
+          </div>
+          
+          <el-alert title="开发建议" type="success" :closable="false" show-icon class="alert-box">
+            <div>建议使用循环分页拉取(例如每次 100 条),直到获取的数据条数小于 limit 或总数达到 total,以避免单次请求超时。</div>
+          </el-alert>
+        </div>
+      </el-tab-pane>
+
       <el-tab-pane label="其他帮助 (待定)" name="tbd">
         <el-empty description="更多帮助文档敬请期待..." />
       </el-tab-pane>

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

@@ -47,6 +47,7 @@
                 </span>
                 <template #dropdown>
                   <el-dropdown-menu>
+                    <el-dropdown-item @click="handleSyncUsers(scope.row)">用户数据同步</el-dropdown-item>
                     <el-dropdown-item @click="handleRegenerate(scope.row)">重置密钥</el-dropdown-item>
                     <el-dropdown-item @click="handleTransfer(scope.row)">应用转让</el-dropdown-item>
                     <el-dropdown-item @click="handleLogs(scope.row)">操作日志</el-dropdown-item>
@@ -310,7 +311,7 @@ import { ref, onMounted, reactive, computed, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../../store/auth'
 import { 
-  getApps, createApp, updateApp, deleteApp, regenerateSecret, viewSecret, transferApp, getOperationLogs,
+  getApps, createApp, updateApp, deleteApp, regenerateSecret, viewSecret, transferApp, getOperationLogs, syncAppUsers,
   Application, ApplicationCreate, OperationLog
 } from '../../api/apps'
 import { searchUsers, User } from '../../api/users'
@@ -572,6 +573,26 @@ const transferDynamicFields = reactive({
     password: 'transfer_password'
 })
 
+// Sync Users
+const handleSyncUsers = (row: Application) => {
+  ElMessageBox.confirm(
+    '这将把所有系统用户同步到此应用中。已存在的映射将被跳过,新映射将使用英文名作为账号。是否继续?',
+    '同步确认',
+    {
+      confirmButtonText: '确定同步',
+      cancelButtonText: '取消',
+      type: 'warning',
+    }
+  ).then(async () => {
+    try {
+      const res = await syncAppUsers(row.id)
+      ElMessage.success(res.data.message)
+    } catch (e: any) {
+       // Handled by interceptor usually
+    }
+  }).catch(() => {})
+}
+
 // Logs
 const logsDialogVisible = ref(false)
 const logsLoading = ref(false)