From 1aafc286215c43bdefcac256fbc6016e9d9ec4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E6=B6=A6=E6=9E=97?= Date: Sat, 23 May 2026 17:37:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AD=A3=E5=BC=8F=20App=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=99=BB=E5=BD=95=E6=A8=A1=E5=9D=97=20v0.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 JWT 认证、极光一键登录、短信 mock 登录与用户表,并补充技术实施文档与部署配置。 Co-authored-by: Cursor --- .env.example | 39 +++ .gitignore | 36 +++ README.md | 215 ++++++++++++++- alembic.ini | 40 +++ alembic/env.py | 66 +++++ alembic/script.py.mako | 26 ++ alembic/versions/.gitkeep | 0 .../versions/9c559acc164a_init_user_table.py | 46 ++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/deps.py | 58 ++++ app/api/v1/__init__.py | 0 app/api/v1/auth.py | 130 +++++++++ app/core/__init__.py | 0 app/core/config.py | 68 +++++ app/core/jiguang.py | 163 ++++++++++++ app/core/logging.py | 20 ++ app/core/security.py | 86 ++++++ app/core/sms.py | 68 +++++ app/crud/__init__.py | 0 app/crud/user.py | 48 ++++ app/db/__init__.py | 0 app/db/base.py | 17 ++ app/db/session.py | 56 ++++ app/main.py | 59 +++++ app/models/__init__.py | 2 + app/models/user.py | 47 ++++ app/schemas/__init__.py | 0 app/schemas/auth.py | 77 ++++++ deploy/nginx/app-api.shaguabijia.com.conf | 33 +++ deploy/shaguabijia-app-server.service | 22 ++ docs/后端技术实现.md | 250 ++++++++++++++++++ pyproject.toml | 54 ++++ secrets/.gitkeep | 0 tests/__init__.py | 0 tests/conftest.py | 48 ++++ tests/test_auth.py | 70 +++++ tests/test_health.py | 20 ++ 38 files changed, 1863 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/.gitkeep create mode 100644 alembic/versions/9c559acc164a_init_user_table.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/deps.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/auth.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/jiguang.py create mode 100644 app/core/logging.py create mode 100644 app/core/security.py create mode 100644 app/core/sms.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/user.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/auth.py create mode 100644 deploy/nginx/app-api.shaguabijia.com.conf create mode 100644 deploy/shaguabijia-app-server.service create mode 100644 docs/后端技术实现.md create mode 100644 pyproject.toml create mode 100644 secrets/.gitkeep create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_health.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..66644d8 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4285625 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 9afa3f3..100823b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,216 @@ # shaguabijia-app-server -傻瓜比价正式 app后端 \ No newline at end of file +傻瓜比价正式 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 " +``` + +### 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 试错顺序)直接参考自试验后台, +逻辑等价。 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..297ef85 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..4d89624 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..5866ecf --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/9c559acc164a_init_user_table.py b/alembic/versions/9c559acc164a_init_user_table.py new file mode 100644 index 0000000..76503a3 --- /dev/null +++ b/alembic/versions/9c559acc164a_init_user_table.py @@ -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 ### diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..20a663c --- /dev/null +++ b/app/api/deps.py @@ -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 解出当前 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)] diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..294ca77 --- /dev/null +++ b/app/api/v1/auth.py @@ -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) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..0cd99e4 --- /dev/null +++ b/app/core/config.py @@ -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() diff --git a/app/core/jiguang.py b/app/core/jiguang.py new file mode 100644 index 0000000..3885469 --- /dev/null +++ b/app/core/jiguang.py @@ -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:] diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..0981021 --- /dev/null +++ b/app/core/logging.py @@ -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) diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..e400747 --- /dev/null +++ b/app/core/security.py @@ -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()), + } diff --git a/app/core/sms.py b/app/core/sms.py new file mode 100644 index 0000000..75e1267 --- /dev/null +++ b/app/core/sms.py @@ -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 diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..aecf39c --- /dev/null +++ b/app/crud/user.py @@ -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 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..f2696ab --- /dev/null +++ b/app/db/base.py @@ -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 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..1014900 --- /dev/null +++ b/app/db/session.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2a6c7ff --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..d288759 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,2 @@ +"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。""" +from app.models.user import User # noqa: F401 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f652db1 --- /dev/null +++ b/app/models/user.py @@ -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"" diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..2705bba --- /dev/null +++ b/app/schemas/auth.py @@ -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 diff --git a/deploy/nginx/app-api.shaguabijia.com.conf b/deploy/nginx/app-api.shaguabijia.com.conf new file mode 100644 index 0000000..dcef0f7 --- /dev/null +++ b/deploy/nginx/app-api.shaguabijia.com.conf @@ -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; + } +} diff --git a/deploy/shaguabijia-app-server.service b/deploy/shaguabijia-app-server.service new file mode 100644 index 0000000..978b273 --- /dev/null +++ b/deploy/shaguabijia-app-server.service @@ -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 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md new file mode 100644 index 0000000..7da6c32 --- /dev/null +++ b/docs/后端技术实现.md @@ -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。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5c382ee --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..562f6b6 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..2e4b55f --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..38cb737 --- /dev/null +++ b/tests/test_health.py @@ -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