feat: 正式 App 后端登录模块 v0.1.0
引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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)]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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:]
|
||||
@@ -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)
|
||||
@@ -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()),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.user import User # noqa: F401
|
||||
@@ -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}>"
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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。
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user