apps.py 41 KB

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