apps.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307
  1. import secrets
  2. import string
  3. import io
  4. import csv
  5. import pandas as pd
  6. import logging
  7. from typing import List
  8. from datetime import datetime
  9. from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, File, Form, Query
  10. from fastapi.responses import StreamingResponse
  11. from sqlalchemy.orm import Session
  12. from sqlalchemy import desc, or_
  13. from app.api.v1 import deps
  14. from app.core import security
  15. from app.models.application import Application
  16. from app.models.user import User
  17. from app.models.mapping import AppUserMapping
  18. from app.core.utils import generate_english_name
  19. from app.schemas.application import (
  20. ApplicationCreate,
  21. ApplicationUpdate,
  22. ApplicationResponse,
  23. ApplicationList,
  24. ApplicationSecretDisplay,
  25. ViewSecretRequest,
  26. RegenerateSecretRequest,
  27. ApplicationTransferRequest,
  28. AppSyncRequest
  29. )
  30. from app.schemas.mapping import (
  31. MappingList,
  32. MappingResponse,
  33. MappingCreate,
  34. MappingUpdate,
  35. MappingDelete,
  36. MappingPreviewResponse,
  37. MappingImportSummary,
  38. MappingStrategy,
  39. ImportLogResponse
  40. )
  41. from app.schemas.user import UserSyncRequest, UserSyncList
  42. from app.services.mapping_service import MappingService
  43. from app.services.sms_service import SmsService
  44. from app.services.log_service import LogService
  45. from app.schemas.operation_log import ActionType, OperationLogList, OperationLogResponse
  46. router = APIRouter()
  47. logger = logging.getLogger(__name__)
  48. def generate_access_token():
  49. return secrets.token_urlsafe(32)
  50. def generate_app_credentials():
  51. # Generate a random 16-char App ID (hex or alphanumeric)
  52. app_id = "app_" + secrets.token_hex(8)
  53. # Generate a strong 32-char App Secret
  54. alphabet = string.ascii_letters + string.digits
  55. app_secret = ''.join(secrets.choice(alphabet) for i in range(32))
  56. return app_id, app_secret
  57. @router.get("/", response_model=ApplicationList, summary="获取应用列表")
  58. def read_apps(
  59. skip: int = 0,
  60. limit: int = 10,
  61. search: str = None,
  62. db: Session = Depends(deps.get_db),
  63. current_user: User = Depends(deps.get_current_active_user),
  64. ):
  65. """
  66. 获取应用列表(分页)。
  67. 超级管理员可以查看所有,开发者只能查看自己的应用。
  68. """
  69. query = db.query(Application).filter(Application.is_deleted == False)
  70. if current_user.role != "SUPER_ADMIN":
  71. query = query.filter(Application.owner_id == current_user.id)
  72. if search:
  73. # Search by name or app_id
  74. query = query.filter(
  75. or_(
  76. Application.app_name.ilike(f"%{search}%"),
  77. Application.app_id.ilike(f"%{search}%")
  78. )
  79. )
  80. total = query.count()
  81. apps = query.order_by(desc(Application.id)).offset(skip).limit(limit).all()
  82. return {"total": total, "items": apps}
  83. @router.get("/{app_id}", response_model=ApplicationResponse, summary="获取单个应用详情")
  84. def read_app(
  85. *,
  86. db: Session = Depends(deps.get_db),
  87. app_id: int,
  88. current_user: User = Depends(deps.get_current_active_user),
  89. ):
  90. """
  91. 获取单个应用详情。
  92. """
  93. app = db.query(Application).filter(Application.id == app_id).first()
  94. if not app:
  95. raise HTTPException(status_code=404, detail="应用未找到")
  96. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  97. raise HTTPException(status_code=403, detail="权限不足")
  98. return app
  99. @router.post("/", response_model=ApplicationSecretDisplay, summary="创建应用")
  100. def create_app(
  101. *,
  102. db: Session = Depends(deps.get_db),
  103. app_in: ApplicationCreate,
  104. current_user: User = Depends(deps.get_current_active_user),
  105. ):
  106. """
  107. 创建新应用。只会返回一次明文密钥。
  108. """
  109. # 1. Generate ID and Secret
  110. app_id, app_secret = generate_app_credentials()
  111. # 2. Generate Access Token
  112. access_token = generate_access_token()
  113. # 3. Store Secret (Plain text needed for HMAC verification)
  114. db_app = Application(
  115. app_id=app_id,
  116. app_secret=app_secret,
  117. access_token=access_token,
  118. app_name=app_in.app_name,
  119. icon_url=app_in.icon_url,
  120. protocol_type=app_in.protocol_type,
  121. redirect_uris=app_in.redirect_uris,
  122. notification_url=app_in.notification_url,
  123. owner_id=current_user.id # Assign owner
  124. )
  125. db.add(db_app)
  126. db.commit()
  127. db.refresh(db_app)
  128. logger.info(f"应用创建成功: {app_in.app_name} (ID: {app_id}, Owner: {current_user.mobile})")
  129. return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret, access_token=access_token)
  130. @router.put("/{app_id}", response_model=ApplicationResponse, summary="更新应用")
  131. def update_app(
  132. *,
  133. db: Session = Depends(deps.get_db),
  134. app_id: int,
  135. app_in: ApplicationUpdate,
  136. current_user: User = Depends(deps.get_current_active_user),
  137. ):
  138. """
  139. 更新应用信息。需要手机验证码和密码验证。
  140. """
  141. app = db.query(Application).filter(Application.id == app_id).first()
  142. if not app:
  143. raise HTTPException(status_code=404, detail="应用未找到")
  144. # Check ownership
  145. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  146. raise HTTPException(status_code=403, detail="权限不足")
  147. # Security Verification
  148. if not app_in.verification_code:
  149. raise HTTPException(status_code=400, detail="需要提供手机验证码")
  150. if not SmsService.verify_code(current_user.mobile, app_in.verification_code):
  151. logger.warning(f"应用更新失败: 验证码错误 (User: {current_user.mobile})")
  152. raise HTTPException(status_code=400, detail="验证码无效或已过期")
  153. update_data = app_in.model_dump(exclude_unset=True)
  154. # Remove security fields from update data
  155. update_data.pop('password', None)
  156. update_data.pop('verification_code', None)
  157. for field, value in update_data.items():
  158. setattr(app, field, value)
  159. db.add(app)
  160. db.commit()
  161. db.refresh(app)
  162. # 5. Log
  163. LogService.create_log(
  164. db=db,
  165. app_id=app.id,
  166. operator_id=current_user.id,
  167. action_type=ActionType.UPDATE,
  168. details=update_data
  169. )
  170. logger.info(f"应用更新成功: {app.app_name} (ID: {app.app_id})")
  171. return app
  172. @router.delete("/{app_id}", response_model=ApplicationResponse, summary="删除应用")
  173. def delete_app(
  174. *,
  175. db: Session = Depends(deps.get_db),
  176. app_id: int,
  177. current_user: User = Depends(deps.get_current_active_user),
  178. ):
  179. """
  180. 软删除应用。
  181. """
  182. app = db.query(Application).filter(Application.id == app_id).first()
  183. if not app:
  184. raise HTTPException(status_code=404, detail="应用未找到")
  185. # Check ownership
  186. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  187. raise HTTPException(status_code=403, detail="权限不足")
  188. app.is_deleted = True
  189. db.add(app)
  190. db.commit()
  191. logger.info(f"应用删除成功: {app.app_name} (ID: {app.app_id}, Operator: {current_user.mobile})")
  192. return app
  193. @router.post("/{app_id}/regenerate-secret", response_model=ApplicationSecretDisplay, summary="重新生成密钥")
  194. def regenerate_secret(
  195. *,
  196. db: Session = Depends(deps.get_db),
  197. app_id: int,
  198. req: RegenerateSecretRequest,
  199. current_user: User = Depends(deps.get_current_active_user),
  200. ):
  201. """
  202. 重新生成应用密钥。需要手机验证码和密码验证。
  203. """
  204. app = db.query(Application).filter(Application.id == app_id).first()
  205. if not app:
  206. raise HTTPException(status_code=404, detail="应用未找到")
  207. # Check ownership
  208. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  209. raise HTTPException(status_code=403, detail="权限不足")
  210. # Security Verification
  211. if not security.verify_password(req.password, current_user.password_hash):
  212. logger.warning(f"重置密钥失败: 密码错误 (User: {current_user.mobile})")
  213. raise HTTPException(status_code=403, detail="密码错误")
  214. if not SmsService.verify_code(current_user.mobile, req.verification_code):
  215. logger.warning(f"重置密钥失败: 验证码错误 (User: {current_user.mobile})")
  216. raise HTTPException(status_code=400, detail="验证码无效或已过期")
  217. _, new_secret = generate_app_credentials()
  218. app.app_secret = new_secret
  219. db.add(app)
  220. db.commit()
  221. # Log
  222. LogService.create_log(
  223. db=db,
  224. app_id=app.id,
  225. operator_id=current_user.id,
  226. action_type=ActionType.REGENERATE_SECRET,
  227. details={"message": "Regenerated App Secret"}
  228. )
  229. logger.info(f"应用密钥已重置: {app.app_name} (ID: {app.app_id})")
  230. return ApplicationSecretDisplay(app_id=app.app_id, app_secret=new_secret, access_token=app.access_token)
  231. @router.post("/{app_id}/view-secret", response_model=ApplicationSecretDisplay, summary="查看密钥")
  232. def view_secret(
  233. *,
  234. db: Session = Depends(deps.get_db),
  235. app_id: int,
  236. req: ViewSecretRequest,
  237. current_user: User = Depends(deps.get_current_active_user),
  238. ):
  239. """
  240. 查看应用密钥。需要验证用户密码。
  241. """
  242. # 1. Verify Password
  243. if not security.verify_password(req.password, current_user.password_hash):
  244. logger.warning(f"查看密钥失败: 密码错误 (User: {current_user.mobile})")
  245. raise HTTPException(status_code=403, detail="密码错误")
  246. app = db.query(Application).filter(Application.id == app_id).first()
  247. if not app:
  248. raise HTTPException(status_code=404, detail="应用未找到")
  249. # Check ownership
  250. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  251. raise HTTPException(status_code=403, detail="权限不足")
  252. # Log
  253. LogService.create_log(
  254. db=db,
  255. app_id=app.id,
  256. operator_id=current_user.id,
  257. action_type=ActionType.VIEW_SECRET,
  258. details={"message": "Viewed App Secret"}
  259. )
  260. logger.info(f"查看应用密钥: {app.app_name} (Operator: {current_user.mobile})")
  261. return ApplicationSecretDisplay(app_id=app.app_id, app_secret=app.app_secret, access_token=app.access_token)
  262. @router.post("/{app_id}/transfer", response_model=ApplicationResponse, summary="转让应用")
  263. def transfer_app(
  264. *,
  265. db: Session = Depends(deps.get_db),
  266. app_id: int,
  267. req: ApplicationTransferRequest,
  268. current_user: User = Depends(deps.get_current_active_user),
  269. ):
  270. """
  271. 将应用转让给其他开发者或超级管理员。
  272. 需要验证:目标用户手机号、当前用户密码、短信验证码。
  273. """
  274. app = db.query(Application).filter(Application.id == app_id).first()
  275. if not app:
  276. raise HTTPException(status_code=404, detail="应用未找到")
  277. # Check ownership
  278. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  279. raise HTTPException(status_code=403, detail="权限不足")
  280. # 1. Verify Password
  281. if not security.verify_password(req.password, current_user.password_hash):
  282. logger.warning(f"转让应用失败: 密码错误 (User: {current_user.mobile})")
  283. raise HTTPException(status_code=403, detail="密码错误")
  284. # 2. Verify SMS Code
  285. if not SmsService.verify_code(current_user.mobile, req.verification_code):
  286. logger.warning(f"转让应用失败: 验证码错误 (User: {current_user.mobile})")
  287. raise HTTPException(status_code=400, detail="验证码无效或已过期")
  288. # 3. Verify Target User
  289. target_user = db.query(User).filter(User.mobile == req.target_mobile, User.is_deleted == 0).first()
  290. if not target_user:
  291. raise HTTPException(status_code=404, detail="目标用户不存在")
  292. if target_user.status != "ACTIVE":
  293. raise HTTPException(status_code=400, detail="目标用户状态不正常")
  294. if target_user.role not in ["DEVELOPER", "SUPER_ADMIN"]:
  295. raise HTTPException(status_code=400, detail="目标用户必须是开发者或超级管理员")
  296. if target_user.id == app.owner_id:
  297. raise HTTPException(status_code=400, detail="应用已归属于该用户")
  298. # 4. Transfer
  299. old_owner_id = app.owner_id
  300. app.owner_id = target_user.id
  301. db.add(app)
  302. db.commit()
  303. db.refresh(app)
  304. # 5. Log
  305. LogService.create_log(
  306. db=db,
  307. app_id=app.id,
  308. operator_id=current_user.id,
  309. action_type=ActionType.TRANSFER,
  310. target_user_id=target_user.id,
  311. target_mobile=target_user.mobile,
  312. details={
  313. "old_owner_id": old_owner_id,
  314. "new_owner_id": target_user.id
  315. }
  316. )
  317. logger.info(f"应用转让成功: {app.app_name} 从 {current_user.mobile} 转让给 {target_user.mobile}")
  318. return app
  319. # ==========================================
  320. # Mappings
  321. # ==========================================
  322. @router.get("/{app_id}/mappings", response_model=MappingList, summary="获取应用映射列表")
  323. def read_mappings(
  324. *,
  325. db: Session = Depends(deps.get_db),
  326. app_id: int,
  327. skip: int = 0,
  328. limit: int = 10,
  329. current_user: User = Depends(deps.get_current_active_user),
  330. ):
  331. """
  332. 获取应用的账号映射列表。
  333. """
  334. app = db.query(Application).filter(Application.id == app_id).first()
  335. if not app:
  336. raise HTTPException(status_code=404, detail="应用未找到")
  337. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  338. raise HTTPException(status_code=403, detail="权限不足")
  339. query = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id)
  340. total = query.count()
  341. mappings = query.order_by(desc(AppUserMapping.id)).offset(skip).limit(limit).all()
  342. # Enrich with user mobile (handled by ORM relation usually, but for Pydantic 'from_attributes')
  343. # We added `user_mobile` to MappingResponse, so we need to ensure it's populated.
  344. # The ORM `mapping.user` is lazy loaded, which is fine for sync code.
  345. result = []
  346. for m in mappings:
  347. result.append(MappingResponse(
  348. id=m.id,
  349. app_id=m.app_id,
  350. user_id=m.user_id,
  351. mapped_key=m.mapped_key,
  352. mapped_email=m.mapped_email,
  353. user_mobile=m.user.mobile if m.user else "Deleted User",
  354. user_status=m.user.status if m.user else "DELETED",
  355. is_active=m.is_active
  356. ))
  357. return {"total": total, "items": result}
  358. @router.post("/{app_id}/mappings", response_model=MappingResponse, summary="创建映射")
  359. def create_mapping(
  360. *,
  361. db: Session = Depends(deps.get_db),
  362. app_id: int,
  363. mapping_in: MappingCreate,
  364. current_user: User = Depends(deps.get_current_active_user),
  365. ):
  366. """
  367. 手动创建映射。
  368. """
  369. app = db.query(Application).filter(Application.id == app_id).first()
  370. if not app:
  371. raise HTTPException(status_code=404, detail="应用未找到")
  372. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  373. raise HTTPException(status_code=403, detail="权限不足")
  374. # Verify Password
  375. if not security.verify_password(mapping_in.password, current_user.password_hash):
  376. logger.warning(f"创建映射失败: 密码错误 (User: {current_user.mobile})")
  377. raise HTTPException(status_code=403, detail="密码错误")
  378. # Normalize input: treat empty strings as None to avoid unique constraint violations
  379. mapped_key = mapping_in.mapped_key if mapping_in.mapped_key else None
  380. mapped_email = mapping_in.mapped_email if mapping_in.mapped_email else None
  381. # 1. Find User or Create
  382. user = db.query(User).filter(User.mobile == mapping_in.mobile, User.is_deleted == 0).first()
  383. new_user_created = False
  384. generated_password = None
  385. if not user:
  386. # Auto create user
  387. password_plain = security.generate_alphanumeric_password(8) # Random password letters+digits
  388. random_suffix = security.generate_alphanumeric_password(6)
  389. user = User(
  390. mobile=mapping_in.mobile,
  391. password_hash=security.get_password_hash(password_plain),
  392. status="ACTIVE",
  393. role="ORDINARY_USER",
  394. name=f"用户{random_suffix}",
  395. english_name=mapped_key
  396. )
  397. db.add(user)
  398. db.commit()
  399. db.refresh(user)
  400. new_user_created = True
  401. generated_password = password_plain
  402. logger.info(f"自动创建用户: {user.mobile}")
  403. # 2. Check if mapping exists
  404. existing = db.query(AppUserMapping).filter(
  405. AppUserMapping.app_id == app_id,
  406. AppUserMapping.user_id == user.id
  407. ).first()
  408. if existing:
  409. raise HTTPException(status_code=400, detail="该用户的映射已存在")
  410. # 3. Check Uniqueness for mapped_email (if provided)
  411. if mapped_email:
  412. email_exists = db.query(AppUserMapping).filter(
  413. AppUserMapping.app_id == app_id,
  414. AppUserMapping.mapped_email == mapped_email
  415. ).first()
  416. if email_exists:
  417. raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapped_email} 已被使用")
  418. # 4. Check Uniqueness for mapped_key
  419. if mapped_key:
  420. key_exists = db.query(AppUserMapping).filter(
  421. AppUserMapping.app_id == app_id,
  422. AppUserMapping.mapped_key == mapped_key
  423. ).first()
  424. if key_exists:
  425. raise HTTPException(status_code=400, detail=f"该应用下账号 {mapped_key} 已被使用")
  426. # 5. Create
  427. mapping = AppUserMapping(
  428. app_id=app_id,
  429. user_id=user.id,
  430. mapped_key=mapped_key,
  431. mapped_email=mapped_email
  432. )
  433. db.add(mapping)
  434. db.commit()
  435. db.refresh(mapping)
  436. # LOGGING
  437. LogService.create_log(
  438. db=db,
  439. app_id=app_id,
  440. operator_id=current_user.id,
  441. action_type=ActionType.MANUAL_ADD,
  442. target_user_id=user.id,
  443. target_mobile=user.mobile,
  444. details={
  445. "mapped_key": mapped_key,
  446. "mapped_email": mapped_email,
  447. "new_user_created": new_user_created
  448. }
  449. )
  450. logger.info(f"映射创建成功: App {app_id} -> User {user.mobile} ({mapped_key})")
  451. return MappingResponse(
  452. id=mapping.id,
  453. app_id=mapping.app_id,
  454. user_id=mapping.user_id,
  455. mapped_key=mapping.mapped_key,
  456. mapped_email=mapping.mapped_email,
  457. user_mobile=user.mobile,
  458. user_status=user.status,
  459. new_user_created=new_user_created,
  460. generated_password=generated_password
  461. )
  462. @router.put("/{app_id}/mappings/{mapping_id}", response_model=MappingResponse, summary="更新映射")
  463. def update_mapping(
  464. *,
  465. db: Session = Depends(deps.get_db),
  466. app_id: int,
  467. mapping_id: int,
  468. mapping_in: MappingUpdate,
  469. current_user: User = Depends(deps.get_current_active_user),
  470. ):
  471. """
  472. 更新映射信息。
  473. """
  474. app = db.query(Application).filter(Application.id == app_id).first()
  475. if not app:
  476. raise HTTPException(status_code=404, detail="应用未找到")
  477. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  478. raise HTTPException(status_code=403, detail="权限不足")
  479. # Verify Password
  480. if not security.verify_password(mapping_in.password, current_user.password_hash):
  481. logger.warning(f"更新映射失败: 密码错误 (User: {current_user.mobile})")
  482. raise HTTPException(status_code=403, detail="密码错误")
  483. mapping = db.query(AppUserMapping).filter(
  484. AppUserMapping.id == mapping_id,
  485. AppUserMapping.app_id == app_id
  486. ).first()
  487. if not mapping:
  488. raise HTTPException(status_code=404, detail="映射未找到")
  489. # Check Uniqueness for mapped_key
  490. if mapping_in.mapped_key is not None and mapping_in.mapped_key != mapping.mapped_key:
  491. if mapping_in.mapped_key:
  492. key_exists = db.query(AppUserMapping).filter(
  493. AppUserMapping.app_id == app_id,
  494. AppUserMapping.mapped_key == mapping_in.mapped_key
  495. ).first()
  496. if key_exists:
  497. raise HTTPException(status_code=400, detail=f"该应用下账号 {mapping_in.mapped_key} 已被使用")
  498. # Check Uniqueness for mapped_email
  499. if mapping_in.mapped_email is not None and mapping_in.mapped_email != mapping.mapped_email:
  500. if mapping_in.mapped_email:
  501. email_exists = db.query(AppUserMapping).filter(
  502. AppUserMapping.app_id == app_id,
  503. AppUserMapping.mapped_email == mapping_in.mapped_email
  504. ).first()
  505. if email_exists:
  506. raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapping_in.mapped_email} 已被使用")
  507. # Capture old values for logging
  508. old_key = mapping.mapped_key
  509. old_email = mapping.mapped_email
  510. if mapping_in.mapped_key is not None:
  511. mapping.mapped_key = mapping_in.mapped_key
  512. if mapping_in.mapped_email is not None:
  513. mapping.mapped_email = mapping_in.mapped_email
  514. db.add(mapping)
  515. db.commit()
  516. db.refresh(mapping)
  517. # LOGGING
  518. LogService.create_log(
  519. db=db,
  520. app_id=app_id,
  521. operator_id=current_user.id,
  522. action_type=ActionType.UPDATE,
  523. target_user_id=mapping.user_id,
  524. target_mobile=mapping.user.mobile if mapping.user else None,
  525. details={
  526. "old": {"mapped_key": old_key, "mapped_email": old_email},
  527. "new": {"mapped_key": mapping.mapped_key, "mapped_email": mapping.mapped_email}
  528. }
  529. )
  530. logger.info(f"映射更新成功: App {app_id} User {mapping.user.mobile if mapping.user else 'unknown'}")
  531. return MappingResponse(
  532. id=mapping.id,
  533. app_id=mapping.app_id,
  534. user_id=mapping.user_id,
  535. mapped_key=mapping.mapped_key,
  536. mapped_email=mapping.mapped_email,
  537. user_mobile=mapping.user.mobile if mapping.user else "Deleted User",
  538. user_status=mapping.user.status if mapping.user else "DELETED",
  539. is_active=mapping.is_active
  540. )
  541. @router.delete("/{app_id}/mappings/{mapping_id}", summary="删除映射")
  542. def delete_mapping(
  543. *,
  544. db: Session = Depends(deps.get_db),
  545. app_id: int,
  546. mapping_id: int,
  547. req: MappingDelete,
  548. current_user: User = Depends(deps.get_current_active_user),
  549. ):
  550. """
  551. 删除映射关系。需验证密码。
  552. """
  553. # Verify Password
  554. if not security.verify_password(req.password, current_user.password_hash):
  555. logger.warning(f"删除映射失败: 密码错误 (User: {current_user.mobile})")
  556. raise HTTPException(status_code=403, detail="密码错误")
  557. mapping = db.query(AppUserMapping).filter(
  558. AppUserMapping.id == mapping_id,
  559. AppUserMapping.app_id == app_id
  560. ).first()
  561. if not mapping:
  562. raise HTTPException(status_code=404, detail="映射未找到")
  563. app = db.query(Application).filter(Application.id == app_id).first()
  564. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  565. raise HTTPException(status_code=403, detail="权限不足")
  566. # Capture for logging
  567. target_user_id = mapping.user_id
  568. target_mobile = mapping.user.mobile if mapping.user else None
  569. db.delete(mapping)
  570. db.commit()
  571. # LOGGING
  572. LogService.create_log(
  573. db=db,
  574. app_id=app_id,
  575. operator_id=current_user.id,
  576. action_type=ActionType.DELETE,
  577. target_user_id=target_user_id,
  578. target_mobile=target_mobile,
  579. details={"mapping_id": mapping_id}
  580. )
  581. logger.info(f"映射删除成功: App {app_id}, Mapping {mapping_id}")
  582. return {"message": "删除成功"}
  583. @router.post("/{app_id}/sync-users", summary="同步所有用户")
  584. def sync_users_to_app(
  585. *,
  586. db: Session = Depends(deps.get_db),
  587. app_id: int,
  588. current_user: User = Depends(deps.get_current_active_user),
  589. ):
  590. """
  591. 一键导入用户管理中的用户数据到应用映射中。
  592. """
  593. app = db.query(Application).filter(Application.id == app_id).first()
  594. if not app:
  595. raise HTTPException(status_code=404, detail="应用未找到")
  596. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  597. raise HTTPException(status_code=403, detail="权限不足")
  598. # Get all active users
  599. users = db.query(User).filter(User.is_deleted == 0).all()
  600. # Get existing mappings (user_ids)
  601. existing_mappings = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id).all()
  602. mapped_user_ids = {m.user_id for m in existing_mappings}
  603. new_mappings = []
  604. logger.info(f"开始同步用户到应用 {app.app_name} (ID: {app_id})")
  605. for user in users:
  606. if user.id in mapped_user_ids:
  607. continue
  608. # Create mapping
  609. mapped_key = user.english_name if user.english_name else user.mobile
  610. mapping = AppUserMapping(
  611. app_id=app.id,
  612. user_id=user.id,
  613. mapped_key=mapped_key,
  614. mapped_email=None,
  615. is_active=True
  616. )
  617. new_mappings.append(mapping)
  618. if new_mappings:
  619. try:
  620. db.bulk_save_objects(new_mappings)
  621. db.commit()
  622. logger.info(f"用户同步完成: 新增 {len(new_mappings)} 条映射")
  623. except Exception as e:
  624. db.rollback()
  625. logger.error(f"批量同步用户失败: {e}。尝试逐条插入。")
  626. # Fallback: try one by one
  627. success_count = 0
  628. for m in new_mappings:
  629. try:
  630. db.add(m)
  631. db.commit()
  632. success_count += 1
  633. except Exception as ex:
  634. db.rollback()
  635. logger.warning(f"单个用户映射失败 (User: {m.user_id}): {ex}")
  636. LogService.create_log(
  637. db=db,
  638. app_id=app.id,
  639. operator_id=current_user.id,
  640. action_type=ActionType.IMPORT,
  641. details={"message": "Sync all users (partial)", "attempted": len(new_mappings), "success": success_count}
  642. )
  643. return {"message": f"同步完成,成功 {success_count} 个,失败 {len(new_mappings) - success_count} 个 (可能是账号冲突)"}
  644. # Log success
  645. LogService.create_log(
  646. db=db,
  647. app_id=app.id,
  648. operator_id=current_user.id,
  649. action_type=ActionType.IMPORT,
  650. details={"message": "Sync all users", "count": len(new_mappings)}
  651. )
  652. return {"message": f"同步成功,新增 {len(new_mappings)} 个用户映射"}
  653. logger.info("用户同步: 没有需要同步的新用户")
  654. return {"message": "没有需要同步的用户"}
  655. @router.post("/{app_id}/sync-users-v2", summary="同步用户 (新版)")
  656. def sync_users_to_app_v2(
  657. *,
  658. db: Session = Depends(deps.get_db),
  659. app_id: int,
  660. sync_req: AppSyncRequest,
  661. current_user: User = Depends(deps.get_current_active_user),
  662. ):
  663. """
  664. 高级用户同步功能。
  665. 支持全量/部分同步,以及可选的默认邮箱初始化。
  666. 需要手机验证码。
  667. """
  668. app = db.query(Application).filter(Application.id == app_id).first()
  669. if not app:
  670. raise HTTPException(status_code=404, detail="应用未找到")
  671. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  672. raise HTTPException(status_code=403, detail="权限不足")
  673. # 1. Verify SMS Code
  674. if not SmsService.verify_code(current_user.mobile, sync_req.verification_code):
  675. logger.warning(f"同步用户失败: 验证码错误 (User: {current_user.mobile})")
  676. raise HTTPException(status_code=400, detail="验证码无效或已过期")
  677. # 2. Determine Target Users
  678. query = db.query(User).filter(User.is_deleted == 0)
  679. if sync_req.mode == "SELECTED":
  680. if not sync_req.user_ids:
  681. raise HTTPException(status_code=400, detail="请选择要同步的用户")
  682. query = query.filter(User.id.in_(sync_req.user_ids))
  683. users = query.all()
  684. if not users:
  685. return {"message": "没有找到可同步的用户"}
  686. # 3. Get existing mappings (user_ids) to skip
  687. existing_mappings = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id).all()
  688. mapped_user_ids = {m.user_id for m in existing_mappings}
  689. # Check if email domain is valid format if provided (simple check)
  690. if sync_req.init_email and not sync_req.email_domain:
  691. raise HTTPException(status_code=400, detail="开启邮箱初始化时必须填写域名")
  692. new_mappings = []
  693. for user in users:
  694. if user.id in mapped_user_ids:
  695. continue
  696. mapped_key = user.english_name if user.english_name else user.mobile
  697. mapped_email = None
  698. if sync_req.init_email and user.english_name:
  699. # Construct email
  700. domain = sync_req.email_domain.strip()
  701. if not domain.startswith("@"):
  702. domain = "@" + domain
  703. mapped_email = f"{user.english_name}{domain}"
  704. mapping = AppUserMapping(
  705. app_id=app.id,
  706. user_id=user.id,
  707. mapped_key=mapped_key,
  708. mapped_email=mapped_email,
  709. is_active=True
  710. )
  711. new_mappings.append(mapping)
  712. if not new_mappings:
  713. return {"message": "所有选中的用户均已存在映射,无需同步"}
  714. # 4. Insert
  715. logger.info(f"开始同步(v2)用户到应用 {app.app_name},计划新增 {len(new_mappings)} 条")
  716. success_count = 0
  717. fail_count = 0
  718. for m in new_mappings:
  719. try:
  720. # Additional check: uniqueness of mapped_key in this app
  721. db.add(m)
  722. db.commit()
  723. success_count += 1
  724. except Exception as e:
  725. db.rollback()
  726. fail_count += 1
  727. logger.warning(f"同步单个映射失败 (User: {m.user_id}): {e}")
  728. # 5. Log
  729. LogService.create_log(
  730. db=db,
  731. app_id=app.id,
  732. operator_id=current_user.id,
  733. action_type=ActionType.SYNC,
  734. details={
  735. "mode": sync_req.mode,
  736. "init_email": sync_req.init_email,
  737. "total_attempted": len(new_mappings),
  738. "success": success_count,
  739. "failed": fail_count
  740. }
  741. )
  742. logger.info(f"同步(v2)完成。成功: {success_count}, 失败: {fail_count}")
  743. msg = f"同步完成。成功: {success_count},失败: {fail_count}"
  744. if fail_count > 0:
  745. msg += " (失败原因可能是账号或邮箱冲突)"
  746. return {"message": msg}
  747. @router.get("/{app_id}/mappings/export", summary="导出映射")
  748. def export_mappings(
  749. *,
  750. db: Session = Depends(deps.get_db),
  751. app_id: int,
  752. current_user: User = Depends(deps.get_current_active_user),
  753. ):
  754. """
  755. 导出所有映射到 Excel (.xlsx)。
  756. """
  757. app = db.query(Application).filter(Application.id == app_id).first()
  758. if not app:
  759. raise HTTPException(status_code=404, detail="应用未找到")
  760. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  761. raise HTTPException(status_code=403, detail="权限不足")
  762. mappings = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id).all()
  763. # Prepare data for DataFrame
  764. data = []
  765. for m in mappings:
  766. mobile = m.user.mobile if m.user else "Deleted User"
  767. data.append({
  768. '手机号': mobile,
  769. '映射账号': m.mapped_key,
  770. '映射邮箱': m.mapped_email or ''
  771. })
  772. # Create DataFrame
  773. df = pd.DataFrame(data)
  774. # If no data, create an empty DataFrame with columns
  775. if not data:
  776. df = pd.DataFrame(columns=['手机号', '映射账号', '映射邮箱'])
  777. # Write to Excel BytesIO
  778. output = io.BytesIO()
  779. with pd.ExcelWriter(output, engine='openpyxl') as writer:
  780. df.to_excel(writer, index=False)
  781. output.seek(0)
  782. filename = f"mappings_app_{app_id}.xlsx"
  783. logger.info(f"导出映射成功: App {app_id}, Count {len(mappings)}")
  784. return StreamingResponse(
  785. output,
  786. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  787. headers={"Content-Disposition": f"attachment; filename={filename}"}
  788. )
  789. @router.post("/{app_id}/mapping/preview", response_model=MappingPreviewResponse, summary="预览映射导入")
  790. async def preview_mapping(
  791. app_id: int,
  792. file: UploadFile = File(...),
  793. db: Session = Depends(deps.get_db),
  794. current_user: User = Depends(deps.get_current_active_user),
  795. ):
  796. """
  797. 预览 Excel/CSV 映射导入。
  798. """
  799. app = db.query(Application).filter(Application.id == app_id).first()
  800. if not app:
  801. raise HTTPException(status_code=404, detail="应用未找到")
  802. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  803. raise HTTPException(status_code=403, detail="权限不足")
  804. contents = await file.read()
  805. filename = file.filename
  806. try:
  807. return MappingService.preview_import(db, app_id, contents, filename)
  808. except Exception as e:
  809. logger.error(f"导入预览失败: {e}", exc_info=True)
  810. raise HTTPException(status_code=400, detail=f"解析文件失败: {str(e)}")
  811. @router.post("/send-import-verification-code", summary="发送导入验证码")
  812. def send_import_verification_code(
  813. current_user: User = Depends(deps.get_current_active_user),
  814. ):
  815. """
  816. 发送验证码给当前登录用户(用于敏感操作验证,如导入)。
  817. """
  818. SmsService.send_code(current_user.mobile)
  819. logger.info(f"发送导入验证码: {current_user.mobile}")
  820. return {"message": "验证码已发送"}
  821. @router.post("/{app_id}/mapping/import", response_model=ImportLogResponse, summary="执行映射导入")
  822. async def import_mapping(
  823. app_id: int,
  824. file: UploadFile = File(...),
  825. strategy: MappingStrategy = Form(MappingStrategy.SKIP),
  826. verification_code: str = Form(...),
  827. db: Session = Depends(deps.get_db),
  828. current_user: User = Depends(deps.get_current_active_user),
  829. ):
  830. """
  831. 执行映射导入操作。需要验证短信验证码。
  832. """
  833. app = db.query(Application).filter(Application.id == app_id).first()
  834. if not app:
  835. raise HTTPException(status_code=404, detail="应用未找到")
  836. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  837. raise HTTPException(status_code=403, detail="权限不足")
  838. contents = await file.read()
  839. filename = file.filename
  840. logger.info(f"开始执行映射导入: App {app_id}, File {filename}, Strategy {strategy}")
  841. try:
  842. result = MappingService.execute_import(db, app_id, contents, filename, strategy, current_user.mobile, verification_code)
  843. except Exception as e:
  844. logger.error(f"执行映射导入异常: {e}", exc_info=True)
  845. raise e
  846. # LOGGING
  847. # For import, we log the summary and the logs structure
  848. LogService.create_log(
  849. db=db,
  850. app_id=app_id,
  851. operator_id=current_user.id,
  852. action_type=ActionType.IMPORT,
  853. details=result.model_dump(mode='json') # Store full result including logs
  854. )
  855. logger.info(f"映射导入完成: 成功 {result.summary.inserted + result.summary.updated}, 失败 {result.summary.failed}")
  856. return result
  857. @router.get("/mapping/users", response_model=UserSyncList, summary="获取全量用户(M2M)")
  858. def get_all_users_m2m(
  859. *,
  860. db: Session = Depends(deps.get_db),
  861. skip: int = 0,
  862. limit: int = 100,
  863. current_app: Application = Depends(deps.get_current_app),
  864. ):
  865. """
  866. 开发者拉取全量用户接口。
  867. 仅返回:手机号、姓名、英文名。
  868. 需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)。
  869. """
  870. query = db.query(User).filter(User.is_deleted == 0)
  871. total = query.count()
  872. users = query.order_by(User.id).offset(skip).limit(limit).all()
  873. return {"total": total, "items": users}
  874. @router.post("/mapping/sync", response_model=MappingResponse, summary="同步映射 (M2M)")
  875. def sync_mapping(
  876. *,
  877. db: Session = Depends(deps.get_db),
  878. sync_in: UserSyncRequest,
  879. current_app: Application = Depends(deps.get_current_app),
  880. ):
  881. """
  882. 从外部平台同步用户映射关系(机器对机器)。
  883. 支持增删改查:
  884. - UPSERT (默认): 创建或更新映射及用户。
  885. - DELETE: 仅删除应用与用户的映射关系,不删除用户。
  886. 需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)。
  887. """
  888. # Normalize input: treat empty strings as None
  889. mapped_key = sync_in.mapped_key if sync_in.mapped_key else None
  890. mapped_email = sync_in.mapped_email if sync_in.mapped_email else None
  891. in_name = sync_in.name if sync_in.name else None
  892. in_english_name = sync_in.english_name if sync_in.english_name else None
  893. logger.info(f"收到 M2M 同步请求: App {current_app.app_id}, Mobile {sync_in.mobile}, Action {sync_in.sync_action}")
  894. # ==========================================
  895. # 1. Handle DELETE Action
  896. # ==========================================
  897. if sync_in.sync_action == "DELETE":
  898. # 查找用户
  899. user = db.query(User).filter(User.mobile == sync_in.mobile).first()
  900. if not user:
  901. # 用户不存在,无法删除映射,直接抛出404或视作成功
  902. logger.warning(f"M2M 删除失败: 用户 {sync_in.mobile} 不存在")
  903. raise HTTPException(status_code=404, detail="用户不存在")
  904. # 查找映射
  905. mapping = db.query(AppUserMapping).filter(
  906. AppUserMapping.app_id == current_app.id,
  907. AppUserMapping.user_id == user.id
  908. ).first()
  909. if not mapping:
  910. logger.warning(f"M2M 删除失败: 映射不存在 (User {sync_in.mobile})")
  911. raise HTTPException(status_code=404, detail="映射关系不存在")
  912. # 构造返回数据(删除前快照,将状态置为 False)
  913. resp_data = MappingResponse(
  914. id=mapping.id,
  915. app_id=mapping.app_id,
  916. user_id=mapping.user_id,
  917. mapped_key=mapping.mapped_key,
  918. mapped_email=mapping.mapped_email,
  919. user_mobile=user.mobile,
  920. user_status=user.status,
  921. is_active=False # 标记为非活跃/已删除
  922. )
  923. # 执行物理删除 (只删映射,不删用户)
  924. db.delete(mapping)
  925. db.commit()
  926. # 记录日志
  927. LogService.create_log(
  928. db=db,
  929. app_id=current_app.id,
  930. operator_id=current_app.owner_id,
  931. action_type=ActionType.DELETE,
  932. target_user_id=user.id,
  933. target_mobile=user.mobile,
  934. details={"mapped_key": mapping.mapped_key, "action": "M2M_DELETE"}
  935. )
  936. logger.info(f"M2M 删除成功: {sync_in.mobile}")
  937. return resp_data
  938. # ==========================================
  939. # 2. Handle UPSERT Action (Existing Logic)
  940. # ==========================================
  941. # 0. Check Uniqueness for Name and English Name (Global Check)
  942. # We exclude the current user (by mobile) to allow updates to self without conflict
  943. if in_name:
  944. name_conflict = db.query(User).filter(
  945. User.name == in_name,
  946. User.mobile != sync_in.mobile
  947. ).first()
  948. if name_conflict:
  949. raise HTTPException(status_code=400, detail=f"姓名 '{in_name}' 已存在")
  950. if in_english_name:
  951. en_name_conflict = db.query(User).filter(
  952. User.english_name == in_english_name,
  953. User.mobile != sync_in.mobile
  954. ).first()
  955. if en_name_conflict:
  956. raise HTTPException(status_code=400, detail=f"英文名 '{in_english_name}' 已存在")
  957. # 1. Find User or Create
  958. user = db.query(User).filter(User.mobile == sync_in.mobile).first()
  959. new_user_created = False
  960. if not user:
  961. # Create New User
  962. # Auto-generate English name if missing but Chinese name is present
  963. if in_name and not in_english_name:
  964. in_english_name = generate_english_name(in_name)
  965. # Validation: Name and English Name are required for new users
  966. if not in_name or not in_english_name:
  967. raise HTTPException(status_code=400, detail="新建用户必须提供姓名和英文名称")
  968. # Auto create user
  969. password = security.generate_alphanumeric_password(8) # Random password letters+digits
  970. user = User(
  971. mobile=sync_in.mobile,
  972. password_hash=security.get_password_hash(password),
  973. status="ACTIVE",
  974. role="ORDINARY_USER",
  975. name=in_name,
  976. english_name=in_english_name
  977. )
  978. db.add(user)
  979. db.commit()
  980. db.refresh(user)
  981. new_user_created = True
  982. logger.info(f"M2M 自动创建用户: {sync_in.mobile}")
  983. else:
  984. # Update Existing User (if fields provided)
  985. updated = False
  986. if in_name is not None and user.name != in_name:
  987. user.name = in_name
  988. updated = True
  989. if in_english_name is not None and user.english_name != in_english_name:
  990. user.english_name = in_english_name
  991. updated = True
  992. if updated:
  993. db.add(user)
  994. db.commit()
  995. db.refresh(user)
  996. # 2. Handle Mapping
  997. mapping = db.query(AppUserMapping).filter(
  998. AppUserMapping.app_id == current_app.id,
  999. AppUserMapping.user_id == user.id
  1000. ).first()
  1001. # Check Uniqueness for mapped_key (if changing or new, and provided)
  1002. if mapped_key and (not mapping or mapping.mapped_key != mapped_key):
  1003. key_exists = db.query(AppUserMapping).filter(
  1004. AppUserMapping.app_id == current_app.id,
  1005. AppUserMapping.mapped_key == mapped_key
  1006. ).first()
  1007. if key_exists:
  1008. raise HTTPException(status_code=400, detail=f"该应用下账号 {mapped_key} 已被使用")
  1009. # Check Uniqueness for mapped_email (if changing or new, and provided)
  1010. if mapped_email and (not mapping or mapping.mapped_email != mapped_email):
  1011. email_exists = db.query(AppUserMapping).filter(
  1012. AppUserMapping.app_id == current_app.id,
  1013. AppUserMapping.mapped_email == mapped_email
  1014. ).first()
  1015. if email_exists:
  1016. raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapped_email} 已被使用")
  1017. new_mapping_created = False
  1018. if mapping:
  1019. # Update existing mapping
  1020. if sync_in.mapped_key is not None:
  1021. mapping.mapped_key = mapped_key
  1022. if sync_in.is_active is not None:
  1023. mapping.is_active = sync_in.is_active
  1024. if sync_in.mapped_email is not None:
  1025. mapping.mapped_email = mapped_email
  1026. else:
  1027. # Create new mapping
  1028. new_mapping_created = True
  1029. mapping = AppUserMapping(
  1030. app_id=current_app.id,
  1031. user_id=user.id,
  1032. mapped_key=mapped_key,
  1033. mapped_email=mapped_email,
  1034. is_active=sync_in.is_active if sync_in.is_active is not None else True
  1035. )
  1036. db.add(mapping)
  1037. db.commit()
  1038. db.refresh(mapping)
  1039. # LOGGING
  1040. LogService.create_log(
  1041. db=db,
  1042. app_id=current_app.id,
  1043. operator_id=current_app.owner_id,
  1044. action_type=ActionType.SYNC_M2M,
  1045. target_user_id=user.id,
  1046. target_mobile=user.mobile,
  1047. details={
  1048. "mapped_key": mapped_key,
  1049. "mapped_email": mapped_email,
  1050. "new_user_created": new_user_created,
  1051. "new_mapping_created": new_mapping_created
  1052. }
  1053. )
  1054. logger.info(f"M2M 同步成功: {sync_in.mobile} (Mapping: {mapping.id})")
  1055. return MappingResponse(
  1056. id=mapping.id,
  1057. app_id=mapping.app_id,
  1058. user_id=mapping.user_id,
  1059. mapped_key=mapping.mapped_key,
  1060. mapped_email=mapping.mapped_email,
  1061. user_mobile=user.mobile,
  1062. user_status=user.status,
  1063. is_active=mapping.is_active
  1064. )
  1065. # ==========================================
  1066. # Operation Logs
  1067. # ==========================================
  1068. @router.get("/{app_id}/logs", response_model=OperationLogList, summary="获取操作日志")
  1069. def read_logs(
  1070. *,
  1071. db: Session = Depends(deps.get_db),
  1072. app_id: int,
  1073. skip: int = 0,
  1074. limit: int = 20,
  1075. action_type: ActionType = Query(None),
  1076. keyword: str = Query(None, description="搜索手机号"),
  1077. start_date: datetime = Query(None),
  1078. end_date: datetime = Query(None),
  1079. current_user: User = Depends(deps.get_current_active_user),
  1080. ):
  1081. """
  1082. 获取应用操作日志。
  1083. """
  1084. app = db.query(Application).filter(Application.id == app_id).first()
  1085. if not app:
  1086. raise HTTPException(status_code=404, detail="应用未找到")
  1087. if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
  1088. raise HTTPException(status_code=403, detail="权限不足")
  1089. total, logs = LogService.get_logs(
  1090. db=db,
  1091. app_id=app_id,
  1092. skip=skip,
  1093. limit=limit,
  1094. action_type=action_type,
  1095. keyword=keyword,
  1096. start_date=start_date,
  1097. end_date=end_date
  1098. )
  1099. result = []
  1100. for log in logs:
  1101. # Enrich operator mobile
  1102. operator_mobile = log.operator.mobile if log.operator else "Unknown"
  1103. result.append(OperationLogResponse(
  1104. id=log.id,
  1105. app_id=log.app_id,
  1106. action_type=log.action_type,
  1107. target_mobile=log.target_mobile,
  1108. details=log.details,
  1109. operator_id=log.operator_id,
  1110. operator_mobile=operator_mobile,
  1111. target_user_id=log.target_user_id,
  1112. created_at=log.created_at
  1113. ))
  1114. return {"total": total, "items": result}