| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- from typing import Any, Optional
- from fastapi import APIRouter, Depends, HTTPException, Body
- from sqlalchemy.orm import Session
- from pydantic import BaseModel
- from app.api.v1 import deps
- from app.core.config import settings
- from app.core import security
- from app.core.utils import generate_english_name
- from app.schemas.user import UserRegister, User as UserSchema
- from app.models.user import User, UserStatus, UserRole
- from app.models.application import Application
- from app.models.client_distribution import ClientDistribution, ClientVersion
- from app.schemas.client_distribution import (
- DistributionPublicResponse,
- LatestVersionInfo,
- )
- from app.core.minio import minio_storage
- from app.core.version import version_sort_key
- from app.core.distribution_share import decode_distribution_share_id
- from app.services.sms_service import SmsService
- from app.services.captcha_service import CaptchaService
- router = APIRouter()
- class DownloadLinksPublic(BaseModel):
- mobile: Optional[str] = None
- pc: Optional[str] = None
- @router.get(
- "/download-links",
- response_model=DownloadLinksPublic,
- summary="客户端下载页地址(登录页等公开使用)",
- )
- def get_download_links():
- """从环境变量读取;值为下载落地页 URL,前端直接跳转即可。"""
- return DownloadLinksPublic(
- mobile=(settings.CLIENT_DOWNLOAD_URL_MOBILE or None),
- pc=(settings.CLIENT_DOWNLOAD_URL_PC or None),
- )
- @router.post("/register", response_model=UserSchema, summary="开发者注册")
- def register_developer(
- req: UserRegister,
- db: Session = Depends(deps.get_db),
- ):
- """
- 开发者公开注册接口。
- 如果是系统第一个用户,将自动成为超级管理员并激活。
- 否则,创建状态为 PENDING,角色为 DEVELOPER 的用户。
- """
- # 0. Verify SMS Code
- if not SmsService.verify_code(req.mobile, req.sms_code):
- raise HTTPException(status_code=400, detail="短信验证码无效")
- # 1. Check if mobile exists
- existing = db.query(User).filter(User.mobile == req.mobile).first()
- if existing:
- raise HTTPException(status_code=400, detail="该手机号已注册")
-
- # 2. Check if this is the first user
- user_count = db.query(User).count()
- is_first_user = (user_count == 0)
-
- role = UserRole.SUPER_ADMIN if is_first_user else UserRole.DEVELOPER
- status = UserStatus.ACTIVE if is_first_user else UserStatus.PENDING
- # 3. Create User
- english_name = generate_english_name(req.name)
-
- user = User(
- mobile=req.mobile,
- name=req.name,
- english_name=english_name,
- password_hash=security.get_password_hash(req.password),
- status=status,
- role=role,
- is_deleted=0
- )
-
- db.add(user)
- db.commit()
- db.refresh(user)
-
- return user
- class AppPublicInfo(BaseModel):
- app_id: str
- app_name: str
- protocol_type: str
- redirect_uris: str # JSON string
- @router.get("/apps/{app_id}", response_model=AppPublicInfo, summary="获取应用公开信息")
- def get_app_public_info(
- app_id: str,
- db: Session = Depends(deps.get_db),
- ):
- """
- 获取应用的公开信息(协议类型、重定向 URIs)。
- 用于登录页面的重定向逻辑。
- """
- app = db.query(Application).filter(Application.app_id == app_id).first()
- if not app:
- raise HTTPException(status_code=404, detail="应用未找到")
-
- return AppPublicInfo(
- app_id=app.app_id,
- app_name=app.app_name,
- protocol_type=app.protocol_type,
- redirect_uris=app.redirect_uris or "[]"
- )
- class SmsSendRequest(BaseModel):
- mobile: str
- captcha_id: str
- captcha_code: str
- class PasswordResetRequest(BaseModel):
- mobile: str
- sms_code: str
- new_password: str
- @router.post("/sms/send", summary="发送短信验证码")
- def send_sms(
- req: SmsSendRequest
- ):
- """
- 发送短信验证码。需要图形验证码验证。
- """
- # 1. Verify Captcha
- if not CaptchaService.verify_captcha(req.captcha_id, req.captcha_code):
- raise HTTPException(status_code=400, detail="图形验证码无效")
-
- # 2. Send SMS
- SmsService.send_code(req.mobile)
-
- return {"message": "短信发送成功"}
- @router.post("/pwd/reset", summary="重置密码")
- def reset_password(
- req: PasswordResetRequest,
- db: Session = Depends(deps.get_db),
- ):
- """
- 使用短信验证码重置密码。
- """
- # 1. Verify SMS Code
- if not SmsService.verify_code(req.mobile, req.sms_code):
- raise HTTPException(status_code=400, detail="短信验证码无效")
-
- # 2. Find User
- user = db.query(User).filter(User.mobile == req.mobile).first()
- if not user:
- raise HTTPException(status_code=404, detail="用户未找到")
-
- # 3. Update Password
- if not security.validate_password_strength(req.new_password):
- raise HTTPException(status_code=400, detail="密码强度不足,必须包含字母和数字")
- user.password_hash = security.get_password_hash(req.new_password)
- db.add(user)
- db.commit()
-
- return {"message": "密码重置成功"}
- @router.get("/distribution/{share_id}", response_model=DistributionPublicResponse, summary="获取分发公开信息及最新版本")
- def get_distribution_public(
- share_id: str,
- db: Session = Depends(deps.get_db),
- ):
- """
- 公开接口,无需登录。根据分享 ID(由 id+created_at 编码)获取分发信息及最新版本(含预签名下载链接)。
- 用于公开下载页 /d/:share_id
- """
- dist_id = decode_distribution_share_id(share_id)
- if dist_id is None:
- raise HTTPException(status_code=404, detail="分发不存在")
- dist = db.query(ClientDistribution).filter(
- ClientDistribution.id == dist_id,
- ClientDistribution.is_deleted == 0
- ).first()
- if not dist:
- raise HTTPException(status_code=404, detail="分发不存在")
- all_versions = (
- db.query(ClientVersion)
- .filter(ClientVersion.distribution_id == dist_id)
- .all()
- )
- latest = None
- latest_versions_by_platform = {}
- if all_versions:
- latest = max(all_versions, key=lambda v: version_sort_key(v.version_code))
- # 按平台分组,每组取版本号最新的一条
- by_platform = {}
- for v in all_versions:
- p = (v.platform or "").strip().lower()
- if not p:
- continue
- if p not in by_platform or version_sort_key(v.version_code) > version_sort_key(by_platform[p].version_code):
- by_platform[p] = v
- for p, v in by_platform.items():
- download_url = minio_storage.get_distribution_presigned_url(v.object_key)
- latest_versions_by_platform[p] = LatestVersionInfo(
- id=v.id,
- version_code=v.version_code,
- version_name=v.version_name,
- release_notes=v.release_notes,
- file_size=v.file_size,
- platform=v.platform,
- created_at=v.created_at,
- download_url=download_url or "",
- )
- latest_info = None
- if latest:
- download_url = minio_storage.get_distribution_presigned_url(latest.object_key)
- latest_info = LatestVersionInfo(
- id=latest.id,
- version_code=latest.version_code,
- version_name=latest.version_name,
- release_notes=latest.release_notes,
- file_size=latest.file_size,
- platform=latest.platform,
- created_at=latest.created_at,
- download_url=download_url or "",
- )
- icon_url = dist.icon_url
- if icon_url and not str(icon_url).startswith("http"):
- icon_url = minio_storage.get_distribution_presigned_url(icon_url) or icon_url
- return DistributionPublicResponse(
- id=dist.id,
- name=dist.name,
- description=dist.description,
- icon_url=icon_url,
- latest_version=latest_info,
- latest_versions_by_platform=latest_versions_by_platform if latest_versions_by_platform else None,
- )
|