feat: 正式 App 后端登录模块 v0.1.0

引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
马润林
2026-05-23 17:37:18 +08:00
parent 3e37e4ac85
commit 1aafc28621
38 changed files with 1863 additions and 1 deletions
+39
View File
@@ -0,0 +1,39 @@
# ===== 环境 =====
# dev / prod。dev 模式下若 JG_PRIVATE_KEY_PATH 指向的文件不存在,jverify-login 接口会拒绝调用
# (而不是 import 时炸进程),并提示用 mock 模式跑通其他链路。
APP_ENV=dev
APP_NAME=shaguabijia-app-server
APP_DEBUG=true
# ===== 数据库 =====
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
DATABASE_URL=sqlite:///./data/app.db
# ===== JWT =====
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET_KEY=change-me-in-prod-please-use-a-long-random-string
JWT_ALGORITHM=HS256
# access token 有效期(分钟),默认 2 小时
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
# refresh token 有效期(天),默认 30 天
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
# ===== 极光一键登录 =====
# 控制台 → 应用设置 → 应用信息 拿到
JG_APP_KEY=966b451a8d9cfe12d173ea9d
JG_MASTER_SECRET=dae6d856c7556ac1b8f2deda
# RSA 私钥路径(本地开发可指到项目里的 secrets/jverify_rsa_private.pem,生产用 /opt 下绝对路径)
JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== CORS =====
# 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类
CORS_ALLOW_ORIGINS=
+36
View File
@@ -0,0 +1,36 @@
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
env/
*.egg-info/
build/
dist/
.eggs/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.env
.env.*
!.env.example
*.db
*.db-journal
*.db-shm
*.db-wal
data/
secrets/*
!secrets/.gitkeep
*.pem
*.key
.DS_Store
.idea/
.vscode/
*.swp
+214 -1
View File
@@ -1,3 +1,216 @@
# shaguabijia-app-server
傻瓜比价正式 app后端
傻瓜比价正式 App 后端 — FastAPI + SQLAlchemy + JWT。
> 占坑期已有的试验后台 `shaguabijia-server` 是无状态 mock,本项目是正式版,
> 引入账号体系、JWT 登录态、Alembic 迁移、SQLite 起步可平滑切 PostgreSQL。
详细技术说明见 [docs/后端技术实现.md](docs/后端技术实现.md)(登录链路、极光 RSA、部署 checklist)。
---
## 技术栈
| 维度 | 选型 | 说明 |
|---|---|---|
| 语言 | Python 3.11+ | |
| Web | FastAPI 0.115+ | |
| ASGI | uvicorn[standard] | |
| ORM | SQLAlchemy 2.0 | Mapped/DeclarativeBase 新风格 |
| 迁移 | Alembic | render_as_batch 兼容 SQLite |
| DB | SQLite | 后续切 PostgreSQL 只改 `DATABASE_URL` |
| Auth | JWT (PyJWT) | access(2h) + refresh(30d) |
| 极光一键登录 | httpx + cryptography | RSA 解密,逻辑参考试验后台 |
| 配置 | pydantic-settings | `.env` 加载 |
| 测试 | pytest + TestClient | |
---
## 目录结构
```
shaguabijia-app-server/
├── app/
│ ├── main.py FastAPI 入口 + /health + CORS
│ ├── core/
│ │ ├── config.py Settings (pydantic-settings)
│ │ ├── logging.py 统一日志配置
│ │ ├── security.py JWT 签发 / 校验 / token 对
│ │ ├── jiguang.py 极光 REST 调用 + RSA 解密
│ │ └── sms.py 短信验证码 (mock 实现)
│ ├── db/
│ │ ├── base.py DeclarativeBase
│ │ └── session.py engine + SessionLocal + get_db
│ ├── models/
│ │ └── user.py User 表
│ ├── schemas/
│ │ └── auth.py 登录/Token 相关 Pydantic
│ ├── crud/
│ │ └── user.py upsert_user_for_login 等
│ └── api/
│ ├── deps.py get_current_user (Bearer 校验)
│ └── v1/
│ └── auth.py /api/v1/auth/* 路由
├── alembic/ 迁移脚本
│ ├── env.py
│ ├── script.py.mako
│ └── versions/ (空,第一次跑 alembic revision 生成)
├── alembic.ini
├── deploy/
│ ├── shaguabijia-app-server.service systemd unit
│ └── nginx/
│ └── app-api.shaguabijia.com.conf
├── secrets/ 不入 git。放 jverify_rsa_private.pem
├── data/ 不入 git。SQLite 文件默认在这里
├── tests/ 冒烟 + auth 流程
├── pyproject.toml
├── .env.example
└── README.md
```
---
## API 接口
| 方法 | 路径 | 说明 | 鉴权 |
|---|---|---|---|
| GET | `/health` | 健康检查 | 无 |
| POST | `/api/v1/auth/jverify-login` | 极光一键登录(`loginToken` → 注册即登录 → 签发 token 对) | 无 |
| POST | `/api/v1/auth/sms/send` | 发短信验证码(mock 阶段任意 6 位通过) | 无 |
| POST | `/api/v1/auth/sms/login` | 手机号+验证码登录 | 无 |
| POST | `/api/v1/auth/refresh` | 用 `refresh_token` 换新的 token 对 | 无 |
| GET | `/api/v1/auth/me` | 获取当前登录用户 | `Bearer access_token` |
| POST | `/api/v1/auth/logout` | 登出(占位,客户端清 token) | `Bearer access_token` |
登录类接口的响应统一格式:
```json
{
"access_token": "eyJxxx...",
"refresh_token": "eyJxxx...",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_expires_in": 2592000,
"user": {
"id": 1,
"phone": "13800138000",
"nickname": null,
"avatar_url": null,
"register_channel": "jverify",
"status": "active",
"created_at": "2026-05-23T04:30:00Z",
"last_login_at": "2026-05-23T04:30:00Z"
}
}
```
文档启动后看 `http://localhost:8770/docs` (Swagger UI)。
---
## 本地开发
### 1. 装依赖
```bash
cd shaguabijia-app-server
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
### 2. 准备配置
```bash
cp .env.example .env
# 编辑 .env,生产部署务必改 JWT_SECRET_KEY
```
如果要测极光一键登录,把 RSA 私钥放到 `./secrets/jverify_rsa_private.pem`
没放也不影响其他接口(`sms`/`/health` 等),只是 `jverify-login` 会 502。
### 3. 建表(首次)
```bash
# 生成首版 migration(已有 User model,会自动生成 create_table)
alembic revision --autogenerate -m "init user table"
# 应用 migration
alembic upgrade head
```
后续改 model 后:
```bash
alembic revision --autogenerate -m "add nickname index"
alembic upgrade head
```
### 4. 跑起来
```bash
uvicorn app.main:app --reload --port 8770
```
冒烟:
```bash
curl http://localhost:8770/health
# {"status":"ok"}
# mock 走通登录闭环
curl -X POST http://localhost:8770/api/v1/auth/sms/send -H 'Content-Type: application/json' -d '{"phone":"13800138000"}'
curl -X POST http://localhost:8770/api/v1/auth/sms/login -H 'Content-Type: application/json' -d '{"phone":"13800138000","code":"123456"}'
# 拿到 access_token 后:
curl http://localhost:8770/api/v1/auth/me -H "Authorization: Bearer <access_token>"
```
### 5. 测试
```bash
pytest -q
```
---
## 部署(生产)
参考 `deploy/`,部署到 `app-api.shaguabijia.com`:
```bash
# 一次性
ssh server "apt-get install -y python3.11-venv python3-pip"
# 后续更新
rsync -avz --exclude='__pycache__' --exclude='.venv' --exclude='data' --exclude='secrets/jverify_rsa_private.pem' \
./ server:/opt/shaguabijia-app-server/
# 私钥单独同步(只首次 / 私钥更换时)
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
ssh server "
cd /opt/shaguabijia-app-server &&
.venv/bin/pip install --quiet -e . &&
.venv/bin/alembic upgrade head &&
systemctl restart shaguabijia-app-server
"
```
详见 `deploy/shaguabijia-app-server.service``deploy/nginx/app-api.shaguabijia.com.conf`
---
## 与试验后台的差异(为什么不直接复用 shaguabijia-server)
| 维度 | 试验后台 (shaguabijia-server) | 本项目 (shaguabijia-app-server) |
|---|---|---|
| 数据库 | stdlib sqlite3,无 ORM | SQLAlchemy 2.0 + Alembic |
| 业务 | mock parse(无账号体系) | 完整账号体系 + JWT |
| 登录态 | 无,客户端发 phone 当 ID | JWT access+refresh |
| 配置 | 硬编码 | pydantic-settings + `.env` |
| 短信登录 | 无 | 有(mock 实现,留扩展点) |
| 域名 | api.shaguabijia.com (试验) | app-api.shaguabijia.com (正式) |
极光一键登录的具体实现(REST 调用、RSA padding 试错顺序)直接参考自试验后台,
逻辑等价。
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+66
View File
@@ -0,0 +1,66 @@
"""Alembic 环境。
跟标准模板的差别:
- 从 app.core.config.settings 读 DATABASE_URL,不再走 alembic.ini 的 sqlalchemy.url
- import app.db.base 让 Base.metadata 包含所有 model 表(model 文件首次导入时注册到 metadata)
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base # noqa: F401 (触发 model 注册)
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 让 alembic 用我们的 DATABASE_URL,不是 alembic.ini 里的(空)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""生成 SQL 但不连库。一般用于 review。"""
context.configure(
url=settings.DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=settings.DATABASE_URL.startswith("sqlite"),
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""常规模式:连库执行 migration。
render_as_batch=True 让 SQLite 也能做 ALTER COLUMN(SQLite 原生不支持,
Alembic 用"重建表"的方式模拟)。
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=settings.DATABASE_URL.startswith("sqlite"),
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
@@ -0,0 +1,46 @@
"""init user table
Revision ID: 9c559acc164a
Revises:
Create Date: 2026-05-23 13:56:26.790399
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9c559acc164a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('phone', sa.String(length=20), nullable=False),
sa.Column('register_channel', sa.String(length=20), nullable=False),
sa.Column('nickname', sa.String(length=64), nullable=True),
sa.Column('avatar_url', sa.String(length=512), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_phone'), ['phone'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_phone'))
op.drop_table('user')
# ### end Alembic commands ###
View File
View File
+58
View File
@@ -0,0 +1,58 @@
"""API 层共享依赖:DB session、当前登录用户。"""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.security import TokenError, decode_token
from app.db.session import get_db
from app.models.user import User
logger = logging.getLogger("shagua.deps")
# auto_error=True → 没传 Authorization header 时直接 403。但我们想 401,
# 所以手动 auto_error=False + raise HTTPException
_bearer = HTTPBearer(auto_error=False, scheme_name="Bearer")
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User:
"""从 Authorization: Bearer <access_token> 解出当前 user。
失败时统一 401,WWW-Authenticate 头让客户端知道要重新登录。
"""
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials, expected_type="access")
except TokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
) from e
user_id = int(payload["sub"])
user = db.get(User, user_id)
if user is None or user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="user not found or disabled",
headers={"WWW-Authenticate": "Bearer"},
)
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[Session, Depends(get_db)]
View File
+130
View File
@@ -0,0 +1,130 @@
"""认证相关 endpoint。
路由前缀 `/api/v1/auth`,包含:
POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT)
POST /sms/send 发短信验证码(mock 阶段任意 6 位通过)
POST /sms/login 手机号 + 验证码登录
POST /refresh 用 refresh_token 换新的 token 对
GET /me 当前登录用户(Bearer access_token)
POST /logout 客户端登出(目前服务端无状态,仅返回 ok。后续加 jti 黑名单)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.core.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.core.security import TokenError, decode_token, issue_token_pair
from app.core.sms import SmsError, send_code, verify_code
from app.crud import user as crud_user
from app.schemas.auth import (
JverifyLoginRequest,
LogoutResponse,
RefreshRequest,
SmsLoginRequest,
SmsSendRequest,
SmsSendResponse,
TokenPair,
TokenWithUser,
UserOut,
)
logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(db, user) -> TokenWithUser:
"""登录类接口共用的响应组装。"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
)
# ===================== 极光一键登录 =====================
@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
logger.info(
"jverify_login operator=%s token_len=%d",
req.operator or "-",
len(req.login_token),
)
try:
phone = verify_and_get_phone(req.login_token)
except JiguangError as e:
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
user = crud_user.upsert_user_for_login(db, phone=phone, register_channel="jverify")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
return _login_response(db, user)
# ===================== 短信登录 =====================
@router.post("/sms/send", response_model=SmsSendResponse, summary="发送短信验证码 (mock)")
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
try:
cooldown = send_code(req.phone)
except SmsError as e:
raise HTTPException(status_code=429, detail=str(e)) from e
from app.core.config import settings # 局部 import 避免循环
return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)
@router.post("/sms/login", response_model=TokenWithUser, summary="手机号+验证码登录")
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
if not verify_code(req.phone, req.code):
raise HTTPException(status_code=400, detail="invalid sms code")
user = crud_user.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
if user.status != "active":
raise HTTPException(status_code=403, detail="account disabled")
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
return _login_response(db, user)
# ===================== Refresh =====================
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
try:
payload = decode_token(req.refresh_token, expected_type="refresh")
except TokenError as e:
raise HTTPException(status_code=401, detail=str(e)) from e
user_id = int(payload["sub"])
user = crud_user.get_user_by_id(db, user_id)
if user is None or user.status != "active":
raise HTTPException(status_code=401, detail="user not found or disabled")
return TokenPair(**issue_token_pair(user.id))
# ===================== 当前用户 =====================
@router.get("/me", response_model=UserOut, summary="获取当前登录用户")
def me(user: CurrentUser) -> UserOut:
return UserOut.model_validate(user)
# ===================== Logout =====================
@router.post("/logout", response_model=LogoutResponse, summary="登出(占位,客户端清 token)")
def logout(user: CurrentUser) -> LogoutResponse:
# 当前服务端无状态,真正的 token 失效靠客户端删 token。
# TODO: 加 jti 黑名单表后,这里把 access_token 的 jti 写黑名单
logger.info("logout user_id=%d", user.id)
return LogoutResponse(ok=True)
View File
+68
View File
@@ -0,0 +1,68 @@
"""全局配置。
用 pydantic-settings 从环境变量 + .env 文件加载。所有可调参数集中在这里,
业务代码通过 `from app.core.config import settings` 引用,不再读 os.environ。
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# ===== 环境 =====
APP_ENV: Literal["dev", "prod"] = "dev"
APP_NAME: str = "shaguabijia-app-server"
APP_DEBUG: bool = True
# ===== 数据库 =====
DATABASE_URL: str = "sqlite:///./data/app.db"
# ===== JWT =====
JWT_SECRET_KEY: str = "change-me"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 120
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# ===== 极光 =====
JG_APP_KEY: str = ""
JG_MASTER_SECRET: str = ""
JG_PRIVATE_KEY_PATH: str = "./secrets/jverify_rsa_private.pem"
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC: int = 15
# ===== 短信 =====
SMS_MOCK: bool = True
SMS_CODE_TTL_SEC: int = 300
SMS_SEND_INTERVAL_SEC: int = 60
# ===== CORS =====
CORS_ALLOW_ORIGINS: str = ""
@property
def cors_origins_list(self) -> list[str]:
if not self.CORS_ALLOW_ORIGINS.strip():
return []
return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()]
@property
def is_prod(self) -> bool:
return self.APP_ENV == "prod"
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+163
View File
@@ -0,0 +1,163 @@
"""极光一键登录服务端核验 + RSA 解密手机号。
链路:
Android SDK loginAuth → loginToken
→ 本服务 POST 极光 /v1/web/loginTokenVerify (Basic Auth = AppKey:MasterSecret)
→ 极光返回 RSA 公钥加密的手机号 (base64)
→ 用配套 RSA 私钥解密
→ 返回明文手机号
参考实现:试验后台 shaguabijia-server/app/api/auth.py。
"""
from __future__ import annotations
import base64
import logging
from pathlib import Path
import httpx
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from app.core.config import settings
logger = logging.getLogger("shagua.jg")
class JiguangError(Exception):
"""极光接口 / 解密失败的统一异常,api 层 catch 后翻成 4xx/5xx。"""
_private_key_cache: RSAPrivateKey | None = None
def _load_private_key() -> RSAPrivateKey:
"""懒加载 RSA 私钥并缓存。首次调用时如果文件不存在抛 JiguangError,
让单个请求失败 + 日志,而不是 import 时炸进程。"""
global _private_key_cache
if _private_key_cache is not None:
return _private_key_cache
path = Path(settings.JG_PRIVATE_KEY_PATH)
if not path.is_file():
raise JiguangError(
f"RSA private key not found at {path}. "
f"Set JG_PRIVATE_KEY_PATH or place the .pem file there."
)
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
if not isinstance(key, RSAPrivateKey):
raise JiguangError("loaded key is not an RSA private key")
_private_key_cache = key
return key
def _verify_login_token(login_token: str) -> str:
"""调极光 REST,返回 RSA 加密后的 base64 手机号字符串。"""
if not settings.JG_APP_KEY or not settings.JG_MASTER_SECRET:
raise JiguangError("JG_APP_KEY / JG_MASTER_SECRET not configured")
auth_b64 = base64.b64encode(
f"{settings.JG_APP_KEY}:{settings.JG_MASTER_SECRET}".encode()
).decode()
try:
resp = httpx.post(
settings.JG_VERIFY_ENDPOINT,
json={"loginToken": login_token, "exID": ""},
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth_b64}",
},
timeout=settings.JG_REQUEST_TIMEOUT_SEC,
)
except httpx.HTTPError as e:
logger.exception("[JG] http error calling %s", settings.JG_VERIFY_ENDPOINT)
raise JiguangError(f"jg http error: {e}") from e
if resp.status_code != 200:
body = resp.text[:500]
logger.error("[JG] HTTP %s body=%s", resp.status_code, body)
raise JiguangError(f"jg http {resp.status_code}")
data = resp.json()
code = data.get("code")
if code != 8000:
logger.error("[JG] verify failed code=%s content=%s", code, data.get("content"))
raise JiguangError(f"jg verify failed code={code}")
encrypted = data.get("phone") or ""
if not encrypted:
logger.error("[JG] response missing phone field: %s", data)
raise JiguangError("jg response missing phone")
return encrypted
def _decrypt_phone(encrypted_b64: str) -> str:
"""RSA 解密 base64 密文。
极光文档没明说 padding。实测 PKCS1v15 在 OAEP 密文上会"假成功"返回垃圾
(UnicodeDecodeError 0x8e),因此按 OAEP-SHA1 → OAEP-SHA256 → PKCS1v15 顺序
尝试,谁能解出 11 位纯数字手机号就用谁。
base64 padding 兼容:JG 各运营商可能返回不带 `=` 的 base64,先按 4 字节对齐补齐。
"""
private_key = _load_private_key()
padded_b64 = encrypted_b64 + "=" * (-len(encrypted_b64) % 4)
ciphertext = base64.b64decode(padded_b64)
paddings = [
("OAEP-SHA1", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None,
)),
("OAEP-SHA256", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)),
("PKCS1v15", padding.PKCS1v15()),
]
last_err: Exception | None = None
for name, pad in paddings:
try:
pt = private_key.decrypt(ciphertext, pad)
except Exception as e:
last_err = e
logger.debug("[JG] decrypt padding=%s failed: %s", name, e)
continue
try:
phone = pt.decode("utf-8")
except UnicodeDecodeError:
# PKCS1v15 在错误密文上会"假成功"返回垃圾;跳到下一种 padding
logger.debug(
"[JG] decrypt padding=%s yielded non-UTF8 bytes head=%s",
name, pt[:4].hex(),
)
continue
if phone.isdigit() and len(phone) == 11:
logger.info("[JG] decrypted with padding=%s", name)
return phone
if phone.isascii() and phone.isprintable():
logger.warning(
"[JG] decrypted with padding=%s, non-standard format: %r", name, phone,
)
return phone
logger.debug("[JG] decrypt padding=%s yielded non-phone string: %r", name, phone[:20])
raise JiguangError(f"all RSA paddings failed; last error: {last_err}")
def verify_and_get_phone(login_token: str) -> str:
"""对外暴露的唯一函数:loginToken → 明文手机号。失败抛 JiguangError。"""
encrypted = _verify_login_token(login_token)
return _decrypt_phone(encrypted)
def mask_phone(phone: str) -> str:
"""日志用,不打明文。"""
if len(phone) < 5:
return "***"
return phone[:3] + "****" + phone[-2:]
+20
View File
@@ -0,0 +1,20 @@
"""统一日志配置。
业务代码用 `logger = logging.getLogger("shagua.xxx")` 即可,本模块在 main.py 启动时调一次。
"""
from __future__ import annotations
import logging
import sys
def setup_logging(debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
# 第三方库降噪
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+86
View File
@@ -0,0 +1,86 @@
"""JWT 签发与校验。
设计:
- access_token : 短期(默认 2 小时),每个业务请求 Bearer 携带
- refresh_token: 长期(默认 30 天),过期时 POST /refresh 换新的 access
两者结构一致(都是 JWT),靠 payload 里的 `typ` 字段区分,防止用 refresh 当 access 用。
为什么不用 OAuth2 + session 表?
- 占坑 + 早期阶段,JWT 自包含、零状态,够用
- 后续需要"主动作废"(改密码/账号被盗)时再加 jti 黑名单表
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
import jwt
from app.core.config import settings
TokenType = Literal["access", "refresh"]
class TokenError(Exception):
"""token 解析/校验失败时统一抛这个,api 层捕获后返回 401。"""
def _now() -> datetime:
return datetime.now(timezone.utc)
def create_token(*, user_id: int, token_type: TokenType) -> tuple[str, datetime]:
"""签发 token,返回 (token_string, expires_at_utc)。"""
now = _now()
if token_type == "access":
expire = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
else:
expire = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
payload: dict[str, Any] = {
"sub": str(user_id),
"typ": token_type,
"iat": int(now.timestamp()),
"exp": int(expire.timestamp()),
}
token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
return token, expire
def decode_token(token: str, *, expected_type: TokenType | None = None) -> dict[str, Any]:
"""解析并校验 token。
- 签名错/过期 → TokenError
- expected_type 不匹配 → TokenError (防 refresh 当 access 用)
"""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except jwt.ExpiredSignatureError as e:
raise TokenError("token expired") from e
except jwt.InvalidTokenError as e:
raise TokenError(f"invalid token: {e}") from e
if expected_type is not None and payload.get("typ") != expected_type:
raise TokenError(f"wrong token type: want={expected_type} got={payload.get('typ')}")
if "sub" not in payload:
raise TokenError("token missing sub")
return payload
def issue_token_pair(user_id: int) -> dict[str, Any]:
"""登录/刷新成功后调,一次性发回 access + refresh。
返回字段对齐 OAuth2 习惯,前端易接:
access_token / refresh_token / token_type / expires_in
"""
access, access_exp = create_token(user_id=user_id, token_type="access")
refresh, refresh_exp = create_token(user_id=user_id, token_type="refresh")
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "Bearer",
"expires_in": int((access_exp - _now()).total_seconds()),
"refresh_expires_in": int((refresh_exp - _now()).total_seconds()),
}
+68
View File
@@ -0,0 +1,68 @@
"""短信验证码服务。
当前实现:**mock 模式**。
- send_code(): 不真发短信,仅 log。记录 phone → 发送时间(防 60s 内重复发)
- verify_code(): 任意 6 位数字均通过(配合前台 demo 行为)
后续接真供应商(阿里云 / 腾讯云)时:
- send_code() 改为调供应商 API,把生成的 6 位码存到 cache(redis / sqlite)
- verify_code() 比对 cache 里的码,且验过即作废
进程内存方案占坑期够用;切真实供应商时一并切 redis。
"""
from __future__ import annotations
import logging
import time
from threading import Lock
from app.core.config import settings
logger = logging.getLogger("shagua.sms")
# 进程内最近发送时间表:{phone: epoch_seconds},用于 60s 内拒发
_last_sent: dict[str, float] = {}
_lock = Lock()
class SmsError(Exception):
"""业务异常:发送过频 / 验证码不对。api 层 catch 后翻成 4xx。"""
def send_code(phone: str) -> int:
"""发送(或 mock 发送)验证码。
Returns: 距下一次可发的秒数(=0 表示刚刚已发,需等 SMS_SEND_INTERVAL_SEC 秒)
Raises: SmsError 如果上次发送在 SMS_SEND_INTERVAL_SEC 内
"""
now = time.time()
with _lock:
last = _last_sent.get(phone, 0.0)
elapsed = now - last
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"send too frequent, retry in {remain}s")
_last_sent[phone] = now
if settings.SMS_MOCK:
logger.info("[SMS-MOCK] send to %s****, code=any-6-digits", phone[:3])
else:
# TODO: 接真实短信供应商
logger.warning("[SMS] real provider not implemented yet, phone=%s****", phone[:3])
raise SmsError("sms provider not configured")
return settings.SMS_SEND_INTERVAL_SEC
def verify_code(phone: str, code: str) -> bool:
"""校验验证码。mock 模式下任意 6 位数字均通过。"""
if settings.SMS_MOCK:
if len(code) == 6 and code.isdigit():
logger.info("[SMS-MOCK] verify ok for %s****", phone[:3])
return True
logger.info("[SMS-MOCK] verify fail (need 6 digits) for %s****", phone[:3])
return False
# TODO: 比对供应商发出的真实验证码
logger.warning("[SMS] real provider not implemented yet, phone=%s****", phone[:3])
return False
View File
+48
View File
@@ -0,0 +1,48 @@
"""user 表的 CRUD 工具函数。
业务策略 = "注册即登录":phone 不存在则 insert,存在则更新 last_login_at。
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import User
def get_user_by_id(db: Session, user_id: int) -> User | None:
return db.get(User, user_id)
def get_user_by_phone(db: Session, phone: str) -> User | None:
stmt = select(User).where(User.phone == phone)
return db.execute(stmt).scalar_one_or_none()
def upsert_user_for_login(
db: Session,
*,
phone: str,
register_channel: str,
) -> User:
"""登录入口统一调:有则更新 last_login_at,无则新建。
返回 commit 后的 user。调用方拿到 user.id 直接签 JWT。
"""
user = get_user_by_phone(db, phone)
now = datetime.now(timezone.utc)
if user is None:
user = User(
phone=phone,
register_channel=register_channel,
last_login_at=now,
)
db.add(user)
else:
user.last_login_at = now
# 被禁用的账号被尝试登录时,不自动启用,直接抛(在调用方处理)
db.commit()
db.refresh(user)
return user
View File
+17
View File
@@ -0,0 +1,17 @@
"""SQLAlchemy 2.0 Declarative Base。
所有 ORM model 继承 `Base`。Alembic 通过 `Base.metadata` 拿到完整表结构生成 migration。
"""
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
class Base(DeclarativeBase):
"""所有 model 的公共基类。"""
# 在 Alembic env.py 里 import Base 才能扫描到所有 model,
# 这里也 import 一次确保 metadata 包含全部表。
# (新增 model 时记得加到 app.models.__init__ 里)
from app import models # noqa: E402,F401
+56
View File
@@ -0,0 +1,56 @@
"""SQLAlchemy engine + Session 工厂。
FastAPI 路由通过 `Depends(get_db)` 拿到一个 request-scope 的 Session,
请求结束自动 close。
"""
from __future__ import annotations
import os
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
def _ensure_sqlite_dir(url: str) -> None:
"""SQLite 文件路径如果在子目录,首启时自动 mkdir,避免 sqlalchemy 报 unable to open。"""
prefix = "sqlite:///"
if not url.startswith(prefix):
return
path = url[len(prefix):]
dirname = os.path.dirname(path)
if dirname:
os.makedirs(dirname, exist_ok=True)
_ensure_sqlite_dir(settings.DATABASE_URL)
# SQLite 跨线程访问要 check_same_thread=False;PG/MySQL 不需要这个参数
_connect_args: dict = {}
if settings.DATABASE_URL.startswith("sqlite"):
_connect_args["check_same_thread"] = False
engine = create_engine(
settings.DATABASE_URL,
connect_args=_connect_args,
# echo 在 dev 下打 SQL,生产关掉
echo=settings.APP_DEBUG and not settings.is_prod,
future=True,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
def get_db() -> Iterator[Session]:
"""FastAPI 依赖。请求开始建 session,请求结束自动 close。
出异常时 SQLAlchemy 默认会标记 transaction 为 invalid,close 时自动 rollback。
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+59
View File
@@ -0,0 +1,59 @@
"""FastAPI 入口。
通过 `uvicorn app.main:app --reload` 启动。
"""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.auth import router as auth_router
from app.core.config import settings
from app.core.logging import setup_logging
setup_logging(debug=settings.APP_DEBUG)
logger = logging.getLogger("shagua.main")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
# 这里只 log,不自动 create_all,避免生产环境意外建出过时 schema。
logger.info(
"started env=%s debug=%s db=%s",
settings.APP_ENV,
settings.APP_DEBUG,
settings.DATABASE_URL.split("://", 1)[0],
)
yield
logger.info("shutting down")
app = FastAPI(
title=settings.APP_NAME,
version="0.1.0",
docs_url="/docs" if not settings.is_prod else None,
redoc_url="/redoc" if not settings.is_prod else None,
lifespan=lifespan,
)
if settings.cors_origins_list:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health", tags=["meta"])
def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(auth_router)
+2
View File
@@ -0,0 +1,2 @@
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
from app.models.user import User # noqa: F401
+47
View File
@@ -0,0 +1,47 @@
"""用户表。
登录方式有两种,但都映射到同一行 user:
- 极光一键登录 → phone 唯一索引
- 短信验证码登录(mock 阶段) → 也是 phone 唯一索引
注册即登录:phone 不存在则 insert,存在则更新 last_login_at。
后续如果加微信/Apple 登录,新增 oauth_account 表,这张表不动。
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
# 注册渠道:jverify / sms。后续加 wechat / apple 时扩
register_channel: Mapped[str] = mapped_column(String(20), nullable=False, default="jverify")
nickname: Mapped[str | None] = mapped_column(String(64), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 账号状态:active / disabled / deleted
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_login_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<User id={self.id} phone={self.phone}>"
View File
+77
View File
@@ -0,0 +1,77 @@
"""Auth 相关请求 / 响应 schemas。
跟客户端约定:
- 字段统一用 snake_case
- 时间字段统一 ISO 8601 (UTC)
- 登录类接口成功时统一返回 TokenWithUser
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
# ===== 用户对外信息 =====
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # 允许直接 UserOut.model_validate(orm_user)
id: int
phone: str
nickname: str | None = None
avatar_url: str | None = None
register_channel: str
status: str
created_at: datetime
last_login_at: datetime
# ===== Token 通用结构 =====
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int = Field(..., description="access_token 剩余秒数")
refresh_expires_in: int = Field(..., description="refresh_token 剩余秒数")
class TokenWithUser(TokenPair):
user: UserOut
# ===== 极光一键登录 =====
class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
operator: str = Field("", description="CM/CU/CT,用于日志,可选")
# ===== 短信验证码 =====
class SmsSendRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
class SmsSendResponse(BaseModel):
sent: bool
mock: bool = Field(..., description="是否 mock 发送(true 时验证码不会真发,任意 6 位通过)")
cooldown_sec: int = Field(..., description="多少秒后才能再发一次")
class SmsLoginRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
code: str = Field(..., min_length=4, max_length=8)
# ===== Refresh =====
class RefreshRequest(BaseModel):
refresh_token: str = Field(..., min_length=1)
# ===== Logout =====
class LogoutResponse(BaseModel):
ok: bool = True
+33
View File
@@ -0,0 +1,33 @@
# 80 → 301 → 443
server {
listen 80;
listen [::]:80;
server_name app-api.shaguabijia.com;
return 301 https://$host$request_uri;
}
# 443 SSL 反代到本地 uvicorn (127.0.0.1:8770)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app-api.shaguabijia.com;
ssl_certificate /etc/nginx/ssl/app-api.shaguabijia.com.pem;
ssl_certificate_key /etc/nginx/ssl/app-api.shaguabijia.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
client_max_body_size 4m;
location / {
proxy_pass http://127.0.0.1:8770;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Shaguabijia App Backend (FastAPI / uvicorn)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/shaguabijia-app-server
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
EnvironmentFile=/opt/shaguabijia-app-server/.env
ExecStart=/opt/shaguabijia-app-server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8770 --workers 1 --log-level info
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/shaguabijia-app-server
ProtectHome=true
[Install]
WantedBy=multi-user.target
+250
View File
@@ -0,0 +1,250 @@
# 傻瓜比价 App Server — 后端技术实现
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
> 仓库:`shaguabijia-app-server`
> 当前版本:v0.1.0(登录模块)
> 最后更新:2026-05-23
---
## 1. 本次实施范围
本阶段目标是搭正式 App 后端的**账号与登录**能力,供 Android 正式版(`shaguabijia-app-android`)对接。
已实现:
| 能力 | 状态 | 说明 |
|---|---|---|
| 极光一键登录 | ✅ 已联调 | `loginToken` → 极光 REST 验真 → RSA 解密手机号 → 注册/登录 |
| 短信验证码登录 | ✅ mock 可用 | 开发阶段任意 6 位通过,便于无短信供应商时联调 |
| JWT 双 token | ✅ | access(2h) + refresh(30d) |
| 用户表 + ORM | ✅ | SQLAlchemy 2.0 + Alembic |
| `/me` 鉴权 | ✅ | Bearer access_token |
| 单元测试 | ✅ | health + sms 登录闭环 + refresh + 限流 |
未做(后续迭代):
- 真实短信供应商(阿里云/腾讯云)
- logout 服务端 token 黑名单(jti)
- 昵称/头像修改、注销账号
- PostgreSQL 生产切库(当前 SQLite 起步)
---
## 2. 与试验后台的关系
| 维度 | 试验后台 `shaguabijia-server` | 正式后台 `shaguabijia-app-server` |
|---|---|---|
| 域名 | `api.shaguabijia.com` | `app-api.shaguabijia.com` |
| 数据库 | stdlib sqlite3,手写 SQL | SQLAlchemy 2.0 + Alembic |
| 账号体系 | 仅占坑(phone 当 ID) | User 表 + JWT |
| 极光验 token | ✅ 有 | ✅ 移植并增强 |
| 短信登录 | ❌ | ✅ mock |
| 配置 | 硬编码 | pydantic-settings + `.env` |
**极光一键登录的核心逻辑**(REST 调用、RSA padding 试错顺序)直接参考试验后台 `app/api/auth.py`,
等价实现在 `app/core/jiguang.py`
试验后台 RSA 私钥部署在服务器 `/opt/shaguabijia-server/secrets/jverify_rsa_private.pem`(不入 git)。
正式后台因原私钥不可用,在极光控制台**重新生成 1024-bit RSA 密钥对**并上传公钥。
---
## 3. 技术栈
| 维度 | 选型 | 说明 |
|---|---|---|
| 语言 | Python 3.11+ | 本地开发用 3.13 |
| Web | FastAPI 0.115+ | OpenAPI 自动生成 |
| ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` |
| ORM | SQLAlchemy 2.0 | Mapped 新风格 |
| 迁移 | Alembic | `render_as_batch` 兼容 SQLite |
| DB | SQLite | 生产可改 `DATABASE_URL` 切 PostgreSQL |
| Auth | PyJWT | HS256,access + refresh |
| 极光 | httpx + cryptography | REST 验 token + RSA 解密 |
| 配置 | pydantic-settings | `.env` 加载 |
| 测试 | pytest + httpx TestClient | 临时文件 SQLite |
---
## 4. 登录链路
### 4.1 极光一键登录(主路径)
```
Android SDK loginAuth
→ 客户端拿到 loginToken
→ POST /api/v1/auth/jverify-login { login_token, operator }
→ 后端 POST 极光 loginTokenVerify (Basic Auth = AppKey:MasterSecret)
→ 极光返回 RSA 加密的手机号(base64)
→ 后端用私钥解密(PKCS1v15,实测电信 CT 走此 padding)
→ upsert User(phone 唯一) → 签 JWT → 返回 TokenWithUser
```
**关键配置**(与 Android 端必须一致):
| 项 | 值 |
|---|---|
| 包名 | `com.jishisongfu.shaguabijia` |
| 极光 AppKey | `966b451a8d9cfe12d173ea9d` |
| RSA 公钥 | 极光控制台「加密公钥」(1024-bit,≤220 字符) |
| RSA 私钥 | `secrets/jverify_rsa_private.pem`(不入 git) |
**RSA 密钥说明**:
- 密钥对由我方生成(`openssl genrsa 1024`),公钥上传极光控制台
- 极光控制台限制公钥长度 ≤220 字符,因此用 **1024-bit** 而非 2048-bit
- 私钥路径由 `JG_PRIVATE_KEY_PATH` 指定,懒加载;文件不存在时 `jverify-login` 返回 502
### 4.2 短信登录(mock 路径)
```
POST /api/v1/auth/sms/send { phone } → 60s 冷却,不真发短信
POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过(SMS_MOCK=true)
→ upsert User → 签 JWT → 返回 TokenWithUser
```
### 4.3 Token 刷新
```
POST /api/v1/auth/refresh { refresh_token }
→ 校验 refresh token → 签新 access+refresh 对
```
Android 端通过 OkHttp `Authenticator` 在 401 时自动调用。
---
## 5. 数据模型
### User 表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 |
| phone | VARCHAR UNIQUE | 手机号,登录主键 |
| register_channel | VARCHAR | `jverify` / `sms` |
| nickname | VARCHAR NULL | 预留 |
| avatar_url | VARCHAR NULL | 预留 |
| status | VARCHAR | `active` / `disabled` |
| created_at | DATETIME | 注册时间 |
| last_login_at | DATETIME | 最近登录 |
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册。
---
## 6. API 一览
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/health` | 无 | 健康检查 |
| POST | `/api/v1/auth/jverify-login` | 无 | 极光一键登录 |
| POST | `/api/v1/auth/sms/send` | 无 | 发短信(mock) |
| POST | `/api/v1/auth/sms/login` | 无 | 短信登录 |
| POST | `/api/v1/auth/refresh` | 无 | 刷新 token |
| GET | `/api/v1/auth/me` | Bearer | 当前用户 |
| POST | `/api/v1/auth/logout` | Bearer | 登出占位 |
Swagger UI: `http://localhost:8770/docs`
---
## 7. 目录结构
```
app/
├── main.py # FastAPI 入口,lifespan,CORS
├── core/
│ ├── config.py # pydantic-settings
│ ├── security.py # JWT 签发/校验
│ ├── jiguang.py # 极光 REST + RSA 解密
│ └── sms.py # 短信 mock(内存验证码)
├── db/
│ ├── base.py
│ └── session.py # engine + get_db
├── models/user.py
├── schemas/auth.py
├── crud/user.py
└── api/
├── deps.py # get_current_user
└── v1/auth.py # 登录路由
alembic/ # 数据库迁移
deploy/ # systemd + nginx
secrets/ # RSA 私钥(不入 git)
tests/ # pytest
```
---
## 8. 本地联调(真机 + LAN)
后端监听所有网卡,供同一 WiFi 下的测试机访问:
```bash
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
```
Android debug 包 `BASE_URL` 指向开发机 LAN IP(配置在 `local.properties`,不入 git):
```
debug.api.host=192.168.x.x
debug.api.port=8770
```
真机浏览器访问 `http://192.168.x.x:8770/health` 应返回 `{"status":"ok"}`
---
## 9. 部署要点
```bash
# 代码同步(排除 secrets 和 data)
rsync -avz --exclude='.venv' --exclude='data' --exclude='secrets/*.pem' \
./ server:/opt/shaguabijia-app-server/
# 私钥单独 scp(仅首次或轮换时)
scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/
# 迁移 + 重启
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server"
```
生产 checklist:
- [ ] `JWT_SECRET_KEY` 换成随机长字符串
- [ ] `APP_ENV=prod`, `APP_DEBUG=false`
- [ ] RSA 私钥权限 `chmod 600`
- [ ] nginx SSL 证书有效
- [ ] `alembic upgrade head` 已执行
---
## 10. 已知问题与后续
| 项 | 说明 |
|---|---|
| logout 无服务端失效 | 目前靠客户端清 token;后续可加 jti 黑名单表 |
| SMS 为 mock | 上线前需接真实短信供应商并设 `SMS_MOCK=false` |
| SQLite 并发 | 生产流量上来后切 PostgreSQL,只改 `DATABASE_URL` |
| 极光 RSA 1024-bit | 极光控制台限制所致;仅加密 11 位手机号,可接受 |
---
## 11. 联调验证记录(2026-05-23)
本地 LAN 联调已通过:
```
# 短信登录
POST /api/v1/auth/sms/login → 200, user_id=2, phone=177****82
# 极光一键登录
POST /api/v1/auth/jverify-login → 200, operator=CT, token_len=448
[JG] decrypted with padding=PKCS1v15
jverify_login ok user_id=3, phone=153****91
```
Android 端包名、签名、极光 AppKey 与控制台一致,SDK init 8000,loginAuth 6000。
+54
View File
@@ -0,0 +1,54 @@
[project]
name = "shaguabijia-app-server"
version = "0.1.0"
description = "Shaguabijia 正式 App 后端 (FastAPI + SQLAlchemy + JWT)"
requires-python = ">=3.11"
dependencies = [
# Web 框架 & ASGI
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
# 数据校验
"pydantic>=2.9.0",
"pydantic-settings>=2.5.0",
"email-validator>=2.2.0",
# ORM & 迁移
"sqlalchemy>=2.0.35",
"alembic>=1.13.3",
# JWT 签名 / 校验
"pyjwt[crypto]>=2.9.0",
# 极光一键登录:RSA 解密极光返回的加密手机号
"cryptography>=42.0.0",
# HTTP 客户端 (调极光 REST)
"httpx>=0.27.0",
# multipart form (FastAPI 表单上传依赖)
"python-multipart>=0.0.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.27.0",
"ruff>=0.6.0",
]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP"]
ignore = ["E501"]
View File
View File
+48
View File
@@ -0,0 +1,48 @@
"""测试用 fixtures。
测试 DB 用临时文件 SQLite。
- 不用 in-memory:in-memory 默认 per-connection,跨连接看不到表。
- 用临时文件保证 SessionLocal 每次新连都看到同一份 schema。
顺序:set env(必须在 import app.* 之前) → import app → 建表 → TestClient
"""
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterator
# 临时 db 文件路径,进程退出后清理
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_tmp_db.close()
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp_db.name}"
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
os.environ.setdefault("JG_APP_KEY", "test-key")
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
os.environ.setdefault("SMS_MOCK", "true")
os.environ.setdefault("APP_ENV", "dev")
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
import pytest
from fastapi.testclient import TestClient
from app.db.base import Base
from app.db.session import engine
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def _setup_db() -> Iterator[None]:
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
try:
os.unlink(_tmp_db.name)
except OSError:
pass
@pytest.fixture()
def client() -> TestClient:
return TestClient(app)
+70
View File
@@ -0,0 +1,70 @@
"""auth 流程测试(不依赖真实极光,只测 sms mock 路径 + JWT 闭环)。"""
from __future__ import annotations
def test_sms_login_and_me_flow(client) -> None:
"""sms_send → sms_login → 用 access_token 调 /me 拿到自己。"""
phone = "13800138000"
# 1. 发短信(mock,任意 6 位均通过)
r = client.post("/api/v1/auth/sms/send", json={"phone": phone})
assert r.status_code == 200, r.text
body = r.json()
assert body["sent"] is True
assert body["mock"] is True
# 2. 登录
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
data = r.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["user"]["phone"] == phone
assert data["user"]["register_channel"] == "sms"
access = data["access_token"]
refresh = data["refresh_token"]
# 3. /me
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200, r.text
assert r.json()["phone"] == phone
# 4. /me 不带 token → 401
r = client.get("/api/v1/auth/me")
assert r.status_code == 401
# 5. 用 refresh 换新 token
r = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert r.status_code == 200, r.text
assert "access_token" in r.json()
# 6. 用 access 当 refresh 拒绝(防 token 类型混用)
r = client.post("/api/v1/auth/refresh", json={"refresh_token": access})
assert r.status_code == 401
# 7. logout
r = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["ok"] is True
def test_sms_send_too_frequent(client) -> None:
phone = "13900139000"
r1 = client.post("/api/v1/auth/sms/send", json={"phone": phone})
assert r1.status_code == 200
r2 = client.post("/api/v1/auth/sms/send", json={"phone": phone})
assert r2.status_code == 429
def test_sms_login_bad_code(client) -> None:
"""mock 模式下,5 位通过 schema 长度校验,但业务层只接受 6 位 → 400。"""
phone = "13700137000"
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "12345"})
assert r.status_code == 400
assert "invalid sms code" in r.json()["detail"]
def test_phone_format_validation(client) -> None:
r = client.post("/api/v1/auth/sms/send", json={"phone": "1234567"})
assert r.status_code == 422
+20
View File
@@ -0,0 +1,20 @@
"""最小冒烟:服务能起、/health 通。"""
from __future__ import annotations
def test_health_ok(client) -> None:
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
def test_openapi_loads(client) -> None:
resp = client.get("/openapi.json")
assert resp.status_code == 200
paths = resp.json()["paths"]
# auth endpoints 都注册了
assert "/api/v1/auth/jverify-login" in paths
assert "/api/v1/auth/sms/send" in paths
assert "/api/v1/auth/sms/login" in paths
assert "/api/v1/auth/refresh" in paths
assert "/api/v1/auth/me" in paths