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
|
# 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