Browse Source

新增access_token

liuq 3 months ago
parent
commit
05316e78bb

+ 23 - 3
backend/app/api/v1/deps.py

@@ -1,6 +1,6 @@
 from typing import Generator, Optional
 from typing import Generator, Optional
 from fastapi import Depends, HTTPException, status, Response
 from fastapi import Depends, HTTPException, status, Response
-from fastapi.security import OAuth2PasswordBearer
+from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from jose import jwt, JWTError
 from jose import jwt, JWTError
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from datetime import datetime
 from datetime import datetime
@@ -16,6 +16,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
     auto_error=False # Allow optional token
     auto_error=False # Allow optional token
 )
 )
 
 
+token_header_scheme = APIKeyHeader(name="X-App-Access-Token", auto_error=False)
+
 def get_db() -> Generator:
 def get_db() -> Generator:
     try:
     try:
         db = SessionLocal()
         db = SessionLocal()
@@ -121,12 +123,30 @@ def get_current_active_user_optional(
 
 
 def get_current_app(
 def get_current_app(
     db: Session = Depends(get_db),
     db: Session = Depends(get_db),
-    token: str = Depends(reusable_oauth2)
+    token: Optional[str] = Depends(reusable_oauth2),
+    access_token: Optional[str] = Depends(token_header_scheme)
 ) -> Application:
 ) -> Application:
     """
     """
     Get application from token (Machine-to-Machine auth).
     Get application from token (Machine-to-Machine auth).
-    Subject format: "app:{id}"
+    Supports:
+    1. JWT Bearer Token (Subject: "app:{id}")
+    2. Permanent Access Token (Header: X-App-Access-Token)
     """
     """
+    # 1. Try Access Token first if present
+    if access_token:
+        # Use simple auth with permanent token
+        app = db.query(Application).filter(Application.access_token == access_token).first()
+        if not app:
+             raise HTTPException(status_code=403, detail="Invalid access token")
+        return app
+
+    # 2. Try JWT Bearer Token
+    if not token:
+         raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Not authenticated",
+        )
+
     try:
     try:
         payload = jwt.decode(
         payload = jwt.decode(
             token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
             token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]

+ 82 - 15
backend/app/api/v1/endpoints/apps.py

@@ -35,6 +35,9 @@ from app.services.mapping_service import MappingService
 
 
 router = APIRouter()
 router = APIRouter()
 
 
+def generate_access_token():
+    return secrets.token_urlsafe(32)
+
 def generate_app_credentials():
 def generate_app_credentials():
     # Generate a random 16-char App ID (hex or alphanumeric)
     # Generate a random 16-char App ID (hex or alphanumeric)
     app_id = "app_" + secrets.token_hex(8)
     app_id = "app_" + secrets.token_hex(8)
@@ -86,12 +89,15 @@ def create_app(
     """
     """
     # 1. Generate ID and Secret
     # 1. Generate ID and Secret
     app_id, app_secret = generate_app_credentials()
     app_id, app_secret = generate_app_credentials()
+    # 2. Generate Access Token
+    access_token = generate_access_token()
     
     
-    # 2. Store Secret (Plain text needed for HMAC verification)
+    # 3. Store Secret (Plain text needed for HMAC verification)
     
     
     db_app = Application(
     db_app = Application(
         app_id=app_id,
         app_id=app_id,
         app_secret=app_secret,
         app_secret=app_secret,
+        access_token=access_token,
         app_name=app_in.app_name,
         app_name=app_in.app_name,
         icon_url=app_in.icon_url,
         icon_url=app_in.icon_url,
         protocol_type=app_in.protocol_type,
         protocol_type=app_in.protocol_type,
@@ -104,7 +110,7 @@ def create_app(
     db.commit()
     db.commit()
     db.refresh(db_app)
     db.refresh(db_app)
     
     
-    return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret)
+    return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret, access_token=access_token)
 
 
 @router.put("/{app_id}", response_model=ApplicationResponse, summary="更新应用")
 @router.put("/{app_id}", response_model=ApplicationResponse, summary="更新应用")
 def update_app(
 def update_app(
@@ -181,7 +187,7 @@ def regenerate_secret(
     db.add(app)
     db.add(app)
     db.commit()
     db.commit()
     
     
-    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=new_secret)
+    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=new_secret, access_token=app.access_token)
 
 
 @router.post("/{app_id}/view-secret", response_model=ApplicationSecretDisplay, summary="查看密钥")
 @router.post("/{app_id}/view-secret", response_model=ApplicationSecretDisplay, summary="查看密钥")
 def view_secret(
 def view_secret(
@@ -206,7 +212,7 @@ def view_secret(
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
         raise HTTPException(status_code=403, detail="权限不足")
         raise HTTPException(status_code=403, detail="权限不足")
         
         
-    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=app.app_secret)
+    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=app.app_secret, access_token=app.access_token)
 
 
 # ==========================================
 # ==========================================
 # Mappings
 # Mappings
@@ -269,11 +275,21 @@ def create_mapping(
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
         raise HTTPException(status_code=403, detail="权限不足")
         raise HTTPException(status_code=403, detail="权限不足")
 
 
-    # 1. Check if user exists
+    # 1. Find User or Create
     user = db.query(User).filter(User.mobile == mapping_in.mobile).first()
     user = db.query(User).filter(User.mobile == mapping_in.mobile).first()
     if not user:
     if not user:
-        raise HTTPException(status_code=404, detail=f"手机号为 {mapping_in.mobile} 的用户未找到")
-        
+        # Auto create user
+        password = secrets.token_urlsafe(8) # Random password
+        user = User(
+            mobile=mapping_in.mobile,
+            password_hash=security.get_password_hash(password),
+            status="ACTIVE",
+            role="DEVELOPER"
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
+
     # 2. Check if mapping exists
     # 2. Check if mapping exists
     existing = db.query(AppUserMapping).filter(
     existing = db.query(AppUserMapping).filter(
         AppUserMapping.app_id == app_id,
         AppUserMapping.app_id == app_id,
@@ -281,8 +297,26 @@ def create_mapping(
     ).first()
     ).first()
     if existing:
     if existing:
         raise HTTPException(status_code=400, detail="该用户的映射已存在")
         raise HTTPException(status_code=400, detail="该用户的映射已存在")
+
+    # 3. Check Uniqueness for mapped_email (if provided)
+    if mapping_in.mapped_email:
+        email_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == app_id,
+            AppUserMapping.mapped_email == mapping_in.mapped_email
+        ).first()
+        if email_exists:
+            raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapping_in.mapped_email} 已被使用")
+
+    # 4. Check Uniqueness for mapped_key
+    if mapping_in.mapped_key:
+        key_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == app_id,
+            AppUserMapping.mapped_key == mapping_in.mapped_key
+        ).first()
+        if key_exists:
+            raise HTTPException(status_code=400, detail=f"该应用下账号 {mapping_in.mapped_key} 已被使用")
         
         
-    # 3. Create
+    # 5. Create
     mapping = AppUserMapping(
     mapping = AppUserMapping(
         app_id=app_id,
         app_id=app_id,
         user_id=user.id,
         user_id=user.id,
@@ -434,12 +468,22 @@ def sync_mapping(
     """
     """
     从外部平台同步用户映射关系(机器对机器)。
     从外部平台同步用户映射关系(机器对机器)。
     只同步映射关系,不创建或更新用户本身。
     只同步映射关系,不创建或更新用户本身。
-    需要应用访问令牌。
+    需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)
     """
     """
-    # 1. Find User
+    # 1. Find User or Create
     user = db.query(User).filter(User.mobile == sync_in.mobile).first()
     user = db.query(User).filter(User.mobile == sync_in.mobile).first()
     if not user:
     if not user:
-        raise HTTPException(status_code=404, detail=f"User with mobile {sync_in.mobile} not found")
+        # Auto create user
+        password = secrets.token_urlsafe(8) # Random password
+        user = User(
+            mobile=sync_in.mobile,
+            password_hash=security.get_password_hash(password),
+            status="ACTIVE",
+            role="DEVELOPER"
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
 
 
     # 2. Handle Mapping
     # 2. Handle Mapping
     mapping = db.query(AppUserMapping).filter(
     mapping = db.query(AppUserMapping).filter(
@@ -447,10 +491,31 @@ def sync_mapping(
         AppUserMapping.user_id == user.id
         AppUserMapping.user_id == user.id
     ).first()
     ).first()
 
 
+    # Check Uniqueness for mapped_key (if changing or new, and provided)
+    if sync_in.mapped_key and (not mapping or mapping.mapped_key != sync_in.mapped_key):
+        key_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == current_app.id,
+            AppUserMapping.mapped_key == sync_in.mapped_key
+        ).first()
+        if key_exists:
+             raise HTTPException(status_code=400, detail=f"该应用下账号 {sync_in.mapped_key} 已被使用")
+
+    # Check Uniqueness for mapped_email (if changing or new, and provided)
+    if sync_in.mapped_email and (not mapping or mapping.mapped_email != sync_in.mapped_email):
+        email_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == current_app.id,
+            AppUserMapping.mapped_email == sync_in.mapped_email
+        ).first()
+        if email_exists:
+             raise HTTPException(status_code=400, detail=f"该应用下邮箱 {sync_in.mapped_email} 已被使用")
+
     if mapping:
     if mapping:
         # Update existing mapping
         # Update existing mapping
-        mapping.mapped_key = sync_in.mapped_key
-        if sync_in.mapped_email:
+        if sync_in.mapped_key is not None:
+            mapping.mapped_key = sync_in.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:
             mapping.mapped_email = sync_in.mapped_email
             mapping.mapped_email = sync_in.mapped_email
     else:
     else:
         # Create new mapping
         # Create new mapping
@@ -458,7 +523,8 @@ def sync_mapping(
             app_id=current_app.id,
             app_id=current_app.id,
             user_id=user.id,
             user_id=user.id,
             mapped_key=sync_in.mapped_key,
             mapped_key=sync_in.mapped_key,
-            mapped_email=sync_in.mapped_email
+            mapped_email=sync_in.mapped_email,
+            is_active=sync_in.is_active if sync_in.is_active is not None else True
         )
         )
         db.add(mapping)
         db.add(mapping)
 
 
@@ -471,5 +537,6 @@ def sync_mapping(
         user_id=mapping.user_id,
         user_id=mapping.user_id,
         mapped_key=mapping.mapped_key,
         mapped_key=mapping.mapped_key,
         mapped_email=mapping.mapped_email,
         mapped_email=mapping.mapped_email,
-        user_mobile=user.mobile
+        user_mobile=user.mobile,
+        is_active=mapping.is_active
     )
     )

+ 3 - 0
backend/app/models/application.py

@@ -28,6 +28,9 @@ class Application(Base):
     
     
     notification_url = Column(String(255), nullable=True)
     notification_url = Column(String(255), nullable=True)
     
     
+    # Permanent Access Token for M2M operations (User Mapping Sync)
+    access_token = Column(String(128), unique=True, index=True, nullable=True)
+
     # Ownership & Logic Delete
     # Ownership & Logic Delete
     owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
     owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
     is_deleted = Column(Boolean, default=False, nullable=False)
     is_deleted = Column(Boolean, default=False, nullable=False)

+ 6 - 2
backend/app/models/mapping.py

@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint
+from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, Boolean
 from sqlalchemy.orm import relationship
 from sqlalchemy.orm import relationship
 from app.core.database import Base
 from app.core.database import Base
 
 
@@ -10,11 +10,14 @@ class AppUserMapping(Base):
     user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
     user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
     
     
     # The username/email/id in the target application
     # The username/email/id in the target application
-    mapped_key = Column(String(100), nullable=False)
+    mapped_key = Column(String(100), nullable=True)
     
     
     # Optional email in the target application
     # Optional email in the target application
     mapped_email = Column(String(100), nullable=True)
     mapped_email = Column(String(100), nullable=True)
 
 
+    # Status: True = Active, False = Disabled
+    is_active = Column(Boolean, default=True, nullable=False)
+
     # Relationships
     # Relationships
     application = relationship("Application")
     application = relationship("Application")
     user = relationship("User")
     user = relationship("User")
@@ -22,5 +25,6 @@ class AppUserMapping(Base):
     __table_args__ = (
     __table_args__ = (
         UniqueConstraint('app_id', 'user_id', name='uq_app_user'),
         UniqueConstraint('app_id', 'user_id', name='uq_app_user'),
         UniqueConstraint('app_id', 'mapped_key', name='uq_app_mapped_key'),
         UniqueConstraint('app_id', 'mapped_key', name='uq_app_mapped_key'),
+        UniqueConstraint('app_id', 'mapped_email', name='uq_app_mapped_email'),
     )
     )
 
 

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

@@ -42,6 +42,7 @@ class ApplicationList(BaseModel):
 class ApplicationSecretDisplay(BaseModel):
 class ApplicationSecretDisplay(BaseModel):
     app_id: str
     app_id: str
     app_secret: str
     app_secret: str
+    access_token: Optional[str] = None
 
 
 class ViewSecretRequest(BaseModel):
 class ViewSecretRequest(BaseModel):
     password: str
     password: str

+ 3 - 2
backend/app/schemas/mapping.py

@@ -36,16 +36,17 @@ class MappingImportSummary(BaseModel):
 
 
 class MappingCreate(BaseModel):
 class MappingCreate(BaseModel):
     mobile: str
     mobile: str
-    mapped_key: str
+    mapped_key: Optional[str] = None
     mapped_email: Optional[str] = None
     mapped_email: Optional[str] = None
 
 
 class MappingResponse(BaseModel):
 class MappingResponse(BaseModel):
     id: int
     id: int
     app_id: int
     app_id: int
     user_id: int
     user_id: int
-    mapped_key: str
+    mapped_key: Optional[str] = None
     mapped_email: Optional[str] = None
     mapped_email: Optional[str] = None
     user_mobile: str  # Convenient to have
     user_mobile: str  # Convenient to have
+    is_active: bool = True
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 2 - 1
backend/app/schemas/user.py

@@ -32,8 +32,9 @@ class UserSyncRequest(BaseModel):
     mobile: str
     mobile: str
     password: Optional[str] = None
     password: Optional[str] = None
     status: Optional[str] = None
     status: Optional[str] = None
-    mapped_key: str # External User ID
+    mapped_key: Optional[str] = None # External User 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
 
 
 class UserInDBBase(UserBase):
 class UserInDBBase(UserBase):
     id: int
     id: int

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

@@ -23,6 +23,7 @@ export interface ApplicationCreate {
 export interface AppSecretResponse {
 export interface AppSecretResponse {
   app_id: string
   app_id: string
   app_secret: string
   app_secret: string
+  access_token: string
 }
 }
 
 
 export interface ApplicationListResponse {
 export interface ApplicationListResponse {

+ 16 - 10
frontend/src/views/Help.vue

@@ -103,10 +103,17 @@ sign = hmac.new(secret.encode(), query_string.encode(), hashlib.sha256).hexdiges
         <div class="content">
         <div class="content">
             <p>适用于外部平台与统一认证平台的后端直接对接场景。</p>
             <p>适用于外部平台与统一认证平台的后端直接对接场景。</p>
             
             
-            <h4>1. 获取应用令牌 (Client Credentials)</h4>
-            <p>使用 App ID 和 App Secret 获取 Access Token。</p>
+            <h4>1. 认证方式 (Authentication)</h4>
+            <p>平台支持两种认证方式进行接口调用:</p>
+            
+            <h5>方式 A:永久令牌 (推荐)</h5>
+            <p>在应用详情中查看并获取永久有效的 <code>Access Token</code>。</p>
+            <p><strong>Header</strong>: <code>X-App-Access-Token: your_permanent_access_token</code></p>
+            
+            <h5>方式 B:临时令牌 (Client Credentials)</h5>
+            <p>使用 App ID 和 App Secret 获取短期有效的 Access Token。</p>
             <p><strong>接口</strong>: <code>POST /api/v1/auth/app-token</code></p>
             <p><strong>接口</strong>: <code>POST /api/v1/auth/app-token</code></p>
-            <p><strong>注意</strong>: Access Token 有效期为 <strong>30分钟</strong>。请在有效期内复用 Token,无需每次请求都重新生成。</p>
+            <p><strong>有效期</strong>: 30分钟。需在 Header 中传递: <code>Authorization: Bearer &lt;token&gt;</code></p>
             <pre class="code-block">
             <pre class="code-block">
 {
 {
     "app_id": "app_xxxx",
     "app_id": "app_xxxx",
@@ -114,17 +121,16 @@ sign = hmac.new(secret.encode(), query_string.encode(), hashlib.sha256).hexdiges
 }</pre>
 }</pre>
             <p><strong>响应</strong>: <code>{"access_token": "...", "token_type": "bearer"}</code></p>
             <p><strong>响应</strong>: <code>{"access_token": "...", "token_type": "bearer"}</code></p>
 
 
-            <h4>2. 同步用户信息</h4>
-            <p>将外部系统的用户信息(新增或变更)同步到统一平台,并自动建立账号映射关系。</p>
-            <p><strong>接口</strong>: <code>POST /api/v1/users/sync</code></p>
-            <p><strong>Header</strong>: <code>Authorization: Bearer &lt;access_token&gt;</code></p>
+            <h4>2. 同步账号映射</h4>
+            <p>将外部系统的用户账号映射关系同步到统一平台。支持新增、更新及停用映射。</p>
+            <p><strong>接口</strong>: <code>POST /api/v1/apps/mapping/sync</code></p>
+            <p><strong>注意</strong>: 目标手机号必须已在统一平台注册。</p>
             <pre class="code-block">
             <pre class="code-block">
 {
 {
-    "mobile": "13800138000",      // 统一平台标识
+    "mobile": "13800138000",      // 统一平台标识 (必须已存在)
     "mapped_key": "user_001",     // 外部系统唯一标识 (工号/ID)
     "mapped_key": "user_001",     // 外部系统唯一标识 (工号/ID)
     "mapped_email": "a@b.com",    // 外部邮箱 (可选)
     "mapped_email": "a@b.com",    // 外部邮箱 (可选)
-    "password": "new_password",   // 若用户不存在,将使用此密码创建 (可选)
-    "status": "ACTIVE"            // 状态 (可选)
+    "is_active": true             // 状态 (可选, true=启用, false=停用)
 }</pre>
 }</pre>
         </div>
         </div>
       </el-collapse-item>
       </el-collapse-item>

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

@@ -127,6 +127,10 @@
           <strong>App Secret:</strong>
           <strong>App Secret:</strong>
           <span class="secret-text">{{ secretData.app_secret }}</span>
           <span class="secret-text">{{ secretData.app_secret }}</span>
         </div>
         </div>
+        <div class="credential-item" v-if="secretData.access_token">
+          <strong>Access Token (永久):</strong>
+          <div class="token-text">{{ secretData.access_token }}</div>
+        </div>
       </div>
       </div>
       <template #footer>
       <template #footer>
         <el-button type="primary" @click="secretDialogVisible = false">关闭</el-button>
         <el-button type="primary" @click="secretDialogVisible = false">关闭</el-button>
@@ -178,7 +182,8 @@ const form = reactive<ApplicationCreate>({
 
 
 const secretData = reactive({
 const secretData = reactive({
   app_id: '',
   app_id: '',
-  app_secret: ''
+  app_secret: '',
+  access_token: ''
 })
 })
 
 
 // Methods
 // Methods
@@ -243,6 +248,7 @@ const handleSubmit = async () => {
       const res = await createApp(form)
       const res = await createApp(form)
       secretData.app_id = res.data.app_id
       secretData.app_id = res.data.app_id
       secretData.app_secret = res.data.app_secret
       secretData.app_secret = res.data.app_secret
+      secretData.access_token = res.data.access_token
       secretIsNew.value = true
       secretIsNew.value = true
       secretDialogVisible.value = true
       secretDialogVisible.value = true
       dialogVisible.value = false
       dialogVisible.value = false
@@ -270,6 +276,7 @@ const confirmViewSecret = async () => {
     const res = await viewSecret(viewingAppId.value!, adminPassword.value)
     const res = await viewSecret(viewingAppId.value!, adminPassword.value)
     secretData.app_id = res.data.app_id
     secretData.app_id = res.data.app_id
     secretData.app_secret = res.data.app_secret
     secretData.app_secret = res.data.app_secret
+    secretData.access_token = res.data.access_token
     secretIsNew.value = false
     secretIsNew.value = false
     verifyDialogVisible.value = false
     verifyDialogVisible.value = false
     secretDialogVisible.value = true
     secretDialogVisible.value = true
@@ -302,6 +309,7 @@ const handleRegenerate = (row: Application) => {
     const res = await regenerateSecret(row.id)
     const res = await regenerateSecret(row.id)
     secretData.app_id = res.data.app_id
     secretData.app_id = res.data.app_id
     secretData.app_secret = res.data.app_secret
     secretData.app_secret = res.data.app_secret
+    secretData.access_token = res.data.access_token
     secretIsNew.value = true
     secretIsNew.value = true
     secretDialogVisible.value = true
     secretDialogVisible.value = true
   })
   })
@@ -351,6 +359,16 @@ onMounted(() => {
   color: #f56c6c;
   color: #f56c6c;
   font-weight: bold;
   font-weight: bold;
 }
 }
+.token-text {
+  color: #67c23a;
+  font-weight: bold;
+  word-break: break-all;
+  margin-top: 5px;
+  background: #fff;
+  padding: 5px;
+  border-radius: 4px;
+  border: 1px dashed #dcdfe6;
+}
 .action-buttons {
 .action-buttons {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 4 - 5
需求功能概述.md

@@ -1,7 +1,3 @@
-好的,我们来做一个**最详尽的总结**,让你能够直接使用 Cursor AI 来进行开发。这份总结将覆盖所有已讨论的功能模块、技术选型、关键实现细节、API 设计以及可能的挑战。
-
----
-
 ### 项目概览:统一认证平台 (Unified Authentication Platform - UAP)
 ### 项目概览:统一认证平台 (Unified Authentication Platform - UAP)
 
 
 **核心价值**:
 **核心价值**:
@@ -54,6 +50,7 @@
 *   **主键**: `id` (Integer, PK)
 *   **主键**: `id` (Integer, PK)
 *   **标识**: `app_id` (String(32), Unique, Index, Not Null) - 分配给第三方系统
 *   **标识**: `app_id` (String(32), Unique, Index, Not Null) - 分配给第三方系统
 *   **密钥**: `app_secret_hash` (String(128), Not Null) - 用于接口签名
 *   **密钥**: `app_secret_hash` (String(128), Not Null) - 用于接口签名
+*   **访问令牌**: `access_token` (String(64), Unique) - 永久有效的应用访问令牌,用于 M2M 接口鉴权。
 *   **信息**: `app_name`, `icon_url`
 *   **信息**: `app_name`, `icon_url`
 *   **协议类型**: `protocol_type` (Enum: OIDC, SIMPLE_API)
 *   **协议类型**: `protocol_type` (Enum: OIDC, SIMPLE_API)
 *   **OIDC 配置 (Hydra 对应)**: `redirect_uris` (Text, Not Null - JSON/Array), `grant_types`, `response_types` (Store in Hydra DB, but link via `app_id`)
 *   **OIDC 配置 (Hydra 对应)**: `redirect_uris` (Text, Not Null - JSON/Array), `grant_types`, `response_types` (Store in Hydra DB, but link via `app_id`)
@@ -65,6 +62,7 @@
 *   **主键**: `id` (Integer, PK)
 *   **主键**: `id` (Integer, PK)
 *   **关联**: `app_id` (FK to Applications), `user_id` (FK to Users)
 *   **关联**: `app_id` (FK to Applications), `user_id` (FK to Users)
 *   **核心映射**: `mapped_key` (String(100), Not Null) - 目标系统的账号(邮箱、用户名等)
 *   **核心映射**: `mapped_key` (String(100), Not Null) - 目标系统的账号(邮箱、用户名等)
+*   **状态**: `is_active` (Boolean, Default True) - 映射关系是否启用。
 *   **唯一性约束**: `UNIQUE(app_id, user_id)` 确保一个用户在同一应用下只映射一次。
 *   **唯一性约束**: `UNIQUE(app_id, user_id)` 确保一个用户在同一应用下只映射一次。
 *   **可选约束**: `UNIQUE(app_id, mapped_key)` (如果目标系统的账号在该应用内必须唯一)。
 *   **可选约束**: `UNIQUE(app_id, mapped_key)` (如果目标系统的账号在该应用内必须唯一)。
 
 
@@ -118,7 +116,8 @@
     *   Payload includes `event_type` (ADD, UPDATE, DISABLE), `app_id`, `user_data` (mobile, name, mapped_key, status).
     *   Payload includes `event_type` (ADD, UPDATE, DISABLE), `app_id`, `user_data` (mobile, name, mapped_key, status).
     *   Signature `X-UAP-Signature` must be verified by the receiving system.
     *   Signature `X-UAP-Signature` must be verified by the receiving system.
     *   **Logging**: Failed calls are logged to `WebhookLogs` table. Manual retry option for admins.
     *   **Logging**: Failed calls are logged to `WebhookLogs` table. Manual retry option for admins.
-*   **API (Pull - Detail Query)**:
+*   **API (Pull - Detail Query & Sync)**:
+    *   `POST /api/v1/apps/mapping/sync`: 同步映射关系(新增/更新/停用)。支持 Header `X-App-Access-Token`。
     *   `GET /api/v1/apps/{app_id}/users/query?mobile={mobile}`: Returns user details and mappings. Used by legacy systems during login if local user not found.
     *   `GET /api/v1/apps/{app_id}/users/query?mobile={mobile}`: Returns user details and mappings. Used by legacy systems during login if local user not found.
     *   `GET /api/v1/apps/{app_id}/users/changes?since={timestamp}`: Returns a list of changes. Less critical if Webhook is primary.
     *   `GET /api/v1/apps/{app_id}/users/changes?since={timestamp}`: Returns a list of changes. Less critical if Webhook is primary.