open_api.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. from typing import Any, Optional
  2. from fastapi import APIRouter, Depends, HTTPException, Body
  3. from sqlalchemy.orm import Session
  4. from pydantic import BaseModel
  5. from app.api.v1 import deps
  6. from app.core.config import settings
  7. from app.core import security
  8. from app.core.utils import generate_english_name
  9. from app.schemas.user import UserRegister, User as UserSchema
  10. from app.models.user import User, UserStatus, UserRole
  11. from app.models.application import Application
  12. from app.models.client_distribution import ClientDistribution, ClientVersion
  13. from app.schemas.client_distribution import (
  14. DistributionPublicResponse,
  15. LatestVersionInfo,
  16. )
  17. from app.core.minio import minio_storage
  18. from app.core.version import version_sort_key
  19. from app.core.distribution_share import decode_distribution_share_id
  20. from app.services.sms_service import SmsService
  21. from app.services.captcha_service import CaptchaService
  22. router = APIRouter()
  23. class DownloadLinksPublic(BaseModel):
  24. mobile: Optional[str] = None
  25. pc: Optional[str] = None
  26. @router.get(
  27. "/download-links",
  28. response_model=DownloadLinksPublic,
  29. summary="客户端下载页地址(登录页等公开使用)",
  30. )
  31. def get_download_links():
  32. """从环境变量读取;值为下载落地页 URL,前端直接跳转即可。"""
  33. return DownloadLinksPublic(
  34. mobile=(settings.CLIENT_DOWNLOAD_URL_MOBILE or None),
  35. pc=(settings.CLIENT_DOWNLOAD_URL_PC or None),
  36. )
  37. @router.post("/register", response_model=UserSchema, summary="开发者注册")
  38. def register_developer(
  39. req: UserRegister,
  40. db: Session = Depends(deps.get_db),
  41. ):
  42. """
  43. 开发者公开注册接口。
  44. 如果是系统第一个用户,将自动成为超级管理员并激活。
  45. 否则,创建状态为 PENDING,角色为 DEVELOPER 的用户。
  46. """
  47. # 0. Verify SMS Code
  48. if not SmsService.verify_code(req.mobile, req.sms_code):
  49. raise HTTPException(status_code=400, detail="短信验证码无效")
  50. # 1. Check if mobile exists
  51. existing = db.query(User).filter(User.mobile == req.mobile).first()
  52. if existing:
  53. raise HTTPException(status_code=400, detail="该手机号已注册")
  54. # 2. Check if this is the first user
  55. user_count = db.query(User).count()
  56. is_first_user = (user_count == 0)
  57. role = UserRole.SUPER_ADMIN if is_first_user else UserRole.DEVELOPER
  58. status = UserStatus.ACTIVE if is_first_user else UserStatus.PENDING
  59. # 3. Create User
  60. english_name = generate_english_name(req.name)
  61. user = User(
  62. mobile=req.mobile,
  63. name=req.name,
  64. english_name=english_name,
  65. password_hash=security.get_password_hash(req.password),
  66. status=status,
  67. role=role,
  68. is_deleted=0
  69. )
  70. db.add(user)
  71. db.commit()
  72. db.refresh(user)
  73. return user
  74. class AppPublicInfo(BaseModel):
  75. app_id: str
  76. app_name: str
  77. protocol_type: str
  78. redirect_uris: str # JSON string
  79. @router.get("/apps/{app_id}", response_model=AppPublicInfo, summary="获取应用公开信息")
  80. def get_app_public_info(
  81. app_id: str,
  82. db: Session = Depends(deps.get_db),
  83. ):
  84. """
  85. 获取应用的公开信息(协议类型、重定向 URIs)。
  86. 用于登录页面的重定向逻辑。
  87. """
  88. app = db.query(Application).filter(Application.app_id == app_id).first()
  89. if not app:
  90. raise HTTPException(status_code=404, detail="应用未找到")
  91. return AppPublicInfo(
  92. app_id=app.app_id,
  93. app_name=app.app_name,
  94. protocol_type=app.protocol_type,
  95. redirect_uris=app.redirect_uris or "[]"
  96. )
  97. class SmsSendRequest(BaseModel):
  98. mobile: str
  99. captcha_id: str
  100. captcha_code: str
  101. class PasswordResetRequest(BaseModel):
  102. mobile: str
  103. sms_code: str
  104. new_password: str
  105. @router.post("/sms/send", summary="发送短信验证码")
  106. def send_sms(
  107. req: SmsSendRequest
  108. ):
  109. """
  110. 发送短信验证码。需要图形验证码验证。
  111. """
  112. # 1. Verify Captcha
  113. if not CaptchaService.verify_captcha(req.captcha_id, req.captcha_code):
  114. raise HTTPException(status_code=400, detail="图形验证码无效")
  115. # 2. Send SMS
  116. SmsService.send_code(req.mobile)
  117. return {"message": "短信发送成功"}
  118. @router.post("/pwd/reset", summary="重置密码")
  119. def reset_password(
  120. req: PasswordResetRequest,
  121. db: Session = Depends(deps.get_db),
  122. ):
  123. """
  124. 使用短信验证码重置密码。
  125. """
  126. # 1. Verify SMS Code
  127. if not SmsService.verify_code(req.mobile, req.sms_code):
  128. raise HTTPException(status_code=400, detail="短信验证码无效")
  129. # 2. Find User
  130. user = db.query(User).filter(User.mobile == req.mobile).first()
  131. if not user:
  132. raise HTTPException(status_code=404, detail="用户未找到")
  133. # 3. Update Password
  134. if not security.validate_password_strength(req.new_password):
  135. raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
  136. user.password_hash = security.get_password_hash(req.new_password)
  137. db.add(user)
  138. db.commit()
  139. return {"message": "密码重置成功"}
  140. @router.get("/distribution/{share_id}", response_model=DistributionPublicResponse, summary="获取分发公开信息及最新版本")
  141. def get_distribution_public(
  142. share_id: str,
  143. db: Session = Depends(deps.get_db),
  144. ):
  145. """
  146. 公开接口,无需登录。根据分享 ID(由 id+created_at 编码)获取分发信息及最新版本(含预签名下载链接)。
  147. 用于公开下载页 /d/:share_id
  148. """
  149. dist_id = decode_distribution_share_id(share_id)
  150. if dist_id is None:
  151. raise HTTPException(status_code=404, detail="分发不存在")
  152. dist = db.query(ClientDistribution).filter(
  153. ClientDistribution.id == dist_id,
  154. ClientDistribution.is_deleted == 0
  155. ).first()
  156. if not dist:
  157. raise HTTPException(status_code=404, detail="分发不存在")
  158. all_versions = (
  159. db.query(ClientVersion)
  160. .filter(ClientVersion.distribution_id == dist_id)
  161. .all()
  162. )
  163. latest = None
  164. latest_versions_by_platform = {}
  165. if all_versions:
  166. latest = max(all_versions, key=lambda v: version_sort_key(v.version_code))
  167. # 按平台分组,每组取版本号最新的一条
  168. by_platform = {}
  169. for v in all_versions:
  170. p = (v.platform or "").strip().lower()
  171. if not p:
  172. continue
  173. if p not in by_platform or version_sort_key(v.version_code) > version_sort_key(by_platform[p].version_code):
  174. by_platform[p] = v
  175. for p, v in by_platform.items():
  176. download_url = minio_storage.get_distribution_presigned_url(v.object_key)
  177. latest_versions_by_platform[p] = LatestVersionInfo(
  178. id=v.id,
  179. version_code=v.version_code,
  180. version_name=v.version_name,
  181. release_notes=v.release_notes,
  182. file_size=v.file_size,
  183. platform=v.platform,
  184. created_at=v.created_at,
  185. download_url=download_url or "",
  186. )
  187. latest_info = None
  188. if latest:
  189. download_url = minio_storage.get_distribution_presigned_url(latest.object_key)
  190. latest_info = LatestVersionInfo(
  191. id=latest.id,
  192. version_code=latest.version_code,
  193. version_name=latest.version_name,
  194. release_notes=latest.release_notes,
  195. file_size=latest.file_size,
  196. platform=latest.platform,
  197. created_at=latest.created_at,
  198. download_url=download_url or "",
  199. )
  200. icon_url = dist.icon_url
  201. if icon_url and not str(icon_url).startswith("http"):
  202. icon_url = minio_storage.get_distribution_presigned_url(icon_url) or icon_url
  203. return DistributionPublicResponse(
  204. id=dist.id,
  205. name=dist.name,
  206. description=dist.description,
  207. icon_url=icon_url,
  208. latest_version=latest_info,
  209. latest_versions_by_platform=latest_versions_by_platform if latest_versions_by_platform else None,
  210. )