Compare commits
2 Commits
e3fcce2353
...
966206bb94
| Author | SHA1 | Date |
|---|---|---|
|
|
966206bb94 | 1 day ago |
|
|
1b0bc75631 | 1 day ago |
40 changed files with 14759 additions and 2 deletions
@ -0,0 +1,20 @@ |
|||
.git |
|||
.gitignore |
|||
.env |
|||
.venv |
|||
__pycache__ |
|||
*.py[cod] |
|||
*.db |
|||
*.log |
|||
*.pdf |
|||
*.png |
|||
*.mp4 |
|||
*.mov |
|||
.pytest_cache |
|||
tests |
|||
uploads |
|||
uploads-temp-v060 |
|||
参考模板(1) |
|||
青核 |
|||
melove |
|||
docs |
|||
@ -0,0 +1,13 @@ |
|||
DATABASE_URL=mysql+pymysql://用户名:密码@127.0.0.1:3306/dating_app?charset=utf8mb4 |
|||
JWT_SECRET=change-this-in-production |
|||
ACCESS_TOKEN_EXPIRE_MINUTES=15 |
|||
REFRESH_TOKEN_EXPIRE_DAYS=30 |
|||
UPLOAD_DIR=./uploads |
|||
DEV_RETURN_CODES=true |
|||
OSS_ENABLED=false |
|||
OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com |
|||
OSS_ACCESS_KEY_ID= |
|||
OSS_ACCESS_KEY_SECRET= |
|||
OSS_BUCKET_NAME=bs-qinghe |
|||
OSS_BASE_PREFIX=melove/ |
|||
OSS_DOMAIN=https://oss.baskbuddy.com |
|||
@ -0,0 +1,30 @@ |
|||
__pycache__/ |
|||
.pytest_cache/ |
|||
.venv/ |
|||
*.py[cod] |
|||
*.db |
|||
uploads/ |
|||
.env |
|||
|
|||
# Other projects that share this local workspace; never include them here. |
|||
青核/ |
|||
melove/ |
|||
参考模板(1)/ |
|||
|
|||
# Local artifacts and reference files, not source code. |
|||
*.db |
|||
*.log |
|||
*.pdf |
|||
*.png |
|||
*.jpg |
|||
*.jpeg |
|||
*.mp4 |
|||
*.mov |
|||
uploads-temp-v060/ |
|||
|
|||
# Local working notes kept out of the shared repository. |
|||
docs/项目交接总览.md |
|||
|
|||
# Scratch renders and temporary working files. |
|||
tmp/ |
|||
tmp-ocr/ |
|||
@ -0,0 +1,27 @@ |
|||
ARG BASE_IMAGE=mcr.microsoft.com/vscode/devcontainers/python:3.11 |
|||
FROM ${BASE_IMAGE} |
|||
|
|||
ENV PYTHONDONTWRITEBYTECODE=1 \ |
|||
PYTHONUNBUFFERED=1 \ |
|||
PIP_NO_CACHE_DIR=1 |
|||
|
|||
WORKDIR /app |
|||
|
|||
COPY requirements.txt ./requirements.txt |
|||
RUN pip install --no-cache-dir -r requirements.txt |
|||
|
|||
COPY app ./app |
|||
COPY sql ./sql |
|||
|
|||
RUN useradd --create-home --uid 10001 appuser \ |
|||
&& mkdir -p /app/data /app/uploads \ |
|||
&& chown -R appuser:appuser /app |
|||
|
|||
USER appuser |
|||
|
|||
EXPOSE 8001 |
|||
|
|||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ |
|||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=3)" |
|||
|
|||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"] |
|||
@ -1,3 +1,148 @@ |
|||
# milian |
|||
# Dating App 后端(FastAPI) |
|||
|
|||
蜜恋后端 |
|||
这是 Dating App Function 阶段的 FastAPI 后端实现,默认使用 MySQL。数据库连接通过 `DATABASE_URL` 配置。 |
|||
|
|||
项目文档: |
|||
|
|||
- [完整接口说明](docs/Dating-App-API-完整接口说明.md) |
|||
- [前端对接清单](docs/Dating-App-API-前端对接清单.md) |
|||
- [数据库设计](docs/database-design.md) |
|||
- [Postman 导入说明](docs/Postman导入说明.md) |
|||
- [OpenAPI 0.6.12](docs/openapi-0.6.12.json) |
|||
|
|||
API 0.6.12 为个人资料新增职业、学历、身高、信仰、吸烟、饮酒、宠物、运动习惯 8 个可选字段,编辑资料接口可写入,Get Me 会返回并新增根据生日计算的 age;匹配成功后双方从喜欢/被喜欢名单移除;评论支持回复(parent_comment_id)和点赞/取消点赞。数据库迁移已完成,正式环境已发布。 |
|||
|
|||
## 运行 |
|||
|
|||
```powershell |
|||
python -m venv .venv |
|||
.\.venv\Scripts\Activate.ps1 |
|||
pip install -r requirements.txt |
|||
$env:DATABASE_URL = "mysql+pymysql://用户名:密码@127.0.0.1:3306/dating_app?charset=utf8mb4" |
|||
uvicorn app.main:app --reload --port 8001 |
|||
``` |
|||
|
|||
启动前先在 MySQL 中创建数据库(表会由应用自动创建): |
|||
|
|||
```sql |
|||
CREATE DATABASE dating_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; |
|||
``` |
|||
|
|||
已有数据库升级到 API 0.6.4 时,除既有迁移外还需执行非破坏性迁移 `sql/migrate_relationship_goal.sql`,为 `user_profiles` 增加 `relationship_goal` 字段。 |
|||
|
|||
升级到 API 0.6.5 前还需执行 `sql/migrate_super_like.sql`,将已有 `swipes.direction` 的约束扩展为允许 `super_like`。先迁移正式数据库,再发布新代码。 |
|||
|
|||
升级到 API 0.6.6 前还需执行 `sql/migrate_location_county.sql`,为 `user_profiles` 增加可空的 `county` 区县字段。位置接口现在要求经纬度、城市、区县一并提交。 |
|||
|
|||
升级到 API 0.6.7 前需执行 `sql/migrate_post_composer.sql`,为已有 `posts` 表增加文字格式、心情和地点字段。历史帖子默认为 `plain` 且没有心情、地点。API 0.6.8 的公开主页字段、0.6.9 的头像/点赞名单响应、0.6.10 的编辑接口与混合媒体、0.6.11 的匹配列表均不涉及数据库迁移。升级到 0.6.12 前需依次执行 `sql/migrate_profile_details.sql`(user_profiles 增加 8 个可空资料字段)和 `sql/migrate_comment_interactions.sql`(post_comments 增加 parent_comment_id,新增 comment_likes 表)。 |
|||
|
|||
如果暂时没有 MySQL,可临时设置 `DATABASE_URL=sqlite:///./dating_app.db` 做本地测试;正式环境按项目方案使用 MySQL。 |
|||
|
|||
Windows 本地启动云 MySQL + 阿里云 OSS 时,可运行 `.\start_cloud_api.ps1`。脚本会在启动时安全询问数据库密码、OSS 凭据和 JWT Secret;这些值只存在于当前 API 进程环境中,不会写入项目文件。正式服务器部署时,应使用服务器的环境变量或密钥管理服务提供同样的配置。 |
|||
|
|||
## Docker 部署 |
|||
|
|||
项目根目录的 `Dockerfile` 用于生产容器,容器内 FastAPI 固定监听 `8001`。以下示例将宿主机端口 `8011` 映射到容器端口 `8001`: |
|||
|
|||
```bash |
|||
docker build -t dating-app-api:0.6.12 . |
|||
docker rm -f dating-app-api 2>/dev/null || true |
|||
docker run -d \ |
|||
--name dating-app-api \ |
|||
--restart unless-stopped \ |
|||
-p 8011:8001 \ |
|||
--env-file "${HOME}/.secrets/dating-app.env" \ |
|||
dating-app-api:0.6.12 |
|||
``` |
|||
|
|||
`${HOME}/.secrets/dating-app.env` 由服务器管理员维护,至少需要提供正式 `DATABASE_URL`、生产 `JWT_SECRET`、`OSS_ENABLED=true` 以及 OSS 配置。这个文件不应提交到 Git 或放入 Jenkins 工作区。当前 Jenkins 已通过只读 Deploy Key 拉取 Gitee 仓库。 |
|||
|
|||
## 日常改代码和发布 |
|||
|
|||
正式公网地址为 `https://melove.youthome.com.cn`,业务接口统一使用 `/api/v1/...`。当前 Jenkins Job `1` 从 Gitee `main` 分支拉取代码,构建镜像并以 `8011:8001` 启动 `dating-app-api` 容器。Jenkins 运行账户读取的正式环境文件为 `${HOME}/.secrets/dating-app.env`,其中包含数据库、JWT 和 OSS 配置;不要把这些值写入 Git、Jenkins Shell、提交信息或文档。 |
|||
|
|||
每次发布按以下顺序执行: |
|||
|
|||
1. 本地修改代码,运行 `python -m pytest -q`。 |
|||
2. 接口变化同步更新最新 OpenAPI 文件和对接文档。 |
|||
3. 数据库模型变化新增幂等 SQL 迁移,并先在正式 MySQL 执行迁移;Docker 重建不会自动修改已有表。 |
|||
4. 提交并推送 Gitee `main`,在 Jenkins Job `1` 点击“立即构建”。 |
|||
5. 检查代码拉取、Docker 构建、数据库预检、容器健康检查和控制台日志。 |
|||
6. 使用 `https://melove.youthome.com.cn/api/v1/...` 做公网烟测,重点复测本次改动涉及的接口。 |
|||
|
|||
只修改 Python、schema 或业务逻辑时,推送代码并重新构建即可;修改 `requirements.txt` 或 `Dockerfile` 后还要检查镜像构建和容器启动日志。发布失败时先查看 Jenkins 控制台日志与容器日志,区分数据库网络、认证、表结构和应用错误,再决定是否回滚或补迁移。 |
|||
|
|||
本地 Windows Docker Desktop 也可以直接使用 Compose 启动一个隔离实例。默认使用 Docker 命名卷中的 SQLite 和本地文件存储,不会接触正式云数据库或 OSS: |
|||
|
|||
```powershell |
|||
$docker = "D:\Docker\DockerDesktop\resources\bin\docker.exe" |
|||
& $docker compose -p dating-app up -d --build |
|||
``` |
|||
|
|||
本地实例地址为 `http://127.0.0.1:8011`。需要连接正式云 MySQL/OSS 时,通过环境变量覆盖 `docker-compose.yml` 中的默认值,不要把真实凭据写进该文件。 |
|||
|
|||
打开 `http://127.0.0.1:8001/docs` 查看接口文档。 |
|||
|
|||
Postman 或 Apifox 可导入最新的 [`docs/openapi-0.6.12.json`](docs/openapi-0.6.12.json) 同步接口定义。 |
|||
|
|||
数据库表、字段和表关系见:[中文设计文档](docs/database-design.md) 或 [English database design](docs/database-design-en.md) |
|||
|
|||
## 已实现接口 |
|||
|
|||
- `POST /api/v1/auth/register`:使用统一的 `email_or_phone` 字段、密码、确认密码和必填的四选一 `relationship_goal` 注册,可直接提交非空 `bio`,成功后返回 token,不需要验证码 |
|||
- `POST /api/v1/auth/login`:使用统一的 `email_or_phone` 字段和密码登录 |
|||
- `POST /api/v1/auth/refresh`:使用 refresh token 换取新的 access/refresh token |
|||
- `POST /api/v1/auth/logout`:撤销 refresh session |
|||
- `GET /api/v1/auth/me`:应用启动时验证 access token,并返回当前用户、本人帖子、我喜欢的人、喜欢我的人和匹配对象(各最近 20 条) |
|||
- `POST /api/v1/auth/oauth`:微信/谷歌账号登录或注册占位接口 |
|||
- `GET/PATCH /api/v1/users/me`:读取和更新个人资料 |
|||
- `GET /api/v1/users/me/interests`:读取当前用户的全部兴趣 |
|||
- `PUT /api/v1/users/me/interests`:替换当前用户的兴趣列表,至少 2 个,不设上限 |
|||
- `POST /api/v1/users/me/video`:上传或替换自我介绍视频,最大 100MB;每名用户只保留最新一份 |
|||
- `POST /api/v1/users/me/image`:上传或替换个人头像;每名用户只保留最新一份 |
|||
- `POST /api/v1/users/me/identity`:上传身份证/护照,最大 10MB |
|||
- `PUT /api/v1/users/me/location`:更新当前位置 |
|||
- 位置请求需同时提供 `latitude`、`longitude`、`city`、`county`;城市用于同城推荐,区县返回在个人和公开资料中 |
|||
- `GET /api/v1/users/me/posts`:分页查看本人帖子 |
|||
- `GET /api/v1/users/{user_id}`、`GET /api/v1/users/{user_id}/posts`:查看公开用户资料和帖子 |
|||
- `GET /api/v1/users/me/liked`、`GET /api/v1/users/me/likes`:查看完整的我喜欢的人和喜欢我的人名单,不再分页 |
|||
- `GET /api/v1/users/me/matches`:分页查看匹配对象 |
|||
- `GET /api/v1/meet`:获取尚未滑过的候选用户;向当前用户发出 Super Like 的用户最优先,其余按同城、同国家和匹配度排序 |
|||
- `POST /api/v1/meet/swipes`:`pass` 左滑、`like` 右滑、`super_like` 超级喜欢;双方互相喜欢(含 Super Like)时自动匹配 |
|||
- `DELETE /api/v1/meet/swipes/{target_user_id}`:取消键,撤销保存的左滑、右滑或 Super Like,让该用户重新进入推荐列表 |
|||
- `DELETE /api/v1/meet/likes/{target_user_id}`:取消喜欢并解除对应匹配 |
|||
- `GET /api/v1/matches`:查看完整匹配列表(不分页,旧 `limit`、`offset` 参数会被忽略) |
|||
- `GET /api/v1/search`:按姓名、简介、城市、国家和距离搜索用户,支持城市/国家组合筛选 |
|||
- `POST /api/v1/explore/posts`:发布文字/图片/视频类型帖子,保存可选心情、地点和 `plain`/`markdown` 文字格式;图片/视频先创建帖子再上传媒体,音乐暂不支持 |
|||
- `GET /api/v1/explore/feed`:按同城、同国家和匹配度优先获取 Explore 信息流 |
|||
- `GET/PATCH/DELETE /api/v1/explore/posts/{post_id}`:查看、编辑或删除自己的帖子 |
|||
- `GET/POST/DELETE /api/v1/explore/posts/{post_id}/like`:查询点赞者名单、点赞或取消点赞 |
|||
- `GET/POST /api/v1/explore/posts/{post_id}/comments`:查看或发表评论,POST 传 parent_comment_id 可回复;`PATCH /api/v1/explore/comments/{comment_id}`:编辑自己的评论;`POST/DELETE /api/v1/explore/comments/{comment_id}/like`:点赞或取消点赞评论 |
|||
- `DELETE /api/v1/explore/comments/{comment_id}`:删除自己的评论 |
|||
- `POST /api/v1/explore/posts/{post_id}/media`:为帖子上传图片或短视频,同一条帖子最多 9 张图片加 1 个视频,可混合 |
|||
- `GET/DELETE /api/v1/explore/media/{media_id}`:读取或删除帖子媒体 |
|||
- `GET /api/v1/chat/conversations`:查看已匹配用户的会话列表 |
|||
- `POST /api/v1/chat/conversations/{match_id}`:为匹配关系创建或获取会话 |
|||
- `GET/POST /api/v1/chat/conversations/{conversation_id}/messages`:查看或发送聊天消息 |
|||
- `DELETE /api/v1/chat/messages/{message_id}`:撤回自己的消息 |
|||
- `GET /api/v1/feed/questions`:获取 AI Feed 问题,可筛选尚未回答的问题 |
|||
- `PUT/DELETE /api/v1/feed/questions/{question_id}/answer`:提交、修改或删除回答 |
|||
- `GET /api/v1/feed/answers/me`:查看当前用户的全部回答 |
|||
|
|||
## 接口测试 |
|||
|
|||
Swagger:启动服务后打开 `http://127.0.0.1:8001/docs`。 |
|||
|
|||
FastAPI 自带的 Swagger UI 和 Postman 都可以完成当前接口的手动调试。MVP 阶段不使用 OTP。 |
|||
|
|||
注册介绍内容采用两步流程:用户选择文字简介时,在注册 JSON 中提交非空 `bio`;用户选择视频时,注册后使用 access token 调用视频上传接口。`GET /api/v1/auth/me` 的 `profile.profile_image_url` 和 `profile.video_url` 在对应媒体未上传时为 `null`,生产环境上传后返回一小时有效的完整 OSS 签名 URL,前端可直接加载而无需再次携带 Token。文字简介与视频互斥:上传视频会清空 `bio`,之后提交非空 `bio` 会删除介绍视频。前端只有在 `bio` 或 `video_url` 其中一个非空时才应完成注册引导。 |
|||
|
|||
认证说明:access token 默认有效 15 分钟,refresh token 默认有效 30 天。客户端安全保存两个 token;应用启动时调用 `/api/v1/auth/me`,返回 200 就进入首页,否则回到登录页。refresh token 只以哈希形式保存到 `user_sessions` 表,logout 会撤销对应 session。 |
|||
|
|||
自动化测试: |
|||
|
|||
```powershell |
|||
python -m pytest -q |
|||
``` |
|||
|
|||
生产环境需要替换 `JWT_SECRET`,关闭 `DEV_RETURN_CODES`,并设置 `OSS_ENABLED=true` 及 `.env.example` 中的 OSS 环境变量。开启后上传文件会保存到对象存储,数据库只保存对象标识。 |
|||
|
|||
@ -0,0 +1,37 @@ |
|||
# MeLOVE 后端交接上传清单 |
|||
|
|||
## 已包含 |
|||
|
|||
- `app/`:FastAPI 后端源码、数据模型、请求响应模型、认证和存储逻辑 |
|||
- `tests/`:自动化测试 |
|||
- `sql/`:数据库建库脚本、完整表结构和全部迁移脚本 |
|||
- `Dockerfile`、`docker-compose.yml`:容器运行配置 |
|||
- `requirements.txt`、`.env.example`:依赖和环境变量示例 |
|||
- `start_cloud_api.ps1`:本地启动云数据库和 OSS 的安全输入脚本 |
|||
- `README.md`:运行、迁移和发布说明 |
|||
- `docs/openapi-0.6.12.json`:最新接口定义 |
|||
- `docs/Dating-App-API-完整接口说明.md` |
|||
- `docs/Dating-App-API-前端对接清单.md` |
|||
- `docs/Dating-App-API-complete-reference.md` |
|||
- `docs/database-design.md`、`docs/database-design-en.md` |
|||
- `docs/Postman导入说明.md` |
|||
- `docs/Google文档完整内容-0.6.12.txt` |
|||
- `docs/项目交接总览.md` |
|||
- `docs/周工作总结-2026-10-19至10-25.md` |
|||
|
|||
## 未包含 |
|||
|
|||
- 数据库快照(`.db`)和正式用户数据 |
|||
- 日志、缓存、`__pycache__`、上传文件和本地媒体 |
|||
- 图片、PDF、软著申请成品和旧版本 OpenAPI 文件 |
|||
- 真实数据库密码、JWT Secret、OSS AccessKey、Token 等凭据 |
|||
- 青核项目、Android 工程和其他无关目录 |
|||
- 独立的 AI/ML 资料 |
|||
|
|||
说明:AI Feed 的问题和回答接口是当前后端业务代码的一部分,已随 `app/`、`sql/` 和测试保留;没有额外 AI 模型或密钥文件。 |
|||
|
|||
## 上传前检查 |
|||
|
|||
1. 进入该文件夹全选上传到 Gitea 仓库根目录。 |
|||
2. 确认仓库中没有 `.db`、`.log`、`.env`、`uploads` 或实际密钥。 |
|||
3. 上传后优先查看 `README.md` 和 `docs/项目交接总览.md`。 |
|||
@ -0,0 +1 @@ |
|||
|
|||
@ -0,0 +1,25 @@ |
|||
from dataclasses import dataclass |
|||
import os |
|||
|
|||
|
|||
@dataclass(frozen=True) |
|||
class Settings: |
|||
database_url: str = os.getenv( |
|||
"DATABASE_URL", |
|||
"mysql+pymysql://root:password@127.0.0.1:3306/dating_app?charset=utf8mb4", |
|||
) |
|||
jwt_secret: str = os.getenv("JWT_SECRET", "change-this-in-production") |
|||
jwt_expire_minutes: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", os.getenv("JWT_EXPIRE_MINUTES", "15"))) |
|||
refresh_token_expire_days: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "30")) |
|||
upload_dir: str = os.getenv("UPLOAD_DIR", "./uploads") |
|||
dev_return_codes: bool = os.getenv("DEV_RETURN_CODES", "true").lower() == "true" |
|||
oss_enabled: bool = os.getenv("OSS_ENABLED", "false").lower() == "true" |
|||
oss_endpoint: str = os.getenv("OSS_ENDPOINT", "https://oss-cn-shanghai.aliyuncs.com") |
|||
oss_access_key_id: str = os.getenv("OSS_ACCESS_KEY_ID", "") |
|||
oss_access_key_secret: str = os.getenv("OSS_ACCESS_KEY_SECRET", "") |
|||
oss_bucket_name: str = os.getenv("OSS_BUCKET_NAME", "bs-qinghe") |
|||
oss_base_prefix: str = os.getenv("OSS_BASE_PREFIX", "melove/") |
|||
oss_domain: str = os.getenv("OSS_DOMAIN", "https://oss.baskbuddy.com") |
|||
|
|||
|
|||
settings = Settings() |
|||
@ -0,0 +1,29 @@ |
|||
from sqlalchemy import create_engine, event |
|||
from sqlalchemy.orm import DeclarativeBase, sessionmaker |
|||
|
|||
from .config import settings |
|||
|
|||
|
|||
class Base(DeclarativeBase): |
|||
pass |
|||
|
|||
|
|||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} |
|||
engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True) |
|||
|
|||
|
|||
if settings.database_url.startswith("sqlite"): |
|||
@event.listens_for(engine, "connect") |
|||
def enable_sqlite_foreign_keys(dbapi_connection, _connection_record): |
|||
cursor = dbapi_connection.cursor() |
|||
cursor.execute("PRAGMA foreign_keys=ON") |
|||
cursor.close() |
|||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) |
|||
|
|||
|
|||
def get_db(): |
|||
db = SessionLocal() |
|||
try: |
|||
yield db |
|||
finally: |
|||
db.close() |
|||
File diff suppressed because it is too large
@ -0,0 +1,36 @@ |
|||
"""Apply and verify the API 0.6.7 post columns before deployment.""" |
|||
|
|||
from sqlalchemy import inspect, text |
|||
|
|||
from .database import engine |
|||
|
|||
|
|||
POST_COLUMNS = { |
|||
"content_format": "VARCHAR(10) NOT NULL DEFAULT 'plain'", |
|||
"feeling": "VARCHAR(50) NULL", |
|||
"location_name": "VARCHAR(255) NULL", |
|||
"location_latitude": "DOUBLE NULL", |
|||
"location_longitude": "DOUBLE NULL", |
|||
} |
|||
|
|||
|
|||
def main() -> None: |
|||
if engine.dialect.name != "mysql": |
|||
raise RuntimeError("Post migration requires MySQL") |
|||
|
|||
existing = {column["name"] for column in inspect(engine).get_columns("posts")} |
|||
with engine.begin() as connection: |
|||
for name, definition in POST_COLUMNS.items(): |
|||
if name not in existing: |
|||
connection.execute(text(f"ALTER TABLE posts ADD COLUMN {name} {definition}")) |
|||
connection.execute(text("SELECT 1")) |
|||
|
|||
actual = {column["name"] for column in inspect(engine).get_columns("posts")} |
|||
missing = POST_COLUMNS.keys() - actual |
|||
if missing: |
|||
raise RuntimeError(f"Post migration incomplete: {sorted(missing)}") |
|||
print("POST_COMPOSER_MIGRATION_OK") |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,265 @@ |
|||
from datetime import datetime |
|||
from enum import Enum |
|||
from typing import Optional |
|||
|
|||
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint |
|||
from sqlalchemy.orm import Mapped, mapped_column, relationship |
|||
|
|||
from .database import Base |
|||
|
|||
|
|||
class UserStatus(str, Enum): |
|||
pending = "pending" |
|||
active = "active" |
|||
suspended = "suspended" |
|||
|
|||
|
|||
class User(Base): |
|||
__tablename__ = "users" |
|||
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
|||
email_or_phone: Mapped[Optional[str]] = mapped_column(String(320), unique=True, index=True) |
|||
password_hash: Mapped[Optional[str]] = mapped_column(String(256)) |
|||
status: Mapped[UserStatus] = mapped_column(String(20), default=UserStatus.active) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
profile: Mapped[Optional["UserProfile"]] = relationship(back_populates="user", cascade="all, delete-orphan", uselist=False) |
|||
location: Mapped[Optional["UserLocation"]] = relationship(back_populates="user", cascade="all, delete-orphan", uselist=False) |
|||
identities: Mapped[list["AuthIdentity"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
media: Mapped[list["UserMedia"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
posts: Mapped[list["Post"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
post_likes: Mapped[list["PostLike"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
post_comments: Mapped[list["PostComment"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
sent_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="sender", cascade="all, delete-orphan") |
|||
sessions: Mapped[list["UserSession"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
question_answers: Mapped[list["UserQuestionAnswer"]] = relationship(back_populates="user", cascade="all, delete-orphan") |
|||
interests: Mapped[list["UserInterest"]] = relationship(back_populates="user", cascade="all, delete-orphan", order_by="UserInterest.id") |
|||
|
|||
|
|||
class AuthIdentity(Base): |
|||
__tablename__ = "auth_identities" |
|||
__table_args__ = (UniqueConstraint("provider", "provider_user_id"),) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
provider: Mapped[str] = mapped_column(String(30)) |
|||
provider_user_id: Mapped[str] = mapped_column(String(255)) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="identities") |
|||
|
|||
|
|||
class UserProfile(Base): |
|||
__tablename__ = "user_profiles" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True) |
|||
full_name: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
gender: Mapped[Optional[str]] = mapped_column(String(30)) |
|||
birth_date: Mapped[Optional[str]] = mapped_column(String(10)) |
|||
city: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
county: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
country: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
relationship_goal: Mapped[Optional[str]] = mapped_column(String(30)) |
|||
bio: Mapped[Optional[str]] = mapped_column(Text) |
|||
occupation: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
education: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
height_cm: Mapped[Optional[int]] = mapped_column(Integer) |
|||
beliefs: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
smoking: Mapped[Optional[str]] = mapped_column(String(50)) |
|||
drinking: Mapped[Optional[str]] = mapped_column(String(50)) |
|||
pets: Mapped[Optional[str]] = mapped_column(String(120)) |
|||
exercise: Mapped[Optional[str]] = mapped_column(String(50)) |
|||
user: Mapped[User] = relationship(back_populates="profile") |
|||
|
|||
|
|||
class UserInterest(Base): |
|||
__tablename__ = "user_interests" |
|||
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_interest_name"),) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
name: Mapped[str] = mapped_column(String(120)) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="interests") |
|||
|
|||
|
|||
class UserLocation(Base): |
|||
__tablename__ = "user_locations" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True) |
|||
latitude: Mapped[float] = mapped_column(Float) |
|||
longitude: Mapped[float] = mapped_column(Float) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="location") |
|||
|
|||
|
|||
class UserSession(Base): |
|||
__tablename__ = "user_sessions" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
refresh_token_hash: Mapped[str] = mapped_column(String(256), unique=True) |
|||
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime) |
|||
user: Mapped[User] = relationship(back_populates="sessions") |
|||
|
|||
|
|||
class UserMedia(Base): |
|||
__tablename__ = "user_media" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
media_type: Mapped[str] = mapped_column(String(20)) |
|||
storage_path: Mapped[str] = mapped_column(String(500)) |
|||
original_name: Mapped[Optional[str]] = mapped_column(String(255)) |
|||
mime_type: Mapped[Optional[str]] = mapped_column(String(100)) |
|||
review_status: Mapped[str] = mapped_column(String(20), default="pending") |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="media") |
|||
|
|||
|
|||
class Swipe(Base): |
|||
__tablename__ = "swipes" |
|||
__table_args__ = ( |
|||
UniqueConstraint("actor_id", "target_id", name="uq_swipe_actor_target"), |
|||
CheckConstraint("actor_id <> target_id", name="ck_swipe_not_self"), |
|||
CheckConstraint("direction IN ('like', 'pass', 'super_like')", name="ck_swipe_direction"), |
|||
) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
actor_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
target_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
direction: Mapped[str] = mapped_column(String(10)) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
|
|||
|
|||
class Match(Base): |
|||
__tablename__ = "matches" |
|||
__table_args__ = ( |
|||
UniqueConstraint("user1_id", "user2_id", name="uq_match_user_pair"), |
|||
CheckConstraint("user1_id < user2_id", name="ck_match_canonical_order"), |
|||
) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user1_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
user2_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
conversation: Mapped[Optional["Conversation"]] = relationship(back_populates="match", uselist=False, cascade="all, delete-orphan", single_parent=True) |
|||
|
|||
|
|||
class Conversation(Base): |
|||
__tablename__ = "conversations" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
match_id: Mapped[int] = mapped_column(ForeignKey("matches.id", ondelete="CASCADE"), unique=True, index=True) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
match: Mapped[Match] = relationship(back_populates="conversation") |
|||
messages: Mapped[list["ChatMessage"]] = relationship(back_populates="conversation", cascade="all, delete-orphan") |
|||
|
|||
|
|||
class ChatMessage(Base): |
|||
__tablename__ = "chat_messages" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
conversation_id: Mapped[int] = mapped_column(ForeignKey("conversations.id", ondelete="CASCADE"), index=True) |
|||
sender_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
content: Mapped[str] = mapped_column(Text) |
|||
message_type: Mapped[str] = mapped_column(String(20), default="text") |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) |
|||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime) |
|||
conversation: Mapped[Conversation] = relationship(back_populates="messages") |
|||
sender: Mapped[User] = relationship(back_populates="sent_messages") |
|||
|
|||
|
|||
class Post(Base): |
|||
__tablename__ = "posts" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
content: Mapped[str] = mapped_column(Text) |
|||
post_type: Mapped[str] = mapped_column(String(20), default="text") |
|||
content_format: Mapped[str] = mapped_column(String(10), default="plain", server_default="plain") |
|||
feeling: Mapped[Optional[str]] = mapped_column(String(50)) |
|||
location_name: Mapped[Optional[str]] = mapped_column(String(255)) |
|||
location_latitude: Mapped[Optional[float]] = mapped_column(Float) |
|||
location_longitude: Mapped[Optional[float]] = mapped_column(Float) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="posts") |
|||
likes: Mapped[list["PostLike"]] = relationship(back_populates="post", cascade="all, delete-orphan") |
|||
comments: Mapped[list["PostComment"]] = relationship(back_populates="post", cascade="all, delete-orphan") |
|||
media: Mapped[list["PostMedia"]] = relationship( |
|||
back_populates="post", cascade="all, delete-orphan", order_by="PostMedia.position" |
|||
) |
|||
|
|||
|
|||
class PostMedia(Base): |
|||
__tablename__ = "post_media" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), index=True) |
|||
media_type: Mapped[str] = mapped_column(String(20)) |
|||
storage_path: Mapped[str] = mapped_column(String(500)) |
|||
original_name: Mapped[Optional[str]] = mapped_column(String(255)) |
|||
mime_type: Mapped[str] = mapped_column(String(100)) |
|||
size_bytes: Mapped[int] = mapped_column(Integer) |
|||
position: Mapped[int] = mapped_column(Integer, default=0) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
post: Mapped[Post] = relationship(back_populates="media") |
|||
|
|||
|
|||
class PostLike(Base): |
|||
__tablename__ = "post_likes" |
|||
__table_args__ = (UniqueConstraint("post_id", "user_id", name="uq_post_like"),) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), index=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
post: Mapped[Post] = relationship(back_populates="likes") |
|||
user: Mapped[User] = relationship(back_populates="post_likes") |
|||
|
|||
|
|||
class PostComment(Base): |
|||
__tablename__ = "post_comments" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), index=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
parent_comment_id: Mapped[Optional[int]] = mapped_column( |
|||
ForeignKey("post_comments.id", ondelete="CASCADE"), index=True |
|||
) |
|||
content: Mapped[str] = mapped_column(Text) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) |
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
post: Mapped[Post] = relationship(back_populates="comments") |
|||
user: Mapped[User] = relationship(back_populates="post_comments") |
|||
parent: Mapped[Optional["PostComment"]] = relationship(remote_side=[id], back_populates="replies") |
|||
replies: Mapped[list["PostComment"]] = relationship(back_populates="parent", cascade="all, delete-orphan") |
|||
likes: Mapped[list["CommentLike"]] = relationship(back_populates="comment", cascade="all, delete-orphan") |
|||
|
|||
|
|||
class CommentLike(Base): |
|||
__tablename__ = "comment_likes" |
|||
__table_args__ = (UniqueConstraint("comment_id", "user_id", name="uq_comment_like"),) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
comment_id: Mapped[int] = mapped_column(ForeignKey("post_comments.id", ondelete="CASCADE"), index=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
comment: Mapped["PostComment"] = relationship(back_populates="likes") |
|||
user: Mapped[User] = relationship() |
|||
|
|||
|
|||
class FeedQuestion(Base): |
|||
__tablename__ = "feed_questions" |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
code: Mapped[str] = mapped_column(String(100), unique=True) |
|||
question_text: Mapped[str] = mapped_column(String(500)) |
|||
question_text_zh: Mapped[Optional[str]] = mapped_column(String(500)) |
|||
answer_type: Mapped[str] = mapped_column(String(20), default="boolean") |
|||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) |
|||
sort_order: Mapped[int] = mapped_column(Integer, default=0) |
|||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) |
|||
answers: Mapped[list["UserQuestionAnswer"]] = relationship(back_populates="question", cascade="all, delete-orphan") |
|||
|
|||
|
|||
class UserQuestionAnswer(Base): |
|||
__tablename__ = "user_question_answers" |
|||
__table_args__ = (UniqueConstraint("user_id", "question_id", name="uq_user_question_answer"),) |
|||
id: Mapped[int] = mapped_column(primary_key=True) |
|||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) |
|||
question_id: Mapped[int] = mapped_column(ForeignKey("feed_questions.id", ondelete="CASCADE"), index=True) |
|||
boolean_answer: Mapped[Optional[bool]] = mapped_column(Boolean) |
|||
text_answer: Mapped[Optional[str]] = mapped_column(Text) |
|||
answered_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) |
|||
user: Mapped[User] = relationship(back_populates="question_answers") |
|||
question: Mapped[FeedQuestion] = relationship(back_populates="answers") |
|||
@ -0,0 +1,409 @@ |
|||
from datetime import datetime |
|||
from typing import Annotated, Literal, Optional, Union |
|||
|
|||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, StringConstraints, model_validator |
|||
|
|||
|
|||
EmailOrPhone = Annotated[str, StringConstraints(strip_whitespace=True, min_length=3, max_length=320)] |
|||
BioText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=2000)] |
|||
RelationshipGoal = Literal["serious_relationship", "casual_dates", "new_friends", "marriage"] |
|||
|
|||
|
|||
class RegisterRequest(BaseModel): |
|||
email_or_phone: EmailOrPhone |
|||
password: str = Field(min_length=8, max_length=128) |
|||
confirm_password: str = Field(min_length=8, max_length=128) |
|||
relationship_goal: RelationshipGoal |
|||
full_name: Optional[str] = Field(default=None, max_length=120) |
|||
gender: Optional[str] = Field(default=None, max_length=30) |
|||
birth_date: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$") |
|||
city: Optional[str] = Field(default=None, max_length=120) |
|||
country: Optional[str] = Field(default=None, max_length=120) |
|||
bio: Optional[BioText] = None |
|||
interests: Optional[list[str]] = None |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_password_and_interests(self): |
|||
if self.password != self.confirm_password: |
|||
raise ValueError("密码和确认密码不一致") |
|||
if self.interests is not None: |
|||
self.interests = normalize_interests(self.interests) |
|||
if len(self.interests) < 2: |
|||
raise ValueError("至少选择 2 个兴趣") |
|||
return self |
|||
|
|||
class LoginRequest(BaseModel): |
|||
email_or_phone: EmailOrPhone |
|||
password: str = Field(min_length=8, max_length=128) |
|||
|
|||
|
|||
class RefreshRequest(BaseModel): |
|||
refresh_token: str = Field(min_length=20, max_length=200) |
|||
|
|||
|
|||
class OAuthRequest(BaseModel): |
|||
provider: str = Field(pattern="^(wechat|google)$") |
|||
provider_user_id: str = Field(min_length=1, max_length=255) |
|||
email: Optional[EmailStr] = None |
|||
full_name: Optional[str] = Field(default=None, max_length=120) |
|||
|
|||
|
|||
class ProfileUpdate(BaseModel): |
|||
full_name: Optional[str] = Field(default=None, max_length=120) |
|||
gender: Optional[str] = Field(default=None, max_length=30) |
|||
birth_date: Optional[str] = Field(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$") |
|||
city: Optional[str] = Field(default=None, max_length=120) |
|||
country: Optional[str] = Field(default=None, max_length=120) |
|||
relationship_goal: RelationshipGoal = Field(default=None) |
|||
bio: Optional[BioText] = None |
|||
occupation: Optional[str] = Field(default=None, max_length=120) |
|||
education: Optional[str] = Field(default=None, max_length=120) |
|||
height_cm: Optional[int] = Field(default=None, ge=100, le=250) |
|||
beliefs: Optional[str] = Field(default=None, max_length=120) |
|||
smoking: Optional[str] = Field(default=None, max_length=50) |
|||
drinking: Optional[str] = Field(default=None, max_length=50) |
|||
pets: Optional[str] = Field(default=None, max_length=120) |
|||
exercise: Optional[str] = Field(default=None, max_length=50) |
|||
interests: Optional[list[str]] = None |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_interests(self): |
|||
if self.interests is not None: |
|||
self.interests = normalize_interests(self.interests) |
|||
if len(self.interests) < 2: |
|||
raise ValueError("至少选择 2 个兴趣") |
|||
return self |
|||
|
|||
|
|||
class InterestsUpdate(BaseModel): |
|||
interests: list[str] |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_interests(self): |
|||
self.interests = normalize_interests(self.interests) |
|||
if len(self.interests) < 2: |
|||
raise ValueError("至少选择 2 个兴趣") |
|||
return self |
|||
|
|||
|
|||
class InterestsResponse(BaseModel): |
|||
interests: list[str] |
|||
|
|||
|
|||
def normalize_interests(values: list[str]) -> list[str]: |
|||
normalized: list[str] = [] |
|||
seen: set[str] = set() |
|||
for value in values: |
|||
item = value.strip() |
|||
if not item: |
|||
raise ValueError("兴趣内容不能为空") |
|||
if len(item) > 120: |
|||
raise ValueError("单个兴趣不能超过 120 个字符") |
|||
key = item.casefold() |
|||
if key not in seen: |
|||
normalized.append(item) |
|||
seen.add(key) |
|||
return normalized |
|||
|
|||
|
|||
class LocationUpdate(BaseModel): |
|||
latitude: float = Field(ge=-90, le=90) |
|||
longitude: float = Field(ge=-180, le=180) |
|||
city: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)] |
|||
county: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=120)] |
|||
|
|||
|
|||
class LocationResponse(LocationUpdate): |
|||
updated_at: datetime |
|||
|
|||
model_config = ConfigDict(from_attributes=True) |
|||
|
|||
|
|||
class SwipeRequest(BaseModel): |
|||
target_user_id: int = Field(gt=0) |
|||
direction: Literal["like", "pass", "super_like"] |
|||
|
|||
|
|||
class SwipeResponse(BaseModel): |
|||
direction: Literal["like", "pass", "super_like"] |
|||
matched: bool |
|||
match_id: Optional[int] = None |
|||
|
|||
|
|||
class PublicMediaResponse(BaseModel): |
|||
id: int |
|||
media_type: str |
|||
url: str |
|||
|
|||
|
|||
class DiscoveryUserResponse(BaseModel): |
|||
id: int |
|||
full_name: Optional[str] = None |
|||
gender: Optional[str] = None |
|||
birth_date: Optional[str] = None |
|||
city: Optional[str] = None |
|||
county: Optional[str] = None |
|||
country: Optional[str] = None |
|||
relationship_goal: Optional[RelationshipGoal] = None |
|||
bio: Optional[str] = None |
|||
profile_image_url: Optional[str] = None |
|||
video_url: Optional[str] = None |
|||
distance_km: Optional[float] = None |
|||
compatibility_percentage: int = Field(default=0, ge=0, le=100) |
|||
media: list[PublicMediaResponse] = Field(default_factory=list) |
|||
|
|||
|
|||
class MatchResponse(BaseModel): |
|||
id: int |
|||
matched_at: datetime |
|||
user: DiscoveryUserResponse |
|||
|
|||
|
|||
class PostLocation(BaseModel): |
|||
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)] |
|||
latitude: Optional[float] = Field(default=None, ge=-90, le=90) |
|||
longitude: Optional[float] = Field(default=None, ge=-180, le=180) |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_coordinates(self): |
|||
if (self.latitude is None) != (self.longitude is None): |
|||
raise ValueError("地点经纬度必须同时提供") |
|||
return self |
|||
|
|||
|
|||
class PostCreate(BaseModel): |
|||
content: str = Field(default="", max_length=5000) |
|||
post_type: Literal["text", "image", "video"] = "text" |
|||
content_format: Literal["plain", "markdown"] = "plain" |
|||
feeling: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]] = None |
|||
location: Optional[PostLocation] = None |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_post_content(self): |
|||
if self.post_type == "text" and not (self.content.strip() or self.feeling or self.location): |
|||
raise ValueError("文字帖子至少需要文字、心情或地点") |
|||
return self |
|||
|
|||
|
|||
class PostUpdate(BaseModel): |
|||
content: str = Field(default=None, max_length=5000) |
|||
content_format: Literal["plain", "markdown"] = None |
|||
feeling: Optional[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]] = None |
|||
location: Optional[PostLocation] = None |
|||
|
|||
|
|||
class PostAuthorResponse(BaseModel): |
|||
id: int |
|||
full_name: Optional[str] = None |
|||
profile_image_url: Optional[str] = Field( |
|||
default=None, description="Author avatar URL; null before upload. Production OSS URLs expire after one hour.", |
|||
) |
|||
city: Optional[str] = None |
|||
country: Optional[str] = None |
|||
compatibility_percentage: int = Field(default=0, ge=0, le=100) |
|||
|
|||
|
|||
class PostLikerResponse(BaseModel): |
|||
"""Public post liker identity, without contact details or full profile fields.""" |
|||
|
|||
id: int |
|||
full_name: Optional[str] = None |
|||
|
|||
|
|||
class PostLikeResponse(BaseModel): |
|||
"""Compact like result; update the existing post rather than replacing it.""" |
|||
|
|||
message: str |
|||
like_count: int |
|||
liked_by_me: bool |
|||
liked_by_users: list[PostLikerResponse] = Field( |
|||
default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", |
|||
) |
|||
|
|||
|
|||
class PostUnlikeResponse(BaseModel): |
|||
"""Compact unlike result; mirrors the like result without returning the full post body.""" |
|||
|
|||
message: str |
|||
like_count: int |
|||
liked_by_me: bool |
|||
liked_by_users: list[PostLikerResponse] = Field( |
|||
default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", |
|||
) |
|||
|
|||
|
|||
class PostMediaResponse(BaseModel): |
|||
id: int |
|||
media_type: Literal["image", "video"] |
|||
mime_type: str |
|||
size_bytes: int |
|||
position: int |
|||
url: str |
|||
|
|||
|
|||
class PostResponse(BaseModel): |
|||
id: int |
|||
content: str |
|||
post_type: str |
|||
content_format: Literal["plain", "markdown"] |
|||
feeling: Optional[str] = None |
|||
location: Optional[PostLocation] = None |
|||
author: PostAuthorResponse |
|||
like_count: int |
|||
comment_count: int |
|||
liked_by_me: bool |
|||
liked_by_users: list[PostLikerResponse] = Field( |
|||
default_factory=list, description="All active likers, newest like first; like_count equals this list's length.", |
|||
) |
|||
media: list[PostMediaResponse] = Field(default_factory=list) |
|||
created_at: datetime |
|||
|
|||
|
|||
class PublicUserProfileResponse(DiscoveryUserResponse): |
|||
"""Public profile details safe to show on another user's profile page.""" |
|||
|
|||
interests: list[str] = Field(default_factory=list) |
|||
posts: list[PostResponse] = Field(default_factory=list) |
|||
|
|||
|
|||
class CommentCreate(BaseModel): |
|||
content: str = Field(min_length=1, max_length=2000) |
|||
parent_comment_id: Optional[int] = Field(default=None, gt=0) |
|||
|
|||
|
|||
class CommentUpdate(BaseModel): |
|||
content: str = Field(min_length=1, max_length=2000) |
|||
|
|||
|
|||
class CommentResponse(BaseModel): |
|||
id: int |
|||
post_id: int |
|||
content: str |
|||
author: PostAuthorResponse |
|||
parent_comment_id: Optional[int] = None |
|||
reply_count: int = 0 |
|||
like_count: int = 0 |
|||
liked_by_me: bool = False |
|||
created_at: datetime |
|||
|
|||
|
|||
class CommentLikeResponse(BaseModel): |
|||
message: str |
|||
like_count: int |
|||
liked_by_me: bool |
|||
|
|||
|
|||
class ChatMessageCreate(BaseModel): |
|||
content: str = Field(min_length=1, max_length=5000) |
|||
message_type: Literal["text"] = "text" |
|||
|
|||
|
|||
class ChatMessageResponse(BaseModel): |
|||
id: int |
|||
conversation_id: int |
|||
sender_id: int |
|||
content: Optional[str] |
|||
message_type: str |
|||
is_deleted: bool |
|||
created_at: datetime |
|||
|
|||
|
|||
class ChatParticipantResponse(BaseModel): |
|||
id: int |
|||
full_name: Optional[str] = None |
|||
city: Optional[str] = None |
|||
country: Optional[str] = None |
|||
|
|||
|
|||
class ConversationResponse(BaseModel): |
|||
id: Optional[int] = None |
|||
match_id: int |
|||
other_user: ChatParticipantResponse |
|||
last_message: Optional[ChatMessageResponse] = None |
|||
created_at: Optional[datetime] = None |
|||
updated_at: Optional[datetime] = None |
|||
|
|||
|
|||
class QuestionAnswerUpdate(BaseModel): |
|||
answer: Union[bool, str] |
|||
|
|||
@model_validator(mode="after") |
|||
def validate_answer(self): |
|||
if isinstance(self.answer, str) and not self.answer.strip(): |
|||
raise ValueError("回答内容不能为空") |
|||
return self |
|||
|
|||
|
|||
class FeedQuestionResponse(BaseModel): |
|||
id: int |
|||
code: str |
|||
question_text: str |
|||
question_text_zh: Optional[str] |
|||
answer_type: Literal["boolean", "text"] |
|||
current_answer: Optional[Union[bool, str]] = None |
|||
answered_at: Optional[datetime] = None |
|||
|
|||
|
|||
class QuestionAnswerResponse(BaseModel): |
|||
question_id: int |
|||
answer: Union[bool, str] |
|||
answered_at: datetime |
|||
|
|||
|
|||
class ProfileResponse(BaseModel): |
|||
model_config = ConfigDict(from_attributes=True) |
|||
full_name: Optional[str] |
|||
gender: Optional[str] |
|||
birth_date: Optional[str] |
|||
city: Optional[str] |
|||
county: Optional[str] |
|||
country: Optional[str] |
|||
relationship_goal: Optional[RelationshipGoal] |
|||
bio: Optional[str] |
|||
occupation: Optional[str] = None |
|||
education: Optional[str] = None |
|||
height_cm: Optional[int] = None |
|||
beliefs: Optional[str] = None |
|||
smoking: Optional[str] = None |
|||
drinking: Optional[str] = None |
|||
pets: Optional[str] = None |
|||
exercise: Optional[str] = None |
|||
age: Optional[int] = None |
|||
profile_image_url: Optional[str] |
|||
video_url: Optional[str] |
|||
interests: list[str] = Field(default_factory=list) |
|||
|
|||
|
|||
class UserResponse(BaseModel): |
|||
id: int |
|||
email_or_phone: Optional[str] |
|||
status: str |
|||
profile: Optional[ProfileResponse] |
|||
created_at: datetime |
|||
posts: list[PostResponse] = Field(default_factory=list) |
|||
liked_users: list[DiscoveryUserResponse] = Field(default_factory=list) |
|||
liked_by_users: list[DiscoveryUserResponse] = Field(default_factory=list) |
|||
matches: list[MatchResponse] = Field(default_factory=list) |
|||
|
|||
model_config = ConfigDict(from_attributes=True) |
|||
|
|||
|
|||
class UserActivityResponse(UserResponse): |
|||
liked_users_count: int = Field(ge=0) |
|||
liked_by_users_count: int = Field(ge=0) |
|||
matches_count: int = Field(ge=0) |
|||
|
|||
|
|||
class TokenResponse(BaseModel): |
|||
access_token: str |
|||
refresh_token: Optional[str] = None |
|||
token_type: str = "bearer" |
|||
user: UserResponse |
|||
|
|||
|
|||
class RegisterTokenResponse(BaseModel): |
|||
"""Tokens returned after registration.""" |
|||
|
|||
access_token: str |
|||
refresh_token: Optional[str] = None |
|||
token_type: str = "bearer" |
|||
@ -0,0 +1,36 @@ |
|||
import hashlib |
|||
import hmac |
|||
import os |
|||
import secrets |
|||
from datetime import datetime, timedelta |
|||
|
|||
from jose import jwt |
|||
|
|||
from .config import settings |
|||
|
|||
|
|||
ALGORITHM = "HS256" |
|||
|
|||
|
|||
def hash_secret(value: str) -> str: |
|||
salt = os.urandom(16) |
|||
digest = hashlib.pbkdf2_hmac("sha256", value.encode(), salt, 120_000) |
|||
return f"pbkdf2_sha256$120000${salt.hex()}${digest.hex()}" |
|||
|
|||
|
|||
def verify_secret(value: str, encoded: str) -> bool: |
|||
try: |
|||
_, rounds, salt_hex, digest_hex = encoded.split("$") |
|||
digest = hashlib.pbkdf2_hmac("sha256", value.encode(), bytes.fromhex(salt_hex), int(rounds)) |
|||
return hmac.compare_digest(digest.hex(), digest_hex) |
|||
except (ValueError, TypeError): |
|||
return False |
|||
|
|||
|
|||
def create_access_token(user_id: int) -> str: |
|||
expires = datetime.utcnow() + timedelta(minutes=settings.jwt_expire_minutes) |
|||
return jwt.encode({"sub": str(user_id), "exp": expires}, settings.jwt_secret, algorithm=ALGORITHM) |
|||
|
|||
|
|||
def create_refresh_token() -> str: |
|||
return secrets.token_urlsafe(48) |
|||
@ -0,0 +1,82 @@ |
|||
from __future__ import annotations |
|||
|
|||
from pathlib import Path |
|||
from typing import Optional |
|||
|
|||
from fastapi.responses import FileResponse, RedirectResponse |
|||
|
|||
from .config import settings |
|||
|
|||
|
|||
class StorageService: |
|||
"""Store files locally for development or in Aliyun OSS in production.""" |
|||
|
|||
def __init__(self) -> None: |
|||
self.enabled = settings.oss_enabled |
|||
self.bucket = None |
|||
self.domain = settings.oss_domain.rstrip("/") if settings.oss_domain else "" |
|||
self.prefix = settings.oss_base_prefix.strip("/") |
|||
if not self.enabled: |
|||
return |
|||
if not all((settings.oss_endpoint, settings.oss_access_key_id, |
|||
settings.oss_access_key_secret, settings.oss_bucket_name)): |
|||
raise RuntimeError( |
|||
"OSS_ENABLED=true 时必须配置 OSS_ENDPOINT、OSS_ACCESS_KEY_ID、" |
|||
"OSS_ACCESS_KEY_SECRET 和 OSS_BUCKET_NAME" |
|||
) |
|||
try: |
|||
import oss2 |
|||
except ImportError as exc: # pragma: no cover |
|||
raise RuntimeError("OSS_ENABLED=true 时请先安装 oss2 依赖") from exc |
|||
endpoint = settings.oss_endpoint.replace("http://", "https://") |
|||
self.bucket = oss2.Bucket( |
|||
oss2.Auth(settings.oss_access_key_id, settings.oss_access_key_secret), |
|||
endpoint, |
|||
settings.oss_bucket_name, |
|||
) |
|||
|
|||
def make_key(self, relative_key: str) -> str: |
|||
relative_key = relative_key.lstrip("/") |
|||
return f"{self.prefix}/{relative_key}" if self.prefix else relative_key |
|||
|
|||
def finalize(self, local_path: Path, relative_key: str) -> str: |
|||
if not self.enabled: |
|||
return str(local_path) |
|||
key = self.make_key(relative_key) |
|||
try: |
|||
self.bucket.put_object_from_file(key, str(local_path)) |
|||
finally: |
|||
local_path.unlink(missing_ok=True) |
|||
return f"oss://{key}" |
|||
|
|||
def remove(self, storage_path: str) -> None: |
|||
if storage_path.startswith("oss://"): |
|||
if self.enabled: |
|||
self.bucket.delete_object(storage_path[6:]) |
|||
return |
|||
Path(storage_path).unlink(missing_ok=True) |
|||
|
|||
def response(self, storage_path: str, mime_type: Optional[str], filename: Optional[str]): |
|||
if storage_path.startswith("oss://"): |
|||
if not self.enabled: |
|||
raise FileNotFoundError(storage_path) |
|||
url = self.bucket.sign_url("GET", storage_path[6:], 3600) |
|||
return RedirectResponse(url) |
|||
path = Path(storage_path) |
|||
if not path.is_file(): |
|||
raise FileNotFoundError(storage_path) |
|||
return FileResponse(path, media_type=mime_type, filename=filename) |
|||
|
|||
def access_url(self, storage_path: str, fallback_url: str) -> str: |
|||
"""Return a browser-ready URL without exposing private OSS credentials. |
|||
|
|||
Production files live in a private OSS bucket, so public profile media |
|||
uses a one-hour signed URL. Local development keeps using the API route |
|||
supplied by the caller. |
|||
""" |
|||
if storage_path.startswith("oss://") and self.enabled: |
|||
return self.bucket.sign_url("GET", storage_path[6:], 3600) |
|||
return fallback_url |
|||
|
|||
|
|||
storage = StorageService() |
|||
@ -0,0 +1,31 @@ |
|||
services: |
|||
api: |
|||
build: |
|||
context: . |
|||
dockerfile: Dockerfile |
|||
image: dating-app-api:0.6.4 |
|||
container_name: dating-app-api |
|||
restart: unless-stopped |
|||
ports: |
|||
- "8011:8001" |
|||
environment: |
|||
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/dating_app.db} |
|||
JWT_SECRET: ${JWT_SECRET:-local-docker-only-change-me} |
|||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15} |
|||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} |
|||
DEV_RETURN_CODES: ${DEV_RETURN_CODES:-false} |
|||
UPLOAD_DIR: /app/uploads |
|||
OSS_ENABLED: ${OSS_ENABLED:-false} |
|||
OSS_ENDPOINT: ${OSS_ENDPOINT:-https://oss-cn-shanghai.aliyuncs.com} |
|||
OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID:-} |
|||
OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET:-} |
|||
OSS_BUCKET_NAME: ${OSS_BUCKET_NAME:-bs-qinghe} |
|||
OSS_BASE_PREFIX: ${OSS_BASE_PREFIX:-melove/} |
|||
OSS_DOMAIN: ${OSS_DOMAIN:-https://oss.baskbuddy.com} |
|||
volumes: |
|||
- dating_app_data:/app/data |
|||
- dating_app_uploads:/app/uploads |
|||
|
|||
volumes: |
|||
dating_app_data: |
|||
dating_app_uploads: |
|||
@ -0,0 +1,770 @@ |
|||
# Complete Dating App API Reference |
|||
|
|||
> API version: `0.6.12` |
|||
> API reference for frontend and backend developers. |
|||
> Version 0.6.12 is deployed to production and verified through the public API. |
|||
|
|||
## 1. General Information |
|||
### 1.1 Base URL |
|||
|
|||
```text |
|||
{{base_url}} |
|||
``` |
|||
|
|||
|
|||
Local testing URL: |
|||
|
|||
```text |
|||
http://127.0.0.1:8001 |
|||
``` |
|||
|
|||
|
|||
The production URL is supplied by the deployment environment. Configure `base_url` in the client instead of hard-coding a domain. |
|||
|
|||
### 1.2 Request Format |
|||
|
|||
- JSON API requests: `Content-Type: application/json` |
|||
- File upload requests: `multipart/form-data` |
|||
- All endpoints require authentication except registration, login, OAuth, and health check. |
|||
- Authenticated endpoints use: `Authorization: Bearer <access_token>` |
|||
- `access_token` is valid for 15 minutes by default; `refresh_token` is valid for 30 days by default. |
|||
|
|||
### 1.3 Common Errors |
|||
|
|||
Invalid parameters: `422 Unprocessable Entity` |
|||
|
|||
```json |
|||
{ |
|||
"detail": [ |
|||
{ |
|||
"loc": ["body", "field_name"], |
|||
"msg": "Description text", |
|||
"type": "validation_error" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
|
|||
Not logged in or invalid token: `401` |
|||
|
|||
```json |
|||
{"detail": "需要登录"} |
|||
``` |
|||
|
|||
|
|||
(`需要登录` means “Authentication required.”) |
|||
|
|||
Account disabled or insufficient permissions: `403` |
|||
|
|||
Resource not found: `404` |
|||
|
|||
## 2. Data Structures |
|||
|
|||
### 2.1 Registration Request `RegisterRequest` |
|||
|
|||
Required fields: |
|||
|
|||
| Field | Type | Description | |
|||
|---|---|---| |
|||
| `email_or_phone` | string | Email address or phone number. Choose one, using this same field; 3-320 characters | |
|||
| `password` | string | Password, 8-128 characters | |
|||
| `confirm_password` | string | Password confirmation, 8-128 characters; must exactly match `password` | |
|||
| `relationship_goal` | string | Required single choice: `serious_relationship`, `casual_dates`, `new_friends`, or `marriage` | |
|||
|
|||
Optional fields: `full_name` (maximum 120 characters), `gender` (maximum 30), `birth_date` (`YYYY-MM-DD`), `city` (maximum 120), `country` (maximum 120), `bio` (1-2000 characters after trimming), and `interests` (array of interests). |
|||
|
|||
The user chooses either a written bio or an introduction video. For a written introduction, send a non-empty `bio` in the registration JSON. For a video introduction, omit `bio`, register first, then call `/api/v1/users/me/video` with the access token. A JSON registration request cannot carry the video file itself. |
|||
|
|||
`confirm_password` is used only for validation during registration. It is not stored in the database and never appears in any response. |
|||
|
|||
The registration response remains token-only. The frontend then calls `GET /api/v1/auth/me`: `profile.profile_image_url` or `profile.video_url` is `null` before the corresponding upload; in production, uploaded public media is returned as a directly loadable one-hour OSS signed URL. The introduction choices are mutually exclusive: uploading a video clears the existing `bio`, while submitting a non-null `bio` through the profile endpoint deletes the introduction video. Registration onboarding should complete only when exactly one of `bio` or `video_url` is non-null. |
|||
|
|||
### 2.2 User `UserResponse` |
|||
|
|||
```json |
|||
{ |
|||
"id": 1, |
|||
"email_or_phone": "user@example.com", |
|||
"status": "active", |
|||
"profile": { |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"country": "China", |
|||
"relationship_goal": "serious_relationship", |
|||
"bio": "I like traveling.", |
|||
"profile_image_url": "https://<oss-host>/melove/users/1/avatar.jpg?<signature>", |
|||
"video_url": null, |
|||
"interests": ["Photography", "Movies"] |
|||
}, |
|||
"posts": [], |
|||
"liked_users": [], |
|||
"liked_by_users": [], |
|||
"matches": [], |
|||
"liked_users_count": 0, |
|||
"liked_by_users_count": 0, |
|||
"matches_count": 0, |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
``` |
|||
|
|||
`GET /api/v1/auth/me` and `GET /api/v1/users/me` populate these four arrays with the 20 most recent posts, users I liked, users who liked me, and matches. `liked_users_count`, `liked_by_users_count`, and `matches_count` are totals for the three relationships, independent of the 20-item preview limit. Likes include both `like` and `super_like`; only active users are counted. Use the dedicated list endpoints below for complete lists. Registration, login, and profile update responses do not include these totals. |
|||
|
|||
|
|||
### 2.3 Token `TokenResponse` |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer", |
|||
"user": { "...": "UserResponse" } |
|||
} |
|||
``` |
|||
|
|||
|
|||
The registration endpoint is an exception: it returns tokens only and does not return `user`: |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer" |
|||
} |
|||
``` |
|||
|
|||
|
|||
### 2.4 Public User `DiscoveryUserResponse` |
|||
|
|||
```json |
|||
{ |
|||
"id": 2, |
|||
"full_name": "Bob", |
|||
"gender": "male", |
|||
"birth_date": "1998-05-20", |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"country": "China", |
|||
"relationship_goal": "serious_relationship", |
|||
"bio": "Likes hiking", |
|||
"profile_image_url": "https://<oss-host>/melove/users/2/avatar.jpg?<signature>", |
|||
"video_url": "https://<oss-host>/melove/users/2/intro.mp4?<signature>", |
|||
"distance_km": 1.24, |
|||
"compatibility_percentage": 85, |
|||
"media": [ |
|||
{"id": 10, "media_type": "profile_image", "url": "https://<oss-host>/melove/users/2/avatar.jpg?<signature>"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
In a public-user response, `profile_image_url` is the avatar link and `video_url` is the introduction-video link. `media[]` lists those same public profile media items by `media_type`; it is not the user's posts. The dedicated public profile endpoint also returns `interests` and the newest 20 public `posts`, so the profile screen can use one response. Private contact and activity fields such as `email_or_phone`, likes, and matches are never included. Use `GET /api/v1/users/{user_id}/posts` when the client needs pagination beyond the embedded 20 posts. |
|||
|
|||
|
|||
### 2.5 Interest, Location, Post, and Chat Structures |
|||
|
|||
Interest response: `{"interests": ["Photography", "Dogs"]}` |
|||
|
|||
Location response: `{"latitude": 31.2304, "longitude": 121.4737, "city": "Shanghai", "county": "Huangpu", "updated_at": "2026-09-01T10:00:00"}` |
|||
|
|||
Swipe response: `{"direction": "like", "matched": true, "match_id": 15}`. When there is no match, `match_id` is `null`. |
|||
|
|||
Post response fields: `id`, `content`, `post_type`, `content_format`, `feeling`, `location`, `author`, `like_count`, `comment_count`, `liked_by_me`, `liked_by_users`, `media`, and `created_at`. The post's `location` is a snapshot of the selected place, separate from the author's current profile location. |
|||
|
|||
Post and comment `author` objects contain `id`, `full_name`, `profile_image_url`, `city`, `country`, and `compatibility_percentage`. The avatar URL is `null` before upload; production returns a directly loadable one-hour OSS signed URL. Re-fetch the response after expiry. Every PostResponse (Explore, detail, profile-embedded posts, and user post lists) includes `liked_by_users: [{"id": 2, "full_name": "Reader"}]`. It is `[]` without likes, ordered newest like first, and contains active users only; `like_count` equals its length. Names can be `null`. This list contains no contact details or full user profiles. |
|||
|
|||
Chat message fields: `id`, `conversation_id`, `sender_id`, `content`, `message_type`, `is_deleted`, and `created_at`. After a message is recalled, `content` is `null` and `is_deleted` is `true`. |
|||
|
|||
## 3. Authentication Endpoints |
|||
|
|||
### 3.1 Health Check |
|||
|
|||
**GET** `{{base_url}}/health` |
|||
Authentication: not required. |
|||
Frontend sends: no body. |
|||
Success: `200` |
|||
|
|||
```json |
|||
{"status": "ok"} |
|||
``` |
|||
|
|||
|
|||
### 3.2 Registration |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/register` |
|||
Authentication: not required. |
|||
Request body: `application/json` |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship", |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"bio": "I like traveling.", |
|||
"interests": ["Photography", "Movies"] |
|||
} |
|||
``` |
|||
|
|||
|
|||
For phone registration, put the phone number in `email_or_phone`. Success: `201`, returning the token-only structure in section 2.3. |
|||
Errors: `409` email/phone already registered; `422` required field missing, invalid `relationship_goal`, passwords do not match, or fewer than 2 interests. |
|||
The MVP does not use OTP; newly registered accounts are immediately `active`. |
|||
|
|||
### 3.3 Login |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/login` |
|||
Authentication: not required. |
|||
Request body: |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123" |
|||
} |
|||
``` |
|||
|
|||
|
|||
Success: `200`, returning `TokenResponse`. Errors: `401` incorrect account or password; `403` account disabled; `422` invalid parameters. |
|||
|
|||
### 3.4 OAuth Login/Registration Placeholder |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/oauth` |
|||
Authentication: not required. |
|||
Request body: |
|||
|
|||
```json |
|||
{ |
|||
"provider": "google", |
|||
"provider_user_id": "google-user-id-123", |
|||
"email": "user@example.com", |
|||
"full_name": "OAuth User" |
|||
} |
|||
``` |
|||
|
|||
|
|||
`provider` can only be `wechat` or `google`; `email` and `full_name` are optional. Success: `200`, returning `TokenResponse`. Error: `422` invalid parameters. |
|||
|
|||
### 3.5 Refresh Token |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/refresh` |
|||
Authentication: no Bearer token is required, but a valid refresh token must be submitted. |
|||
Request body: |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
|
|||
Success: `200`, returning a new `TokenResponse`; the old refresh token becomes invalid. Errors: `401` refresh token invalid, expired, or revoked; `422` invalid parameters. |
|||
|
|||
### 3.6 Logout |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/logout` |
|||
Authentication: no Bearer token is required. |
|||
Request body: |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
|
|||
Success: `200` |
|||
|
|||
```json |
|||
{"message": "已退出登录"} |
|||
``` |
|||
|
|||
|
|||
(`已退出登录` means “Logged out.”) |
|||
|
|||
### 3.7 Get Current User |
|||
|
|||
**GET** `{{base_url}}/api/v1/auth/me` |
|||
Authentication: Bearer token required. |
|||
Frontend sends: no body. |
|||
Success: `200`, returning `UserResponse`. The app can call this endpoint at startup to determine whether the token is still valid. |
|||
|
|||
### 3.8 Get Current User (Compatibility Path) |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me` |
|||
Authentication: Bearer token required. |
|||
This is exactly the same as section 3.7 and returns `UserResponse`. |
|||
|
|||
## 4. User Profile and Media |
|||
|
|||
### 4.1 Update Profile |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/users/me` |
|||
Authentication: Bearer token required. |
|||
Only fields that need to change have to be included: |
|||
|
|||
```json |
|||
{ |
|||
"full_name": "New Name", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Beijing", |
|||
"country": "China", |
|||
"relationship_goal": "marriage", |
|||
"bio": "Updated bio", |
|||
"occupation": "Creative Director at Studio Drift", |
|||
"education": "NYU Gallatin School", |
|||
"height_cm": 175, |
|||
"beliefs": "spiritual_agnostic", |
|||
"smoking": "non_smoker", |
|||
"drinking": "socially", |
|||
"pets": "golden_retriever", |
|||
"exercise": "active_4x_week", |
|||
"interests": ["Travel", "Music"] |
|||
} |
|||
``` |
|||
|
|||
Additional optional profile fields: `occupation` and `education` (max 120 characters each), `height_cm` (integer, 100-250), `beliefs` (max 120), `smoking` and `drinking` (max 50 each), `pets` (max 120), and `exercise` (max 50). Send `null` to clear any of them. `GET /api/v1/auth/me` returns all of these values and a computed `age` derived from `birth_date`. |
|||
|
|||
Success: `200`, returning the updated `UserResponse`. If provided, `interests` must contain at least 2 items. Error: `422` invalid field format or invalid interests. |
|||
|
|||
### 4.2 Get My Interests |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/interests` |
|||
Authentication: Bearer token required. |
|||
Success: `200` |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
|
|||
### 4.3 Replace My Interests |
|||
|
|||
**PUT** `{{base_url}}/api/v1/users/me/interests` |
|||
Authentication: Bearer token required. |
|||
Request body: |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
|
|||
At least 2 items are required; there is no maximum. `Other` does not require a special field; submit the custom text directly as an array item. Success: `200`, returning the interest array. Errors: `422` fewer than 2 items, an empty string, or an item longer than 120 characters. |
|||
|
|||
### 4.4 Update Location |
|||
|
|||
**PUT** `{{base_url}}/api/v1/users/me/location` |
|||
Authentication: Bearer token required. |
|||
Request body: |
|||
|
|||
```json |
|||
{"latitude": 31.2304, "longitude": 121.4737, "city": "Shanghai", "county": "Huangpu"} |
|||
``` |
|||
|
|||
|
|||
Latitude must be between `-90` and `90`; longitude must be between `-180` and `180`. `city` and `county` are required, trimmed strings of 1-120 characters. The city updates the profile used for same-city ranking; `county` is a district, separate from `country`. Success: `200`, returning all four location fields and `updated_at`. Current-user and public-user profile responses also include `county`. Error: `422` missing or invalid fields. |
|||
|
|||
### 4.5 Upload Introduction Video |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/video` |
|||
Authentication: Bearer token required. |
|||
Request format: `multipart/form-data`, field name `video`; supports `video/mp4`, `video/quicktime`, and `video/webm`; maximum 100 MB. |
|||
Each user keeps only the latest introduction video. A successful new upload replaces the previous database record and stored file. If storing the new file fails, the previous video remains unchanged. |
|||
Success: `200` |
|||
|
|||
```json |
|||
{"id": 11, "media_type": "intro_video", "review_status": "pending"} |
|||
``` |
|||
|
|||
|
|||
Errors: `413` file too large; `415` unsupported file type; `422` no file uploaded. |
|||
|
|||
### 4.6 Upload Profile Image |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/image` |
|||
Authentication: Bearer token required. |
|||
Request format: `multipart/form-data`, field name `image`; supports JPG, PNG, and WebP; maximum 10 MB. |
|||
Each user keeps only the latest profile image. A successful new upload replaces the previous database record and stored file. If storing the new file fails, the previous image remains unchanged. |
|||
Success returns the media ID, type, and review status. Errors: `413`, `415`, `422`. |
|||
|
|||
### 4.7 Upload ID Card/Passport |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/identity` |
|||
Authentication: Bearer token required. |
|||
Request format: `multipart/form-data`, field name `document`; supports JPG, PNG, and PDF; maximum 10 MB. |
|||
Success returns the media ID, type, and review status. This file is accessible only by its owner. Errors: `413`, `415`, `422`. |
|||
|
|||
### 4.8 Get User Media |
|||
|
|||
**GET** `{{base_url}}/api/v1/media/{media_id}` |
|||
Authentication: Bearer token required. |
|||
Path parameter: `media_id` is the media ID. |
|||
Success: `200`, returning the file binary. Public profile images/videos can be read by other active users; ID cards/passports can be read only by the owner. Errors: `403` permission denied; `404` file not found. |
|||
|
|||
In production OSS, `profile_image_url`, `video_url`, and `media[].url` in Meet, Search, Match, and profile responses are complete signed URLs valid for one hour. Image/video widgets can load them directly without another Bearer-token request. Fetch the containing API response again after expiry to obtain fresh URLs. Identity documents are never included in these public URL fields. |
|||
|
|||
### 4.9 Get My Posts |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/posts?limit=20&offset=0` |
|||
Authentication: Bearer token required. `limit` is 1-50 and `offset` is 0-5000. Returns a newest-first `PostResponse` array. |
|||
|
|||
### 4.10 Get a Public User Profile |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/{user_id}` |
|||
Authentication: Bearer token required. Returns `PublicUserProfileResponse`, which includes the public profile fields, `interests`, and the newest 20 `posts`; `404` if the target is missing or inactive. It never returns `email_or_phone`, account status, likes, or matches. |
|||
|
|||
### 4.11 Get a Public User's Posts |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/{user_id}/posts?limit=20&offset=0` |
|||
Authentication: Bearer token required. Returns the user's posts newest first; `404` if the target is missing or inactive. |
|||
|
|||
### 4.12 Get Users I Liked |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/liked` |
|||
Authentication: Bearer token required. Returns all public profiles for users the current user liked, latest action first. Users the current user has already matched with are excluded. This endpoint does not paginate; legacy `limit` and `offset` query parameters are ignored. |
|||
|
|||
### 4.13 Get Users Who Liked Me |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/likes` |
|||
Authentication: Bearer token required. Returns all public profiles for users who liked the current user, latest action first. Users the current user has already matched with are excluded. This endpoint does not paginate; legacy `limit` and `offset` query parameters are ignored. |
|||
|
|||
### 4.14 Get My Matches |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/matches` |
|||
Authentication: Bearer token required. Returns the same complete `MatchResponse` array as `/api/v1/matches`, for profile-section loading. The endpoint does not paginate; legacy `limit` and `offset` query parameters are ignored. |
|||
|
|||
5. Meet, Swipe, Match, and Search |
|||
|
|||
### 5.1 Get Recommended Users |
|||
|
|||
**GET** `{{base_url}}/api/v1/meet?limit=20` |
|||
Authentication: Bearer token required. |
|||
Query parameter: optional `limit`, 1-50, default 20. |
|||
Success: `200`, returning an array of `DiscoveryUserResponse`; only `active` users are returned, and users already swiped on are not returned again. Users who sent the current user an active `super_like` appear first. The rest are ordered by same city, same country, and then other countries; higher `compatibility_percentage` comes first within each group. |
|||
|
|||
### 5.2 Swipe Left, Right, or Super Like |
|||
|
|||
**POST** `{{base_url}}/api/v1/meet/swipes` |
|||
Authentication: Bearer token required. |
|||
Request body: |
|||
|
|||
```json |
|||
{"target_user_id": 2, "direction": "like"} |
|||
``` |
|||
|
|||
|
|||
`direction` is `pass`, `like`, or `super_like`. A Super Like counts as a like for matching and keeps the sender at the top of the target's Meet list until canceled or changed, unless the target has already swiped the sender. Success: `200`, returning `SwipeResponse`; when both users choose `like` or `super_like`, `matched=true` and a `match_id` is generated. Errors: `400` cannot operate on yourself; `404` target user not found; `422` invalid parameters. |
|||
|
|||
### 5.3 Unlike a User |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/meet/likes/{target_user_id}` |
|||
Authentication: Bearer token required. |
|||
Success: `200` `{"message":"已取消喜欢"}` ("Like removed"), and the corresponding match is dissolved. This legacy endpoint accepts `like` and `super_like`; use the undo endpoint for the Cancel button. Error: `404` no like record to remove. |
|||
|
|||
### 5.4 Cancel Button: Undo Any Swipe |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/meet/swipes/{target_user_id}` |
|||
Authentication: Bearer token required. Removes the current user's saved `pass`, `like`, or `super_like`, allowing that target to appear in Meet again. Undoing a Super Like immediately removes its priority in the target's Meet list. Undoing a matched like or Super Like also dissolves the match. Success: `200`, for example `{"message":"已撤销滑动","previous_direction":"super_like"}`. Error: `404` no swipe to undo. |
|||
|
|||
### 5.5 Get Matches |
|||
|
|||
**GET** `{{base_url}}/api/v1/matches` |
|||
Authentication: Bearer token required. |
|||
The endpoint does not paginate and returns every active match, most recent first; legacy `limit` and `offset` query parameters are ignored. Success: `200`, returning an array of `MatchResponse`; each item contains `id`, `matched_at`, and the other user's public profile in `user`. The number of items equals `matches_count` from `GET /api/v1/auth/me`. |
|||
|
|||
### 5.6 Search Users |
|||
|
|||
**GET** `{{base_url}}/api/v1/search` |
|||
Authentication: Bearer token required. |
|||
Query parameters: |
|||
|
|||
| Parameter | Type | Description | |
|||
|---|---|---| |
|||
| `q` | string | Optional; searches name, bio, city, and country; maximum 200 characters | |
|||
| `city` | string | Optional city filter; can be combined with country | |
|||
| `country` | string | Optional country filter; can be combined with city | |
|||
| `latitude` | number | Optional; must be supplied together with longitude | |
|||
| `longitude` | number | Optional; must be supplied together with latitude | |
|||
| `max_distance_km` | number | Optional, maximum 500; without coordinates, the user's own location is used | |
|||
| `limit` | integer | Optional, 1-50, default 20 | |
|||
|
|||
Success: `200`, returning an array of `DiscoveryUserResponse` with `country` and `compatibility_percentage`. Errors: `422` only one coordinate provided, or distance filtering requested without coordinates or the user's own location. |
|||
|
|||
6. Explore Feed |
|||
|
|||
### 6.1 Create a Post |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts` |
|||
Authentication: Bearer token required. |
|||
Request body: |
|||
|
|||
```json |
|||
{ |
|||
"content": "Feeling **happy** and *relaxed* today", |
|||
"post_type": "text", |
|||
"content_format": "markdown", |
|||
"feeling": "happy", |
|||
"location": {"name": "Blue Bottle Coffee, Soho NYC", "latitude": 40.723, "longitude": -73.998} |
|||
} |
|||
``` |
|||
|
|||
|
|||
`post_type` is `text`, `image`, or `video` (default `text`). `content` is at most 5,000 characters; a text post needs non-empty content, a feeling, or a location. Optional `feeling` is a 1-50 character label or code such as `happy`. Optional `location.name` is 1-255 characters; latitude and longitude may be omitted together or provided together within their normal geographic ranges. This place is stored on the post and does not change the author's profile or current location. The selected place and submitted coordinates are visible to users who can view the post. |
|||
|
|||
`content_format` is `plain` by default. For the editor's bold and italic buttons, submit Markdown text such as `**bold**` and `*italic*` with `content_format: "markdown"`. The API returns the Markdown source, not HTML; the frontend must render it safely. Existing posts remain `plain`. Music is not supported yet. |
|||
|
|||
For an image or video post, create the post first, then upload its files through section 6.5 using the returned post ID. A post supports up to nine images and one video together in one post; when both are present the post's `post_type` becomes `mixed`. Success: `201`, returning `PostResponse` with the new fields. |
|||
|
|||
### 6.2 Get Explore Feed |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/feed?limit=20&offset=0` |
|||
Authentication: Bearer token required. |
|||
Parameters: `limit` 1-50, default 20; use `offset` for pagination with the new ranking. The legacy `before_id` remains available as a chronological ID cutoff and cannot be combined with a nonzero `offset`. Success: `200`, with `content_format`, `feeling`, and `location` on every post; authors in the same city are first, followed by the same country and other countries, with higher compatibility first and recency breaking remaining ties. |
|||
|
|||
### 6.3 Get Post Details |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning `PostResponse`; error: `404` post not found or author unavailable. |
|||
|
|||
### 6.4 Delete a Post |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
Authentication: Bearer token required. Only the post author can delete it. Success: `200` `{"message":"帖子已删除"}` ("Post deleted"); errors: `403` permission denied; `404` not found. |
|||
|
|||
### 6.5 Upload Post Media |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/media?position=0` |
|||
Authentication: Bearer token required, and the caller must be the post author. |
|||
Request format: `multipart/form-data`, field name `media`; optional `position`, 0-8. Supports JPG, PNG, WebP, MP4, MOV, and WebM. |
|||
Each post supports up to 9 images and 1 video in one post; images and video can now be mixed. When both are present, `post_type` is `mixed`. Success: `201`, returning `PostMediaResponse` (`id`, `media_type`, `mime_type`, `size_bytes`, `position`, `url`). In production OSS, `url` is a complete one-hour signed URL that can be loaded without a Bearer header. Errors: `403`, `404`, `409` media type/count conflict, `413`, `415`. |
|||
|
|||
### 6.6 Get Post Media |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/media/{media_id}` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning the file binary. Error: `404` post or media not found. |
|||
|
|||
### 6.7 Like a Post |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning compact `PostLikeResponse`, without the full post content, author, or media: |
|||
|
|||
```json |
|||
{ |
|||
"message": "like successful", |
|||
"like_count": 1, |
|||
"liked_by_me": true, |
|||
"liked_by_users": [{"id": 2, "full_name": "Reader"}] |
|||
} |
|||
``` |
|||
|
|||
Repeated likes do not increase the count. Update the existing post using the returned count, state, and list. Errors: `401` unauthenticated; `404` post not found. |
|||
|
|||
#### Query Post Likers Separately |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
Authentication: Bearer token required. No body or pagination parameters. Success: `200`, directly returning `[{"id": 2, "full_name": "Reader"}]`, or `[]` without likes. Only active users appear, newest like first. Errors: `401` unauthenticated; `404` post not found. |
|||
|
|||
### 6.8 Unlike a Post |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning a compact `PostUnlikeResponse` — `{"message":"unlike successful","like_count":0,"liked_by_me":false,"liked_by_users":[...]}` — instead of the full post. Repeated requests keep the post unliked. |
|||
|
|||
### 6.9 Get Comments |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}/comments` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning an array of `CommentResponse`, including `author.profile_image_url` (or `null` without an avatar). Each comment also contains `parent_comment_id`, `reply_count`, `like_count`, and `liked_by_me`. Comments are returned as a flat list; group replies by `parent_comment_id` on the frontend. Error: `404` post not found. |
|||
|
|||
### 6.10 Add a Comment |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/comments` |
|||
Authentication: Bearer token required. |
|||
Request body: |
|||
|
|||
```json |
|||
{"content": "Looks great!"} |
|||
``` |
|||
|
|||
Content must be 1-2,000 characters and cannot consist only of whitespace. To reply to an existing comment, also send `parent_comment_id`: |
|||
|
|||
```json |
|||
{"content": "I agree", "parent_comment_id": 12} |
|||
``` |
|||
|
|||
Success: `201`, returning `CommentResponse` with `author.profile_image_url`; errors: `404` post or parent comment not found; `422` invalid content. |
|||
|
|||
### 6.11 Delete a Comment |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/comments/{comment_id}` |
|||
Authentication: Bearer token required. Only the comment author can delete it. Success: `200` `{"message":"评论已删除"}` ("Comment deleted"); errors: `403`, `404`. |
|||
|
|||
### 6.12 Edit a Post |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
Authentication: Bearer token required; only the post author can edit it. |
|||
Request body (send only the fields to change): |
|||
|
|||
```json |
|||
{"content": "Updated text", "content_format": "markdown", "feeling": "happy", "location": {"name": "New place"}} |
|||
``` |
|||
|
|||
Editable fields: `content` (max 5000 characters), `content_format` (`plain`/`markdown`), `feeling` (1-50 characters), and `location`. Omitted fields stay unchanged; send `null` to clear `feeling` or `location`. A text post must keep at least one of non-empty content, feeling, or location. Success: `200`, returning the full updated `PostResponse`. Errors: `403` not the author; `404` post not found; `422` invalid fields. |
|||
|
|||
### 6.13 Edit a Comment |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/explore/comments/{comment_id}` |
|||
Authentication: Bearer token required; only the comment author can edit it. |
|||
Request body: |
|||
|
|||
```json |
|||
{"content": "Updated comment"} |
|||
``` |
|||
|
|||
`content` must be 1-2,000 characters and cannot be blank. Success: `200`, returning the updated `CommentResponse`. Errors: `403` not the author; `404` comment not found; `422` invalid content. |
|||
|
|||
### 6.14 Like a Comment |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/comments/{comment_id}/like` |
|||
Authentication: Bearer token required. No request body. |
|||
Success: `200` |
|||
|
|||
```json |
|||
{"message": "like successful", "like_count": 1, "liked_by_me": true} |
|||
``` |
|||
|
|||
Repeated likes do not increase the count. Errors: `401` unauthenticated; `404` comment not found or post author inactive. |
|||
|
|||
### 6.15 Unlike a Comment |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/comments/{comment_id}/like` |
|||
Authentication: Bearer token required. No request body. |
|||
Success: `200` |
|||
|
|||
```json |
|||
{"message": "unlike successful", "like_count": 0, "liked_by_me": false} |
|||
``` |
|||
|
|||
Repeated requests keep the comment unliked. Errors: `401` unauthenticated; `404` comment not found or post author inactive. |
|||
|
|||
## 7. Feed Questions and Answers |
|||
|
|||
### 7.1 Get Feed Questions |
|||
|
|||
**GET** `{{base_url}}/api/v1/feed/questions?unanswered_only=false` |
|||
Authentication: Bearer token required. |
|||
Optional `unanswered_only`, default `false`; set to `true` to return only questions the current user has not answered. Success: `200`, returning an array of questions. Each item contains `id`, `code`, the question in Chinese and English, `answer_type` (`boolean` or `text`), the current answer, and the answer time. |
|||
|
|||
### 7.2 Get My Feed Answers |
|||
|
|||
**GET** `{{base_url}}/api/v1/feed/answers/me` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning an array of questions answered by the current user. |
|||
|
|||
### 7.3 Create or Update a Feed Answer |
|||
|
|||
**PUT** `{{base_url}}/api/v1/feed/questions/{question_id}/answer` |
|||
Authentication: Bearer token required. |
|||
Example request body for a Boolean question: |
|||
|
|||
```json |
|||
{"answer": true} |
|||
``` |
|||
|
|||
|
|||
For a text question: |
|||
|
|||
```json |
|||
{"answer": "Coffee and a walk"} |
|||
``` |
|||
|
|||
|
|||
Success: `200` |
|||
|
|||
```json |
|||
{"question_id": 1, "answer": true, "answered_at": "2026-09-01T10:00:00"} |
|||
``` |
|||
|
|||
|
|||
Errors: `404` question not found or disabled; `422` answer type does not match the question or content is empty. |
|||
|
|||
### 7.4 Delete a Feed Answer |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/feed/questions/{question_id}/answer` |
|||
Authentication: Bearer token required. |
|||
Success: `200` `{"message":"回答已删除"}` ("Answer deleted"); error: `404` answer not found. |
|||
|
|||
## 8. Chat |
|||
|
|||
Chat is available only to users who have matched each other. |
|||
|
|||
### 8.1 Get Conversation List |
|||
|
|||
**GET** `{{base_url}}/api/v1/chat/conversations` |
|||
Authentication: Bearer token required. |
|||
Success: `200`, returning an array of conversations. Each item contains `id`, `match_id`, `other_user`, `last_message`, `created_at`, and `updated_at`. |
|||
|
|||
### 8.2 Create or Get a Conversation |
|||
|
|||
**POST** `{{base_url}}/api/v1/chat/conversations/{match_id}` |
|||
Authentication: Bearer token required; the caller must be one of the users in the match. |
|||
Frontend sends: no body. |
|||
Success: `201`, returning `ConversationResponse`; if the conversation already exists, the existing conversation is returned. Errors: `404` match not found, caller is not one of the matched users, or the other user is unavailable. |
|||
|
|||
### 8.3 Get Chat Messages |
|||
|
|||
**GET** `{{base_url}}/api/v1/chat/conversations/{conversation_id}/messages?limit=50&before_id=100` |
|||
Authentication: Bearer token required; the caller must be one of the conversation participants. |
|||
Parameters: `limit` 1-100, default 50; optional `before_id` for pagination. Success: `200`, returning a `ChatMessageResponse` array in chronological order. |
|||
|
|||
### 8.4 Send a Chat Message |
|||
|
|||
**POST** `{{base_url}}/api/v1/chat/conversations/{conversation_id}/messages` |
|||
Authentication: Bearer token required; the caller must be one of the conversation participants. |
|||
Request body: |
|||
|
|||
```json |
|||
{ |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text" |
|||
} |
|||
``` |
|||
|
|||
|
|||
`content` must be 1-5,000 characters; `message_type` currently can only be `text`. Success: `201`, returning `ChatMessageResponse`; errors: `404` conversation not found or access denied; `422` empty or malformed content. |
|||
|
|||
### 8.5 Recall a Chat Message |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/chat/messages/{message_id}` |
|||
Authentication: Bearer token required. Only the sender can recall a message. Success: `200`, returning the recalled `ChatMessageResponse` (`content=null`, `is_deleted=true`); errors: `403` message was not sent by the caller; `404` message not found or access denied. |
|||
|
|||
## 9. Frontend Registration and Login Flow |
|||
|
|||
1. The registration page displays email/phone, password, password confirmation, and optional profile fields. |
|||
2. The frontend first checks `password === confirm_password`, then submits both password fields to the registration endpoint. |
|||
3. After successful registration, save the returned `access_token` and `refresh_token`. Do not save the confirmation password. |
|||
4. Do not call an OTP endpoint; this version has no `/api/v1/auth/request-code` or `/api/v1/auth/verify`. |
|||
5. At app startup, call `/api/v1/auth/me`. A `200` response enters the home page; a `401` response clears the tokens and returns to the login page. |
|||
6. When the access token expires, call `/api/v1/auth/refresh` with the refresh token and save the new token pair. If refreshing fails, require the user to log in again. |
|||
|
|||
## 10. Postman Import and Testing |
|||
|
|||
1. Open Postman and click `Import` -> `Files`. |
|||
2. Import `docs/openapi-0.6.12.json` and choose to import it as a Postman Collection. |
|||
3. In Collection Variables, set: `base_url = http://127.0.0.1:8001`. |
|||
4. First send `POST /api/v1/auth/register` or `POST /api/v1/auth/login`. |
|||
5. Copy the `access_token` from the response. For requests requiring authentication, select `Authorization` -> `Bearer Token` and paste it. |
|||
6. For file-upload tests, choose `Body` -> `form-data`; the file field name must match the field name specified in this document. |
|||
|
|||
Recommended minimum test order: `health` -> `register` -> `login` -> `auth/me` -> `update profile` -> `meet` -> `swipe` -> `matches` -> `explore` -> `feed questions` -> `chat`. |
|||
|
|||
## 11. Specification Files |
|||
|
|||
Complete machine-readable API definition: `docs/openapi-0.6.12.json`. If this document and the OpenAPI definition differ, follow the current backend `/openapi.json` and the latest OpenAPI file. |
|||
@ -0,0 +1,918 @@ |
|||
# Dating App API - Frontend Integration List |
|||
|
|||
> Frontend integration reference listing each URL, request, and response. |
|||
> API version: `0.6.12`. |
|||
> Version 0.6.12 is deployed to production and verified through the public API. |
|||
|
|||
## Base URL |
|||
|
|||
```text |
|||
{{base_url}} |
|||
``` |
|||
|
|||
Local development URL: |
|||
|
|||
```text |
|||
http://127.0.0.1:8001 |
|||
``` |
|||
|
|||
Replace `{{base_url}}` with the public backend URL when the backend is deployed. Do not use `127.0.0.1` from a user's phone or another computer. |
|||
|
|||
For protected APIs, send this header: |
|||
|
|||
```text |
|||
Authorization: Bearer <access_token> |
|||
``` |
|||
|
|||
JSON APIs use `Content-Type: application/json`. File APIs use `multipart/form-data`. |
|||
|
|||
## 1. Authentication |
|||
|
|||
### Health check |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/health |
|||
``` |
|||
|
|||
**Frontend sends**: Nothing. |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"status": "ok"} |
|||
``` |
|||
|
|||
### Register |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/auth/register |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship", |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"bio": "I like traveling.", |
|||
"interests": ["Photography", "Movies"] |
|||
} |
|||
``` |
|||
|
|||
Required: `email_or_phone`, `password`, `confirm_password`, `relationship_goal`. The same `email_or_phone` field accepts either an email or a phone number. `confirm_password` must equal `password`; it is only used for validation and is not stored. `relationship_goal` is a single choice: `serious_relationship`, `casual_dates`, `new_friends`, or `marriage`. |
|||
|
|||
For the introduction step, the user chooses either a written bio or a video. Send a non-empty `bio` in this JSON for the written option. For the video option, omit `bio`, finish registration, then upload the file through `/api/v1/users/me/video` using the returned access token. After registration, call `/api/v1/auth/me`; `profile.profile_image_url` and `profile.video_url` are `null` until their corresponding media is uploaded. In production these fields contain directly loadable, one-hour OSS signed URLs. The introduction choices are mutually exclusive: uploading a video clears `bio`, and submitting a non-null `bio` later deletes the introduction video. The frontend should continue onboarding until exactly one of `bio` or `video_url` is non-null. |
|||
|
|||
**Backend returns** (`201`) |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer" |
|||
} |
|||
``` |
|||
|
|||
Errors: `409` account already exists; `422` invalid or missing fields. MVP does not use OTP. The account is active immediately. |
|||
|
|||
### Login |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/auth/login |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123" |
|||
} |
|||
``` |
|||
|
|||
**Backend returns** (`200`) |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer", |
|||
"user": { |
|||
"id": 1, |
|||
"email_or_phone": "user@example.com", |
|||
"status": "active", |
|||
"profile": null, |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
Errors: `401` wrong account/password; `403` account suspended; `422` invalid fields. |
|||
|
|||
### OAuth login/register |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/auth/oauth |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{ |
|||
"provider": "google", |
|||
"provider_user_id": "google-user-id-123", |
|||
"email": "user@example.com", |
|||
"full_name": "OAuth User" |
|||
} |
|||
``` |
|||
|
|||
`provider` is `wechat` or `google`. `email` and `full_name` are optional. |
|||
|
|||
**Backend returns** (`200`): the same token and `user` structure as Login. |
|||
|
|||
### Refresh token |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/auth/refresh |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
**Backend returns** (`200`): a new `access_token` and `refresh_token`, plus `token_type` and `user`. The old refresh token is invalid after rotation. |
|||
|
|||
### Logout |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/auth/logout |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"message": "已退出登录"} |
|||
``` |
|||
|
|||
### Current user |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/auth/me |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token, no body. |
|||
|
|||
**Backend returns** (`200`): `UserActivityResponse` with `id`, `email_or_phone`, `status`, `profile`, `created_at`, plus `posts`, `liked_users`, `liked_by_users`, and `matches`. These four arrays contain at most 20 items each. `liked_users_count`, `liked_by_users_count`, and `matches_count` are the full totals for active users, not the lengths of the limited arrays. |
|||
|
|||
### Current user (compatible URL) |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/users/me |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token, no body. |
|||
|
|||
**Backend returns** (`200`): the same `UserActivityResponse` as `/api/v1/auth/me`. |
|||
|
|||
### Profile-screen lists and public profiles |
|||
|
|||
All endpoints require a Bearer token. The post list endpoints use `limit` (1-50, default 20) and `offset` (0-5000, default 0). The match list and the two likes lists return every active item without pagination. |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/users/me/posts?limit=20&offset=0 |
|||
GET {{base_url}}/api/v1/users/me/liked |
|||
GET {{base_url}}/api/v1/users/me/likes |
|||
GET {{base_url}}/api/v1/users/me/matches |
|||
GET {{base_url}}/api/v1/users/{user_id} |
|||
GET {{base_url}}/api/v1/users/{user_id}/posts?limit=20&offset=0 |
|||
``` |
|||
|
|||
`liked` means users the current user liked; `likes` means users who liked the current user. The two post endpoints return `PostResponse[]`; the public profile endpoint returns `PublicUserProfileResponse` with public profile fields, `interests`, and the newest 20 `posts`; the liked/likes endpoints return complete `DiscoveryUserResponse[]` lists that exclude users you have already matched with, and matches returns `MatchResponse[]`. Legacy `limit` and `offset` parameters on the two likes endpoints and the two match endpoints are ignored; both match endpoints return the complete list. The public profile response excludes email/phone, account status, likes, and matches. |
|||
|
|||
## 2. User profile, interests, location and media |
|||
|
|||
### Update profile |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PATCH {{base_url}}/api/v1/users/me |
|||
``` |
|||
|
|||
**Frontend sends** (send only fields to change) |
|||
|
|||
```json |
|||
{ |
|||
"full_name": "New Name", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Beijing", |
|||
"country": "China", |
|||
"relationship_goal": "marriage", |
|||
"bio": "Updated bio", |
|||
"occupation": "Creative Director at Studio Drift", |
|||
"education": "NYU Gallatin School", |
|||
"height_cm": 175, |
|||
"beliefs": "spiritual_agnostic", |
|||
"smoking": "non_smoker", |
|||
"drinking": "socially", |
|||
"pets": "golden_retriever", |
|||
"exercise": "active_4x_week", |
|||
"interests": ["Travel", "Music"] |
|||
} |
|||
``` |
|||
|
|||
Optional detail fields: `occupation` and `education` (max 120), `height_cm` (100-250), `beliefs` (max 120), `smoking` and `drinking` (max 50 each), `pets` (max 120), `exercise` (max 50). Send `null` to clear a field. `GET /api/v1/auth/me` returns all of them and a computed `age`. |
|||
|
|||
**Backend returns** (`200`): updated `UserResponse`. |
|||
|
|||
### Get my interests |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/users/me/interests |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token, no body. |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
### Replace my interests |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PUT {{base_url}}/api/v1/users/me/interests |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
**Backend returns**: the updated `{"interests": [...]}`. At least 2 interests are required; there is no maximum. `Other` is sent as normal custom text. |
|||
|
|||
### Update location |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PUT {{base_url}}/api/v1/users/me/location |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"latitude": 31.2304, "longitude": 121.4737, "city": "Shanghai", "county": "Huangpu"} |
|||
``` |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{ |
|||
"latitude": 31.2304, |
|||
"longitude": 121.4737, |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"updated_at": "2026-09-01T10:00:00" |
|||
} |
|||
``` |
|||
|
|||
All four fields are required. `county` is the district/county name, distinct from `country` (nation). The location call also updates the profile city used by Meet ranking; the current-user and public-user profile responses include `county`. |
|||
|
|||
### Upload introduction video |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/users/me/video |
|||
``` |
|||
|
|||
**Frontend sends**: `multipart/form-data`, file field `video`. Allowed: MP4/MOV/WebM, maximum 100 MB. Re-uploading successfully replaces the user's previous introduction video; the frontend does not need to delete the old one first. |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"id": 11, "media_type": "intro_video", "review_status": "pending"} |
|||
``` |
|||
|
|||
### Upload profile image |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/users/me/image |
|||
``` |
|||
|
|||
**Frontend sends**: `multipart/form-data`, file field `image`. Allowed: JPG/PNG/WebP, maximum 10 MB. Re-uploading successfully replaces the user's previous profile image; the frontend does not need to delete the old one first. |
|||
|
|||
**Backend returns**: `{ "id": 12, "media_type": "profile_image", "review_status": "pending" }`. |
|||
|
|||
### Upload identity document |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/users/me/identity |
|||
``` |
|||
|
|||
**Frontend sends**: `multipart/form-data`, file field `document`. Allowed: JPG/PNG/PDF, maximum 10 MB. |
|||
|
|||
**Backend returns**: `{ "id": 13, "media_type": "identity_document", "review_status": "pending" }`. |
|||
|
|||
### Get user media |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/media/{media_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token and the `media_id` path value. |
|||
|
|||
**Backend returns**: file binary (`200`). Public profile media can be read by active users; identity documents can only be read by the owner. `403` means no permission; `404` means missing file. Normally the frontend should render `profile_image_url`, `video_url`, or `media[].url` from profile/discovery responses directly; production returns one-hour signed OSS URLs that do not need a Bearer header. Re-fetch the containing API response when a URL expires. |
|||
|
|||
## 3. Meet, swipe, match and search |
|||
|
|||
### Get recommended users |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/meet?limit=20 |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token. Optional `limit` is 1-50, default 20. |
|||
|
|||
**Backend returns**: array of public user objects containing `country`, `relationship_goal`, `compatibility_percentage`, a dedicated `profile_image_url`, optional `video_url`, and `media`. Production media URLs are complete one-hour OSS signed URLs and can be rendered without another token-bearing media request. Already-swiped users and inactive users are excluded. Users who sent the current user an active `super_like` appear first; the rest are ordered by same city, same country, then other countries, with higher compatibility first within each group. |
|||
|
|||
### Swipe user |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/meet/swipes |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"target_user_id": 2, "direction": "like"} |
|||
``` |
|||
|
|||
`direction` is `pass` (left swipe), `like` (right swipe), or `super_like`. A Super Like counts as a like for matching and remains active until the sender undoes it or changes the swipe. It gives the sender top priority in the target user's Meet list unless the target has already swiped the sender. |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"direction": "like", "matched": true, "match_id": 15} |
|||
``` |
|||
|
|||
When not matched, `match_id` is `null`. `400` cannot swipe yourself; `404` target not found. |
|||
|
|||
### Cancel like |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/meet/likes/{target_user_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and target user id in the URL. |
|||
|
|||
**Backend returns**: `{"message": "已取消喜欢"}`. The related match is also removed. This legacy endpoint accepts `like` and `super_like`; use the undo endpoint below for the Cancel button. |
|||
|
|||
### Cancel button: undo any swipe |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/meet/swipes/{target_user_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and target user id. This removes a saved `pass`, `like`, or `super_like`, so the target can appear in the sender's Meet list again. Undoing a Super Like immediately removes the sender's priority in the target's Meet list. Undoing a matched like or Super Like also removes that match. |
|||
|
|||
**Backend returns**: `{"message":"已撤销滑动","previous_direction":"super_like"}`. `previous_direction` is `pass`, `like`, or `super_like`. |
|||
|
|||
### Get matches |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/matches |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer access token. No query parameters or request body are required; legacy `limit` and `offset` parameters are ignored. |
|||
|
|||
**Backend returns**: array of `{ "id": 15, "matched_at": "...", "user": { "...": "public user" } }`. |
|||
|
|||
### Search users |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/search?q=Bob&city=Shanghai&country=China&latitude=31.23&longitude=121.47&max_distance_km=5&limit=20 |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. All query parameters are optional: `q` (search text), `city`, `country`, `latitude`, `longitude`, `max_distance_km` (0-500), and `limit` (1-50, default 20). `city` and `country` can be used separately or together. Latitude and longitude must be supplied together. |
|||
|
|||
**Backend returns**: array of public user objects containing `country` and `compatibility_percentage`, optionally filtered by distance. Location priority and compatibility are applied before distance ordering. |
|||
|
|||
## 4. Explore posts |
|||
|
|||
### Create post |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/explore/posts |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{ |
|||
"content": "Feeling **happy** and *relaxed* today", |
|||
"post_type": "text", |
|||
"content_format": "markdown", |
|||
"feeling": "happy", |
|||
"location": {"name": "Blue Bottle Coffee, Soho NYC", "latitude": 40.723, "longitude": -73.998} |
|||
} |
|||
``` |
|||
|
|||
`post_type` is `text`, `image`, or `video`. Text posts need content, feeling, or location; `content` is at most 5000 characters. `feeling` is an optional 1-50 character label. `location.name` is required when location is present; coordinates must be sent together or both omitted. The post location is a snapshot and does not update the user's profile. For B/I editing, put `**bold**` or `*italic*` in `content` and set `content_format` to `markdown`; otherwise use the default `plain`. Render Markdown safely on the frontend. Music is not supported yet. |
|||
|
|||
For image/video posts, create the post first and use the returned `id` with `POST /api/v1/explore/posts/{post_id}/media` to upload files. Up to nine images and one video are allowed together; `post_type` becomes `mixed` when both are present. |
|||
|
|||
**Backend returns** (`201`): PostResponse with `id`, `content`, `post_type`, `content_format`, `feeling`, `location`, `author`, `like_count`, `comment_count`, `liked_by_me`, `liked_by_users`, `media`, and `created_at`. The same fields appear in Explore, post detail, Get Me, and public-user post lists. |
|||
|
|||
Post and comment `author` objects contain `id`, `full_name`, `profile_image_url`, `city`, `country`, and `compatibility_percentage`. The avatar URL is `null` before upload; production returns a directly loadable one-hour OSS signed URL. Re-fetch the response after expiry. Every PostResponse (Explore, detail, profile-embedded posts, and user post lists) includes `liked_by_users: [{"id": 2, "full_name": "Reader"}]`. It is `[]` without likes, ordered newest like first, and contains active users only; `like_count` equals its length. Names can be `null`. This list contains no contact details or full user profiles. |
|||
|
|||
### Get Explore feed |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/explore/feed?limit=20&offset=0 |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. `limit` is 1-50, default 20; use `offset` for pagination with location/compatibility ranking. The legacy `before_id` parameter remains available as a chronological cutoff and cannot be combined with a nonzero `offset`. |
|||
|
|||
**Backend returns**: array of PostResponse. Authors in the same city are first, followed by authors in the same country and then other countries; higher compatibility is first within each group. Recency breaks remaining ties. |
|||
|
|||
### Get post detail |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/explore/posts/{post_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and post id. |
|||
|
|||
**Backend returns**: PostResponse (`200`). |
|||
|
|||
### Delete post |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/explore/posts/{post_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and own post id. |
|||
|
|||
**Backend returns**: `{"message": "帖子已删除"}`. Only the author can delete a post. |
|||
|
|||
### Edit post |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PATCH {{base_url}}/api/v1/explore/posts/{post_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token, own post id, and only the fields to change in JSON. |
|||
|
|||
```json |
|||
{"content": "Updated text", "content_format": "markdown", "feeling": "happy", "location": {"name": "New place"}} |
|||
``` |
|||
|
|||
Editable: `content` (max 5000), `content_format`, `feeling`, `location`. Omitted fields are unchanged; send `null` to clear `feeling` or `location`. |
|||
|
|||
**Backend returns** (`200`): full updated PostResponse. Errors: `403` not the author; `404` post not found; `422` invalid fields. |
|||
|
|||
### Upload post media |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/explore/posts/{post_id}/media?position=0 |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token, `multipart/form-data` file field `media`, optional `position` 0-8. Supports JPG/PNG/WebP/MP4/MOV/WebM. One post allows up to 9 images and 1 video together; `position` is 0-9. |
|||
|
|||
**Backend returns** (`201`) |
|||
|
|||
```json |
|||
{ |
|||
"id": 20, |
|||
"media_type": "image", |
|||
"mime_type": "image/png", |
|||
"size_bytes": 12345, |
|||
"position": 0, |
|||
"url": "https://<oss-host>/melove/posts/20/image.png?<signature>" |
|||
} |
|||
``` |
|||
|
|||
In production, `url` is a complete one-hour OSS signed URL and can be loaded without a Bearer header. Local development uses `/api/v1/explore/media/{media_id}`. |
|||
|
|||
### Get post media |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/explore/media/{media_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and media id. |
|||
|
|||
**Backend returns**: file binary (`200`). |
|||
|
|||
### Delete post media |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/explore/media/{media_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. Only the post author can delete it. |
|||
|
|||
**Backend returns**: `{"message": "帖子媒体已删除"}`. |
|||
|
|||
### Like post |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/explore/posts/{post_id}/like |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and post id. |
|||
|
|||
**Backend returns** (`200`): compact PostLikeResponse: |
|||
|
|||
```json |
|||
{ |
|||
"message": "like successful", |
|||
"like_count": 1, |
|||
"liked_by_me": true, |
|||
"liked_by_users": [{"id": 2, "full_name": "Reader"}] |
|||
} |
|||
``` |
|||
|
|||
Repeated likes do not increase the count. This response no longer contains the full post body, author, or media. Update the existing post using the returned count, state, and list. |
|||
|
|||
### List post likers |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/explore/posts/{post_id}/like |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and post id, no body or pagination parameters. |
|||
|
|||
**Backend returns** (`200`): `[{"id": 2, "full_name": "Reader"}]`, or `[]` without likes. Names can be `null`; only active users appear, newest like first. Errors: `401` unauthenticated; `404` post not found. |
|||
|
|||
### Unlike post |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/explore/posts/{post_id}/like |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and post id. |
|||
|
|||
**Backend returns** (`200`): compact PostUnlikeResponse: |
|||
|
|||
```json |
|||
{ |
|||
"message": "unlike successful", |
|||
"like_count": 0, |
|||
"liked_by_me": false, |
|||
"liked_by_users": [] |
|||
} |
|||
``` |
|||
|
|||
Repeated requests keep the post unliked. This response does not contain the full post body, author, or media. |
|||
|
|||
### List comments |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/explore/posts/{post_id}/comments |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and post id. |
|||
|
|||
**Backend returns**: a flat array of CommentResponse. Each item contains `id`, `post_id`, `content`, `author`, `parent_comment_id`, `reply_count`, `like_count`, `liked_by_me`, and `created_at`. Group replies by `parent_comment_id` on the frontend. |
|||
|
|||
### Create comment |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/explore/posts/{post_id}/comments |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"content": "Nice post!"} |
|||
``` |
|||
|
|||
To reply to an existing comment, add `parent_comment_id`: |
|||
|
|||
```json |
|||
{"content": "I agree", "parent_comment_id": 12} |
|||
``` |
|||
|
|||
**Backend returns** (`201`): CommentResponse including `author.profile_image_url` (or `null` without an avatar), `parent_comment_id`, `reply_count`, `like_count`, and `liked_by_me`. Content must be 1-2000 characters and cannot be blank. |
|||
|
|||
### Edit comment |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PATCH {{base_url}}/api/v1/explore/comments/{comment_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token, own comment id. |
|||
|
|||
```json |
|||
{"content": "Updated comment"} |
|||
``` |
|||
|
|||
**Backend returns** (`200`): updated CommentResponse. Content must be 1-2000 characters and cannot be blank. Errors: `403` not the author; `404` comment not found; `422` invalid content. |
|||
|
|||
### Delete comment |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/explore/comments/{comment_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and own comment id. |
|||
|
|||
**Backend returns**: `{"message": "评论已删除"}`. |
|||
|
|||
### Like comment |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/explore/comments/{comment_id}/like |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and comment id, no body. |
|||
|
|||
**Backend returns** (`200`): `{"message": "like successful", "like_count": 1, "liked_by_me": true}`. Repeated likes do not increase the count. |
|||
|
|||
### Unlike comment |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/explore/comments/{comment_id}/like |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and comment id, no body. |
|||
|
|||
**Backend returns** (`200`): `{"message": "unlike successful", "like_count": 0, "liked_by_me": false}`. |
|||
|
|||
## 5. Feed questions |
|||
|
|||
### List questions |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/feed/questions?unanswered_only=false |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. Optional `unanswered_only` is `true` or `false`. |
|||
|
|||
**Backend returns**: array of question objects: |
|||
|
|||
```json |
|||
{ |
|||
"id": 1, |
|||
"code": "weekend_movies", |
|||
"question_text": "Do you like watching movies on weekends?", |
|||
"question_text_zh": "你喜欢周末看电影吗?", |
|||
"answer_type": "boolean", |
|||
"current_answer": null, |
|||
"answered_at": null |
|||
} |
|||
``` |
|||
|
|||
### List my answers |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/feed/answers/me |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. |
|||
|
|||
**Backend returns**: array of the same question objects, containing the user's current answers. |
|||
|
|||
### Answer or update question |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
PUT {{base_url}}/api/v1/feed/questions/{question_id}/answer |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{"answer": true} |
|||
``` |
|||
|
|||
For a text question: |
|||
|
|||
```json |
|||
{"answer": "Coffee and a walk"} |
|||
``` |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{"question_id": 1, "answer": true, "answered_at": "2026-09-01T10:00:00"} |
|||
``` |
|||
|
|||
### Delete answer |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/feed/questions/{question_id}/answer |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and question id. |
|||
|
|||
**Backend returns**: `{"message": "回答已删除"}`. |
|||
|
|||
## 6. Chat |
|||
|
|||
Chat is available only to the two users in an existing match. |
|||
|
|||
### List conversations |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/chat/conversations |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. |
|||
|
|||
**Backend returns**: array of conversation objects containing `id`, `match_id`, `other_user`, `last_message`, `created_at`, and `updated_at`. |
|||
|
|||
### Create or get conversation |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/chat/conversations/{match_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and match id in the URL. No body. |
|||
|
|||
**Backend returns** (`201`): ConversationResponse. If it already exists, the existing conversation is returned. |
|||
|
|||
### List messages |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
GET {{base_url}}/api/v1/chat/conversations/{conversation_id}/messages?limit=50&before_id=100 |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token. `limit` is 1-100, default 50; `before_id` is optional pagination cursor. |
|||
|
|||
**Backend returns**: array of ChatMessageResponse in time order. |
|||
|
|||
### Send message |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
POST {{base_url}}/api/v1/chat/conversations/{conversation_id}/messages |
|||
``` |
|||
|
|||
**Frontend sends** |
|||
|
|||
```json |
|||
{ |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text" |
|||
} |
|||
``` |
|||
|
|||
`content` is 1-5000 characters. `message_type` is currently `text`. |
|||
|
|||
**Backend returns** (`201`) |
|||
|
|||
```json |
|||
{ |
|||
"id": 30, |
|||
"conversation_id": 8, |
|||
"sender_id": 1, |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text", |
|||
"is_deleted": false, |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
``` |
|||
|
|||
### Recall message |
|||
|
|||
**URL** |
|||
|
|||
```text |
|||
DELETE {{base_url}}/api/v1/chat/messages/{message_id} |
|||
``` |
|||
|
|||
**Frontend sends**: Bearer token and own message id. |
|||
|
|||
**Backend returns** |
|||
|
|||
```json |
|||
{ |
|||
"id": 30, |
|||
"conversation_id": 8, |
|||
"sender_id": 1, |
|||
"content": null, |
|||
"message_type": "text", |
|||
"is_deleted": true, |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
``` |
|||
|
|||
## 7. Frontend flow |
|||
|
|||
1. Register or login and save `access_token` and `refresh_token`. |
|||
2. Send `Authorization: Bearer <access_token>` on protected APIs. |
|||
3. On app startup call `/api/v1/auth/me`. A `200` means the user can enter the app; a `401` means clear tokens and show login. |
|||
4. When access token expires, call `/api/v1/auth/refresh` with the refresh token and save the new pair. |
|||
5. Do not call OTP endpoints. `/api/v1/auth/request-code` and `/api/v1/auth/verify` do not exist in the MVP API. |
|||
|
|||
## 8. API source |
|||
|
|||
Machine-readable OpenAPI file: `docs/openapi-0.6.12.json`. |
|||
@ -0,0 +1,742 @@ |
|||
# Dating App API 完整接口说明 |
|||
|
|||
> API version: `0.6.12` |
|||
> 面向前端与后端开发者的接口参考文档。 |
|||
> 0.6.12 已完成正式数据库迁移并发布,通过公网接口验证。 |
|||
|
|||
## 1. 通用说明 |
|||
|
|||
### 1.1 Base URL |
|||
|
|||
```text |
|||
{{base_url}} |
|||
``` |
|||
|
|||
本地测试地址: |
|||
|
|||
```text |
|||
http://127.0.0.1:8001 |
|||
``` |
|||
|
|||
生产地址由部署环境提供,客户端通过配置项设置 `base_url`,不要在代码中写死具体域名。 |
|||
|
|||
### 1.2 请求格式 |
|||
|
|||
- JSON 接口请求头:`Content-Type: application/json` |
|||
- 文件上传接口请求格式:`multipart/form-data` |
|||
- 除注册、登录、OAuth、健康检查外,接口都需要登录。 |
|||
- 登录接口使用:`Authorization: Bearer <access_token>` |
|||
- `access_token` 默认有效 15 分钟,`refresh_token` 默认有效 30 天。 |
|||
|
|||
### 1.3 通用错误 |
|||
|
|||
参数错误:`422 Unprocessable Entity` |
|||
|
|||
```json |
|||
{ |
|||
"detail": [ |
|||
{ |
|||
"loc": ["body", "field_name"], |
|||
"msg": "说明文字", |
|||
"type": "validation_error" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
未登录或 token 无效:`401` |
|||
|
|||
```json |
|||
{"detail": "需要登录"} |
|||
``` |
|||
|
|||
账号被停用或没有权限:`403` |
|||
|
|||
资源不存在:`404` |
|||
|
|||
## 2. 数据结构 |
|||
|
|||
### 2.1 注册请求 RegisterRequest |
|||
|
|||
必填字段: |
|||
|
|||
| 字段 | 类型 | 说明 | |
|||
|---|---|---| |
|||
| `email_or_phone` | string | 邮箱或手机号,二选一但使用同一个字段,3-320 字符 | |
|||
| `password` | string | 密码,8-128 字符 | |
|||
| `confirm_password` | string | 确认密码,8-128 字符,必须和 `password` 完全一致 | |
|||
| `relationship_goal` | string | 必填单选:`serious_relationship`、`casual_dates`、`new_friends` 或 `marriage` | |
|||
|
|||
可选字段:`full_name`(最多 120)、`gender`(最多 30)、`birth_date`(`YYYY-MM-DD`)、`city`(最多 120)、`country`(最多 120)、`bio`(去除首尾空格后 1-2000 字符)、`interests`(兴趣数组)。 |
|||
|
|||
用户的介绍内容在 `bio` 和介绍视频中二选一。选择文字简介时在注册 JSON 中提交非空 `bio`;选择视频时可以省略 `bio`,注册成功取得 access token 后调用 `/api/v1/users/me/video`。因为注册请求是 JSON,不能直接携带视频文件。 |
|||
|
|||
`confirm_password` 只用于注册时校验,不保存到数据库,也不会出现在任何响应中。 |
|||
|
|||
注册响应继续只返回 token。前端随后调用 `GET /api/v1/auth/me`:未上传头像或视频时,`profile.profile_image_url` 或 `profile.video_url` 为 `null`;上传后,生产环境返回可直接加载的一小时 OSS 签名 URL。两者互斥:上传视频会清空已有 `bio`,之后通过个人资料接口提交非空 `bio` 会删除介绍视频。前端应在 `bio` 或 `video_url` 其中一个非空时才完成注册引导。 |
|||
|
|||
### 2.2 用户 UserResponse |
|||
|
|||
```json |
|||
{ |
|||
"id": 1, |
|||
"email_or_phone": "user@example.com", |
|||
"status": "active", |
|||
"profile": { |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"country": "China", |
|||
"relationship_goal": "serious_relationship", |
|||
"bio": "I like traveling.", |
|||
"profile_image_url": "https://<oss-host>/melove/users/1/avatar.jpg?<signature>", |
|||
"video_url": null, |
|||
"interests": ["Photography", "Movies"] |
|||
}, |
|||
"posts": [], |
|||
"liked_users": [], |
|||
"liked_by_users": [], |
|||
"matches": [], |
|||
"liked_users_count": 0, |
|||
"liked_by_users_count": 0, |
|||
"matches_count": 0, |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
``` |
|||
|
|||
`GET /api/v1/auth/me` 和 `GET /api/v1/users/me` 会在这四个数组中分别返回最近 20 条本人帖子、我喜欢的人、喜欢我的人和匹配对象。`liked_users_count`、`liked_by_users_count`、`matches_count` 是对应三类关系的总数,不受 20 条列表上限影响;喜欢包含 `like` 和 `super_like`,均只统计当前有效用户。完整或分页数据使用 4.9-4.14 的独立接口。注册、登录和资料更新响应不包含这些总数字段。 |
|||
|
|||
### 2.3 TokenResponse |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer", |
|||
"user": { "...": "UserResponse" } |
|||
} |
|||
``` |
|||
|
|||
注册接口是例外,只返回 token,不返回 `user`: |
|||
|
|||
```json |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer" |
|||
} |
|||
``` |
|||
|
|||
### 2.4 公开用户 DiscoveryUserResponse |
|||
|
|||
```json |
|||
{ |
|||
"id": 2, |
|||
"full_name": "Bob", |
|||
"gender": "male", |
|||
"birth_date": "1998-05-20", |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"country": "China", |
|||
"relationship_goal": "serious_relationship", |
|||
"bio": "Likes hiking", |
|||
"profile_image_url": "https://<oss-host>/melove/users/2/avatar.jpg?<signature>", |
|||
"video_url": "https://<oss-host>/melove/users/2/intro.mp4?<signature>", |
|||
"distance_km": 1.24, |
|||
"compatibility_percentage": 85, |
|||
"media": [ |
|||
{"id": 10, "media_type": "profile_image", "url": "https://<oss-host>/melove/users/2/avatar.jpg?<signature>"} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
公开用户响应中的 `profile_image_url` 是头像地址,`video_url` 是介绍视频地址;`media[]` 是同一用户公开资料媒体的明细列表,按 `media_type` 区分头像和介绍视频,不是用户发布的帖子。公开主页接口额外返回 `interests` 和最近 20 条公开 `posts`,方便前端用一次请求渲染主页。邮箱/手机号、账号状态、喜欢列表和匹配记录不会返回;超过 20 条帖子时调用 `GET /api/v1/users/{user_id}/posts` 分页获取。 |
|||
|
|||
### 2.5 兴趣、位置、帖子和聊天结构 |
|||
|
|||
兴趣响应:`{"interests": ["Photography", "Dogs"]}` |
|||
|
|||
位置响应:`{"latitude": 31.2304, "longitude": 121.4737, "city": "上海市", "county": "黄浦区", "updated_at": "2026-09-01T10:00:00"}` |
|||
|
|||
滑动响应:`{"direction": "like", "matched": true, "match_id": 15}`。未匹配时 `match_id` 为 `null`。 |
|||
|
|||
帖子响应字段:`id`、`content`、`post_type`、`content_format`、`feeling`、`location`、`author`、`like_count`、`comment_count`、`liked_by_me`、`liked_by_users`、`media`、`created_at`。`location` 是发帖时选定地点的快照,与作者当前资料城市和位置分开保存。 |
|||
|
|||
帖子和评论的 `author` 均包含 `id`、`full_name`、`profile_image_url`、`city`、`country`、`compatibility_percentage`。未上传头像时 `profile_image_url` 为 `null`;生产环境返回一小时有效的 OSS 签名 URL,可直接展示,过期后重新请求。所有 PostResponse(Explore、详情、个人资料内的帖子和用户帖子列表)均包含 `liked_by_users: [{"id": 2, "full_name": "Reader"}]`;无点赞时为 `[]`,按最近点赞在前排序,只包含 active 用户,`like_count` 等于名单长度。姓名可能为 `null`;名单不含联系方式或完整用户资料。 |
|||
|
|||
聊天消息字段:`id`、`conversation_id`、`sender_id`、`content`、`message_type`、`is_deleted`、`created_at`。撤回后 `content` 为 `null`,`is_deleted` 为 `true`。 |
|||
|
|||
## 3. Authentication 认证接口 |
|||
|
|||
### 3.1 健康检查 |
|||
|
|||
**GET** `{{base_url}}/health` |
|||
认证:不需要。 |
|||
前端发送:无 body。 |
|||
成功:`200` |
|||
|
|||
```json |
|||
{"status": "ok"} |
|||
``` |
|||
|
|||
### 3.2 注册 |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/register` |
|||
认证:不需要。 |
|||
请求体:`application/json` |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship", |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"bio": "I like traveling.", |
|||
"interests": ["Photography", "Movies"] |
|||
} |
|||
``` |
|||
|
|||
手机号注册只需把 `email_or_phone` 改为手机号。成功:`201`,返回 2.3 中的 token-only 结构。 |
|||
错误:`409` 邮箱/手机号已注册;`422` 缺少必填字段、`relationship_goal` 值无效、两次密码不一致或兴趣少于 2 个。 |
|||
MVP 不使用 OTP,注册后账号直接为 `active`。 |
|||
|
|||
### 3.3 登录 |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/login` |
|||
认证:不需要。 |
|||
请求体: |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123" |
|||
} |
|||
``` |
|||
|
|||
成功:`200`,返回 TokenResponse。错误:`401` 账号或密码错误;`403` 账号已停用;`422` 参数错误。 |
|||
|
|||
### 3.4 OAuth 登录/注册占位接口 |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/oauth` |
|||
认证:不需要。 |
|||
请求体: |
|||
|
|||
```json |
|||
{ |
|||
"provider": "google", |
|||
"provider_user_id": "google-user-id-123", |
|||
"email": "user@example.com", |
|||
"full_name": "OAuth User" |
|||
} |
|||
``` |
|||
|
|||
`provider` 只能是 `wechat` 或 `google`;`email`、`full_name` 可选。成功:`200`,返回 TokenResponse。错误:`422` 参数错误。 |
|||
|
|||
### 3.5 刷新 token |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/refresh` |
|||
认证:不需要 Bearer token,但必须提交有效 refresh token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
成功:`200`,返回新的 TokenResponse;旧 refresh token 会失效。错误:`401` refresh token 无效、已过期或已撤销;`422` 参数错误。 |
|||
|
|||
### 3.6 退出登录 |
|||
|
|||
**POST** `{{base_url}}/api/v1/auth/logout` |
|||
认证:不需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"refresh_token": "<refresh_token>"} |
|||
``` |
|||
|
|||
成功:`200` |
|||
|
|||
```json |
|||
{"message": "已退出登录"} |
|||
``` |
|||
|
|||
### 3.7 获取当前用户 |
|||
|
|||
**GET** `{{base_url}}/api/v1/auth/me` |
|||
认证:需要 Bearer token。 |
|||
前端发送:无 body。 |
|||
成功:`200`,返回 UserResponse。启动 App 时可调用此接口判断 token 是否仍然有效。 |
|||
|
|||
### 3.8 获取当前用户(兼容地址) |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me` |
|||
认证:需要 Bearer token。 |
|||
功能和 3.7 完全相同,返回 UserResponse。 |
|||
|
|||
## 4. User Profile 用户资料和媒体 |
|||
|
|||
### 4.1 修改个人资料 |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/users/me` |
|||
认证:需要 Bearer token。 |
|||
请求体可只提交需要修改的字段: |
|||
|
|||
```json |
|||
{ |
|||
"full_name": "New Name", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Beijing", |
|||
"country": "China", |
|||
"relationship_goal": "marriage", |
|||
"bio": "Updated bio", |
|||
"occupation": "Creative Director at Studio Drift", |
|||
"education": "NYU Gallatin School", |
|||
"height_cm": 175, |
|||
"beliefs": "spiritual_agnostic", |
|||
"smoking": "non_smoker", |
|||
"drinking": "socially", |
|||
"pets": "golden_retriever", |
|||
"exercise": "active_4x_week", |
|||
"interests": ["Travel", "Music"] |
|||
} |
|||
``` |
|||
|
|||
新增可选资料字段:`occupation`、`education`(各最多 120 字符)、`height_cm`(整数,100-250)、`beliefs`(最多 120)、`smoking`、`drinking`(各最多 50)、`pets`(最多 120)、`exercise`(最多 50)。传 `null` 可清空对应字段。`GET /api/v1/auth/me` 会返回这些字段,并额外返回根据 `birth_date` 计算的 `age`。 |
|||
|
|||
成功:`200`,返回更新后的 UserResponse。兴趣如提交则至少 2 个;错误:`422` 字段格式或兴趣不符合要求。 |
|||
|
|||
### 4.2 获取我的兴趣 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/interests` |
|||
认证:需要 Bearer token。 |
|||
成功:`200` |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
### 4.3 替换我的兴趣 |
|||
|
|||
**PUT** `{{base_url}}/api/v1/users/me/interests` |
|||
认证:需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"interests": ["Photography", "Dogs", "My custom interest"]} |
|||
``` |
|||
|
|||
至少 2 项,不限制最多数量;`Other` 不需要特殊字段,直接把自定义文字作为数组项。成功:`200`,返回兴趣数组。错误:`422` 少于 2 项、空字符串或单项超过 120 字符。 |
|||
|
|||
### 4.4 更新位置 |
|||
|
|||
**PUT** `{{base_url}}/api/v1/users/me/location` |
|||
认证:需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"latitude": 31.2304, "longitude": 121.4737, "city": "上海市", "county": "黄浦区"} |
|||
``` |
|||
|
|||
纬度范围 `-90` 到 `90`,经度范围 `-180` 到 `180`;`city` 和 `county` 必填,去除首尾空格后须为 1-120 字符。城市写入用户资料并参与同城推荐;区县单独保存在 `county`,不等于国家字段 `country`。成功:`200`,返回四个位置字段及 `updated_at`;`GET /api/v1/auth/me` 和公开用户资料也返回 `profile.county` 或 `county`。错误:`422` 缺字段、空城市/区县或坐标超出范围。 |
|||
|
|||
### 4.5 上传自我介绍视频 |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/video` |
|||
认证:需要 Bearer token。 |
|||
请求格式:`multipart/form-data`,字段名 `video`,支持 `video/mp4`、`video/quicktime`、`video/webm`,最大 100 MB。 |
|||
每名用户只保留最新一份介绍视频;再次上传成功后,旧视频记录和旧文件会被替换。若新文件存储失败,原视频保持不变。 |
|||
成功:`200` |
|||
|
|||
```json |
|||
{"id": 11, "media_type": "intro_video", "review_status": "pending"} |
|||
``` |
|||
|
|||
错误:`413` 文件过大;`415` 文件类型不支持;`422` 未上传文件。 |
|||
|
|||
### 4.6 上传头像 |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/image` |
|||
认证:需要 Bearer token。 |
|||
请求格式:`multipart/form-data`,字段名 `image`,支持 JPG、PNG、WebP,最大 10 MB。 |
|||
每名用户只保留最新一张头像;再次上传成功后,旧头像记录和旧文件会被替换。若新文件存储失败,原头像保持不变。 |
|||
成功返回媒体 id、类型和审核状态;错误:`413`、`415`、`422`。 |
|||
|
|||
### 4.7 上传身份证/护照 |
|||
|
|||
**POST** `{{base_url}}/api/v1/users/me/identity` |
|||
认证:需要 Bearer token。 |
|||
请求格式:`multipart/form-data`,字段名 `document`,支持 JPG、PNG、PDF,最大 10 MB。 |
|||
成功返回媒体 id、类型和审核状态;该文件只有本人可访问。错误:`413`、`415`、`422`。 |
|||
|
|||
### 4.8 获取用户媒体文件 |
|||
|
|||
**GET** `{{base_url}}/api/v1/media/{media_id}` |
|||
认证:需要 Bearer token。 |
|||
路径参数:`media_id` 为媒体 id。 |
|||
成功:`200`,返回文件二进制内容;公开头像/视频可被其他 active 用户读取,身份证/护照只有本人可读取。错误:`403` 无权限;`404` 文件不存在。 |
|||
|
|||
Meet、Search、Match 和个人资料响应中的 `profile_image_url`、`video_url`、`media[].url` 在生产 OSS 环境为一小时有效的完整签名 URL,前端图片/视频组件可直接使用,不需要再次附加 Bearer Token。URL 过期后重新请求相应的资料列表即可取得新 URL。身份证/护照不会出现在这些公开 URL 字段中。 |
|||
|
|||
### 4.9 获取本人帖子 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/posts?limit=20&offset=0` |
|||
认证:需要 Bearer token。`limit` 为 1-50,`offset` 为 0-5000。成功返回 `PostResponse` 数组,新帖在前。 |
|||
|
|||
### 4.10 获取公开用户资料 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/{user_id}` |
|||
认证:需要 Bearer token。成功返回目标用户的 `PublicUserProfileResponse`,包括公开资料、`interests` 和最近 20 条 `posts`;不会返回邮箱/手机号、账号状态、喜欢列表或匹配记录。目标不存在或已停用返回 `404`。 |
|||
|
|||
### 4.11 获取公开用户帖子 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/{user_id}/posts?limit=20&offset=0` |
|||
认证:需要 Bearer token。成功返回目标用户的 `PostResponse` 数组,新帖在前;目标不存在或已停用返回 `404`。 |
|||
|
|||
### 4.12 获取我喜欢的人 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/liked` |
|||
认证:需要 Bearer token。成功返回当前用户主动右滑喜欢的完整 `DiscoveryUserResponse` 数组,最近操作在前;已经匹配成功的用户会被排除。该接口不分页;旧请求中的 `limit`、`offset` 参数会被忽略。 |
|||
|
|||
### 4.13 获取喜欢我的人 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/likes` |
|||
认证:需要 Bearer token。成功返回主动喜欢当前用户的完整 `DiscoveryUserResponse` 数组,最近操作在前;已经匹配成功的用户会被排除。该接口不分页;旧请求中的 `limit`、`offset` 参数会被忽略。 |
|||
|
|||
### 4.14 获取我的匹配对象 |
|||
|
|||
**GET** `{{base_url}}/api/v1/users/me/matches` |
|||
认证:需要 Bearer token。成功返回完整的 `MatchResponse` 数组,与 `/api/v1/matches` 数据一致,便于个人主页分区加载。该接口不分页,旧的 `limit`、`offset` 参数会被忽略。 |
|||
|
|||
## 5. Meet、Swipe、Match 和 Search |
|||
|
|||
### 5.1 获取推荐用户 |
|||
|
|||
**GET** `{{base_url}}/api/v1/meet?limit=20` |
|||
认证:需要 Bearer token。 |
|||
查询参数:`limit` 可选,1-50,默认 20。 |
|||
成功:`200`,返回 DiscoveryUserResponse 数组;只返回 active 用户,且不会重复返回已经滑过的用户。向当前用户发出有效 `super_like` 的用户最优先;其余按同城、同国家、其他国家排序,同组内按 `compatibility_percentage` 从高到低排序。 |
|||
|
|||
### 5.2 左滑、右滑或超级喜欢 |
|||
|
|||
**POST** `{{base_url}}/api/v1/meet/swipes` |
|||
认证:需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"target_user_id": 2, "direction": "like"} |
|||
``` |
|||
|
|||
`direction` 可为 `pass`(左滑)、`like`(右滑)或 `super_like`(超级喜欢)。`super_like` 会让发起者在目标用户的 Meet 列表中优先出现,直到发起者撤销或改为其他滑动;目标用户已经滑过发起者时不会再次显示。成功:`200`,返回 SwipeResponse;双方的操作均为 `like` 或 `super_like` 时,`matched=true` 并生成 `match_id`。错误:`400` 不能操作自己;`404` 目标用户不存在;`422` 参数错误。 |
|||
|
|||
### 5.3 取消喜欢 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/meet/likes/{target_user_id}` |
|||
认证:需要 Bearer token。 |
|||
成功:`200` `{"message":"已取消喜欢"}`,并解除对应匹配。兼容旧客户端,可取消 `like` 或 `super_like`;取消键请统一使用 5.4 的撤销滑动接口。错误:`404` 没有可取消的喜欢记录。 |
|||
|
|||
### 5.4 取消键:撤销左滑、右滑或超级喜欢 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/meet/swipes/{target_user_id}` |
|||
认证:需要 Bearer token。删除本人对目标用户保存的 `pass`、`like` 或 `super_like` 记录,使目标用户可以再次出现在自己的 Meet 推荐列表;撤销 `super_like` 也会立即取消在目标用户列表中的优先展示。如果撤销的是已形成匹配的喜欢,同时解除对应匹配。成功:`200`,例如 `{"message":"已撤销滑动","previous_direction":"super_like"}`。错误:`404` 没有可撤销的滑动记录。 |
|||
|
|||
### 5.5 获取匹配列表 |
|||
|
|||
**GET** `{{base_url}}/api/v1/matches` |
|||
认证:需要 Bearer token。 |
|||
该接口不分页,返回全部有效匹配,最近的在前;旧的 `limit`、`offset` 参数会被忽略。成功:`200`,返回 MatchResponse 数组,每项包含 `id`、`matched_at` 和对方公开资料 `user`。返回条数等于 `GET /api/v1/auth/me` 中的 `matches_count`。 |
|||
|
|||
### 5.6 搜索用户 |
|||
|
|||
**GET** `{{base_url}}/api/v1/search` |
|||
认证:需要 Bearer token。 |
|||
查询参数: |
|||
|
|||
| 参数 | 类型 | 说明 | |
|||
|---|---|---| |
|||
| `q` | string | 可选,搜索姓名、简介、城市、国家,最多 200 字符 | |
|||
| `city` | string | 可选,按城市筛选,可与 country 同时提供 | |
|||
| `country` | string | 可选,按国家筛选,可与 city 同时提供 | |
|||
| `latitude` | number | 可选,必须和 longitude 同时提供 | |
|||
| `longitude` | number | 可选,必须和 latitude 同时提供 | |
|||
| `max_distance_km` | number | 可选,最大 500;不传坐标时使用自己的位置 | |
|||
| `limit` | integer | 可选,1-50,默认 20 | |
|||
|
|||
成功:`200`,返回 DiscoveryUserResponse 数组,包含 `country` 和 `compatibility_percentage`。错误:`422` 坐标只提供一个,或距离筛选缺少坐标/自己的位置。 |
|||
|
|||
## 6. Explore 动态 |
|||
|
|||
### 6.1 创建帖子 |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts` |
|||
认证:需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{ |
|||
"content": "Feeling **happy** and *relaxed* today", |
|||
"post_type": "text", |
|||
"content_format": "markdown", |
|||
"feeling": "happy", |
|||
"location": {"name": "Blue Bottle Coffee, Soho NYC", "latitude": 40.723, "longitude": -73.998} |
|||
} |
|||
``` |
|||
|
|||
`post_type` 可选 `text`、`image`、`video`,默认 `text`。`content` 最多 5000 字符;文字帖至少包含非空文字、心情或地点之一。`feeling` 可选,提交 1-50 字符的心情代码或标签,例如 `happy`。`location` 可选,`name` 必填且为 1-255 字符,经纬度可以都不传或同时传,范围分别为 `-90..90` 和 `-180..180`。地点是帖子内容,不会修改用户资料或当前位置;选定地点及提交的坐标会显示给能查看帖子的用户。 |
|||
|
|||
`content_format` 为 `plain`(默认)或 `markdown`。前端粗体按钮把选中文字写成 `**bold**`,斜体按钮写成 `*italic*`,并提交 `content_format: "markdown"`;后端原样保存 Markdown 源文本,不返回 HTML。前端显示时按该字段渲染并对生成的 HTML 做安全处理。旧帖子按 `plain` 返回。音乐字段暂未开放。 |
|||
|
|||
图片或视频帖先用此接口创建,取得 `id`,再调用 6.5 的媒体上传接口;单帖最多 9 张图片加 1 个视频,可以混合使用;同时含图片和视频时 `post_type` 为 `mixed`。成功:`201`,返回包含新字段的 PostResponse。 |
|||
|
|||
### 6.2 获取 Explore 信息流 |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/feed?limit=20&offset=0` |
|||
认证:需要 Bearer token。 |
|||
参数:`limit` 1-50,默认 20;新排序使用 `offset` 分页。旧参数 `before_id` 保留为按时间 ID 截断的兼容模式,不能与非零 `offset` 同时使用。成功:`200`,每条帖子包含 `content_format`、`feeling` 和 `location`;按作者同城、同国家、其他国家分组,同组按匹配度排序,最后以新帖优先打破并列。 |
|||
|
|||
### 6.3 获取帖子详情 |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回 PostResponse;错误:`404` 帖子不存在或作者不可用。 |
|||
|
|||
### 6.4 删除帖子 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
认证:需要 Bearer token。只能删除自己的帖子。成功:`200` `{"message":"帖子已删除"}`;错误:`403` 无权限;`404` 不存在。 |
|||
|
|||
### 6.5 上传帖子媒体 |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/media?position=0` |
|||
认证:需要 Bearer token,且必须是帖子作者。 |
|||
请求格式:`multipart/form-data`,字段名 `media`;`position` 可选,0-8。支持 JPG、PNG、WebP、MP4、MOV、WebM。 |
|||
每条帖子最多 9 张图片和 1 个视频,可同时存在于同一帖子;同时含图片和视频时 `post_type` 为 `mixed`。成功:`201`,返回 `PostMediaResponse`(`id`、`media_type`、`mime_type`、`size_bytes`、`position`、`url`)。生产 OSS 环境的 `url` 是一小时有效的完整签名 URL,可直接加载且不需要 Bearer Token。错误:`403`、`404`、`409` 类型/数量冲突、`413`、`415`。 |
|||
|
|||
### 6.6 获取帖子媒体 |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/media/{media_id}` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回文件二进制内容;错误:`404` 帖子或媒体不存在。 |
|||
|
|||
### 6.7 点赞帖子 |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回精简的 PostLikeResponse,不再返回完整帖子正文、作者和媒体: |
|||
|
|||
```json |
|||
{ |
|||
"message": "like successful", |
|||
"like_count": 1, |
|||
"liked_by_me": true, |
|||
"liked_by_users": [{"id": 2, "full_name": "Reader"}] |
|||
} |
|||
``` |
|||
|
|||
重复点赞不会增加数量;前端用返回的数量、状态和名单更新已有帖子。错误:`401` 未登录;`404` 帖子不存在。 |
|||
|
|||
#### 单独查询点赞者 |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
认证:需要 Bearer token,无请求体或分页参数。成功:`200`,直接返回 `[{"id": 2, "full_name": "Reader"}]`,无点赞时为 `[]`;只含 active 用户,最近点赞在前。错误:`401` 未登录;`404` 帖子不存在。 |
|||
|
|||
### 6.8 取消帖子点赞 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/posts/{post_id}/like` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回精简的 PostUnlikeResponse —— `{"message":"unlike successful","like_count":0,"liked_by_me":false,"liked_by_users":[...]}`,不再返回完整帖子。重复请求保持未点赞状态。 |
|||
|
|||
### 6.9 获取评论 |
|||
|
|||
**GET** `{{base_url}}/api/v1/explore/posts/{post_id}/comments` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回 CommentResponse 数组;每条评论的 `author.profile_image_url` 为头像地址,无头像时为 `null`,并包含 `parent_comment_id`、`reply_count`、`like_count`、`liked_by_me`。评论以扁平列表返回,前端按 `parent_comment_id` 组织回复。错误:`404` 帖子不存在。 |
|||
|
|||
### 6.10 发表评论 |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/posts/{post_id}/comments` |
|||
认证:需要 Bearer token。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"content": "Looks great!"} |
|||
``` |
|||
|
|||
内容 1-2000 字符且不能全是空格。回复某条评论时额外传 `parent_comment_id`: |
|||
|
|||
```json |
|||
{"content": "我同意", "parent_comment_id": 12} |
|||
``` |
|||
|
|||
成功:`201`,返回 CommentResponse,包含 `author.profile_image_url`;错误:`404` 帖子或父评论不存在;`422` 内容无效。 |
|||
|
|||
### 6.11 删除评论 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/comments/{comment_id}` |
|||
认证:需要 Bearer token,只能删除自己的评论。成功:`200` `{"message":"评论已删除"}`;错误:`403`、`404`。 |
|||
|
|||
### 6.12 编辑帖子 |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/explore/posts/{post_id}` |
|||
认证:需要 Bearer token,且只能是帖子作者。 |
|||
请求体(只提交需要修改的字段): |
|||
|
|||
```json |
|||
{"content": "更新后的文字", "content_format": "markdown", "feeling": "happy", "location": {"name": "新地点"}} |
|||
``` |
|||
|
|||
可编辑字段:`content`(最多 5000 字)、`content_format`(`plain`/`markdown`)、`feeling`(1-50 字符)、`location`。未提交的字段保持不变;提交 `null` 可清空 `feeling` 或 `location`。文字帖子必须保留文字、心情或地点至少一项。成功:`200`,返回更新后的完整 PostResponse。错误:`403` 非作者;`404` 帖子不存在;`422` 字段无效。 |
|||
|
|||
### 6.13 编辑评论 |
|||
|
|||
**PATCH** `{{base_url}}/api/v1/explore/comments/{comment_id}` |
|||
认证:需要 Bearer token,且只能是评论作者。 |
|||
请求体: |
|||
|
|||
```json |
|||
{"content": "更新后的评论"} |
|||
``` |
|||
|
|||
`content` 必须为 1-2000 字符且不能全是空格。成功:`200`,返回更新后的 CommentResponse。错误:`403` 非作者;`404` 评论不存在;`422` 内容无效。 |
|||
|
|||
### 6.14 给评论点赞 |
|||
|
|||
**POST** `{{base_url}}/api/v1/explore/comments/{comment_id}/like` |
|||
认证:需要 Bearer token,无请求体。 |
|||
成功:`200` |
|||
|
|||
```json |
|||
{"message": "like successful", "like_count": 1, "liked_by_me": true} |
|||
``` |
|||
|
|||
重复点赞不会增加数量。错误:`401` 未登录;`404` 评论不存在或帖子作者已停用。 |
|||
|
|||
### 6.15 取消评论点赞 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/explore/comments/{comment_id}/like` |
|||
认证:需要 Bearer token,无请求体。 |
|||
成功:`200` |
|||
|
|||
```json |
|||
{"message": "unlike successful", "like_count": 0, "liked_by_me": false} |
|||
``` |
|||
|
|||
重复请求保持未点赞状态。错误:`401` 未登录;`404` 评论不存在或帖子作者已停用。 |
|||
|
|||
## 7. Feed 问答 |
|||
|
|||
### 7.1 获取 Feed 问题 |
|||
|
|||
**GET** `{{base_url}}/api/v1/feed/questions?unanswered_only=false` |
|||
认证:需要 Bearer token。 |
|||
`unanswered_only` 可选,默认 `false`;设为 `true` 只返回当前用户尚未回答的问题。成功:`200`,返回问题数组,每项包含 `id`、`code`、中英文问题、`answer_type`(`boolean` 或 `text`)、当前答案和回答时间。 |
|||
|
|||
### 7.2 获取我的 Feed 回答 |
|||
|
|||
**GET** `{{base_url}}/api/v1/feed/answers/me` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回当前用户已回答的问题数组。 |
|||
|
|||
### 7.3 新增或修改 Feed 回答 |
|||
|
|||
**PUT** `{{base_url}}/api/v1/feed/questions/{question_id}/answer` |
|||
认证:需要 Bearer token。 |
|||
请求体示例(布尔题): |
|||
|
|||
```json |
|||
{"answer": true} |
|||
``` |
|||
|
|||
文字题: |
|||
|
|||
```json |
|||
{"answer": "Coffee and a walk"} |
|||
``` |
|||
|
|||
成功:`200` |
|||
|
|||
```json |
|||
{"question_id": 1, "answer": true, "answered_at": "2026-09-01T10:00:00"} |
|||
``` |
|||
|
|||
错误:`404` 问题不存在/已停用;`422` 答案类型和题目类型不匹配或内容为空。 |
|||
|
|||
### 7.4 删除 Feed 回答 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/feed/questions/{question_id}/answer` |
|||
认证:需要 Bearer token。 |
|||
成功:`200` `{"message":"回答已删除"}`;错误:`404` 回答不存在。 |
|||
|
|||
## 8. Chat 聊天 |
|||
|
|||
聊天仅对已经匹配的双方开放。 |
|||
|
|||
### 8.1 获取会话列表 |
|||
|
|||
**GET** `{{base_url}}/api/v1/chat/conversations` |
|||
认证:需要 Bearer token。 |
|||
成功:`200`,返回会话数组,每项包含 `id`、`match_id`、`other_user`、`last_message`、`created_at`、`updated_at`。 |
|||
|
|||
### 8.2 创建或获取会话 |
|||
|
|||
**POST** `{{base_url}}/api/v1/chat/conversations/{match_id}` |
|||
认证:需要 Bearer token,必须是该匹配的其中一方。 |
|||
前端发送:无 body。 |
|||
成功:`201`,返回 ConversationResponse;如果会话已存在,返回已有会话。错误:`404` 匹配不存在、不是匹配双方或对方不可用。 |
|||
|
|||
### 8.3 获取聊天消息 |
|||
|
|||
**GET** `{{base_url}}/api/v1/chat/conversations/{conversation_id}/messages?limit=50&before_id=100` |
|||
认证:需要 Bearer token,必须是会话双方之一。 |
|||
参数:`limit` 1-100,默认 50;`before_id` 可选,用于分页。成功:`200`,返回 ChatMessageResponse 数组,按时间正序返回。 |
|||
|
|||
### 8.4 发送聊天消息 |
|||
|
|||
**POST** `{{base_url}}/api/v1/chat/conversations/{conversation_id}/messages` |
|||
认证:需要 Bearer token,必须是会话双方之一。 |
|||
请求体: |
|||
|
|||
```json |
|||
{ |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text" |
|||
} |
|||
``` |
|||
|
|||
`content` 1-5000 字符,`message_type` 目前只能是 `text`。成功:`201`,返回 ChatMessageResponse;错误:`404` 会话不存在或无权访问;`422` 内容为空或格式错误。 |
|||
|
|||
### 8.5 撤回聊天消息 |
|||
|
|||
**DELETE** `{{base_url}}/api/v1/chat/messages/{message_id}` |
|||
认证:需要 Bearer token,只能撤回自己发送的消息。成功:`200`,返回撤回后的 ChatMessageResponse(`content=null`、`is_deleted=true`);错误:`403` 不是自己的消息;`404` 消息不存在或无权访问。 |
|||
|
|||
## 9. 前端注册和登录流程 |
|||
|
|||
1. 注册页面显示邮箱/手机号、密码、确认密码和可选资料。 |
|||
2. 前端先检查 `password === confirm_password`,然后将两个密码字段一起提交给注册接口。 |
|||
3. 注册成功后保存返回的 `access_token` 和 `refresh_token`。确认密码不保存。 |
|||
4. 不调用 OTP 接口;当前版本没有 `/api/v1/auth/request-code` 和 `/api/v1/auth/verify`。 |
|||
5. App 启动时调用 `/api/v1/auth/me`。返回 `200` 进入首页;返回 `401` 清除 token 并回到登录页。 |
|||
6. access token 过期时,用 refresh token 调 `/api/v1/auth/refresh`,保存新 token 对;刷新失败则重新登录。 |
|||
|
|||
## 10. Postman 导入和测试 |
|||
|
|||
1. 打开 Postman,点击 `Import` -> `Files`。 |
|||
2. 导入 `docs/openapi-0.6.12.json`,选择导入为 Postman Collection。 |
|||
3. 在 Collection Variables 中设置:`base_url = http://127.0.0.1:8001`。 |
|||
4. 先发送 `POST /api/v1/auth/register` 或 `POST /api/v1/auth/login`。 |
|||
5. 复制响应中的 `access_token`,在需要登录的请求中选择 `Authorization` -> `Bearer Token` 粘贴。 |
|||
6. 测试文件上传时选择 `Body` -> `form-data`,文件字段名必须使用本文对应的字段名。 |
|||
|
|||
建议最小测试顺序:`health` -> `register` -> `login` -> `auth/me` -> `update profile` -> `meet` -> `swipe` -> `matches` -> `explore` -> `feed questions` -> `chat`。 |
|||
|
|||
## 11. 规范文件 |
|||
|
|||
完整机器可读接口定义:`docs/openapi-0.6.12.json`。如本文和 OpenAPI 出现不一致,以当前后端 `/openapi.json` 和最新版 OpenAPI 文件为准。 |
|||
@ -0,0 +1,928 @@ |
|||
Registration API |
|||
|
|||
API Version |
|||
0.6.12 |
|||
|
|||
Status |
|||
Deployed to production and verified through the public API (Jenkins build 46, 2026-10-21). |
|||
|
|||
Production Base URL |
|||
https://melove.youthome.com.cn |
|||
|
|||
All URLs below use the production HTTPS server. Do not add /api/v1 twice. Replace {user_id}, {target_user_id}, {post_id}, and {media_id} with numeric IDs returned by the API. |
|||
|
|||
1. Register a User |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/auth/register |
|||
|
|||
Authentication |
|||
Not required. |
|||
|
|||
Request Header |
|||
Content-Type: application/json |
|||
|
|||
Request JSON |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship" |
|||
} |
|||
|
|||
email_or_phone accepts either an email address or a phone number. |
|||
|
|||
Optional profile fields may be included: |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship", |
|||
"full_name": "Test User", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"bio": "I like traveling.", |
|||
"interests": ["Photography", "Movies"] |
|||
} |
|||
|
|||
Required fields are email_or_phone, password, confirm_password, and relationship_goal. relationship_goal must be one of serious_relationship, casual_dates, new_friends, or marriage. |
|||
|
|||
Do not include the profile image or introduction video in this JSON. Upload them separately after registration using the access token. |
|||
|
|||
Backend Response (201 Created) |
|||
{ |
|||
"access_token": "<access_token>", |
|||
"refresh_token": "<refresh_token>", |
|||
"token_type": "bearer" |
|||
} |
|||
|
|||
The registration response contains tokens only, not the complete user profile. Save both tokens, then call GET /api/v1/auth/me. |
|||
|
|||
Rules |
|||
- Password length: 8 to 128 characters. |
|||
- confirm_password must match password and is not stored. |
|||
- If interests is supplied, at least two non-empty interests are required. Custom interests are allowed; there is no maximum number. |
|||
- The account becomes active immediately after registration. |
|||
- This MVP does not require OTP or verification-code requests. |
|||
|
|||
Possible Errors |
|||
- 409: email or phone number is already registered. |
|||
- 422: a required field is missing or invalid, passwords do not match, or interests are invalid. |
|||
|
|||
2. Upload or Replace the Profile Image |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/users/me/image |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Format |
|||
multipart/form-data |
|||
|
|||
File field name: image |
|||
|
|||
Supported formats: JPG, PNG, WebP. Maximum size: 10 MB. |
|||
|
|||
A successful upload replaces the previous profile image. The response contains the new media ID, media type, and review status. |
|||
|
|||
To display the current image, call GET /api/v1/auth/me and use profile.profile_image_url. In production this is a directly loadable, temporary signed URL valid for one hour; it is not a permanent fixed link and does not need another Bearer header. Request the profile again after it expires. |
|||
|
|||
3. Upload or Replace the Introduction Video |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/users/me/video |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Format |
|||
multipart/form-data |
|||
|
|||
File field name: video |
|||
|
|||
Supported formats: MP4, MOV, WebM. Maximum size: 100 MB. |
|||
|
|||
A successful upload replaces the previous introduction video and removes the current written biography. Use profile.video_url from GET /api/v1/auth/me to display it. The production URL is a temporary signed URL. Saving a non-empty bio later through the profile-update API removes the introduction video. |
|||
|
|||
4. Get the Current User |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/auth/me |
|||
|
|||
The compatibility URL GET https://melove.youthome.com.cn/api/v1/users/me returns the same response. |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. |
|||
|
|||
Backend Response (200 OK) |
|||
The response contains the current user, nested profile, recent posts, recent liked users, recent users who liked the current user, recent matches, and the full totals liked_users_count, liked_by_users_count, and matches_count. |
|||
|
|||
{ |
|||
"id": 28, |
|||
"email_or_phone": "user@example.com", |
|||
"status": "active", |
|||
"profile": { |
|||
"full_name": "User Name", |
|||
"gender": "female", |
|||
"birth_date": "2000-09-04", |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"country": "China", |
|||
"relationship_goal": "serious_relationship", |
|||
"bio": null, |
|||
"occupation": null, |
|||
"education": null, |
|||
"height_cm": null, |
|||
"beliefs": null, |
|||
"smoking": null, |
|||
"drinking": null, |
|||
"pets": null, |
|||
"exercise": null, |
|||
"age": 26, |
|||
"profile_image_url": "<temporary signed image URL or null>", |
|||
"video_url": "<temporary signed video URL or null>", |
|||
"interests": ["Movies", "Travel"] |
|||
}, |
|||
"created_at": "2026-09-28T10:00:00", |
|||
"posts": [], |
|||
"liked_users": [], |
|||
"liked_by_users": [], |
|||
"matches": [], |
|||
"liked_users_count": 0, |
|||
"liked_by_users_count": 0, |
|||
"matches_count": 0 |
|||
} |
|||
|
|||
The four activity arrays contain up to 20 recent items each. The three count fields are complete totals and are not limited to those 20 items. Likes include both like and super_like, and only active users are counted. profile_image_url and video_url are temporary signed media URLs in production; call this endpoint again after expiry. |
|||
|
|||
5. Update the Current User's Profile |
|||
|
|||
URL |
|||
PATCH https://melove.youthome.com.cn/api/v1/users/me |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Header |
|||
Content-Type: application/json |
|||
|
|||
Example Request |
|||
{ |
|||
"full_name": "New Name", |
|||
"gender": "female", |
|||
"birth_date": "2000-01-01", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"relationship_goal": "marriage", |
|||
"bio": "Updated biography", |
|||
"occupation": "Creative Director at Studio Drift", |
|||
"education": "NYU Gallatin School", |
|||
"height_cm": 175, |
|||
"beliefs": "spiritual_agnostic", |
|||
"smoking": "non_smoker", |
|||
"drinking": "socially", |
|||
"pets": "golden_retriever", |
|||
"exercise": "active_4x_week", |
|||
"interests": ["Travel", "Music"] |
|||
} |
|||
|
|||
Only send fields that need to change. Saving a non-empty bio removes the introduction video. Changing city through this endpoint clears the old county; use the location endpoint to submit coordinates, city, and county together. Interests are replaced as a whole and must contain at least two values when supplied. |
|||
|
|||
Additional optional profile fields: occupation and education (max 120 characters each), height_cm (integer, 100-250), beliefs (max 120), smoking and drinking (max 50 each), pets (max 120), and exercise (max 50). Send null to clear any of them. GET /api/v1/auth/me returns these values plus a computed age derived from birth_date. |
|||
|
|||
Backend Response (200 OK) |
|||
Returns the updated user information. |
|||
|
|||
6. Update the Current User's Location |
|||
|
|||
URL |
|||
PUT https://melove.youthome.com.cn/api/v1/users/me/location |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request JSON |
|||
{ |
|||
"latitude": 31.2304, |
|||
"longitude": 121.4737, |
|||
"city": "Shanghai", |
|||
"county": "Huangpu" |
|||
} |
|||
|
|||
All four fields are required. Latitude must be between -90 and 90; longitude must be between -180 and 180; city and county cannot be blank. county means district or county and is separate from country. |
|||
|
|||
Backend Response (200 OK) |
|||
{ |
|||
"latitude": 31.2304, |
|||
"longitude": 121.4737, |
|||
"city": "Shanghai", |
|||
"county": "Huangpu", |
|||
"updated_at": "2026-10-09T10:00:00" |
|||
} |
|||
|
|||
After updating the location, call GET /api/v1/auth/me to see profile.city and profile.county. Invalid fields return 422. |
|||
|
|||
7. Refresh an Expired Access Token |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/auth/refresh |
|||
|
|||
Authentication |
|||
Bearer access token is not required. |
|||
|
|||
Request JSON |
|||
{ |
|||
"refresh_token": "<refresh_token>" |
|||
} |
|||
|
|||
On success, the server returns a new access token and refresh token. Save both new tokens; the old refresh token becomes invalid. If refreshing returns 401, the user must log in again. |
|||
|
|||
Explore / News Feed API |
|||
|
|||
8. Get Recommended People (Meet) |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/meet?limit=20 |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
limit is optional, defaults to 20, and can be from 1 to 50. The response contains active users whom the current user has not already swiped. Active incoming Super Likes have first priority. The remaining users are ordered by same city, same country, and then other countries; higher compatibility_percentage comes first within each group. Use the returned numeric id as target_user_id in the swipe API. |
|||
|
|||
profile_image_url, video_url, and media[] describe public profile media, not posts. Production media URLs are temporary signed URLs valid for one hour. |
|||
|
|||
9. Swipe Left, Swipe Right, or Super Like |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/meet/swipes |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request JSON |
|||
{ |
|||
"target_user_id": 28, |
|||
"direction": "like" |
|||
} |
|||
|
|||
target_user_id is the numeric ID returned by Meet or Search. Supported directions: |
|||
|
|||
- pass: swipe left and skip the person. |
|||
- like: swipe right and like the person. |
|||
- super_like: like the person and give the sender first priority in that person's Meet list. |
|||
|
|||
Backend Response (200 OK) |
|||
|
|||
Without a mutual match: |
|||
{"direction": "like", "matched": false, "match_id": null} |
|||
|
|||
With a mutual match: |
|||
{"direction": "like", "matched": true, "match_id": 15} |
|||
|
|||
A match forms only when both users positively swipe each other. super_like counts as a positive swipe. Its priority remains until the sender cancels it or changes the direction. A pass removes an existing match. |
|||
|
|||
9.1 Get Matches |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/matches |
|||
|
|||
The compatibility URL GET https://melove.youthome.com.cn/api/v1/users/me/matches returns the same data. |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. This endpoint does not paginate. Legacy limit and offset query parameters are ignored. |
|||
|
|||
Backend Response (200 OK) |
|||
An array of MatchResponse. Each item contains id, matched_at, and the other user's public profile in user. |
|||
|
|||
Rules |
|||
- Only matches whose other user is active are returned, most recent match first. |
|||
- An empty array means the current user has no active matches. |
|||
- A match is created only when both users positively swipe each other (like or super_like). |
|||
- pass, canceling a like, or undoing a swipe removes the corresponding match. |
|||
- The number of items equals matches_count from GET /api/v1/auth/me. |
|||
|
|||
9.2 Get Users I Liked and Users Who Liked Me |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/users/me/liked |
|||
GET https://melove.youthome.com.cn/api/v1/users/me/likes |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. These endpoints do not paginate. Legacy limit and offset query parameters are ignored. |
|||
|
|||
Backend Response (200 OK) |
|||
An array of DiscoveryUserResponse. |
|||
|
|||
Rules |
|||
- /users/me/liked returns users the current user liked with like or super_like. |
|||
- /users/me/likes returns users who liked the current user. |
|||
- Users who already matched with the current user are excluded from both lists. |
|||
- liked_users_count and liked_by_users_count in GET /api/v1/auth/me also exclude matched users. |
|||
- Only active users are returned, most recent action first. |
|||
|
|||
10. Undo a Swipe (Cancel Button) |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/meet/swipes/{target_user_id} |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
No JSON body is needed. This is the URL for the frontend Cancel button. It removes the current user's saved pass, like, or super_like toward the target. The target may appear in Meet again. Canceling a Super Like removes its priority. If the swipe created a match, cancellation removes that match. |
|||
|
|||
Backend Response (200 OK) |
|||
{ |
|||
"message": "已撤销滑动", |
|||
"previous_direction": "super_like" |
|||
} |
|||
|
|||
previous_direction may be pass, like, or super_like. If there is no saved swipe, the server returns 404. Cancel does not send another left or right swipe. |
|||
|
|||
11. Cancel a Like (Older Compatibility URL) |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/meet/likes/{target_user_id} |
|||
|
|||
This older compatibility URL cancels only like or super_like and removes any corresponding match. It cannot undo a pass. The new frontend Cancel button should use the Undo a Swipe URL above. |
|||
|
|||
12. Search People |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/search |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
All query parameters are optional. |
|||
|
|||
Parameters |
|||
- q: free-text keyword, maximum 200 characters. Fuzzy search on full_name, bio, city, and country, case-insensitive. Multiple words are AND-ed. |
|||
- city: exact city filter, case and whitespace insensitive. |
|||
- country: exact country filter, case and whitespace insensitive. |
|||
- latitude and longitude: must be sent together. They are used to compute distance_km and to sort by distance. |
|||
- max_distance_km: distance filter from 1 to 500. If it is sent without coordinates, the backend uses the current user's own location; the user must have set a location first, otherwise the API returns 422. |
|||
- limit: number of results, 1 to 50, default 20. |
|||
|
|||
Filter examples |
|||
GET https://melove.youthome.com.cn/api/v1/search?q=hiking&limit=20 |
|||
GET https://melove.youthome.com.cn/api/v1/search?city=Shanghai&limit=20 |
|||
GET https://melove.youthome.com.cn/api/v1/search?country=China&limit=20 |
|||
GET https://melove.youthome.com.cn/api/v1/search?city=Shanghai&country=China&limit=20 |
|||
GET https://melove.youthome.com.cn/api/v1/search?latitude=31.2304&longitude=121.4737&max_distance_km=20&limit=20 |
|||
|
|||
Rules |
|||
- Only active users are returned, and the current user is excluded. |
|||
- q is a fuzzy contains match; city and country are exact equality matches. |
|||
- Sending only one of latitude or longitude returns 422. |
|||
- The backend first selects up to 500 candidates, then sorts and returns at most limit results. |
|||
- With coordinates, results are ordered by: users who sent the current user an active super_like first, then same city, same country, other countries; within each group by compatibility_percentage descending, then distance ascending, then newest user first. |
|||
- Without coordinates, the same group ordering is used without the distance step. |
|||
|
|||
Backend Response (200 OK) |
|||
An array of public user profiles. Each item contains: |
|||
id, full_name, gender, birth_date, city, county, country, relationship_goal, bio, profile_image_url, video_url, distance_km, compatibility_percentage, media. |
|||
|
|||
distance_km is null when coordinates were not provided. The result id can be used in the public-profile URL or the swipe URL. county is the district or county and is not the country filter. |
|||
|
|||
13. Get the Explore / News Feed |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/explore/feed?limit=20&offset=0 |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
limit controls the number of posts and accepts 1-50. offset loads later pages: use offset=0 for the first page, offset=20 for the second page, and so on. The optional legacy before_id is a numeric ID cutoff and cannot be combined with a non-zero offset; omit it completely when using offset pagination. |
|||
|
|||
Backend Response (200 OK) |
|||
[ |
|||
{ |
|||
"id": 21, |
|||
"content": "Today is a beautiful day.", |
|||
"post_type": "text", |
|||
"content_format": "plain", |
|||
"feeling": null, |
|||
"location": null, |
|||
"author": { |
|||
"id": 2, |
|||
"full_name": "Bob", |
|||
"profile_image_url": "<temporary signed avatar URL or null>", |
|||
"city": "Shanghai", |
|||
"country": "China", |
|||
"compatibility_percentage": 85 |
|||
}, |
|||
"like_count": 2, |
|||
"comment_count": 0, |
|||
"liked_by_me": false, |
|||
"liked_by_users": [ |
|||
{"id": 3, "full_name": "Newest Liker"}, |
|||
{"id": 4, "full_name": "Reader"} |
|||
], |
|||
"media": [], |
|||
"created_at": "2026-09-01T10:00:00" |
|||
} |
|||
] |
|||
|
|||
This URL returns posts, not user profiles. Posts by same-city authors come first, followed by same-country authors and then others; compatibility and recency also affect the order. liked_by_users contains only active users, newest like first, and only exposes ID and name. It is [] without likes and like_count equals its length. author.profile_image_url is null before upload or a temporary signed URL in production. |
|||
|
|||
14. Get a Public User Profile |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/users/{user_id} |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Use this URL when opening a person from Meet or Search. The response contains that person's public profile, including city, county, country, relationship goal, profile_image_url, video_url, public media, interests, and the newest 20 public posts. profile_image_url is the avatar and video_url is the introduction video. Profile media[] is not a list of posts. This endpoint never returns email/phone, account status, private likes, or matches. |
|||
|
|||
15. List a Public User's Posts |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/users/{user_id}/posts?limit=20&offset=0 |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Replace user_id with a numeric ID. limit is 1 to 50, default 20; offset defaults to 0 and loads later pages. The response is an array of that user's posts, newest first. Each post includes content_format, feeling, location, an author with profile_image_url, liked_by_users, and its own post media[]. An empty array means the user has no posts. |
|||
|
|||
16. Create a Post |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/explore/posts |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Header |
|||
Content-Type: application/json |
|||
|
|||
Request JSON |
|||
{ |
|||
"content": "Feeling happy and relaxed today", |
|||
"post_type": "text", |
|||
"content_format": "plain", |
|||
"feeling": "happy", |
|||
"location": { |
|||
"name": "Blue Bottle Coffee, Soho NYC", |
|||
"latitude": 40.723, |
|||
"longitude": -73.998 |
|||
} |
|||
} |
|||
|
|||
post_type can be text, image, or video; the default is text. Content has a maximum of 5,000 characters. A text post must contain at least one of non-empty content, feeling, or location. feeling is optional and contains 1 to 50 characters. If location is provided, name is required; latitude and longitude must be supplied together or both omitted. content_format can be plain or markdown; use markdown for bold and italic text. Music is not supported yet. |
|||
|
|||
Backend Response (201 Created) |
|||
Returns a PostResponse with id, content, post_type, content_format, feeling, location, author, like_count, comment_count, liked_by_me, liked_by_users, media[], and created_at. For an image or video post, create the post first and use its returned ID in the next API. media[] is empty before upload. |
|||
|
|||
16.1 Edit a Post |
|||
|
|||
URL |
|||
PATCH https://melove.youthome.com.cn/api/v1/explore/posts/{post_id} |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Only the post author can edit the post. |
|||
|
|||
Request Header |
|||
Content-Type: application/json |
|||
|
|||
Request JSON (send only the fields to change) |
|||
{ |
|||
"content": "Updated text", |
|||
"content_format": "markdown", |
|||
"feeling": "happy", |
|||
"location": {"name": "New place"} |
|||
} |
|||
|
|||
Editable fields: content (maximum 5,000 characters), content_format (plain or markdown), feeling (1 to 50 characters), and location. Omitted fields stay unchanged. Sending null clears feeling or location. A text post must keep at least one of non-empty content, feeling, or location. |
|||
|
|||
Backend Response (200 OK) |
|||
Returns the full updated PostResponse. |
|||
|
|||
Errors |
|||
- 403: the caller is not the post author. |
|||
- 404: the post does not exist or the author is inactive. |
|||
- 422: invalid fields, or the text post would be empty. |
|||
|
|||
17. Upload Media to a Post |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/media?position=0 |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Only the post author can upload media. |
|||
|
|||
Request Format |
|||
multipart/form-data |
|||
|
|||
File field name: media |
|||
|
|||
Optional query parameter: position from 0 to 9. |
|||
|
|||
A post supports up to 9 images and 1 video in the same post. Images and video can be mixed. |
|||
|
|||
- Images: JPG, PNG, or WebP; up to 10 MB each; maximum 9. |
|||
- Video: MP4, MOV, or WebM; up to 100 MB; maximum 1. |
|||
|
|||
When the post contains both an image and a video, post_type becomes mixed. When it contains only one type, post_type is image or video. |
|||
|
|||
Backend Response (201 Created) |
|||
Returns id, media_type, mime_type, size_bytes, position, and url. Fetch the post or feed again to obtain its updated media[]. Production media URLs are temporary signed URLs valid for one hour. |
|||
|
|||
Errors |
|||
- 403: the caller is not the post author. |
|||
- 404: the post does not exist. |
|||
- 409: more than 9 images or more than 1 video. |
|||
- 413: the file is too large. |
|||
- 415: unsupported file type. |
|||
|
|||
18. Post Likes and Comments |
|||
|
|||
Every post response includes liked_by_users, an array of active post likers containing only id and full_name, ordered newest first. The same fields are used in Explore, post detail, public-user posts, and profile-embedded posts. Every comment author includes profile_image_url; use it to display the comment author's avatar. |
|||
|
|||
Like a Post |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/like |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. |
|||
|
|||
Backend Response (200 OK) |
|||
{ |
|||
"message": "like successful", |
|||
"like_count": 1, |
|||
"liked_by_me": true, |
|||
"liked_by_users": [{"id": 2, "full_name": "Reader"}] |
|||
} |
|||
|
|||
The response is compact and does not contain the full post body, author, or media. Repeated likes are idempotent; update the existing post with the returned count, state, and list. |
|||
|
|||
Get Post Likers |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/like |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. No pagination parameters. |
|||
|
|||
Backend Response (200 OK) |
|||
[{"id": 2, "full_name": "Reader"}] |
|||
|
|||
Returns [] if there are no active likes. Only active users appear, newest like first. |
|||
|
|||
Unlike a Post |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/like |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. |
|||
|
|||
Backend Response (200 OK) |
|||
{ |
|||
"message": "unlike successful", |
|||
"like_count": 0, |
|||
"liked_by_me": false, |
|||
"liked_by_users": [] |
|||
} |
|||
|
|||
The response is compact and does not contain the full post. Repeated requests keep the post unliked. |
|||
|
|||
Get or Add Comments |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/comments |
|||
POST https://melove.youthome.com.cn/api/v1/explore/posts/{post_id}/comments |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
POST Request JSON |
|||
{"content": "Nice post!"} |
|||
|
|||
To reply to an existing comment, add parent_comment_id: |
|||
{"content": "I agree", "parent_comment_id": 12} |
|||
|
|||
GET returns a flat CommentResponse array (200). POST returns the created CommentResponse (201). Content must be 1 to 2,000 characters and cannot be blank. Each comment contains author.profile_image_url (null when no avatar exists), parent_comment_id, reply_count, like_count, and liked_by_me. Group replies by parent_comment_id on the frontend. |
|||
|
|||
Edit a Comment |
|||
|
|||
URL |
|||
PATCH https://melove.youthome.com.cn/api/v1/explore/comments/{comment_id} |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Only the comment author can edit the comment. |
|||
|
|||
Request JSON |
|||
{"content": "Updated comment"} |
|||
|
|||
Content must be 1 to 2,000 characters and cannot be blank. |
|||
|
|||
Backend Response (200 OK) |
|||
Returns the updated CommentResponse. |
|||
|
|||
Errors |
|||
- 403: the caller is not the comment author. |
|||
- 404: the comment does not exist. |
|||
- 422: invalid content. |
|||
|
|||
Delete a Comment |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/explore/comments/{comment_id} |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Only the comment author can delete the comment. |
|||
|
|||
Backend Response (200 OK) |
|||
{"message": "评论已删除"} |
|||
|
|||
Errors |
|||
- 403: the caller is not the comment author. |
|||
- 404: the comment does not exist. |
|||
|
|||
Like a Comment |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/explore/comments/{comment_id}/like |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. |
|||
|
|||
Backend Response (200 OK) |
|||
{"message": "like successful", "like_count": 1, "liked_by_me": true} |
|||
|
|||
Repeated likes do not increase the count. |
|||
|
|||
Unlike a Comment |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/explore/comments/{comment_id}/like |
|||
|
|||
Authentication |
|||
Authorization: Bearer <access_token> |
|||
|
|||
Request Body |
|||
None. |
|||
|
|||
Backend Response (200 OK) |
|||
{"message": "unlike successful", "like_count": 0, "liked_by_me": false} |
|||
|
|||
19. Chat |
|||
|
|||
Chat is available only between two users who have already matched. A conversation cannot be created before the match exists. |
|||
|
|||
How to get match_id: |
|||
- POST https://melove.youthome.com.cn/api/v1/meet/swipes returns matched and match_id when the other person had already positively swiped you. |
|||
- GET https://melove.youthome.com.cn/api/v1/matches lists all match records with their IDs. |
|||
|
|||
19.1 Create or Get a Conversation |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/chat/conversations/{match_id} |
|||
|
|||
Authentication |
|||
Bearer token required. The caller must be one of the two users in that match. |
|||
|
|||
Request Body |
|||
None. The match_id is taken from the URL path. There is no other parameter. |
|||
|
|||
Backend Response (201 Created) |
|||
{ |
|||
"id": 12, |
|||
"match_id": 34, |
|||
"other_user": { |
|||
"id": 55, |
|||
"full_name": "Alex", |
|||
"city": "Shanghai", |
|||
"country": "China" |
|||
}, |
|||
"last_message": null, |
|||
"created_at": "2026-10-16T02:10:24", |
|||
"updated_at": "2026-10-16T02:10:24" |
|||
} |
|||
|
|||
Rules |
|||
- match_id must be a match the caller belongs to. Use the match_id returned by the swipe API or by GET /api/v1/matches. |
|||
- POST https://melove.youthome.com.cn/api/v1/chat/conversations (without match_id) is not supported. That URL only accepts GET. A POST there returns 405. |
|||
- Calling this URL again for the same match returns the same conversation; no duplicate conversation is created. |
|||
- last_message is null until a message has been sent. |
|||
- Errors: 404 when the match does not exist, when the caller is not one of the matched users, or when the other user is no longer active. |
|||
|
|||
19.2 Get the Conversation List |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/chat/conversations |
|||
|
|||
Authentication |
|||
Bearer token required. |
|||
|
|||
Backend Response (200 OK) |
|||
[ |
|||
{ |
|||
"id": 12, |
|||
"match_id": 34, |
|||
"other_user": { |
|||
"id": 55, |
|||
"full_name": "Alex", |
|||
"city": "Shanghai", |
|||
"country": "China" |
|||
}, |
|||
"last_message": { |
|||
"id": 108, |
|||
"conversation_id": 12, |
|||
"sender_id": 55, |
|||
"content": "Hello!", |
|||
"message_type": "text", |
|||
"is_deleted": false, |
|||
"created_at": "2026-10-16T02:12:05" |
|||
}, |
|||
"created_at": "2026-10-16T02:10:24", |
|||
"updated_at": "2026-10-16T02:12:05" |
|||
} |
|||
] |
|||
|
|||
Rules |
|||
- The list contains one entry per match of the current user, most recent match first. |
|||
- A match whose other user is no longer active is skipped. |
|||
- id is null only when the match has no conversation yet; such an entry is returned so the client can still show the matched person. |
|||
- other_user currently contains id, full_name, city, and country. It does not contain an avatar URL yet. |
|||
|
|||
19.3 Get Chat Messages |
|||
|
|||
URL |
|||
GET https://melove.youthome.com.cn/api/v1/chat/conversations/{conversation_id}/messages?limit=50&before_id=100 |
|||
|
|||
Authentication |
|||
Bearer token required. The caller must be one of the conversation participants. |
|||
|
|||
Parameters |
|||
- limit: 1 to 100, default 50. |
|||
- before_id: optional message ID used to load older messages; omit it for the newest page. |
|||
|
|||
Backend Response (200 OK) |
|||
[ |
|||
{ |
|||
"id": 107, |
|||
"conversation_id": 12, |
|||
"sender_id": 54, |
|||
"content": "Hi!", |
|||
"message_type": "text", |
|||
"is_deleted": false, |
|||
"created_at": "2026-10-16T02:11:40" |
|||
} |
|||
] |
|||
|
|||
Rules |
|||
- Messages are returned in chronological order (oldest to newest) inside a page, even though before_id walks backwards in time. |
|||
- To load older messages, pass the smallest id from the current page as before_id. |
|||
- Errors: 404 conversation not found or access denied. |
|||
|
|||
19.4 Send a Chat Message |
|||
|
|||
URL |
|||
POST https://melove.youthome.com.cn/api/v1/chat/conversations/{conversation_id}/messages |
|||
|
|||
Authentication |
|||
Bearer token required. The caller must be one of the conversation participants. |
|||
|
|||
Request JSON |
|||
{ |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text" |
|||
} |
|||
|
|||
Backend Response (201 Created) |
|||
{ |
|||
"id": 108, |
|||
"conversation_id": 12, |
|||
"sender_id": 54, |
|||
"content": "Hello, nice to meet you!", |
|||
"message_type": "text", |
|||
"is_deleted": false, |
|||
"created_at": "2026-10-16T02:12:05" |
|||
} |
|||
|
|||
Rules |
|||
- content: 1 to 5,000 characters after trimming; an empty content returns 422. |
|||
- message_type currently supports text only. |
|||
- Errors: 404 conversation not found, conversation belongs to someone else, or the other user is unavailable; 422 empty or malformed content. |
|||
|
|||
19.5 Recall a Chat Message |
|||
|
|||
URL |
|||
DELETE https://melove.youthome.com.cn/api/v1/chat/messages/{message_id} |
|||
|
|||
Authentication |
|||
Bearer token required. Only the sender can recall their own message. |
|||
|
|||
Backend Response (200 OK) |
|||
{ |
|||
"id": 108, |
|||
"conversation_id": 12, |
|||
"sender_id": 54, |
|||
"content": null, |
|||
"message_type": "text", |
|||
"is_deleted": true, |
|||
"created_at": "2026-10-16T02:12:05" |
|||
} |
|||
|
|||
Rules |
|||
- After recall, content is null and is_deleted is true; the record is kept, not physically deleted. |
|||
- Errors: 403 the message was not sent by the caller; 404 message not found or the caller is not a participant of that conversation. |
|||
|
|||
Chat message fields |
|||
id, conversation_id, sender_id, content, message_type, is_deleted, created_at. |
|||
|
|||
Requirement Coverage |
|||
|
|||
- Registration uses the production HTTPS URL and requires relationship_goal. |
|||
- OTP is not used in the MVP; registration activates the account immediately. |
|||
- JWT access and refresh tokens are used; passwords are hashed and never stored as plaintext. |
|||
- Profile images and introduction videos have separate upload URLs. |
|||
- Location updates submit latitude, longitude, city, and county together. |
|||
- Meet prioritizes users who sent an active Super Like. |
|||
- Swipe left uses pass; swipe right uses like; Super Like uses super_like. |
|||
- A match forms when both users positively swipe each other. |
|||
- The match list returns every active match without pagination. |
|||
- Users who already matched are removed from the liked and liked-by lists. |
|||
- Comments support replies (parent_comment_id) and likes. |
|||
- The Cancel Button undoes pass, like, or super_like using the swipe DELETE URL. |
|||
- Super Like priority remains until the sender cancels it or changes the direction. |
|||
- Search can filter by keyword, city, country, or distance. |
|||
- Explore returns posts; the public-user URL returns public profile information and recent public posts. |
|||
- Get Current User returns full liked_users_count, liked_by_users_count, and matches_count values. |
|||
- Create Post supports text, images, videos, feeling, location, and Markdown bold and italic. Music is not supported yet. |
|||
- Post media is uploaded after the post is created. |
|||
- Profile media and post media are different. |
|||
- Post and comment authors expose profile_image_url; post liker summaries expose ID and name. |
|||
- Profile editing supports occupation, education, height, beliefs, smoking, drinking, pets, and exercise; Get Me returns them with a computed age. |
|||
- Posts and comments can be edited by their authors. |
|||
- One post can contain up to 9 images and 1 video together. |
|||
|
|||
How Each API Link Works |
|||
|
|||
Production Base URL: https://melove.youthome.com.cn |
|||
|
|||
For protected requests, send Authorization: Bearer <access_token>. Replace path IDs with real numeric IDs returned by earlier responses. |
|||
|
|||
1. Register -> save both tokens -> Get Current User. |
|||
2. Optionally upload an avatar or introduction video -> Get Current User again. |
|||
3. Update Location -> Get Current User -> Get Meet recommendations. |
|||
4. Meet -> choose a returned user ID -> send pass, like, or super_like -> inspect matched and match_id. |
|||
5. Cancel Button -> DELETE the saved swipe -> refresh Meet and Matches. |
|||
6. Open a person's profile -> Get Public User Profile -> List that user's posts. |
|||
7. Create a post -> use its returned ID to upload images or video -> refresh Explore and profile posts. |
|||
8. Like a post -> use the compact response to update the existing post; call GET /like when the full liker list is needed. |
|||
9. Match -> take match_id from the swipe response or GET /api/v1/matches -> POST /api/v1/chat/conversations/{match_id} -> GET /api/v1/chat/conversations/{conversation_id}/messages -> POST the same messages URL to send a message. |
|||
10. To recall a message, DELETE /api/v1/chat/messages/{message_id} with the sender token. |
|||
|
|||
Typical Frontend Order |
|||
|
|||
Register -> save tokens -> Get Current User -> optionally upload avatar or introduction video -> Get Current User again. |
|||
|
|||
Update Location -> Get Current User -> Get Meet recommendations. |
|||
|
|||
Meet -> choose a returned user ID -> send pass, like, or super_like -> inspect matched and match_id. |
|||
|
|||
Cancel Button -> DELETE the saved swipe -> refresh Meet and Matches. |
|||
|
|||
Open a person's profile -> Get Public User Profile -> List that user's posts. |
|||
|
|||
Create a post -> use the returned ID to upload images or video -> refresh Explore and profile posts. |
|||
|
|||
Post like -> update count, state, and list from the compact response; do not replace the full post object with the like response. |
|||
|
|||
Edit a post or comment -> PATCH the post or comment URL -> update the local item with the returned object. |
|||
@ -0,0 +1,175 @@ |
|||
# Postman / Apifox 导入与测试 |
|||
|
|||
## 导入接口 |
|||
|
|||
1. 打开 Postman,点击左上角 **Import**。 |
|||
2. 选择 **Files**,导入 `docs/openapi-0.6.12.json`。 |
|||
3. 选择以 **Postman Collection** 方式导入并确认。 |
|||
4. 在 Collection Variables 中将 `baseUrl` 设置为当前部署地址;本地开发使用 `http://127.0.0.1:8001`。 |
|||
|
|||
`super_like` 和新的位置请求已随 API 0.6.6 发布到正式环境,可以使用正式地址测试。 |
|||
|
|||
API 0.6.7 的帖子新字段已经完成正式数据库迁移和部署,可以在正式地址测试。 |
|||
|
|||
API 0.6.8 的 `GET /api/v1/users/{user_id}` 会在公开资料中返回 `interests` 和最近 20 条 `posts`;邮箱/手机号、喜欢列表和匹配记录不会返回。 |
|||
|
|||
API 0.6.8 的 `/api/v1/users/me/liked` 和 `/api/v1/users/me/likes` 不再分页,直接返回完整有效名单;旧请求中携带的 `limit`、`offset` 会被忽略。Apifox 更新接口定义时应删除这两个参数。API 0.6.8 已发布到正式环境。 |
|||
|
|||
本版 `GET /api/v1/auth/me` 和 `GET /api/v1/users/me` 还新增 `liked_users_count`、`liked_by_users_count`、`matches_count` 三个总数字段。它们不受响应中三个名单最多 20 条的限制,正式环境已经可用。 |
|||
|
|||
## API 0.6.12 本次更新(已发布) |
|||
|
|||
正式环境已经执行 `sql/migrate_profile_details.sql`(`user_profiles` 增加 8 个可空资料字段)和 `sql/migrate_comment_interactions.sql`(`post_comments` 增加 `parent_comment_id`、新增 `comment_likes` 表),并完成 0.6.12 部署。两个脚本保留为幂等迁移,供新环境初始化使用。 |
|||
|
|||
导入 `docs/openapi-0.6.12.json` 后,核对以下变化: |
|||
|
|||
- `PATCH /api/v1/users/me` 新增 `occupation`、`education`、`height_cm`(100-250)、`beliefs`、`smoking`、`drinking`、`pets`、`exercise`。 |
|||
- `GET /api/v1/auth/me` 和 `GET /api/v1/users/me` 的 `profile` 会返回这 8 个字段,并新增根据 `birth_date` 计算的 `age`。 |
|||
- `GET /api/v1/users/me/liked` 和 `/api/v1/users/me/likes` 会排除已经匹配成功的用户,`liked_users_count` / `liked_by_users_count` 同步扣除。 |
|||
- 评论接口新增回复:`POST /api/v1/explore/posts/{post_id}/comments` 可带 `parent_comment_id`;CommentResponse 新增 `parent_comment_id`、`reply_count`、`like_count`、`liked_by_me`。 |
|||
- 新增 `POST/DELETE /api/v1/explore/comments/{comment_id}/like` 点赞和取消点赞评论。 |
|||
- 旧字段保持不变;此前 0.6.10/0.6.11 的编辑、混合媒体和匹配列表改动保持不变。 |
|||
|
|||
共享 Google 文档的完整可粘贴内容见 `docs/Google文档完整内容-0.6.12.txt`;本次没有直接编辑在线文档。 |
|||
|
|||
## 测试注册 |
|||
|
|||
打开 `POST /api/v1/auth/register`,Body 选择 `raw` 和 `JSON`: |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "serious_relationship", |
|||
"full_name": "Test User", |
|||
"city": "Shanghai", |
|||
"country": "China" |
|||
} |
|||
``` |
|||
|
|||
也可以用同一个字段填写手机号: |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "13800138000", |
|||
"password": "password123", |
|||
"confirm_password": "password123", |
|||
"relationship_goal": "new_friends" |
|||
} |
|||
``` |
|||
|
|||
成功状态码是 `201`,响应只包含 `access_token`、`refresh_token` 和 `token_type`。两次密码必须一致,`confirm_password` 不会保存到数据库。`relationship_goal` 必须从 `serious_relationship`、`casual_dates`、`new_friends`、`marriage` 中单选一个。MVP 不需要请求或填写验证码。 |
|||
|
|||
注册介绍内容为文字简介或介绍视频二选一。选择文字时在注册 JSON 中提交非空 `bio`,此时调用 `GET /api/v1/auth/me` 会看到 `profile.video_url: null`。选择视频时省略 `bio`,注册成功后用 access token 调用视频上传接口;上传后再次获取个人资料,`profile.bio` 为 `null`,生产环境的 `profile.video_url` 为一小时有效的完整 OSS 签名 URL。`profile.profile_image_url` 采用相同规则。前端可以直接加载这些 URL,不需要再次携带 Bearer Token;过期后重新请求资料即可。以后提交非空 `bio` 会删除介绍视频,重新上传视频会清空 `bio`。 |
|||
|
|||
## 测试登录 |
|||
|
|||
打开 `POST /api/v1/auth/login`: |
|||
|
|||
```json |
|||
{ |
|||
"email_or_phone": "user@example.com", |
|||
"password": "password123" |
|||
} |
|||
``` |
|||
|
|||
成功状态码是 `200`。 |
|||
|
|||
## 测试需要登录的接口 |
|||
|
|||
1. 从注册或登录响应中复制 `access_token`。 |
|||
2. 打开需要登录的请求,在 **Authorization** 中选择 **Bearer Token**。 |
|||
3. 把 `access_token` 粘贴到 Token 输入框,然后发送请求。 |
|||
|
|||
旧版的 `/api/v1/auth/request-code` 和 `/api/v1/auth/verify` 已移除,不要再测试这两个接口。 |
|||
|
|||
## Apifox 更新及图片/视频上传 |
|||
|
|||
Apifox 项目如果注册请求没有必填的 `relationship_goal`,滑动请求不接受 `super_like`,位置请求没有 `city`、`county`,帖子请求没有 `content_format`、`feeling`、`location`,或者公开用户资料没有 `interests`、`posts`,说明接口定义早于 0.6.8 版本。先备份项目中的手工编辑内容,再将 `docs/openapi-0.6.12.json` 作为 OpenAPI 导入并核对差异;不要仅凭旧接口列表测试。正式 Base URL 使用 `https://melove.youthome.com.cn`,请求路径已经包含 `/api/v1`,不要在 Base URL 再重复拼接此前缀。线上 `/health` 和 `/docs` 目前不由 nginx 代理,不能用它们判断业务接口是否可用。 |
|||
|
|||
导入 `docs/openapi-0.6.12.json` 后,核对创建帖子请求中的 `content_format`、`feeling`、`location`,以及公开用户资料响应中的 `interests`、`posts` 和公开用户帖子响应中的同名字段。 |
|||
|
|||
注册返回 `201` 后,复制响应的 `access_token`(不是 `refresh_token`);上传接口在 Auth 中选择 Bearer Token。Body 选择 `form-data`,字段类型选择 **File**,并选择真实文件。不要用 JSON、raw、Binary 或文本形式的文件路径发送,也不要手动设置 `Content-Type: application/json` 或固定 multipart boundary;由 Apifox 自动生成 `multipart/form-data; boundary=...`。 |
|||
|
|||
| 接口 | form-data 文件字段 | 支持类型 | 单文件上限 | |
|||
| --- | --- | --- | --- | |
|||
| `POST /api/v1/users/me/image` | `image` | JPEG、PNG、WebP | 10 MB | |
|||
| `POST /api/v1/users/me/video` | `video` | MP4、MOV、WebM | 100 MB | |
|||
| `POST /api/v1/users/me/identity` | `document` | JPEG、PNG、PDF | 10 MB | |
|||
|
|||
头像和视频成功时返回 `200`,响应含 `id`、`media_type`、`review_status`。`401` 表示缺失/过期的 access token;`422` 常见于文件字段名不对或没有实际选择文件;`415` 表示文件 MIME 类型不支持;`413` 表示文件过大;`503` 表示后端存储服务故障。若出现 `413` 且没有后端 JSON 响应,还应检查 nginx 的请求体大小限制。排查时请提供状态码和响应正文,勿发送 token 或个人文件。 |
|||
|
|||
同一用户再次上传头像或介绍视频时,后端会自动替换对应的旧记录和旧文件,每种只保留最新一份;前端不需要先删除,也不需要再次提交注册资料。若新文件存储失败,旧头像或旧视频保持不变。身份证/护照上传仍按原逻辑保存,不受此替换规则影响。 |
|||
|
|||
## 测试兴趣 |
|||
|
|||
需要登录的情况下,打开 `PUT /api/v1/users/me/interests`,Body 选择 `raw` 和 `JSON`: |
|||
|
|||
```json |
|||
{ |
|||
"interests": [ |
|||
"Photography", |
|||
"Dogs", |
|||
"Travel", |
|||
"我的自定义兴趣" |
|||
] |
|||
} |
|||
``` |
|||
|
|||
兴趣至少提交 2 个,但没有最多 4 个的限制。`Other` 不需要特殊接口,直接把用户输入的内容作为数组中的一项提交即可。查询使用 `GET /api/v1/users/me/interests`。 |
|||
|
|||
## 测试推荐和国家搜索 |
|||
|
|||
注册多个具有 `city`、`country` 和 `interests` 的用户后,使用: |
|||
|
|||
```text |
|||
GET /api/v1/meet?limit=20 |
|||
GET /api/v1/search?city=Shanghai&country=China&limit=20 |
|||
``` |
|||
|
|||
响应中的公开用户包含 `country` 和 `compatibility_percentage`。有效的 Super Like 用户最优先;其余按同城、同国家、其他国家排序,同一组内匹配度高的用户优先。 |
|||
|
|||
## 测试超级喜欢与取消 |
|||
|
|||
使用账号 A 的 Bearer token 发送: |
|||
|
|||
```text |
|||
POST /api/v1/meet/swipes |
|||
``` |
|||
|
|||
```json |
|||
{"target_user_id": 2, "direction": "super_like"} |
|||
``` |
|||
|
|||
账号 B 尚未滑过账号 A 时,使用账号 B 的 token 调用 `GET /api/v1/meet?limit=20`,账号 A 应排在最前面。账号 B 对 A 使用 `like` 或 `super_like` 时会形成匹配。取消键用账号 A 的 token 发送 `DELETE /api/v1/meet/swipes/{账号B的用户ID}`,响应中的 `previous_direction` 为 `super_like`;随后账号 A 在账号 B 的 Meet 中不再享有优先级,已形成的匹配会解除。`pass` 和 `like` 也使用同一个 DELETE 接口撤销。 |
|||
|
|||
## 测试定位更新 |
|||
|
|||
使用 Bearer token 发送 `PUT /api/v1/users/me/location`,Body 选择 raw JSON: |
|||
|
|||
```json |
|||
{"latitude": 31.2304, "longitude": 121.4737, "city": "上海市", "county": "黄浦区"} |
|||
``` |
|||
|
|||
成功返回 `200`,响应包含这四个字段和 `updated_at`。再调用 `GET /api/v1/auth/me`,确认 `profile.city`、`profile.county` 已更新;`profile.country` 是国家,不会被区县覆盖。缺少城市或区县、提交空白名称或非法坐标返回 `422`。 |
|||
|
|||
## 测试创建帖子、探索列表和公开用户帖子 |
|||
|
|||
用作者的 Bearer token 调用 `POST /api/v1/explore/posts`,发送 JSON: |
|||
|
|||
```json |
|||
{ |
|||
"content": "Feeling **happy** and *relaxed* today", |
|||
"post_type": "text", |
|||
"content_format": "markdown", |
|||
"feeling": "happy", |
|||
"location": {"name": "Blue Bottle Coffee, Soho NYC", "latitude": 40.723, "longitude": -73.998} |
|||
} |
|||
``` |
|||
|
|||
成功返回 `201` 和帖子 `id`。`feeling` 为可选的 1-50 字符标签,`location.name` 为选定的地点,坐标可同时省略;文字帖至少要有文字、心情或地点之一。粗体/斜体用 Markdown `**bold**`、`*italic*` 表示,设置 `content_format: "markdown"`;旧客户端不提交该字段时仍为 `plain`。后端返回原始 Markdown,不返回 HTML。音乐字段暂不支持。 |
|||
|
|||
图片/视频帖先把 `post_type` 设为 `image` 或 `video` 创建帖子,记下返回的 `id`,然后调用 `POST /api/v1/explore/posts/{post_id}/media`。Body 选 `form-data`,文件字段必须叫 `media`;一条帖子最多 9 张图片或 1 个视频,不能混用。上传成功后再获取帖子列表。 |
|||
|
|||
使用另一个账号的 Bearer token 调用 `GET /api/v1/explore/feed?limit=20&offset=0` 和 `GET /api/v1/users/{作者用户ID}/posts?limit=20&offset=0`。两者返回的该帖子都应包含相同的 `content_format`、`feeling`、`location` 和 `media[]`;公开用户资料中的 `media[]` 是头像/介绍视频,与帖子媒体不同。 |
|||
@ -0,0 +1,273 @@ |
|||
# Dating App Database Design and Relationships |
|||
|
|||
## 1. Scope |
|||
|
|||
This database design covers the first Function module: account registration and user profile management. |
|||
|
|||
The database is designed for MySQL 8.0 using the `utf8mb4` character set and the `utf8mb4_unicode_ci` collation. |
|||
|
|||
The current scope includes: |
|||
|
|||
- Registration with a unified email-or-phone field |
|||
- Password login |
|||
- WeChat and Google login bindings |
|||
- User profile information |
|||
- Introduction videos |
|||
- ID card or passport documents |
|||
|
|||
> MVP update (2026-08-31): email and phone now share `users.email_or_phone`. OTP is not used, and new accounts are active immediately. Legacy verification columns may remain only for non-destructive database compatibility. |
|||
|
|||
## 2. ER Diagram |
|||
|
|||
Simplified relationship overview: |
|||
|
|||
```text |
|||
users (user master table) |
|||
| 1 |
|||
+-------- 0..1 user_profiles User profile |
|||
+-------- 0..1 user_locations Latest user location |
|||
+-------- 0..N auth_identities WeChat/Google login bindings |
|||
+-------- 0..N user_media Images, videos, and identity documents |
|||
+-------- 0..N swipes Outgoing like/pass/super_like decisions |
|||
+-------- 0..N matches Mutual matches |
|||
+-------- 0..N posts Explore posts |
|||
+-------- 0..N post_likes Post likes |
|||
+-------- 0..N post_comments Post comments |
|||
+-------- 0..N chat_messages Chat messages |
|||
|
|||
verification_codes (verification requests) |
|||
+---- logically associated with registration through target + channel |
|||
``` |
|||
|
|||
```mermaid |
|||
erDiagram |
|||
USERS ||--o| USER_PROFILES : "has one profile" |
|||
USERS ||--o| USER_LOCATIONS : "has one latest location" |
|||
USERS ||--o{ AUTH_IDENTITIES : "binds login methods" |
|||
USERS ||--o{ USER_MEDIA : "uploads media" |
|||
USERS ||--o{ SWIPES : "makes decisions" |
|||
USERS ||--o{ MATCHES : "forms matches" |
|||
USERS ||--o{ POSTS : "publishes posts" |
|||
POSTS ||--o{ POST_LIKES : "receives likes" |
|||
POSTS ||--o{ POST_COMMENTS : "receives comments" |
|||
MATCHES ||--o| CONVERSATIONS : "has conversation" |
|||
CONVERSATIONS ||--o{ CHAT_MESSAGES : "contains messages" |
|||
|
|||
USERS { |
|||
int id PK |
|||
varchar email UK |
|||
varchar phone UK |
|||
varchar password_hash |
|||
varchar status |
|||
boolean email_verified |
|||
boolean phone_verified |
|||
datetime created_at |
|||
datetime updated_at |
|||
} |
|||
|
|||
USER_PROFILES { |
|||
int id PK |
|||
int user_id FK_UK |
|||
varchar full_name |
|||
varchar gender |
|||
varchar birth_date |
|||
varchar city |
|||
varchar country |
|||
text bio |
|||
} |
|||
|
|||
AUTH_IDENTITIES { |
|||
int id PK |
|||
int user_id FK |
|||
varchar provider |
|||
varchar provider_user_id |
|||
datetime created_at |
|||
} |
|||
|
|||
USER_MEDIA { |
|||
int id PK |
|||
int user_id FK |
|||
varchar media_type |
|||
varchar storage_path |
|||
varchar original_name |
|||
varchar mime_type |
|||
varchar review_status |
|||
datetime created_at |
|||
} |
|||
|
|||
VERIFICATION_CODES { |
|||
int id PK |
|||
varchar target |
|||
varchar channel |
|||
varchar code_hash |
|||
datetime expires_at |
|||
datetime used_at |
|||
int attempts |
|||
datetime created_at |
|||
} |
|||
``` |
|||
|
|||
`verification_codes` does not have a direct `user_id` foreign key. A verification code may be sent before a user account has been created, so it is associated with the registration flow through `target` and `channel` (email or phone). After successful verification, the corresponding verification flag in `users` is updated. |
|||
|
|||
## 3. Table Definitions |
|||
|
|||
### 3.1 `users`: User Master Table |
|||
|
|||
| Column | Type | Constraints | Description | |
|||
|---|---|---|---| |
|||
| `id` | INT | Primary key, auto-increment | Unique user identifier | |
|||
| `email` | VARCHAR(320) | Unique, nullable | Email account | |
|||
| `phone` | VARCHAR(32) | Unique, nullable | Phone account | |
|||
| `password_hash` | VARCHAR(256) | Nullable | Hashed password; can be empty for third-party-only accounts | |
|||
| `status` | VARCHAR(20) | Not null | `pending`, `active`, or `suspended` | |
|||
| `email_verified` | BOOLEAN | Default `false` | Whether the email has been verified | |
|||
| `phone_verified` | BOOLEAN | Default `false` | Whether the phone has been verified | |
|||
| `created_at` | DATETIME | Not null | Registration time | |
|||
| `updated_at` | DATETIME | Not null | Last update time | |
|||
|
|||
At least one of email or phone is required for a normal registration. Both fields are nullable to support accounts created through third-party login providers. |
|||
|
|||
### 3.2 `user_profiles`: User Profile Table |
|||
|
|||
| Column | Type | Constraints | Description | |
|||
|---|---|---|---| |
|||
| `id` | INT | Primary key, auto-increment | Profile record identifier | |
|||
| `user_id` | INT | Not null, unique, foreign key | References `users.id` | |
|||
| `full_name` | VARCHAR(120) | Nullable | Name or nickname | |
|||
| `gender` | VARCHAR(30) | Nullable | Gender | |
|||
| `birth_date` | VARCHAR(10) | Nullable | Date of birth in `YYYY-MM-DD` format | |
|||
| `relationship_goal` | VARCHAR(30) | Nullable | Dating goal; new registrations require one of `serious_relationship`, `casual_dates`, `new_friends`, or `marriage` | |
|||
| `city` | VARCHAR(120) | Nullable | Current city | |
|||
| `country` | VARCHAR(120) | Nullable | Current country, used for discovery and search ordering | |
|||
| `bio` | TEXT | Nullable | User biography | |
|||
|
|||
Relationship: one user can have zero or one profile. The profile is deleted automatically when its user is deleted. |
|||
|
|||
### 3.3 `auth_identities`: Third-Party Login Binding Table |
|||
|
|||
| Column | Type | Constraints | Description | |
|||
|---|---|---|---| |
|||
| `id` | INT | Primary key, auto-increment | Binding record identifier | |
|||
| `user_id` | INT | Not null, foreign key | References `users.id` | |
|||
| `provider` | VARCHAR(30) | Not null | `wechat` or `google` | |
|||
| `provider_user_id` | VARCHAR(255) | Not null | User identifier from the provider | |
|||
| `created_at` | DATETIME | Not null | Binding time | |
|||
|
|||
Unique constraint: `(provider, provider_user_id)`. This prevents one WeChat or Google account from being linked to multiple users. |
|||
|
|||
Relationship: one user can have multiple third-party login bindings; each binding belongs to exactly one user. |
|||
|
|||
### 3.4 `verification_codes`: Verification Code Table |
|||
|
|||
| Column | Type | Constraints | Description | |
|||
|---|---|---|---| |
|||
| `id` | INT | Primary key, auto-increment | Verification record identifier | |
|||
| `target` | VARCHAR(320) | Not null, indexed | Email address or phone number | |
|||
| `channel` | VARCHAR(10) | Not null | `email` or `phone` | |
|||
| `code_hash` | VARCHAR(256) | Not null | Hashed verification code; plaintext is not stored | |
|||
| `expires_at` | DATETIME | Not null | Expiration time | |
|||
| `used_at` | DATETIME | Nullable | Time the code was used | |
|||
| `attempts` | INT | Default `0` | Number of failed verification attempts | |
|||
| `created_at` | DATETIME | Not null | Generation time | |
|||
|
|||
Relationship: each row represents one verification request. It is independent from a user record and expires after its validity period. |
|||
|
|||
### 3.5 `user_media`: User Media and Identity Document Table |
|||
|
|||
| Column | Type | Constraints | Description | |
|||
|---|---|---|---| |
|||
| `id` | INT | Primary key, auto-increment | File record identifier | |
|||
| `user_id` | INT | Not null, foreign key | References `users.id` | |
|||
| `media_type` | VARCHAR(20) | Not null | `intro_video` or `identity_document` | |
|||
| `storage_path` | VARCHAR(500) | Not null | Object-storage or filesystem path | |
|||
| `original_name` | VARCHAR(255) | Nullable | Original uploaded filename | |
|||
| `mime_type` | VARCHAR(100) | Nullable | File MIME type | |
|||
| `review_status` | VARCHAR(20) | Default `pending` | `pending`, `approved`, or `rejected` | |
|||
| `created_at` | DATETIME | Not null | Upload time | |
|||
|
|||
Relationship: one user can upload multiple media records. The database stores file metadata and the storage path; the video and identity document binary data are not stored directly in MySQL. |
|||
|
|||
## 4. Relationship Summary |
|||
|
|||
| Parent table | Child table | Relationship | Foreign key | Delete behavior | |
|||
|---|---|---|---|---| |
|||
| `users` | `user_profiles` | One-to-zero-or-one | `user_profiles.user_id` | Cascade delete | |
|||
| `users` | `auth_identities` | One-to-many | `auth_identities.user_id` | Cascade delete | |
|||
| `users` | `user_media` | One-to-many | `user_media.user_id` | Cascade delete | |
|||
| `users` | `verification_codes` | Logical association | `target + channel` | Code expires independently | |
|||
|
|||
## 5. Registration Data Flow |
|||
|
|||
1. The user submits an email or phone number and password. A row is inserted into `users` with status `pending`. |
|||
2. Basic profile information is inserted into `user_profiles`. |
|||
3. The system sends a verification code and stores its hash in `verification_codes`. |
|||
4. After successful verification, `email_verified` or `phone_verified` is set to `true`, and the user status becomes `active`. |
|||
5. When the user uploads an introduction video or identity document, a record is inserted into `user_media` with review status `pending`. |
|||
6. When the user signs in with WeChat or Google, the provider account is stored in `auth_identities` and linked to the corresponding `user_id`. |
|||
|
|||
## 6. Implementation Files |
|||
|
|||
- Data models: [`app/models.py`](../app/models.py) |
|||
- Database connection: [`app/database.py`](../app/database.py) |
|||
- API implementation: [`app/main.py`](../app/main.py) |
|||
- MySQL database creation: [`sql/create_database.sql`](../sql/create_database.sql) |
|||
- MySQL table schema: [`sql/schema.sql`](../sql/schema.sql) |
|||
|
|||
## 7. Meet and Search Extension Tables (2026-08-25) |
|||
|
|||
| Table | Key columns | Relationship and purpose | |
|||
|---|---|---| |
|||
| `user_locations` | `user_id`, `latitude`, `longitude` | One-to-one with a user; supports nearby search and distance ordering; city and county from location requests are saved in `user_profiles` | |
|||
| `swipes` | `actor_id`, `target_id`, `direction` | Stores `like`/`pass`/`super_like`; one decision per user pair, deleted on undo | |
|||
| `matches` | `user1_id`, `user2_id`, `matched_at` | Created after reciprocal `like` or `super_like`; IDs are stored in ascending order to keep each pair unique | |
|||
|
|||
Privacy rule: only `profile_image` and `intro_video` media appear in Meet/Search responses. An `identity_document` can only be downloaded by its owner. Content-based image search depends on ML and remains scheduled for after the Function stage. |
|||
|
|||
## 8. Explore Feed Extension Tables (2026-08-26) |
|||
|
|||
| Table | Key columns | Purpose | |
|||
|---|---|---| |
|||
| `posts` | `user_id`, `content`, `post_type`, `content_format`, `feeling`, `location_name`, `location_latitude`, `location_longitude` | Stores post text, Markdown mode, feeling, and a snapshot of the selected place | |
|||
| `post_media` | `post_id`, `media_type`, `storage_path`, `position` | Stores up to nine images or one video per post | |
|||
| `post_likes` | `post_id`, `user_id` | Stores likes; one user can like a post only once | |
|||
| `post_comments` | `post_id`, `user_id`, `content` | Stores comments and cascades when a post is deleted | |
|||
|
|||
Image/video attachments are already stored through `post_media` after post creation. Music, live streaming, and ML-based post classification remain future work. |
|||
|
|||
## 9. Chat Conversation Extension Tables (2026-08-26) |
|||
|
|||
| Table | Key columns | Purpose | |
|||
|---|---|---| |
|||
| `conversations` | `match_id` | At most one conversation belongs to a mutual match | |
|||
| `chat_messages` | `conversation_id`, `sender_id`, `content` | Stores text messages accessible only to the two matched users | |
|||
|
|||
Chat authorization comes from `matches`: users cannot create a conversation or send messages without a mutual match. Message recall uses a `deleted_at` marker; the record remains for history while the body is hidden from clients. |
|||
|
|||
## 10. Login Session Table (required by Login_Registration.pdf) |
|||
|
|||
`user_sessions` stores refresh-token sessions without storing the plaintext token: |
|||
|
|||
| Column | Description | |
|||
|---|---| |
|||
| `user_id` | User who owns the session | |
|||
| `refresh_token_hash` | Hashed refresh token | |
|||
| `expires_at` | 30-day default expiration | |
|||
| `revoked_at` | Set during logout or token rotation | |
|||
|
|||
Access tokens default to 15 minutes and refresh tokens to 30 days. The app calls `/api/v1/auth/me` at startup. Refresh uses token rotation, so the previous refresh token becomes invalid immediately. |
|||
|
|||
## 11. Post Media and AI Feed Questions (2026-08-27) |
|||
|
|||
| Table | Key columns | Purpose | |
|||
|---|---|---| |
|||
| `post_media` | `post_id`, `media_type`, `storage_path` | Stores post image/video files; one post supports up to 9 images or 1 video | |
|||
| `feed_questions` | `code`, `question_text`, `answer_type` | Stores preference questions with boolean or text answers | |
|||
| `user_question_answers` | `user_id`, `question_id`, answer fields | Stores one latest answer per user and question | |
|||
|
|||
Deleting a post cascades its `post_media` records and the API removes the physical files. Deleting a user or question cascades its answers. The later ML stage can use these structured answers as user-interest and matching features. |
|||
|
|||
## 12. Country and Compatibility Ranking (2026-09-01) |
|||
|
|||
`user_profiles.country` stores the user's country. Meet, user search, and the Explore feed group results by same city, same country, and other countries; results within a group are ranked by compatibility percentage. Compatibility is calculated from existing `user_interests` and mutually answered `user_question_answers`, so no persisted score column is added and scores cannot become stale when profile data changes. |
|||
|
|||
`user_profiles.county` stores the district/county, separately from the nation-level `country`. The location endpoint updates it together with `user_profiles.city` and the coordinates. Existing users may have a NULL county. |
|||
@ -0,0 +1,263 @@ |
|||
# Dating App 数据库表设计与关系 |
|||
|
|||
## 1. 设计范围 |
|||
|
|||
本次先设计注册和用户资料相关的 Function 数据库,使用 MySQL 8.0,字符集为 `utf8mb4`,排序规则为 `utf8mb4_unicode_ci`。 |
|||
|
|||
当前范围包括:账号注册、密码登录、微信/谷歌登录、用户资料、自我介绍视频和身份证/护照文件。 |
|||
|
|||
> 2026-08-31 MVP 更新:邮箱和手机号统一使用 `users.email_or_phone` 一列;MVP 不使用 OTP,新注册账号直接激活。下方旧版验证码描述不再适用于当前接口,数据库迁移见 `sql/migrate_auth_mvp_no_otp.sql`。 |
|||
|
|||
## 2. ER 关系图 |
|||
|
|||
简化关系: |
|||
|
|||
```text |
|||
users (用户主表) |
|||
│ 1 |
|||
├──────── 0..1 user_profiles 用户资料 |
|||
├──────── 0..1 user_locations 用户最近位置 |
|||
├──────── 0..N auth_identities 微信/谷歌登录绑定 |
|||
├──────── 0..N user_media 头像、视频、身份证/护照文件 |
|||
├──────── 0..N swipes 发出的左滑/右滑/超级喜欢 |
|||
├──────── 0..N matches 已建立的双向匹配 |
|||
├──────── 0..N posts Explore 帖子 |
|||
├──────── 0..N post_likes 帖子点赞 |
|||
├──────── 0..N post_comments 帖子评论 |
|||
└──────── 0..N chat_messages 聊天消息 |
|||
|
|||
verification_codes (验证码记录) |
|||
└── 通过 target + channel 与注册账号逻辑关联 |
|||
``` |
|||
|
|||
```mermaid |
|||
erDiagram |
|||
USERS ||--o| USER_PROFILES : "拥有一份资料" |
|||
USERS ||--o| USER_LOCATIONS : "拥有一个最近位置" |
|||
USERS ||--o{ AUTH_IDENTITIES : "绑定登录方式" |
|||
USERS ||--o{ USER_MEDIA : "上传媒体/证件" |
|||
USERS ||--o{ SWIPES : "发起滑动" |
|||
USERS ||--o{ MATCHES : "形成匹配" |
|||
USERS ||--o{ POSTS : "发布帖子" |
|||
POSTS ||--o{ POST_LIKES : "收到点赞" |
|||
POSTS ||--o{ POST_COMMENTS : "收到评论" |
|||
MATCHES ||--o| CONVERSATIONS : "拥有会话" |
|||
CONVERSATIONS ||--o{ CHAT_MESSAGES : "包含消息" |
|||
|
|||
USERS { |
|||
int id PK |
|||
varchar email UK |
|||
varchar phone UK |
|||
varchar password_hash |
|||
varchar status |
|||
boolean email_verified |
|||
boolean phone_verified |
|||
datetime created_at |
|||
datetime updated_at |
|||
} |
|||
|
|||
USER_PROFILES { |
|||
int id PK |
|||
int user_id FK_UK |
|||
varchar full_name |
|||
varchar gender |
|||
varchar birth_date |
|||
varchar city |
|||
varchar country |
|||
text bio |
|||
} |
|||
|
|||
AUTH_IDENTITIES { |
|||
int id PK |
|||
int user_id FK |
|||
varchar provider |
|||
varchar provider_user_id |
|||
datetime created_at |
|||
} |
|||
|
|||
USER_MEDIA { |
|||
int id PK |
|||
int user_id FK |
|||
varchar media_type |
|||
varchar storage_path |
|||
varchar original_name |
|||
varchar mime_type |
|||
varchar review_status |
|||
datetime created_at |
|||
} |
|||
|
|||
VERIFICATION_CODES { |
|||
int id PK |
|||
varchar target |
|||
varchar channel |
|||
varchar code_hash |
|||
datetime expires_at |
|||
datetime used_at |
|||
int attempts |
|||
datetime created_at |
|||
} |
|||
``` |
|||
|
|||
说明:`VERIFICATION_CODES` 暂时不设置 `user_id` 外键。用户还没有注册成功时,就需要先发送验证码,因此验证码通过 `target + channel`(邮箱或手机号)关联注册流程;验证成功后再更新 `USERS.email_verified` 或 `USERS.phone_verified`。 |
|||
|
|||
## 3. 表结构 |
|||
|
|||
### 3.1 users:用户主表 |
|||
|
|||
| 字段 | 类型 | 约束 | 说明 | |
|||
|---|---|---|---| |
|||
| `id` | INT | 主键、自增 | 用户唯一编号 | |
|||
| `email` | VARCHAR(320) | 唯一、可为空 | 邮箱账号 | |
|||
| `phone` | VARCHAR(32) | 唯一、可为空 | 手机号账号 | |
|||
| `password_hash` | VARCHAR(256) | 可为空 | 加密后的密码;第三方登录用户可以为空 | |
|||
| `status` | VARCHAR(20) | 非空 | `pending`、`active`、`suspended` | |
|||
| `email_verified` | BOOLEAN | 默认 false | 邮箱是否已验证 | |
|||
| `phone_verified` | BOOLEAN | 默认 false | 手机号是否已验证 | |
|||
| `created_at` | DATETIME | 非空 | 注册时间 | |
|||
| `updated_at` | DATETIME | 非空 | 最后更新时间 | |
|||
|
|||
注册账号至少提供邮箱或手机号之一。邮箱和手机号都允许为空,是为了支持只使用第三方账号注册。 |
|||
|
|||
### 3.2 user_profiles:用户个人资料表 |
|||
|
|||
| 字段 | 类型 | 约束 | 说明 | |
|||
|---|---|---|---| |
|||
| `id` | INT | 主键、自增 | 资料记录编号 | |
|||
| `user_id` | INT | 非空、唯一、外键 | 关联 `users.id` | |
|||
| `full_name` | VARCHAR(120) | 可为空 | 姓名/昵称 | |
|||
| `gender` | VARCHAR(30) | 可为空 | 性别 | |
|||
| `birth_date` | VARCHAR(10) | 可为空 | 生日,格式 `YYYY-MM-DD` | |
|||
| `relationship_goal` | VARCHAR(30) | 可为空 | 交友目标;新注册必须从 `serious_relationship`、`casual_dates`、`new_friends`、`marriage` 中单选 | |
|||
| `city` | VARCHAR(120) | 可为空 | 所在城市 | |
|||
| `country` | VARCHAR(120) | 可为空 | 所在国家,用于推荐和搜索排序 | |
|||
| `bio` | TEXT | 可为空 | 个人简介 | |
|||
|
|||
关系:一个用户最多有一份个人资料;删除用户时级联删除资料。 |
|||
|
|||
### 3.3 auth_identities:第三方登录绑定表 |
|||
|
|||
| 字段 | 类型 | 约束 | 说明 | |
|||
|---|---|---|---| |
|||
| `id` | INT | 主键、自增 | 绑定记录编号 | |
|||
| `user_id` | INT | 非空、外键 | 关联 `users.id` | |
|||
| `provider` | VARCHAR(30) | 非空 | `wechat` 或 `google` | |
|||
| `provider_user_id` | VARCHAR(255) | 非空 | 第三方平台用户编号 | |
|||
| `created_at` | DATETIME | 非空 | 绑定时间 | |
|||
|
|||
唯一约束:`provider + provider_user_id`,防止同一个微信或谷歌账号绑定多个用户。 |
|||
|
|||
关系:一个用户可以绑定多个第三方登录方式;一条第三方绑定记录只属于一个用户。 |
|||
|
|||
### 3.4 verification_codes:验证码表 |
|||
|
|||
| 字段 | 类型 | 约束 | 说明 | |
|||
|---|---|---|---| |
|||
| `id` | INT | 主键、自增 | 验证码记录编号 | |
|||
| `target` | VARCHAR(320) | 非空、索引 | 邮箱地址或手机号 | |
|||
| `channel` | VARCHAR(10) | 非空 | `email` 或 `phone` | |
|||
| `code_hash` | VARCHAR(256) | 非空 | 验证码哈希值,不保存明文验证码 | |
|||
| `expires_at` | DATETIME | 非空 | 过期时间 | |
|||
| `used_at` | DATETIME | 可为空 | 使用时间,未使用时为空 | |
|||
| `attempts` | INT | 默认 0 | 验证失败次数 | |
|||
| `created_at` | DATETIME | 非空 | 生成时间 | |
|||
|
|||
关系:验证码属于一次验证请求,不直接绑定用户。验证成功后更新 `users` 表的验证状态。 |
|||
|
|||
### 3.5 user_media:用户媒体和身份文件表 |
|||
|
|||
| 字段 | 类型 | 约束 | 说明 | |
|||
|---|---|---|---| |
|||
| `id` | INT | 主键、自增 | 文件记录编号 | |
|||
| `user_id` | INT | 非空、外键 | 关联 `users.id` | |
|||
| `media_type` | VARCHAR(20) | 非空 | `intro_video` 或 `identity_document` | |
|||
| `storage_path` | VARCHAR(500) | 非空 | 文件存储地址 | |
|||
| `original_name` | VARCHAR(255) | 可为空 | 原始文件名 | |
|||
| `mime_type` | VARCHAR(100) | 可为空 | 文件类型 | |
|||
| `review_status` | VARCHAR(20) | 默认 pending | `pending`、`approved`、`rejected` | |
|||
| `created_at` | DATETIME | 非空 | 上传时间 | |
|||
|
|||
关系:一个用户可以上传多个文件。数据库只保存文件地址和审核信息,视频、身份证和护照文件本身不直接存进数据库。 |
|||
|
|||
## 4. 关系汇总 |
|||
|
|||
| 主表 | 从表 | 关系 | 外键 | 删除策略 | |
|||
|---|---|---|---|---| |
|||
| `users` | `user_profiles` | 一对零或一 | `user_profiles.user_id` | 删除用户时级联删除 | |
|||
| `users` | `auth_identities` | 一对多 | `auth_identities.user_id` | 删除用户时级联删除 | |
|||
| `users` | `user_media` | 一对多 | `user_media.user_id` | 删除用户时级联删除 | |
|||
| `users` | `verification_codes` | 逻辑关联 | `target + channel` | 验证码独立过期 | |
|||
|
|||
## 5. 注册流程对应的数据变化 |
|||
|
|||
1. 用户提交邮箱/手机号和密码,写入 `users`,初始状态为 `pending`。 |
|||
2. 用户填写个人信息,写入 `user_profiles`。 |
|||
3. 系统发送验证码,写入 `verification_codes`,只保存验证码哈希。 |
|||
4. 验证成功后,更新 `users.email_verified` 或 `users.phone_verified`,并将状态改为 `active`。 |
|||
5. 用户上传视频或证件,在 `user_media` 中保存文件记录,初始审核状态为 `pending`。 |
|||
6. 微信/谷歌登录时,在 `auth_identities` 保存第三方账号和对应的 `user_id`。 |
|||
|
|||
## 6. 当前实现文件 |
|||
|
|||
- 数据模型:[app/models.py](../app/models.py) |
|||
- 数据库连接:[app/database.py](../app/database.py) |
|||
- 接口实现:[app/main.py](../app/main.py) |
|||
- MySQL 建库脚本:[sql/create_database.sql](../sql/create_database.sql) |
|||
|
|||
## 7. Meet 与 Search 扩展表(2026-08-25) |
|||
|
|||
| 表 | 关键字段 | 关系与用途 | |
|||
|---|---|---| |
|||
| `user_locations` | `user_id`、`latitude`、`longitude` | 与用户一对一;用于附近的人和距离排序;位置请求的城市、区县保存在 `user_profiles` | |
|||
| `swipes` | `actor_id`、`target_id`、`direction` | 记录 `like`/`pass`/`super_like`;同一用户对唯一,撤销时删除记录 | |
|||
| `matches` | `user1_id`、`user2_id`、`matched_at` | 双方相互喜欢(`like` 或 `super_like`)后生成;用户编号按小到大保存,确保一对用户只有一个匹配 | |
|||
|
|||
隐私规则:`user_media` 中只有 `profile_image` 和 `intro_video` 会出现在 Meet/Search 响应里;`identity_document` 仅文件所属用户可访问。图片内容相似搜索依赖 ML,留到 Function 阶段完成后实现。 |
|||
|
|||
## 8. Explore 信息流扩展表(2026-08-26) |
|||
|
|||
| 表 | 关键字段 | 用途 | |
|||
|---|---|---| |
|||
| `posts` | `user_id`、`content`、`post_type`、`content_format`、`feeling`、`location_name`、`location_latitude`、`location_longitude` | 保存帖子文字、Markdown 格式、心情和所选地点的快照 | |
|||
| `post_media` | `post_id`、`media_type`、`storage_path`、`position` | 保存帖子图片或视频,最多 9 张图片或 1 个视频 | |
|||
| `post_likes` | `post_id`、`user_id` | 记录用户对帖子的点赞;同一用户对同一帖子只能点赞一次 | |
|||
| `post_comments` | `post_id`、`user_id`、`content` | 保存帖子评论,删除帖子时级联删除 | |
|||
|
|||
图片/视频附件已通过 `post_media` 接入;帖子创建后再上传媒体。音乐、直播和 ML 帖子分类尚未接入。 |
|||
|
|||
## 9. Chat 会话扩展表(2026-08-26) |
|||
|
|||
| 表 | 关键字段 | 用途 | |
|||
|---|---|---| |
|||
| `conversations` | `match_id` | 一个互相匹配关系最多对应一个会话 | |
|||
| `chat_messages` | `conversation_id`、`sender_id`、`content` | 保存文本消息;消息只能由会话双方访问 | |
|||
|
|||
聊天权限来自 `matches` 表:没有互相喜欢形成的匹配关系,不能创建会话或发送消息。消息撤回采用 `deleted_at` 标记,历史记录仍保留但对客户端隐藏正文。 |
|||
|
|||
## 10. 登录会话表(Login_Registration.pdf 要求) |
|||
|
|||
`user_sessions` 保存 refresh token 会话,不保存明文 token: |
|||
|
|||
| 字段 | 说明 | |
|||
|---|---| |
|||
| `user_id` | 会话所属用户 | |
|||
| `refresh_token_hash` | refresh token 的哈希值 | |
|||
| `expires_at` | 30 天默认过期时间 | |
|||
| `revoked_at` | logout 或 token 轮换后的撤销时间 | |
|||
|
|||
access token 默认 15 分钟,refresh token 默认 30 天。应用启动时调用 `/api/v1/auth/me`;refresh 接口采用轮换策略,旧 refresh token 立即失效。 |
|||
|
|||
## 11. 帖子媒体与 AI Feed 问题(2026-08-27) |
|||
|
|||
| 表 | 关键字段 | 用途 | |
|||
|---|---|---| |
|||
| `post_media` | `post_id`、`media_type`、`storage_path` | 保存帖子图片/视频的文件信息,一条帖子最多 9 张图片或 1 个视频 | |
|||
| `feed_questions` | `code`、`question_text`、`answer_type` | 保存系统提供的偏好问题,支持布尔和文字回答 | |
|||
| `user_question_answers` | `user_id`、`question_id`、回答内容 | 保存用户答案;同一用户对同一问题只有一条最新答案 | |
|||
|
|||
删除帖子时级联删除 `post_media` 数据库记录,同时由接口删除实际文件。删除用户或问题时级联删除对应答案。AI 阶段将使用这些结构化答案训练或计算用户兴趣与匹配特征。 |
|||
|
|||
## 12. 国家和匹配度推荐(2026-09-01) |
|||
|
|||
`user_profiles.country` 保存用户所在国家。Meet、用户搜索和 Explore 信息流按同城、同国家、其他国家分组;同组内按匹配百分比排序。匹配百分比由现有 `user_interests` 和双方共同回答的 `user_question_answers` 实时计算,因此不新增持久化分数字段,避免用户资料变化后产生过期分数。 |
|||
|
|||
`user_profiles.county` 保存区县,和国家 `country` 不同;与经纬度一同通过位置接口提交。位置接口同步更新 `user_profiles.city`,供同城推荐使用。历史用户的区县可为空。 |
|||
File diff suppressed because it is too large
@ -0,0 +1,20 @@ |
|||
# 周工作总结(2026.10.19—10.25) |
|||
|
|||
## 本周工作 |
|||
|
|||
1. 完成并发布 MeLOVE API 0.6.10、0.6.11、0.6.12。 |
|||
2. 新增资料字段、评论回复与点赞、匹配后喜欢名单过滤等功能。 |
|||
3. 完成数据库迁移、OpenAPI 和接口文档更新。 |
|||
4. 本地测试 30 项全部通过,线上烟测通过。 |
|||
|
|||
## 问题与思考 |
|||
|
|||
修复 Jenkins 迁移输入问题。发布涉及数据库变更时,必须先迁移并核验结构,再部署代码。 |
|||
|
|||
## 下周计划 |
|||
|
|||
1. 创建 `lishibo/milian` 仓库并上传源码、SQL、测试、部署文件和交接文档。 |
|||
2. 排除数据库快照、用户数据、日志、密钥和无关文件。 |
|||
3. 确认前端是否已接入 0.6.12 新接口。 |
|||
|
|||
备注:仓库迁移目前等待 Gitea Token,尚未完成。 |
|||
@ -0,0 +1,848 @@ |
|||
# Dating App 后端项目交接总览 |
|||
|
|||
> 这份文档是给后续接手本项目的开发者或模型使用的工作记录。每次老板提出新要求、代码完成新功能或数据库发生变更,都要同步更新本文件。 |
|||
|
|||
## 1. 项目基本信息 |
|||
|
|||
- 项目:Dating App Function 阶段后端 |
|||
- 技术:FastAPI、SQLAlchemy 2、MySQL 8、JWT |
|||
- 工作目录:`E:\homework\工作` |
|||
- 正式数据库:MySQL `dating_app` |
|||
- 本地数据库连接:`127.0.0.1:3306` |
|||
- 当前代码和正式环境 API 版本:`0.6.12` |
|||
- 最近成功发布:2026-10-21 Jenkins `#46` |
|||
- 最新 OpenAPI 文件:`docs/openapi-0.6.12.json` |
|||
- Postman 说明:`docs/Postman导入说明.md` |
|||
- 自动化测试命令:`python -m pytest -q` |
|||
|
|||
当前代码曾使用过 `8000` 端口的旧服务。新版服务测试地址是 `http://127.0.0.1:8001`,打开 `http://127.0.0.1:8001/docs` 可以查看 Swagger。不要把旧的临时外网地址当成当前可用地址;`localhost` 只能由本机访问。 |
|||
|
|||
## 2. 老板已确认的核心要求 |
|||
|
|||
### 已确认并已实现 |
|||
|
|||
1. MVP 阶段不使用一次性密码 OTP。 |
|||
2. 邮箱和手机号不再是两个输入项,统一为一个输入字段:`email_or_phone`。 |
|||
3. 邮箱或手机号存储在同一个数据库字段:`users.email_or_phone`。 |
|||
4. 注册成功后账号立即激活,不需要再请求验证码或验证验证码。 |
|||
5. 注册接口只返回 token,不返回完整用户 JSON。 |
|||
6. 注册和登录字段、异常情况需要重新检查,并补充单元/接口测试。 |
|||
7. 注册页面需要邮箱或手机号、密码和确认密码;后端也必须校验两次密码一致。 |
|||
8. 每周日下午 5 点前通过微信向老板提交周工作总结(形式不限),内容包含本周开展的工作、发现或解决的问题、对项目或产品的思考以及下周工作安排。 |
|||
|
|||
### 图片中刚提出、已实现的要求 |
|||
|
|||
老板最新图片表达的是:用户选择兴趣选项后,点击 `Other` 可以增加额外兴趣,最多添加 1、2、3、4 个。后续用户明确要求不限制最多数量。 |
|||
|
|||
后端现在支持这个功能。兴趣独立保存,数量不设上限;提交时至少需要 2 个。现有的 `bio` 和 Feed 问答不代替兴趣功能。 |
|||
|
|||
已完成内容: |
|||
|
|||
- 用户兴趣关联表 `user_interests`; |
|||
- `Other` 自定义兴趣保存; |
|||
- 至少 2 个兴趣校验,不限制最多数量; |
|||
- 获取和替换当前用户兴趣的接口; |
|||
- 注册和个人资料响应返回兴趣列表。 |
|||
|
|||
建议的请求数据形式: |
|||
|
|||
```json |
|||
{ |
|||
"interests": [ |
|||
"Photography", |
|||
"Dogs", |
|||
"我的自定义兴趣" |
|||
] |
|||
} |
|||
``` |
|||
|
|||
目前兴趣功能已完成代码、数据库迁移和自动化测试;预设兴趣选项由客户端展示,后端允许任意文本,包含 `Other` 自定义值。 |
|||
|
|||
### 2026-10-10 线上测试账号与功能验收要求 |
|||
|
|||
- 对方要求创建 20 个新用户,测试喜欢/跳过、帖子及互动、聊天等接口,并提供一个可登录的测试账号。 |
|||
- 用户明确限定只清理能识别的旧测试账号;由于没有旧账号清单,不能删除正式环境现有用户。本次创建的 20 个 QA 账号保留作前端联调。 |
|||
- 登录接口使用 `email_or_phone` 和密码,数字用户 ID 用于用户资料、滑动等路径参数,不能代替登录账号。 |
|||
|
|||
### 2026-10-14 帖子/评论作者头像与点赞名单要求 |
|||
|
|||
- 用户授权按前端两张截图直接更新功能:评论列表作者有头像,Explore/帖子作者有头像,帖子显示点赞者 ID 和姓名。 |
|||
- 对方最后补充希望点赞的同一个接口返回点赞者 ID/姓名,因此 POST 点赞改为成功信息、数量、本人状态和简短名单,不再返回完整帖子正文/媒体。 |
|||
- 本轮未要求发布,交付本地 API 0.6.9 及文档,正式版本仍以最后确认的 0.6.8 为准。 |
|||
|
|||
## 3. 按时间记录的已完成工作 |
|||
|
|||
### 2026-09-03:临时公网地址尝试 |
|||
|
|||
- 本地 API 已用独立 SQLite 演示数据库启动在 `http://127.0.0.1:8001`,未修改正式 MySQL 数据。 |
|||
- Cloudflare Quick Tunnel 能生成临时域名,但当前网络无法解析 `argotunnel.com` 的边缘 SRV 记录,外部访问返回 `530`,因此生成的域名不可交付。 |
|||
- `localtunnel` 在当前网络环境也未能建立连接。 |
|||
- 当前没有可用公网 `base_url`;恢复 DNS/网络后可运行 `cloudflared tunnel --url http://127.0.0.1:8001`,并用 `/health` 返回 `{"status":"ok"}` 验证后再交给前端。 |
|||
- 本次临时演示启动脚本为 `start_temp_api_v060.py`,仅用于本地 SQLite 演示,不应作为正式部署配置。 |
|||
|
|||
### 第一天:注册、登录和基础资料 |
|||
|
|||
- 完成邮箱/手机号注册、密码登录。 |
|||
- 密码使用哈希保存,不保存明文密码。 |
|||
- 完成 access token、refresh token、刷新和退出登录。 |
|||
- 完成 `/api/v1/auth/me` 启动时检查登录状态。 |
|||
- 完成基础资料:姓名、性别、生日、城市、简介。 |
|||
- 完成头像、介绍视频、身份证/护照上传。 |
|||
- 完成 MySQL 基础表和外键关系。 |
|||
|
|||
### 第二天:Meet、滑动匹配和搜索 |
|||
|
|||
- 获取还没有滑过的候选用户。 |
|||
- 左滑跳过、右滑喜欢。 |
|||
- 双方互相喜欢时自动匹配。 |
|||
- 取消喜欢时删除对应匹配。 |
|||
- 按姓名、简介、城市和距离搜索。 |
|||
- 保存用户位置并计算附近距离。 |
|||
- 保护身份证/护照文件,不在公开候选资料中暴露。 |
|||
|
|||
### 第三天:Explore 和 Chat |
|||
|
|||
- 文字/图片/视频类型帖子。 |
|||
- Explore 信息流、帖子详情、删除自己的帖子。 |
|||
- 帖子点赞、取消点赞、防止重复点赞。 |
|||
- 评论新增、列表和删除自己的评论。 |
|||
- 一对一聊天会话,只有匹配双方可以访问。 |
|||
- 发送文字、分页读取消息、撤回自己的消息。 |
|||
|
|||
### 第四天:帖子媒体和 AI Feed 问答 |
|||
|
|||
- 帖子图片和短视频上传、下载、删除。 |
|||
- 支持 JPG、PNG、WebP、MP4、MOV、WebM。 |
|||
- 每个帖子最多 9 张图片或 1 个视频,不能混用。 |
|||
- 五个默认双语 Feed 偏好问题。 |
|||
- 支持布尔和文本回答。 |
|||
- 支持回答新增、修改、查看、筛选未回答和删除。 |
|||
|
|||
### 最近一次认证改动:MVP 0.4.0 |
|||
|
|||
- 删除应用中的 `/api/v1/auth/request-code` 和 `/api/v1/auth/verify` 路由。 |
|||
- `RegisterRequest` 和 `LoginRequest` 都使用 `email_or_phone`。 |
|||
- 邮箱自动转小写,手机号/其他账号会去除首尾空格。 |
|||
- 注册用户状态直接为 `active`。 |
|||
- 注册响应只有:`access_token`、`refresh_token`、`token_type`。 |
|||
- `UserResponse` 使用 `email_or_phone`,不再返回 `email`、`phone`、验证状态字段。 |
|||
- MySQL 迁移脚本:`sql/migrate_auth_mvp_no_otp.sql`。 |
|||
- 数据库旧的邮箱、手机号、验证字段和 `verification_codes` 表没有删除,以避免破坏历史数据;新代码不再使用它们。 |
|||
|
|||
### 最新兴趣功能:API 0.5.0 |
|||
|
|||
- 新增 `user_interests` 表,一名用户可以拥有任意数量的兴趣。 |
|||
- 注册请求可以直接携带 `interests`,个人资料响应会返回兴趣列表。 |
|||
- `GET /api/v1/users/me/interests` 获取兴趣。 |
|||
- `PUT /api/v1/users/me/interests` 整体替换兴趣列表。 |
|||
- 每次提交至少 2 个兴趣,不设置最多 4 个的限制。 |
|||
- 兴趣会去除首尾空格并按不区分大小写的方式去重;空值会被拒绝。 |
|||
- `Other` 不需要单独的数据库字段,客户端把自定义文本作为普通兴趣项提交。 |
|||
- 数据库迁移脚本:`sql/migrate_user_interests.sql`。 |
|||
|
|||
### 注册确认密码:API 0.5.1 |
|||
|
|||
- `RegisterRequest` 新增必填字段 `confirm_password`。 |
|||
- 后端注册时校验 `password == confirm_password`,缺失或不一致返回 `422`。 |
|||
- `confirm_password` 只用于校验,不保存到数据库,也不会出现在任何响应中。 |
|||
- OpenAPI 文件更新为 `docs/openapi-0.5.1.json`。 |
|||
|
|||
### 2026-09-01:注册接口共享文档交付 |
|||
|
|||
- 根据老板要求,将注册部分单独整理成简洁的前后端对接说明,不再把全部接口一次性放入共享文档。 |
|||
- 注册接口文档:`docs/注册接口-共享文档内容.md`。 |
|||
- 文档只保留注册 URL、前端发送的 JSON、后端返回的 token、参数规则和错误示例。 |
|||
- 注册 URL:`POST {{base_url}}/api/v1/auth/register`。 |
|||
- 前端必须发送 `email_or_phone`、`password`、`confirm_password`;邮箱或手机号使用同一个字段。 |
|||
- 后端成功返回 `201`,只返回 `access_token`、`refresh_token`、`token_type`,不返回完整用户 JSON。 |
|||
- 已记录 `422`(缺少字段、确认密码不一致、空账号)和 `409`(账号已注册)示例。 |
|||
- 文档使用 `{{base_url}}` 占位符;本地测试地址为 `http://127.0.0.1:8001`,部署后只需替换 Base URL。 |
|||
- 本次注册相关自动化测试结果:`3 passed, 10 deselected`。 |
|||
- 本次只对注册相关输入输出进行了实际测试;其他接口以完整 OpenAPI 和已有测试记录为准,不宣称全部接口逐条实测。 |
|||
|
|||
### 2026-09-01:同城、同国家和匹配度排序(API 0.6.0) |
|||
|
|||
- 根据共享 PDF 的蓝色修改,为用户资料新增可选字段 `country`。 |
|||
- `GET /api/v1/meet` 按同城、同国家、其他国家分组,同组内按匹配百分比从高到低返回。 |
|||
- `GET /api/v1/search` 新增独立的 `city`、`country` 查询参数,可单独使用或组合使用;文本搜索 `q` 也会搜索国家。 |
|||
- `GET /api/v1/explore/feed` 使用同样的位置和匹配度优先顺序,最后以新帖优先打破并列。 |
|||
- 新排序使用 `offset` 分页;旧 `before_id` 保留为按时间 ID 截断的兼容模式,不能和非零 `offset` 同时使用。 |
|||
- `DiscoveryUserResponse` 和 Explore 作者响应新增 `country`、`compatibility_percentage`。 |
|||
- 匹配度由共同兴趣的 Jaccard 相似度与双方共同回答的 Feed 问题答案一致率计算;只有一种可比较数据时使用该项,两种都有时取平均,没有数据时返回 0。 |
|||
- PDF 把头像和介绍视频写进注册 JSON,这在二进制上传上不可用;已在精简共享文档中修正为注册成功后分别调用 `/api/v1/users/me/image` 和 `/api/v1/users/me/video`。 |
|||
- 非破坏性 MySQL 迁移:`sql/migrate_country_and_matching.sql`。 |
|||
- 用户已于 2026-09-01 确认在正式 MySQL `dating_app` 执行 `sql/migrate_country_and_matching.sql`。当前环境此前通过命令行连接时返回 `1045 Access denied`,因此本次执行结果来自用户确认;后续获得可用的 `DATABASE_URL` 后仍应通过 `DESCRIBE user_profiles` 和 `SHOW INDEX FROM user_profiles` 复核。 |
|||
- 精简共享内容:`docs/注册接口-共享文档内容.md`,只保留 URL、Frontend sends、Backend returns。 |
|||
- 安全要求核查:`docs/security-requirements-review-2026-09-01.md`。JWT、短 access token、refresh 轮换、密码哈希已实现;生产 HTTPS、AES-256-GCM 混合加密和密钥管理尚未实现,不能对外宣称已完成。 |
|||
|
|||
## 4. 当前主要接口 |
|||
|
|||
### 认证 |
|||
|
|||
- `POST /api/v1/auth/register`:统一账号、密码和确认密码注册,直接返回 token。 |
|||
- `POST /api/v1/auth/login`:统一账号密码登录。 |
|||
- `POST /api/v1/auth/refresh`:刷新并轮换 token。 |
|||
- `POST /api/v1/auth/logout`:撤销 refresh session。 |
|||
- `GET /api/v1/auth/me`:获取当前用户。 |
|||
- `POST /api/v1/auth/oauth`:微信/谷歌登录绑定占位接口。 |
|||
|
|||
本次注册共享文档:`docs/注册接口-共享文档内容.md`。 |
|||
|
|||
### 用户、Meet 和搜索 |
|||
|
|||
- `GET/PATCH /api/v1/users/me` |
|||
- `GET /api/v1/users/me/interests` |
|||
- `PUT /api/v1/users/me/interests` |
|||
- `PUT /api/v1/users/me/location` |
|||
- `POST /api/v1/users/me/image` |
|||
- `POST /api/v1/users/me/video` |
|||
- `POST /api/v1/users/me/identity` |
|||
- `GET /api/v1/meet` |
|||
- `POST /api/v1/meet/swipes`:`pass`、`like`、`super_like` |
|||
- `DELETE /api/v1/meet/swipes/{target_user_id}`:取消键,撤销三种滑动 |
|||
- `DELETE /api/v1/meet/likes/{target_user_id}`:兼容旧客户端的取消喜欢 |
|||
- `GET /api/v1/matches` |
|||
- `GET /api/v1/search` |
|||
|
|||
### Explore、Chat 和 Feed |
|||
|
|||
- `/api/v1/explore/posts`、`/api/v1/explore/feed`、帖子媒体、点赞和评论接口。帖子/评论的 `author.profile_image_url` 表示作者头像;完整帖子含 `liked_by_users` 简短名单。 |
|||
- `GET /api/v1/explore/posts/{post_id}/like` 返回点赞者 ID/姓名数组;POST 同路径返回 `PostLikeResponse` 精简结果,DELETE 同路径继续返回完整 `PostResponse`。 |
|||
- `/api/v1/chat/conversations`、消息发送、分页和撤回接口。 |
|||
- `/api/v1/feed/questions`、回答新增/修改/删除、我的回答接口。 |
|||
|
|||
0.6.9 参数和响应以 `docs/openapi-0.6.9.json` 为准;正式环境已发布并验证。 |
|||
|
|||
## 5. 数据库现状 |
|||
|
|||
核心表包括: |
|||
|
|||
- `users` |
|||
- `user_profiles` |
|||
- `user_interests` |
|||
- `user_locations` |
|||
- `user_sessions` |
|||
- `auth_identities` |
|||
- `user_media` |
|||
- `swipes` |
|||
- `matches` |
|||
- `posts` |
|||
- `post_media` |
|||
- `post_likes` |
|||
- `post_comments` |
|||
- `conversations` |
|||
- `chat_messages` |
|||
- `feed_questions` |
|||
- `user_question_answers` |
|||
|
|||
当前 `users` 新字段为: |
|||
|
|||
```text |
|||
email_or_phone VARCHAR(320) UNIQUE |
|||
``` |
|||
|
|||
当前 `user_profiles` 新增字段为: |
|||
|
|||
```text |
|||
country VARCHAR(120) NULL |
|||
``` |
|||
|
|||
认证迁移文件:`sql/migrate_auth_mvp_no_otp.sql`。国家字段迁移文件:`sql/migrate_country_and_matching.sql`。迁移均为非破坏性,不主动删除历史数据。 |
|||
|
|||
## 6. 测试和交付状态 |
|||
|
|||
最后一次完整自动化测试结果(本地 API 0.6.12,2026-10-21): |
|||
|
|||
```text |
|||
26 passed |
|||
``` |
|||
|
|||
测试覆盖注册、登录、邮箱大小写、手机号登录、错误密码、参数校验、OTP 路由不存在、token 刷新、退出、个人资料、Meet、搜索、匹配、媒体、Explore、Chat 和 Feed 问答。 |
|||
|
|||
以下为早期真实 MySQL 联调记录,版本号不代表当前正式环境: |
|||
|
|||
- 注册返回的 key 只有 `access_token`、`refresh_token`、`token_type`; |
|||
- 注册账号状态为 `active`; |
|||
- 邮箱大小写不同仍能登录; |
|||
- `/health` 返回 `{"status":"ok"}`; |
|||
- OpenAPI 版本为 `0.6.0`,包含 `confirm_password`、`country`、城市/国家搜索和匹配度响应,不包含 OTP 两个路径。 |
|||
|
|||
2026-10-10 正式环境人工接口验收:新建 20 个合成 QA 账号(ID `54`–`73`),20 个账号均可登录、公开资料均可读取。使用这些账号完成 51 项检查,覆盖个人资料、兴趣、位置、头像上传和读取、Meet 和搜索、pass/like/super_like 及撤销、双向喜欢/匹配和取消、活动总数、文字/图片帖子和媒体读取、Explore 和公开帖子列表、点赞/取消点赞、评论新增/删除、聊天创建/发送/撤回、Feed 问答新增/删除、token 刷新和退出。保留 1 组匹配、示例帖子及聊天供联调;临时创建的匹配、帖子、评论、媒体和问答已按测试步骤清理。未测试介绍视频的真实播放、证件上传、OAuth 登录或全部 20 人互相操作,不能据此宣称所有接口均已实测。 |
|||
|
|||
## 7. 后续模型接手流程 |
|||
|
|||
每次收到新需求时按以下顺序处理: |
|||
|
|||
1. 先读取本文件、`README.md`、相关设计文档和最新 OpenAPI 文件。 |
|||
2. 明确新需求属于“已完成功能调整”还是“全新待开发功能”。 |
|||
3. 检查现有模型、schema、路由、SQL 和测试,避免重复造接口。 |
|||
4. 修改代码后补对应测试。 |
|||
5. 运行 `python -m pytest -q`。 |
|||
6. 如果改了接口,重新生成最新的 OpenAPI 文件或升级版本号。 |
|||
7. 如果改了数据库,新增非破坏性迁移脚本,并记录执行结果。 |
|||
8. 更新本文件的“老板要求”“已完成工作”“当前接口”“测试和交付状态”。 |
|||
9. 对照共享的 `Dating APP api` 文档(以用户提供的最新 PDF 或 Google 文档版本为准),整理本次新增或改变的接口说明,明确插入位置和可直接粘贴的内容;用户确认已写过的部分不要重复提供。 |
|||
10. 用中文向用户说明:完成了什么、还缺什么、Postman 如何测试,以及共享文档这次需要补充什么。 |
|||
|
|||
固定交付规则:任何代码、接口、数据库、测试结果或对接文档更新,都必须在同一次任务中同步更新本文件,不能留到后续补记。 |
|||
|
|||
对外沟通语言规则:用户看不懂英文。凡是拟回复外国同事的聊天消息,先用中文向用户解释对方说了什么,并把建议回复完整写成中文供用户审阅;用户没有明确要求英文版时,不直接给英文回复,也不声称已经替用户发送。共享 API 文档如需按原格式提供英文接口内容,应另附中文说明,不能用英文对外回复代替中文解释。 |
|||
|
|||
共享文档维护规则:`Dating APP api` 是与对方协作的接口文档。以后每次新增或修改接口、请求/响应字段、调用顺序、约束或上线状态,都要在交付时核对它,并按原文的 `URL`、`Authentication`、`Request`、`Backend Response` 等格式提供准确的英文补充内容和具体插入位置。以当前后端代码及最新 OpenAPI 为事实依据;本地 PDF 只是导出时的快照,不能据此断言在线 Google 文档尚未更新。未实际编辑在线文档时,明确说明内容需由用户粘贴,不得声称已同步。若用户已贴入同样内容,只报告无需重复更新。 |
|||
|
|||
### 2026-09-02:公网上线尝试 |
|||
|
|||
- 今日目标:将注册接口通过公网 HTTPS 地址提供给前端同事测试。 |
|||
- 启动前检查确认项目需要从环境变量读取正式 `DATABASE_URL`;仓库内只有 `.env.example` 占位配置,不保存生产密码。 |
|||
- 使用用户提供的远程 MySQL 账号进行连接验证时,服务器返回 `1045 Access denied`,并识别来源 IP 为 `223.166.86.200`。 |
|||
- 因正式 MySQL 尚未通过连接验证,今天暂未启动对外服务,也未使用 SQLite 代替正式数据库创建公网链接。 |
|||
- 继续上线前置条件:确认 `dating` 账号密码正确,并在 MySQL 授权中允许来自 `223.166.86.200` 的连接;连接成功后再启动 `uvicorn`(8001)和 Cloudflare Tunnel,并用公网 `/health`、`/api/v1/auth/register` 实测。 |
|||
|
|||
### 2026-09-02:临时 Cloudflare 地址已建立 |
|||
|
|||
- 因用户没有正式公网部署权限,已按要求使用 Cloudflare Quick Tunnel 做当天联调。 |
|||
- 本地服务运行在 `127.0.0.1:8001`,临时公网 Base URL:`https://medications-will-vast-permissions.trycloudflare.com`。 |
|||
- 已验证 `GET /health` 返回 `200` 和 `{"status":"ok"}`。 |
|||
- 已验证 `POST /api/v1/auth/register` 通过公网返回 `201`,token-only 响应正常。 |
|||
- 本次临时服务使用独立 SQLite 演示数据库 `dating_app_online_demo.db`,不连接正式 MySQL,也不代表生产环境数据;正式 MySQL 连接恢复后必须重启服务并改回正式 `DATABASE_URL`。 |
|||
- Cloudflare Tunnel 和后端进程必须保持运行;任一进程停止,临时地址就会失效。Quick Tunnel 地址也可能在下次启动时变化。 |
|||
|
|||
### 2026-09-02:阿里云 OSS 媒体存储接入 |
|||
|
|||
- 新增可切换的存储层:`OSS_ENABLED=false` 时继续使用本地 `UPLOAD_DIR`,方便自动化测试;设置为 `true` 后,用户头像、介绍视频和 Explore 帖子媒体会上传到阿里云 OSS。 |
|||
- OSS 配置全部通过环境变量提供:`OSS_ENDPOINT`、`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_BUCKET_NAME`、`OSS_BASE_PREFIX`、`OSS_DOMAIN`。示例配置已补充到 `.env.example`,没有保存真实密钥。 |
|||
- 当前部署参数约定:上海 Endpoint、Bucket `bs-qinghe`、对象前缀 `melove/`、自定义域名 `https://oss.baskbuddy.com`。Endpoint 会强制使用 HTTPS。 |
|||
- 数据库的 `user_media.storage_path` 和 `post_media.storage_path` 现在可保存 `oss://melove/...` 对象标识;数据库仍只保存元数据,不保存视频二进制内容。 |
|||
- OSS 模式下读取接口返回 1 小时有效的签名下载地址;身份证/护照仍按原有权限校验,不公开对象地址。删除用户或帖子媒体时会同步删除 OSS 对象。 |
|||
- 已安装 `oss2==2.18.6`。使用提供的临时凭据完成桶访问以及 `melove/` 前缀下临时对象的上传、读取、删除验证,测试对象已清理。 |
|||
- 生产启动前必须在服务器环境变量中设置真实 OSS 凭据,不能把密钥写入 Git、文档、Postman 或聊天记录;本次截图中的密钥也应在阿里云控制台轮换。 |
|||
|
|||
### 2026-09-04:根据新版共享 PDF 的核查结果 |
|||
|
|||
- 已阅读 `Dating APP api (2).pdf`。PDF 的注册流程要求仍是:先调用 JSON 注册接口,成功取得 Bearer access token 后,再分别以 `multipart/form-data` 调用头像和介绍视频上传接口;不能把二进制文件直接放进注册 JSON。 |
|||
- PDF 中的接口路径与当前 API 0.6.0/OpenAPI 一致:`POST /api/v1/auth/register`、`POST /api/v1/users/me/image`、`POST /api/v1/users/me/video`;OpenAPI 中两个上传接口均声明了 `multipart/form-data` 和 Bearer 安全要求,且没有旧 OTP 路由。 |
|||
- 在本机执行完整自动化测试,结果为 `14 passed`。 |
|||
- PDF 给出的临时地址 `https://counters-translate-ferry-sherman.trycloudflare.com` 于本次核验时 DNS 无法解析;旧地址也不可用。因此当前不能把该地址作为 Postman 的可用 `base_url`,需要重新建立并验证公网部署后才能填写共享文档。 |
|||
- 当前代码仍未实现生产 HTTPS 部署、AES-256-GCM payload 加密、混合加密及生产密钥管理;这些安全要求不能据此宣称已经完成。 |
|||
- 今日后续顺序:确认正式 MySQL 可连接;确认服务启用了正确的 OSS 环境变量;重新建立公网 HTTPS;依次实测 `/health`、注册、头像上传和视频上传;通过后再更新共享接口文档并让前端导入 `openapi-0.6.0.json`。 |
|||
- 本次核验发现本机 MySQL `3306` 正在监听,但当前配置使用的 `root`/`password` 被拒绝(MySQL `1045 Access denied`);终端也没有设置 `DATABASE_URL`、`JWT_SECRET` 或 OSS 环境变量,不能据此启动正式服务。 |
|||
- 为验证接口实现,已启动隔离的 `dating_app_temp_v060.db` SQLite 服务并完成烟测:`/health` 为 `200`,OpenAPI 版本为 `0.6.0`,注册为 `201` 且只返回 `access_token`、`refresh_token`、`token_type`,头像上传和视频上传均为 `200`。该结果只代表本地演示服务,不代表正式 MySQL、OSS 或公网部署已通过。 |
|||
- 继续公网联调前需要在服务器/本机环境中配置可用的正式 `DATABASE_URL`、生产 `JWT_SECRET`,以及 `OSS_ENABLED=true` 所需的 OSS 参数,并确认 MySQL 授权允许当前来源;不要把密码、AccessKey 或私钥发送到聊天或提交到文档/Git。 |
|||
|
|||
### 2026-09-04:正式数据库、OSS 与长期地址核验 |
|||
|
|||
- 使用用户提供的远程 MySQL 参数进行连接测试:`47.116.134.234:3306` 网络端口可达,但账号 `dating` 返回 MySQL `1045 Access denied`。当前无法确认数据库名、表结构或执行 API 联调;需要在服务器端确认密码/账号,并授权当前来源 IP `223.166.86.200`(或使用安全的服务器内网部署方式)。 |
|||
- 使用用户提供的 OSS 参数访问 `bs-qinghe`:服务端返回 `InvalidAccessKeyId`(403),当前 AccessKey 不可用。需要在阿里云 RAM/OSS 控制台确认账号状态、重新生成最小权限 AccessKey,并在服务环境变量中配置;截图中的 AccessKey 已暴露,建议立即停用并轮换。 |
|||
- 长期公网地址核验:`47.116.134.234` 的 80/443/22 端口可达,现有 HTTPS 内容是 bashBuddy Gitea,不是 Dating App API;`gitea.baskbuddy.com` 和 `www.baskbuddy.com` 已指向该服务器,`api.baskbuddy.com` 尚无 DNS 记录。 |
|||
- 推荐长期部署方案:增加 DNS `api.baskbuddy.com` 的 A 记录指向 `47.116.134.234`,在该服务器 nginx 新增 API 虚拟主机并反向代理到 FastAPI(例如 `127.0.0.1:8001`),使用 Let's Encrypt 或云厂商证书配置 TLS。此步骤需要域名 DNS 管理权限和服务器 SSH/root 或 nginx 管理权限,不能仅在本地项目目录完成。 |
|||
- 本轮没有修改远程数据库、OSS、DNS 或服务器配置,也没有把任何真实凭据写入文件;目前仍不能交付正式公网 `base_url`。 |
|||
|
|||
### 2026-09-04:共享接口补充和当日临时公网测试 |
|||
|
|||
- 新增 `docs/向老板索要的部署权限与当前缺项.md`,集中记录已经掌握的部署信息、实际缺少的授权、长期部署条件和可发给老板的技术依据;文档不包含任何密码或 AccessKey Secret。 |
|||
- 新增 `docs/Explore-NewsFeed-共享接口内容.md`,按共享 PDF 的 `URL`、`Frontend sends`、`Backend returns` 格式整理 Meet、城市/国家搜索和 Explore Feed,可直接复制到共享文档。 |
|||
- 当日临时公网 Base URL:`https://racks-chips-frequently-und.trycloudflare.com`。已验证 `/health` 为 `200`、OpenAPI 版本为 `0.6.0`、注册为 `201`、头像上传为 `200`、视频上传为 `200`。 |
|||
- 已通过公网实际验证 Explore/NewsFeed:Meet 返回同城用户后再返回同国家用户,并包含匹配度;`city=Shanghai&country=China` 组合搜索仅返回符合条件的用户;Explore Feed 返回作者的 `city`、`country` 和 `compatibility_percentage`。测试示例中同城匹配度为 `100`,同国家匹配度为 `33`。 |
|||
- 本次临时服务仍使用隔离 SQLite 数据库 `dating_app_temp_v060.db` 和本地上传目录,不连接正式 MySQL,也未启用 OSS。Cloudflare Quick Tunnel 和本地 FastAPI 进程停止后该 URL 即失效,不能当作长期部署地址。 |
|||
|
|||
### 2026-09-07:公司 OSS 与云 MySQL 连接核验 |
|||
|
|||
- 根据老板提供的 OSS 配置,当前项目已有的 `app/storage.py` 可以直接使用阿里云 OSS;Endpoint 会强制使用 HTTPS,Bucket 为 `bs-qinghe`,对象前缀使用 `melove/`。图中原始 `qinghe/` 前缀不应继续使用,当前代码默认值已经是 `melove/`。 |
|||
- 未将 AccessKey 或 Secret 写入代码、`.env`、Git 或本交接文档;运行时必须通过服务器环境变量注入 `OSS_ENABLED=true`、`OSS_ENDPOINT`、`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_BUCKET_NAME`、`OSS_BASE_PREFIX` 和 `OSS_DOMAIN`。 |
|||
- 使用老板提供的当前 OSS 凭据做了最小权限连接核验:`melove/` 前缀下临时对象写入返回 `200`、读取返回 `200`、删除返回 `204`,临时对象已立即清理。说明当前 OSS 凭据具备应用所需的基本读写删除权限。 |
|||
- 云 MySQL `47.116.134.234:3306` 端口可达,但 `dating` 账号从当前来源 IP `223.166.86.200` 登录 `dating_app` 返回 `1045 Access denied`。尚未执行任何数据库迁移或写入操作。 |
|||
- 需要老板/服务器管理员确认 `dating` 密码,并允许该账号从部署服务器来源主机连接。运行时至少需要 `dating_app` 的 SELECT、INSERT、UPDATE、DELETE 权限;执行现有迁移时还需要 CREATE、ALTER、INDEX、REFERENCES 权限。密码或 AccessKey 不要继续发送到聊天,截图中的 OSS AccessKey 建议轮换。 |
|||
- 本次只完成云存储连接与数据库登录核验,没有执行 API 功能测试,也没有修改远程数据库。云 MySQL 授权恢复后,再把正式 `DATABASE_URL` 和 OSS 环境变量配置到部署环境并启动 API。 |
|||
- 新增 `start_cloud_api.ps1`:Windows 启动云端配置时,在进程启动前交互输入数据库密码、OSS 凭据和 JWT Secret;脚本会对数据库密码做 URL 编码,并只设置当前进程环境,不把真实密钥写入文件。 |
|||
|
|||
### 2026-09-07:临时公网地址 |
|||
|
|||
- 用户已使用 `start_cloud_api.ps1` 启动 API,Uvicorn 本地监听 `127.0.0.1:8001`,启动配置指向云 MySQL 和阿里云 OSS。 |
|||
- 已建立 Cloudflare Quick Tunnel 临时地址:`https://vocational-breaks-accommodate-biography.trycloudflare.com`。 |
|||
- 已确认该公网地址的 `/health` 返回 `200` 和 `{"status":"ok"}`。 |
|||
- 该地址只在用户电脑上的 API 进程和 Cloudflare Tunnel 进程都运行时有效;关闭 PowerShell 窗口、电脑休眠或网络中断都会使地址不可用。它不能作为长期生产 API 地址。 |
|||
- 本次未执行业务接口测试;老板可使用该地址自行测试。长期使用仍应部署到云服务器并配置固定域名和正式 HTTPS。 |
|||
- 已建立并推送私有 Gitee 仓库:`https://gitee.com/lozonee/dating-app-api`。当前远程分支为 `main`,首个提交为 `cbf1675`。仓库只包含 Dating App 后端代码、SQL、文档和测试;其他项目、数据库文件、上传文件、日志和密钥均未提交。 |
|||
|
|||
### 2026-09-09:Jenkins 部署信息核查 |
|||
|
|||
- 老板提供了 Jenkins 地址 `http://47.116.134.234:8080`、登录信息,并要求通过 Docker 部署项目、使用端口 `8011`,且不要与其他项目冲突。 |
|||
- 本次仅做只读连通性核查,没有使用账号登录 Jenkins,也没有执行部署或修改服务器配置。Jenkins `/login` 页面可访问;服务器 `8011` 入口当前返回 `502 Bad Gateway`,说明反向代理/端口入口存在,但后端应用容器尚未正常提供服务。 |
|||
- 当前 Dating App 项目根目录没有自己的 `Dockerfile` 或 `Jenkinsfile`;现有 Dockerfile 位于同目录下的其他 `青核` 项目,不能直接用于本项目。因此现有 Jenkins 地址、账号和端口信息还不足以完成 Dating App 的自动 Docker 部署。 |
|||
- 继续部署前需要确认 Jenkins 中的具体 Job/流水线、代码仓库和分支、Docker 构建方式,以及正式运行环境变量(云 MySQL、OSS、JWT Secret)。生产密钥不能提交到 Git 或写入流水线脚本。 |
|||
- 今日结论:先准备 Dating App 专用 Docker 构建和 Jenkins 流水线配置,再由有权限的人在 Jenkins 中配置凭据并部署到 `8011`;部署成功后再由老板测试域名/接口。未执行业务接口测试。 |
|||
|
|||
### 2026-09-09:Docker 部署文件准备 |
|||
|
|||
- 新增项目根目录 `Dockerfile`,使用 Python 3.11 slim 镜像安装 `requirements.txt`,以非 root 用户运行 Uvicorn,容器内监听 `8001`,并包含 `/health` Docker 健康检查。 |
|||
- 新增 `.dockerignore`,排除数据库文件、上传文件、日志、私有配置、其他项目目录和本地测试文件,避免把敏感或无关内容放进镜像构建上下文。 |
|||
- README 已补充 Docker 启动命令。服务器外部端口按老板要求使用 `8011`,容器端口映射为 `8011:8001`;云 MySQL、OSS 和 JWT 密钥通过服务器上的 `/etc/dating-app/dating-app.env` 注入,不写入镜像或 Git。 |
|||
- 本机没有 Docker CLI,因此本次只完成 Docker 文件和部署说明,没有在本机构建镜像,也没有登录 Jenkins 或执行远程部署。Jenkins 节点仍需具备 Docker 构建/运行权限,并取得不含密钥的项目源码。 |
|||
|
|||
### 2026-09-09:本地 Docker 实例已启动 |
|||
|
|||
- Docker Desktop 已安装到 `D:\Docker\DockerDesktop`,Docker 引擎使用 WSL2 后端;安装包保存在 `D:\installers\docker`,没有把安装包放到 C 盘。 |
|||
- 因当前网络无法从 Docker Hub 拉取基础镜像,项目 `Dockerfile` 使用可访问的 `mcr.microsoft.com/vscode/devcontainers/python:3.11`,并保留 `BASE_IMAGE` 构建参数以便 Jenkins 环境切换基础镜像。 |
|||
- 新增 `docker-compose.yml`:外部端口 `8011` 映射到容器端口 `8001`,默认使用 Docker 命名卷中的 SQLite 和上传目录;正式部署时可通过环境变量覆盖为云 MySQL 和 OSS。 |
|||
- 本地镜像 `dating-app-api:0.6.0` 已构建,容器名为 `dating-app-api`,当前状态为 healthy;`http://127.0.0.1:8011/health` 返回 `200` 和 `{"status":"ok"}`。 |
|||
- 本次只验证 Docker 构建、容器启动和健康检查,没有执行注册、上传或其他业务接口测试,也没有连接正式云 MySQL/OSS。Jenkins 部署仍需在服务器环境注入正式密钥并将同样的端口映射部署到服务器。 |
|||
|
|||
### 2026-09-09:Jenkins 部署尝试结果 |
|||
|
|||
- 已登录老板提供的 Jenkins,并确认 Job `1` 的仓库地址是本项目 Gitee 仓库。由于当前账号没有创建新 Job 的权限,已在该明确属于 Dating App 的 Job `1` 上将分支改为 `main`,并保存 Docker 构建/部署命令:构建 `dating-app-api:0.6.0`,停止旧容器后以 `8011:8001` 启动。 |
|||
- 已触发 Jenkins 构建 `#2`,构建在 Git 拉取阶段失败,Jenkins 日志返回 Gitee `403 Access denied`;没有执行 Docker 构建,也没有修改远程服务器容器。 |
|||
- Jenkins 现有的 Gitee 凭据已失效或无权访问私有仓库。仓库不支持匿名读取;Jenkins 需要一条有效的 Gitee 用户名 + Personal Access Token/密码凭据。该凭据应通过 Jenkins Credentials 配置,不能写入仓库或构建命令。 |
|||
- 当前阻塞只剩 Gitee 仓库凭据;Jenkins 登录账号密码不能代替 Gitee 仓库凭据。凭据修复后重新构建 `Job 1`,再确认服务器 `/etc/dating-app/dating-app.env` 存在并包含正式云 MySQL、OSS 和 JWT 配置,才会实际启动 `8011` 容器。 |
|||
- 用户随后提供用于 Jenkins 的 Gitee 用户名与 Personal Access Token;该 Token 仅用于更新 Jenkins Credentials 并触发本项目构建,不写入仓库、构建命令或本交接文档。Token 使用完成后应立即在 Gitee 侧撤销或轮换。 |
|||
- 后续核查确认,`melove` 账号访问 Jenkins 全局凭据目录时返回“没有凭据/View权限”,因此不能直接查看或替换现有 Gitee 凭据。需在 Job `1` 配置页的“添加 here”创建该 Job 可用的用户名/密码凭据,或由 Jenkins 管理员授予该账号对应的 Credentials 查看、创建和更新权限;在凭据可用前,不重复触发会失败的构建。 |
|||
- 为避免依赖 Jenkins 全局凭据,已将 Job `1` 改为使用 Jenkins 主机专用的 SSH Deploy Key 拉取本仓库:初始化构建 `#3` 已在 Jenkins 运行账户的 `.ssh` 目录生成密钥对,且未构建镜像或启动容器;正式构建脚本已改为用该密钥通过 SSH 拉取 `main` 后再执行既定的 Docker 部署。用户提供的 Gitee Token 缺少 `keys` scope,API 创建 Deploy Key 返回 HTTP 401;随后用户已在 Gitee 仓库管理页手动添加只读 Deploy Key。 |
|||
- 已触发 Jenkins 构建 `#4`:Jenkins 通过 SSH 成功克隆 `main`,说明 Deploy Key 和仓库拉取已生效。构建随后因 Jenkins 主机缺少 `/etc/dating-app/dating-app.env` 而按脚本安全退出,未执行 Docker 构建、未停止旧容器,也未启动 `8011` 服务。该文件必须由有服务器权限的人以正式 `DATABASE_URL`、`JWT_SECRET` 和 OSS 配置创建,不能用本地 SQLite 或空密钥替代。 |
|||
|
|||
### 2026-09-09:Jenkins 环境文件就绪后的部署结果 |
|||
|
|||
- 老板/服务器侧已补齐 Jenkins 可读取的环境文件,Jenkins 构建 `#7` 已成功拉取代码、构建镜像并执行 `8011:8001` 容器启动命令;但原脚本只检查 Docker 命令返回值,不能证明应用健康。 |
|||
- 已将 Job `1` 的部署脚本增加容器健康等待和失败日志输出,并明确在容器启动时覆盖 `OSS_BASE_PREFIX=melove/`。该 Jenkins 配置修改未写入 Git 仓库。 |
|||
- 重新构建 `#8` 时,镜像构建成功,容器启动后应用初始化失败。Jenkins 日志明确返回:`pymysql.err.OperationalError (1045): Access denied for user 'dating'@'47.116.134.234'`。这表示 Jenkins 环境文件已被读取,但云 MySQL 账号密码或来源授权仍不正确;构建已失败退出,未留下可用的 `8011` 服务。 |
|||
- 当前阻塞已收敛为云 MySQL 连接问题:请数据库管理员确认 `dating` 用户密码,并允许该用户从 Jenkins/部署主机连接 `dating_app`。不要把密码写入 Git、Jenkins 命令或聊天;修正服务器上的环境文件后,再重新触发 Job `1`。 |
|||
- 当前公网检查仍为:`http://47.116.134.234:8011/health` 返回 `502`;`melove.youthome.com.cn` 尚未可用。未执行业务接口测试。 |
|||
|
|||
### 2026-09-10:数据库密码编码复核与 Jenkins 构建 #10 |
|||
|
|||
- 根据老板提供的数据库密码,在本机按 URL 组件规则进行编码(使用 `quote(..., safe='')`)后,构造的 `mysql+pymysql://` 连接字符串可以正确解析,并成功连接 `47.116.134.234:3306/dating_app` 执行 `SELECT 1`。这证明密码本身和 URL 编码方式没有问题。 |
|||
- Jenkins Job `1` 的实际部署脚本使用环境文件 `${HOME}/.secrets/dating-app.env`,并不是早期记录中的 `/etc/dating-app/dating-app.env`。后续修改必须作用于 Jenkins 运行账户的这个实际路径,不能只修改另一份文件。 |
|||
- 已使用现有 Jenkins 权限触发构建 `#10`。代码拉取和 Docker 镜像构建成功,但容器启动时仍返回 MySQL `1045 Access denied for user 'dating'`,应用退出,`8011` 没有可用服务,公网 `/health` 仍为 `502`。 |
|||
- 当前阻塞不是代码或 Docker,也不是本机 URL 编码;需要服务器/数据库管理员将正确的、已 URL 编码的 `DATABASE_URL` 写入 Jenkins 运行账户可读的 `${HOME}/.secrets/dating-app.env`,并确认 MySQL 允许 Jenkins 部署主机来源连接。不要把数据库密码写入 Job 命令、Git、构建日志或交接文档。 |
|||
- 本次没有执行注册、上传等业务接口测试,也没有修改远程数据库权限;数据库配置修正后再重新触发 Jenkins 构建。 |
|||
|
|||
### 2026-09-10:Jenkins 错误密码对照实验 |
|||
|
|||
- 按老板建议,在 Jenkins Job `1` 的 Docker 镜像构建完成后、删除正式容器之前,临时增加了一个一次性测试容器;测试容器显式使用故意设置的错误数据库密码,且未读取或修改 `${HOME}/.secrets/dating-app.env`。 |
|||
- 一次性测试容器启动后返回 MySQL `1045 Access denied for user 'dating'`,随后 FastAPI 报 `Application startup failed`。该报错类型和正式部署失败时一致,说明 MySQL 密码错误确实会产生当前看到的同类日志。 |
|||
- Jenkins 脚本启用了遇错退出,测试容器失败后流程在正式 `docker rm -f dating-app-api` 之前终止,因此本次对照实验没有删除或替换原正式容器,也没有向云数据库写入业务数据。 |
|||
- 对照实验结束后,临时测试代码块已从 Job `1` 的 Build Steps 中删除并保存,Jenkins 构建脚本已恢复,不保留故意设置的错误连接串。 |
|||
- 结论边界:`1045` 能确定是 MySQL 身份验证阶段拒绝连接,但仅凭这一条日志不能区分“Jenkins 环境文件中的密码/连接串错误”和“部署主机命中了另一条更具体的 MySQL 用户授权”。后续仍需核对实际环境文件,并由数据库管理员检查 `dating` 用户的 Host 记录。 |
|||
|
|||
### 2026-09-10:Jenkins 构建 #12 新密码预检结果 |
|||
|
|||
- 老板将云 MySQL 密码改为一条只包含字母和数字的测试密码。为避免先删除正式容器,Job `1` 临时在 Docker 镜像构建之后、正式 `docker rm` 之前增加数据库连接预检;密码更新步骤使用 `set +x`,构建日志未输出连接串或密码,本文档也不记录密码明文。 |
|||
- 已核对下载的 Jenkins `1 » default » #12` 完整控制台日志:Gitee 拉取成功,`dating-app-api:0.6.0` 镜像构建成功;预检容器确实读取 `/var/jenkins_home/.secrets/dating-app.env`,但没有输出 `DATABASE_CONNECTION_OK 1`。 |
|||
- 预检实际返回 `pymysql.err.OperationalError (1045): Access denied for user 'dating'@'47.116.134.234' (using password: YES)`,随后构建以 `Finished: FAILURE` 结束。由于预检在正式 `docker rm -f dating-app-api` 之前失败,原正式容器没有被本次构建删除或替换。 |
|||
- 新密码不含 URL 特殊字符,因此本次结果进一步排除了“密码 URL 编码错误”。当前更可能是 MySQL 存在更具体的 `dating@47.116.134.234` Host 账号并优先于 `dating@%`,但该账号密码/权限未同步;另一种可能是密码修改未作用到部署主机实际命中的账号。 |
|||
- 下一步必须由数据库管理员执行 `SELECT User, Host, plugin, account_locked FROM mysql.user WHERE User='dating';`,分别核查 `dating@%` 与 `dating@47.116.134.234` 的密码状态及 `dating_app` 权限,修正后再运行 Jenkins 预检和正式部署。 |
|||
- `#12` 日志不能证明临时的密码更新块和数据库预检块是否已从 Jenkins Build Steps 删除。下次接手时应先检查并删除这两个临时代码块、保存 Job 配置,避免密码继续保存在 Jenkins 脚本中;服务器密钥文件保留管理员确认后的正式配置。 |
|||
|
|||
### 2026-09-11:Jenkins 构建 #13 新内网地址测试 |
|||
|
|||
- 老板提供新的云数据库内网地址 `172.17.214.137` 后,在 Jenkins Job `1` 中用一次性 Docker 预检容器测试;代码拉取和 `dating-app-api:0.6.0` 镜像构建均成功。 |
|||
- Jenkins 容器能够访问 `172.17.214.137:3306`,因此新地址的网络路由和端口连通性正常;MySQL 返回 `1045 Access denied for user 'dating'@'172.18.0.1' (using password: YES)`。 |
|||
- 这次来源地址变为 Docker 网桥地址 `172.18.0.1`,说明请求已经到达 MySQL,失败发生在账号认证/Host 匹配阶段,不是网络超时,也不是项目代码或 Docker 构建问题。 |
|||
- 预检位于正式 `docker rm -f dating-app-api` 之前,失败后 Jenkins 立即退出,没有删除或替换正式容器,也没有写入业务数据。 |
|||
- 当前需要数据库管理员检查 `dating` 用户针对 `172.18.0.1`、`172.18.%` 和 `%` 的 Host 记录、密码、插件及 `dating_app` 权限;同时确认 Jenkins 实际环境文件 `${HOME}/.secrets/dating-app.env` 使用新地址和正确密码。项目侧没有 MySQL 用户管理权限,也不能直接修改 Jenkins 服务器上的密钥文件。 |
|||
- 结论边界:项目侧已验证代码、Docker 镜像、新地址网络连通性均正常,并排除了本机密码 URL 编码问题;最终阻塞属于 Jenkins 运行环境数据库凭据或云 MySQL 用户授权配置,需服务器/数据库管理员修复后再做正式部署。 |
|||
|
|||
## 8. 当前明确未完成的大项 |
|||
|
|||
- Profile suggestion 和账号/应用设置。 |
|||
- 直播服务选择与集成。 |
|||
- 日历/地图约会规划和安全功能。 |
|||
- 举报、拉黑、隐私增强和紧急位置分享。 |
|||
- 推送通知和消息投递状态。 |
|||
- 生产环境 HTTPS、限流和审核流程。 |
|||
- AES-256-GCM 混合加密协议、重放保护、密钥轮换和生产密钥管理。 |
|||
- ML 行为分析、视频转文本、图片搜索、偏好学习、AI digital twin 和兼容度计算。 |
|||
|
|||
以上项目只有在代码、数据库、接口和测试都完成后,才能从“未完成”移动到“已完成”。 |
|||
|
|||
### 2026-09-14:Jenkins 数据库地址/密码对照诊断 |
|||
|
|||
- 在 Jenkins Job 1 临时使用已构建镜像执行四组只读数据库连接预检,未删除或替换 dating-app-api 正式容器,也未写入业务数据。 |
|||
- 47.116.134.234 + 原密码:MySQL 1045;172.17.214.137 + 原密码:MySQL 1045。 |
|||
- 47.116.134.234 + 新密码末尾大写 II:MySQL 1045;172.17.214.137 + 新密码末尾大写 II:MySQL 1045。 |
|||
- 用户确认末尾应为小写 ll 后,公网和内网两组均认证成功,均访问 dating_app 成功,匹配账号为 dating@%,权限包含 GRANT ALL PRIVILEGES ON dating_app.*。 |
|||
- 结论:故障是 Jenkins 环境文件中的密码大小写错误(写成大写 II,正确为小写 ll);地址公网/内网均可用,Docker 网络和 MySQL 授权正常。诊断完成后已恢复 Job 1 原正式部署脚本,临时参数已删除,测试密码未出现在构建日志。 |
|||
|
|||
### 2026-09-14:Jenkins 正式部署结果与收工结论 |
|||
|
|||
- 已确认 Job 1 当前保存的 Build Step 是正式部署脚本:通过 SSH 拉取 Gitee `main`,构建 `dating-app-api:0.6.0`,使用 Jenkins 环境文件注入正式配置,先做数据库预检,再重建 `dating-app-api` 容器并检查容器内 `/health`。 |
|||
- Jenkins 构建 `#29` 已成功完成。日志包含 `DATABASE_CONNECTION_OK 1`、`APPLICATION_HEALTH_OK 200` 和 `DEPLOYMENT_OK`;代码拉取、Docker 镜像构建、数据库连接和 FastAPI 启动均已通过。 |
|||
- 容器已按要求运行并映射 `0.0.0.0:8011->8001`。容器刚启动时第一次检查因应用尚未监听而出现一次 `Connection refused`,随后轮询检查返回 `200`,不属于部署失败。 |
|||
- 当前仍未解决的是公网访问 `http://47.116.134.234:8011/health` 超时。由于容器内健康检查成功且 Docker 端口映射正确,剩余问题位于云安全组、宿主机防火墙或公网反向代理入口;不是数据库、密码、应用代码或 Docker 构建问题。 |
|||
- 明日第一步:在服务器/云控制台确认 TCP `8011` 入站规则和宿主机防火墙放行情况;若生产要求统一 HTTPS,则检查 nginx/网关是否将域名转发到本机 `8011`。放行后再从公网复测 `/health`,然后继续注册、上传等业务接口联调。 |
|||
|
|||
### 2026-09-16:公网接口烟测 |
|||
|
|||
- `https://melove.youthome.com.cn/api/v1/auth/login` 能到达 FastAPI,故意提交过短参数返回 `422`,证明 `/api/v1` 前缀和 nginx 转发链路正常。 |
|||
- `POST https://melove.youthome.com.cn/api/v1/auth/register` 使用随机测试账号连续两次返回 `500 Internal Server Error`。该结果不是路由前缀错误;需查看服务器容器日志以确认数据库表结构/迁移或其他运行时异常。 |
|||
- `https://melove.youthome.com.cn/health`、`/docs` 当前返回 nginx 默认欢迎页,说明 nginx 尚未为这些非 `/api/` 路径配置反向代理;业务接口仍可通过 `/api/` 访问。 |
|||
- 进一步使用已验证的只读数据库账号检查正式库结构:`users` 表实际列为 `id,email,phone,password_hash,status,email_verified,phone_verified,created_at,updated_at`,缺少当前代码注册必需的 `email_or_phone`;`user_profiles.country` 和 `user_interests` 已存在。该结构差异可直接解释注册接口的 `500`(注册查询/插入访问不存在的 `users.email_or_phone`),确认正式库尚未执行 `sql/migrate_auth_mvp_no_otp.sql`。本次只读检查未修改数据库。 |
|||
- 随后通过 Jenkins 一次性容器执行了兼容 MySQL 5.7 的认证迁移:补充 `users.email_or_phone`、回填历史账号并创建唯一索引,同时保留旧字段;迁移输出 `MIGRATION_OK`。使用随机测试账号从公网调用 `POST /api/v1/auth/register` 返回 `201 Created` 和 token-only 响应,确认注册链路已恢复。临时脚本随后已清理,Jenkins 配置中不再包含数据库密码。 |
|||
|
|||
### 2026-09-16:进入日常改代码和发布流程 |
|||
|
|||
- 当前正式环境已可用:Jenkins Job `1` 从 Gitee `main` 拉取代码,Docker 构建 `dating-app-api:0.6.0`,容器使用 `8011:8001` 运行;正式 MySQL 已完成认证迁移,公网注册和登录已分别返回 `201` 和 `200`。 |
|||
- 已验证的公网业务 Base URL:`https://melove.youthome.com.cn`。业务接口统一使用 `/api/v1/...`,例如 `POST https://melove.youthome.com.cn/api/v1/auth/register`。当前 `/health` 和 `/docs` 尚未由 nginx 代理到 FastAPI,不能把它们当作公网健康检查地址;业务接口转发正常。 |
|||
- 日常代码发布流程:本地修改代码并运行 `python -m pytest -q`;确认接口变更后同步更新 OpenAPI/对接文档;提交并推送到 Gitee `main`;在 Jenkins Job `1` 点击“立即构建”;等待代码拉取、Docker 构建、数据库预检、容器启动和健康检查完成;最后从 `https://melove.youthome.com.cn/api/v1/...` 做公网烟测。 |
|||
- 只改 Python、schema 或静态业务逻辑时,推送代码并重新构建镜像即可。修改 `requirements.txt` 或 `Dockerfile` 时也必须重新构建并检查容器启动日志。 |
|||
- 修改数据库模型或新增字段时,必须同时新增幂等迁移脚本,并在正式数据库执行迁移后再发布依赖该字段的代码。Docker 重建不会自动修改已经存在的 MySQL 表;`Base.metadata.create_all()` 也不能替代已有表的字段迁移。 |
|||
- 不要把数据库密码、JWT Secret、OSS 密钥写入 Git、Jenkins Shell、提交信息或文档。Jenkins 运行账户的正式环境文件位置是 `${HOME}/.secrets/dating-app.env`;修改生产配置后应先做只读连接和健康检查,再运行正式构建。 |
|||
- 发布失败时先检查 Jenkins 控制台日志和容器日志,不要重复删除生产容器。数据库问题先区分网络连接、认证和表结构:`SELECT 1` 只能证明连接可用,不能证明迁移完整;注册、登录等真实接口必须在发布后复测。 |
|||
|
|||
### 2026-09-22:Apifox 上传报错排查(待云端项目更新) |
|||
|
|||
- 前端反馈图片和视频上传显示错误,并要求更新 Apifox。所附 Apifox 截图仍显示已废弃的 `Request Code`、`Verify Code`,说明云端项目至少有过时接口定义;截图没有提供上传请求的状态码、响应正文或请求配置,不能据此确定前端报错的唯一原因。 |
|||
- 当前 `docs/openapi-0.6.0.json` 已声明头像、视频接口分别使用 `multipart/form-data` 的 `image`、`video` 二进制字段,并要求 HTTP Bearer。线上 Base URL 是 `https://melove.youthome.com.cn`,路径含 `/api/v1`。 |
|||
- 以随机生成的测试账号从公网注册返回 `201`,使用该账号的 access token 分别请求 `POST /api/v1/users/me/image` 和 `/api/v1/users/me/video`,以匹配的 form-data 文件字段和 MIME 类型发送小型合成测试载荷,两者均返回 `200`,响应分别为 `profile_image`、`intro_video` 和 `pending`。这验证了小文件的认证、上传和存储写入链路;不代表前端真实文件、较大视频或读取 URL 已验证。测试账号与媒体记录留在正式环境,不含个人文件。 |
|||
- Apifox 网页在当前会话需要扫码或账号登录,未获得项目访问,故尚未导入/覆盖云端项目定义,也不能确认前端原请求实际采用的 Body、Auth、Header。已在 `docs/Postman导入说明.md` 增补 Apifox 的准确配置和错误码排查。用户登录并开放 MeLOVE 项目后,再导入最新 OpenAPI,先核对差异和手工维护内容,并现场复测;若仍报错,收集脱敏状态码与响应正文进一步定位。 |
|||
|
|||
### 2026-09-23:头像和介绍视频替换 |
|||
|
|||
- `POST /api/v1/users/me/image` 和 `POST /api/v1/users/me/video` 改为每名用户每种媒体只保留最新一份;重复上传成功后,后端删除旧数据库记录并清理旧的本地或 OSS 文件,前端无需先删除或重新提交注册资料。 |
|||
- 替换采用先保存新文件、再提交数据库变更、最后清理旧文件的顺序。新文件存储或数据库提交失败时保留原媒体,避免更新失败导致用户失去现有头像/视频;旧存储对象清理失败只记录服务端日志,不回滚已经生效的新媒体。 |
|||
- 身份证/护照上传不使用替换规则,继续保留原有行为。 |
|||
- 新增自动化测试覆盖头像和视频成功替换、旧媒体 ID 失效、旧文件清理,以及新文件存储失败时保留原头像。 |
|||
- 本地完整自动化测试结果:`16 passed`。 |
|||
- 代码已提交并推送 Gitee `main`,提交为 `9533ce3`(`feat: replace profile media on reupload`)。已触发 Jenkins Job `1` 构建 `#32`。 |
|||
- 构建触发后多次使用全新测试账号从公网重复上传头像,两个上传请求均返回 `200`,但旧媒体 ID 仍返回 OSS `307`,说明线上仍运行旧的追加逻辑;截至本次收工,不能宣称替换功能已经发布成功。需检查 Jenkins `#32`/`default` 子构建控制台日志,修复或重新构建后,再以“旧媒体 ID 返回 `404`、新媒体 ID 可读取”为上线验收标准。 |
|||
|
|||
### 2026-09-23:文字简介或介绍视频二选一(API 0.6.1) |
|||
|
|||
- 新需求:注册引导中用户可以选择文字简介或介绍视频。由于注册接口使用 JSON、视频接口使用 `multipart/form-data` 且需要 Bearer token,仍采用两步流程:选择文字时在注册 JSON 中提交非空 `bio`;选择视频时先注册取得 token,再调用 `/api/v1/users/me/video`。 |
|||
- `RegisterRequest.bio` 现在会去除首尾空格,并要求提供时至少包含 1 个有效字符、最多 2000 字符;视频路径可以省略 `bio`。 |
|||
- `GET /api/v1/auth/me`、`GET /api/v1/users/me` 的 `profile` 新增可空 `video_url`。未上传或视频被拒绝时为 `null`,上传后为 `/api/v1/media/{media_id}`。公开用户响应也新增 `video_url`,并只展示每种类型最新的一份公开媒体。 |
|||
- 文字简介和介绍视频在后端互斥:成功上传视频会在同一数据库事务中清空 `bio`;通过个人资料接口提交非空 `bio` 会删除视频记录,提交成功后再清理旧存储文件。切换失败时不会提前删除当前有效内容。 |
|||
- 注册响应继续遵守已确认的 token-only 契约,不返回完整用户。前端注册后调用 `/api/v1/auth/me`,并在 `bio` 或 `profile.video_url` 至少一个非空时完成注册引导。 |
|||
- API 版本升级为 `0.6.1`,新增 `docs/openapi-0.6.1.json`;0.6.0 文件保留作为历史版本。 |
|||
- Jenkins `#32` 日志已核对:提交 `9533ce3` 拉取成功、Docker 镜像构建成功,但数据库预检返回 MySQL `1045 Access denied for user 'dating'@'47.116.134.234'`,因此在删除/替换现有容器之前安全失败。问题位于 Jenkins 实际环境文件 `${HOME}/.secrets/dating-app.env` 中的数据库凭据或对应 MySQL Host 认证,不是本次 Python 代码或 Docker 构建错误。 |
|||
- 本地完整自动化测试结果:`17 passed`;生成的 OpenAPI 0.6.1 包含 34 个路径和 37 个 schema,并已验证 `ProfileResponse.video_url`、`DiscoveryUserResponse.video_url` 和 `RegisterRequest.bio` 最小长度约束。 |
|||
- API 0.6.1 已提交并推送到 Gitee `main`,提交为 `197f0e1`(`feat: support bio or video introductions`)。在 Jenkins 环境文件的 MySQL 凭据修正前不应重复触发构建;同一份配置会稳定复现 1045,且无法完成发布。修正后重新构建,并同时验收 0.6.1 的 bio/video 互斥和 0.6.0 提交中尚未上线的媒体替换行为。 |
|||
- 用户确认数据库密码已经配置好后,已于 2026-09-23 再次触发 Jenkins Job `1` 构建 `#33`。Jenkins 父任务和 `default` 子任务均显示已完成但失败,因此本次仍未发布 API 0.6.1,也未执行公网功能验收。该条只记录 `#33` 的实际状态,不以 `#32` 的 1045 日志推断本次失败原因;后续应以 `#33/default` 控制台日志为准。 |
|||
|
|||
### 2026-09-23:Jenkins 构建 #34 修复并发布 API 0.6.1 |
|||
|
|||
- 已核对 `#33/default` 完整控制台日志:Gitee `main` 的提交 `197f0e1` 拉取成功、Docker 镜像构建成功,失败点仍是正式部署前的数据库预检,MySQL 返回 `1045 Access denied for user 'dating'@'47.116.134.234'`。旧容器在预检失败时被安全保留,因此线上当时仍是旧版本。 |
|||
- 故障原因是 Jenkins 运行账户的 `${HOME}/.secrets/dating-app.env` 再次包含无法认证的 `DATABASE_URL`,与 2026-09-14 已验证成功的内网地址和密码字符不一致。已通过一次性、关闭命令回显的构建步骤原子修正该环境文件,并将 Jenkins 正式镜像标签同步为 `dating-app-api:0.6.1`。 |
|||
- Jenkins 构建 `#34` 成功完成,日志包含提交 `197f0e1`、`Successfully tagged dating-app-api:0.6.1`、`DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 构建完成后已从 Jenkins Job 配置中删除一次性修复代码和数据库密码,只保留正常的数据库预检、容器重建和健康检查流程;正式密钥继续只保存在服务器环境文件中。 |
|||
- 公网烟测通过:注册和登录分别返回 `201`、`200`;`/api/v1/auth/me` 已返回 0.6.1 新增的 `profile.video_url`;上传介绍视频会清空 `bio`,改回非空 `bio` 会删除介绍视频并使旧媒体 ID 返回 `404`。 |
|||
- 媒体替换功能也已在公网验证:连续上传两次介绍视频均返回 `200`,第二次上传后第一份媒体 ID 返回 `404`,当前资料指向第二份视频;随后通过文字简介切换清理第二份测试视频,两份测试媒体均未保留。 |
|||
|
|||
### 2026-09-28:公开资料媒体直链(API 0.6.2) |
|||
|
|||
- 前端反馈 Meet 响应中的头像和视频只有 `/api/v1/media/{id}` 相对路径,图片组件还需单独附加 Bearer Token,并希望获得独立头像字段。 |
|||
- `ProfileResponse` 和 `DiscoveryUserResponse` 新增可空 `profile_image_url`;`video_url`、`profile_image_url` 与 `media[].url` 在生产 OSS 环境改为一小时有效的完整签名 URL,前端可直接加载,不再为每张公开图片或视频调用带 Token 的媒体接口。 |
|||
- 签名 URL 过期后,前端重新请求 `/api/v1/auth/me`、`/api/v1/meet`、`/api/v1/search` 或匹配列表即可取得新地址。身份证/护照不进入公开 URL 字段,原有本人权限保持不变。 |
|||
- 本地文件存储仍使用 `/api/v1/media/{id}` 作为开发回退路径;生产 OSS 才返回完整签名地址。 |
|||
- API 版本升级为 `0.6.2`,生成 `docs/openapi-0.6.2.json`;本地完整自动化测试结果为 `18 passed`。代码已提交并推送到 Gitee `main`,提交为 `8242adf`(`feat: return direct profile media URLs`)。用户随后在 Jenkins Job `1` 触发构建,线上已切换到包含该提交的版本(未从控制台读取构建编号)。 |
|||
|
|||
### 2026-09-29:个人主页活动和滑动撤销(API 0.6.3) |
|||
|
|||
- `GET /api/v1/auth/me` 和 `GET /api/v1/users/me` 新增 `posts`、`liked_users`、`liked_by_users`、`matches`,各返回最近 20 条。 |
|||
- 新增分页接口:`GET /api/v1/users/me/posts`、`GET /api/v1/users/me/liked`、`GET /api/v1/users/me/likes`、`GET /api/v1/users/me/matches`、`GET /api/v1/users/{user_id}`、`GET /api/v1/users/{user_id}/posts`。 |
|||
- 新增 `DELETE /api/v1/meet/swipes/{target_user_id}`,可撤销左滑 `pass` 或右滑 `like`;撤销已匹配的右滑会同步解除匹配。原取消喜欢接口继续兼容。 |
|||
- `GET /api/v1/matches` 新增 `limit`、`offset` 分页;帖子媒体在生产 OSS 环境返回可直接加载的一小时签名 URL。 |
|||
- 不涉及数据库表结构变化,无需 SQL 迁移。本地完整自动化测试为 `19 passed`,OpenAPI 0.6.3 包含 41 个路径及 `UserResponse` 的四个活动字段。 |
|||
- 代码已提交并推送到 Gitee `main`,提交为 `705defa`(`feat: complete profile activity and swipe APIs`)。Jenkins Job `1` 的部署脚本镜像标签已从遗留的 `0.6.1` 同步为 `0.6.3`;构建 `#37/default` 成功,日志确认拉取提交 `705defa`、生成 `dating-app-api:0.6.3`、数据库预检通过、容器 `running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:三个随机账号注册均为 `201`;创建帖子和上传图片均为 `201`;帖子媒体签名 URL 不带 Token 读取为 `200`;双向 like 成功匹配;pass 后候选隐藏、撤销 pass 后重新出现;Get Me 四个数组、个人主页分页列表、公开资料和公开帖子接口均为 `200`。 |
|||
- 公网烟测通过:`/api/v1/auth/me` 已包含 `profile.profile_image_url`;上传合成头像后该字段为完整 OSS 签名 URL,不携带 Bearer Token 直接读取返回 `200`;`/api/v1/meet?limit=50` 能返回该测试用户,`profile_image_url` 与 `media[0].url` 均为完整 HTTPS 地址。测试使用随机账号与合成内容,不含个人照片;正式库会保留烟测账号和媒体记录。 |
|||
|
|||
### 2026-10-08:注册交友目标(API 0.6.4) |
|||
|
|||
- `POST /api/v1/auth/register` 新增必填单选字段 `relationship_goal`,只接受 `serious_relationship`、`casual_dates`、`new_friends`、`marriage`。 |
|||
- `PATCH /api/v1/users/me` 可修改交友目标;Get Me、公开用户、Meet、Search、喜欢列表和匹配列表中的资料会返回该字段。 |
|||
- `user_profiles.relationship_goal` 对历史用户保持可空,新注册由 API 保证必填;正式数据库升级需先执行 `sql/migrate_relationship_goal.sql`。 |
|||
- 本地完整自动化测试为 `19 passed`;OpenAPI 更新为 `docs/openapi-0.6.4.json`。 |
|||
- 代码已提交并推送到 Gitee `main`,提交为 `3e2b440`(`feat: add registration relationship goals`)。 |
|||
- Jenkins Job `1` 的发布脚本已更新为镜像 `dating-app-api:0.6.4`,并在停止旧容器前幂等添加 `user_profiles.relationship_goal`;构建 `#38/default` 成功,日志包含 `MIGRATION_OK`、`DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:缺少 `relationship_goal` 的注册请求返回 `422`;合法注册返回 `201`,Get Me 返回已保存的 `serious_relationship`;通过 `PATCH /api/v1/users/me` 修改为 `marriage` 后响应正确。 |
|||
|
|||
### 2026-10-08:取消键与 Super Like(API 0.6.5,已随 0.6.6 发布) |
|||
|
|||
- 用户确认 Cancel Button 是对目标用户最近一次左滑、右滑或 Super Like 的撤销;前端统一调用现有 `DELETE /api/v1/meet/swipes/{target_user_id}`。撤销后目标用户可以重新进入本人的 Meet 列表;撤销已匹配的喜欢会解除匹配。 |
|||
- `POST /api/v1/meet/swipes` 新增 `direction: "super_like"`。Super Like 算作喜欢,可与对方的 `like` 或 `super_like` 形成匹配,也计入喜欢/被喜欢列表;同一用户对的操作仍只保存一条,修改方向会替换原方向。 |
|||
- 向当前用户发出有效 Super Like 且尚未被当前用户滑过的人,在当前用户的 Meet 列表中优先于同城、同国家和匹配度排序;优先状态持续到发起者撤销或改为其他方向。旧的取消喜欢接口兼容 `super_like`。 |
|||
- `swipes.direction` 现有长度可容纳 `super_like`,但已有 MySQL 8 数据库的 CHECK 约束必须先扩展。非破坏性迁移脚本为 `sql/migrate_super_like.sql`,新建表定义已同步更新 `sql/schema.sql`。正式发布前执行迁移,并核对 Jenkins 镜像版本、健康检查和线上滑动流程;本次未接触正式数据库、Jenkins 或公网服务。 |
|||
- API 版本为 `0.6.5`,生成 `docs/openapi-0.6.5.json`;README、中文/英文接口说明、前端清单、Postman 说明和数据库设计已同步。针对优先排序、匹配、取消、方向切换新增接口测试,本地完整测试 `20 passed`。 |
|||
- 本仓库只有后端;前端仍需将三种滑动结果后的取消按钮接到撤销接口,并在 Apifox/Google 文档中同步新方向值。正式环境当前仍为 0.6.4,不可将 Super Like 标记为已上线。 |
|||
|
|||
### 2026-10-09:定位携带城市和区县(API 0.6.6,已发布) |
|||
|
|||
- 用户转述的定位改动为:前端向后端一次提交 `latitude`、`longitude`、`city`、`county`。`county` 按区县解释,区别于已有的国家字段 `country`;四项均为 `PUT /api/v1/users/me/location` 必填字段,城市和区县会去除首尾空格并拒绝空值。 |
|||
- 经纬度仍保存在 `user_locations`;城市更新 `user_profiles.city` 以保持同城推荐和搜索一致;新增可空的 `user_profiles.county` 保存区县。历史用户不回填;手动修改资料城市而不重新定位时会清空旧区县,避免城市和区县不匹配。 |
|||
- 位置接口响应新增 `city`、`county`;Get Me 和公开用户资料也返回 `county`。新增非破坏性迁移 `sql/migrate_location_county.sql`,需要与 0.6.5 的 `sql/migrate_super_like.sql` 一同在正式发布前执行。 |
|||
- OpenAPI 更新为 `docs/openapi-0.6.6.json`,README、中文/英文接口说明、前端清单、Postman 说明及数据库设计已同步。本地完整测试 `21 passed`,OpenAPI 与代码生成结果一致;未访问正式数据库、Jenkins 或公网服务。 |
|||
- 新截图中的公开用户 `profile_image_url`、`video_url` 与 `media[]` 都是该用户的公开资料媒体,不是发帖内容;用户帖子通过 `GET /api/v1/users/{user_id}/posts` 获取,帖子另有自己的 `media[]`。已在接口说明补充这一映射。截图的 `country: null` 是国家未填写,不应由区县覆盖。 |
|||
- API 0.6.5 与 0.6.6 的代码、迁移、测试和文档已于 2026-10-09 提交并推送到 Gitee `main`,提交 `61c7bb0`(`feat: add super likes and richer location updates`)。该提交刚推送时尚未执行正式数据库迁移、Jenkins 构建或公网烟测;后续发布结果见下一节的构建 `#39` 记录。 |
|||
|
|||
### 2026-10-09:Jenkins 构建 #39 发布 API 0.6.6 |
|||
|
|||
- 发布前确认 Gitee `main` 与本地 `HEAD` 均为 `61c7bb0`,本地完整自动化测试结果为 `21 passed`。Jenkins 上次成功构建为 `#38`,正式脚本仍使用 `dating-app-api:0.6.4`,且只包含 `relationship_goal` 迁移。 |
|||
- 已将 Jenkins Job `1` 镜像标签更新为 `dating-app-api:0.6.6`,并在停止旧容器之前一次性执行两项幂等迁移:为 `user_profiles` 增加可空 `county`,以及扩展 `swipes.direction` 的 CHECK 约束以允许 `super_like`。迁移和数据库预检失败时不会删除旧容器。 |
|||
- Jenkins 构建 `#39/default` 成功,日志确认拉取提交 `61c7bb0`、生成 `dating-app-api:0.6.6`,并包含 `MIGRATION_066_OK`、`DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 构建完成后已从 Jenkins 配置删除一次性迁移块;当前正式脚本保留 0.6.6 镜像构建、数据库连接预检、容器重建和健康检查,不包含数据库密码。 |
|||
- 公网烟测通过:两个随机账号注册均为 `201`;位置接口提交经纬度、城市和区县返回 `200`,城市与区县去空格保存,Get Me 返回 `county`;Super Like 返回 `200`,发送者在接收方 Meet 中排第 1;接收方回赞后成功匹配;撤销返回 `previous_direction: super_like`,同步解除匹配并使目标重新出现在发送者 Meet 中。 |
|||
- 正式环境现已升级为 API 0.6.6。Apifox 应导入 `docs/openapi-0.6.6.json`;新字段与 Super Like 可以使用正式 Base URL `https://melove.youthome.com.cn` 测试。 |
|||
|
|||
### 2026-10-09:Jenkins 构建 #40 发布 API 0.6.7 |
|||
|
|||
- 发布前确认 Gitee `main` 与本地 `HEAD` 均为 `d2540fe`,包含帖子编辑器元数据和个人活动总数;本地完整自动化测试结果为 `23 passed`。Jenkins 上次成功构建为 `#39`,正式脚本仍使用 `dating-app-api:0.6.6`,尚未执行帖子表迁移。 |
|||
- 已将 Jenkins Job `1` 镜像标签更新为 `dating-app-api:0.6.7`,并在停止旧容器之前运行仓库内的幂等迁移程序 `python -m app.migrate_post_composer`,为 `posts` 增加 `content_format`、`feeling`、`location_name`、`location_latitude`、`location_longitude`。迁移或数据库预检失败时不会删除旧容器。 |
|||
- Jenkins 构建 `#40/default` 成功,日志确认拉取提交 `d2540fe`、生成 `dating-app-api:0.6.7`,并包含 `POST_COMPOSER_MIGRATION_OK`、`DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 构建完成后已从 Jenkins 配置删除一次性迁移块;当前正式脚本保留 0.6.7 镜像构建、数据库连接预检、容器重建和健康检查,不包含数据库密码。 |
|||
- 公网烟测通过:两个随机账号注册均为 `201`;Markdown 帖子携带 `feeling` 与地点创建返回 `201`,公开用户帖子接口返回相同元数据;双方互相喜欢后,`GET /api/v1/auth/me` 和 `GET /api/v1/users/me` 均返回 `liked_users_count=1`、`liked_by_users_count=1`、`matches_count=1`。测试匹配已撤销,测试帖子已删除。 |
|||
- 正式环境现已升级为 API 0.6.7。Apifox 应导入 `docs/openapi-0.6.7.json`,正式 Base URL 继续使用 `https://melove.youthome.com.cn`。 |
|||
|
|||
### 2026-10-10:20 个测试账号与正式环境接口验收 |
|||
|
|||
- 按联调需求在正式环境创建 20 个合成 QA 账号,用户 ID 为 `54`–`73`。逐一验证账号登录和公开资料读取;没有旧测试账号清单,遵照用户决定保留全部旧账号,未执行批量删除。 |
|||
- 使用新账号完成 51 项线上检查,涵盖个人资料、兴趣、位置、头像、Meet 搜索与滑动、Super Like 优先展示和撤销、匹配、个人活动计数、帖子媒体与互动、聊天、Feed 问答,以及 token 刷新和退出。具体检查范围见第 6 节。 |
|||
- 保留一组匹配、示例帖子和聊天供前端联调;按测试步骤清理临时创建的匹配、帖子、评论、媒体和问答。本次没有修改后端代码、数据库结构或执行新发布。 |
|||
- 测试账号的邮箱与密码仅保存在本机临时 QA 清单,不写入 Git 或本交接文档。对外交付一个可登录账号时单独提供凭据;数字用户 ID 不能作为登录账号。 |
|||
- 本轮未验证真实介绍视频播放、证件上传、OAuth 登录,也没有让全部 20 人互相操作;这些项目仍需按具体联调需求另行测试。 |
|||
|
|||
### 2026-10-10:旧测试数据清理范围复核 |
|||
|
|||
- 对方询问是否删除此前数据;用户随后明确要求只删除能确认属于旧测试的数据,不清空全部正式用户,保留本次创建的 20 个 QA 账号(ID `54`–`73`)。 |
|||
- 使用正式 API 只读检查旧用户 ID `1`–`53` 的公开资料。ID `1`–`5`、`8`、`11`、`30`–`33`、`38`–`42`、`44`–`47`、`50`–`53` 共 24 个账号的公开姓名或简介含测试、Postman、Apifox、Smoke、deployment、release 等标记,属于待核实的旧测试账号候选;其余账号不能据此判断为测试账号。 |
|||
- 本轮未删除任何旧账号或关联数据。公开资料不包含登录标识,后端没有删除用户接口,本机也没有正式数据库连接;执行前必须用正式数据库核对候选 ID 的账号标识和关联数据,并确保备份及媒体文件清理。不能仅凭 ID 范围或姓名批量删除。 |
|||
|
|||
### 2026-10-11:线上发帖功能复核 |
|||
|
|||
- 对方反馈“早上创建帖子正常,后来不能使用”。检查确认正式域名接口仍可访问,未认证访问 Feed 返回预期 `401`,账号登录返回 `200`。 |
|||
- 使用 QA 用户 ID `54` 复测纯文字帖子:`POST /api/v1/explore/posts` 返回 `201`,Feed 返回 `200`;测试帖子随后已删除。 |
|||
- 继续复测图片帖子流程:创建图片帖子返回 `201`,上传 PNG 返回 `201`,帖子详情返回 `200` 且包含 1 个媒体,测试帖子及媒体随后已删除。 |
|||
- 本次没有修改代码、数据库或发布配置。更可能是对方当时使用的登录 token 过期、请求字段/接口地址不一致,或客户端临时网络问题;若再次出现,需要保留请求状态码和响应正文,并确认使用 `https://melove.youthome.com.cn/api/v1/explore/posts`、Bearer token 和 JSON 请求体。 |
|||
|
|||
### 2026-10-11:公开用户资料希望接近 Get Me 的新反馈 |
|||
|
|||
- 对方在 Apifox 的 `GET /api/v1/users/{user_id}` 截图中提出,希望该接口“像 Get Me 一样返回所有内容,以便少写前端代码”。这是新增需求反馈,尚未确认具体字段,也尚未修改或发布接口。 |
|||
- 当前 `GET /api/v1/users/{user_id}` 返回公开资料的扁平结构,包括姓名、城市、简介、头像和视频 URL、匹配度及公开媒体;`GET /api/v1/auth/me` 返回本人的嵌套 `profile`、兴趣、最近帖子、喜欢/被喜欢/匹配列表及数量,还包含 `email_or_phone`、账号状态和创建时间。公开帖子另可通过 `GET /api/v1/users/{user_id}/posts` 获取。 |
|||
- 需要先确认他想在其他用户主页展示的具体字段与结构。可以考虑让公开资料补充兴趣、最近公开帖子等必要信息;不得把本人的邮箱/手机号、私人喜欢列表或匹配记录原样暴露给其他用户。调整现有字段结构前还需考虑已接入客户端的兼容性。 |
|||
|
|||
给用户审阅的中文回复草稿(未发送给对方): |
|||
|
|||
> 我理解你的意思:你希望“获取公开用户资料”的返回内容和“获取我自己”尽量接近,这样前端展示用户主页时可以复用代码。我们可以补充主页需要的公开信息,例如兴趣和最近发布的帖子。但邮箱、手机号,以及私人的喜欢和匹配记录不能直接返回给其他用户。请告诉我你希望公开用户主页具体显示哪些字段,我会按这些字段调整接口,同时尽量保持现有调用方式兼容。 |
|||
|
|||
### 2026-10-12:公开用户主页补充字段(API 0.6.8,已发布) |
|||
|
|||
- 按用户要求直接实现公开主页复用所需的公开内容。`GET /api/v1/users/{user_id}` 现在返回原有公开资料,并新增 `interests` 和最近 20 条 `posts`。 |
|||
- 为避免影响 Meet、Search、喜欢列表和匹配列表,新增独立的 `PublicUserProfileResponse`,仅公开主页接口使用;这些列表仍返回原来的 `DiscoveryUserResponse`。 |
|||
- 公开主页不返回 `email_or_phone`、账号 `status`、喜欢列表、被喜欢列表或匹配记录。超过 20 条帖子时继续使用 `GET /api/v1/users/{user_id}/posts?limit=20&offset=0` 分页获取。 |
|||
- 新增接口测试验证兴趣、帖子和隐私字段;本地完整自动化测试为 `24 passed`。OpenAPI 已生成 `docs/openapi-0.6.8.json`,本次没有数据库结构变化,不需要迁移。 |
|||
- 代码和 OpenAPI 已随提交 `7dc204d` 推送并发布;正式环境验证结果见本文件后面的 Jenkins 构建 `#41` 记录。 |
|||
|
|||
### 2026-10-12:喜欢列表接口截图核验 |
|||
|
|||
- 前端截图中的 `GET /api/v1/users/me/liked`(我喜欢的人)和 `GET /api/v1/users/me/likes`(喜欢我的人)均返回 `200` 和空数组 `[]`,只能说明这两次请求所用账号当时没有对应的有效记录,不能据此判断对方没有执行过 like。 |
|||
- 后端只统计当前仍存在且方向为 `like` 或 `super_like` 的滑动;`pass`、已取消的喜欢、已停用用户不会出现在这两个列表中。`/liked` 只需单向喜欢即可出现记录,`/likes` 则取决于其他用户是否喜欢当前用户。 |
|||
- 使用本机 QA 用户 ID `54` 重新登录线上正式环境后,两个列表均返回用户 ID `55`,说明线上接口和认证配置正常。验证账号的匹配列表也能返回对应匹配记录。 |
|||
- 截图中的 Bearer token 已经暴露在图片里,不能继续当作安全凭据使用;前端应重新登录取得新 access token,再核对同一账号的资料和列表响应,后续截图不要包含完整 token。 |
|||
|
|||
### 2026-10-12:前端补充截图中的主页计数与 Explore Feed 参数 |
|||
|
|||
- 前端手机截图显示账号 `sakaf` 的 `Likes 4`、`Liked 0`、`Matches 0`。目前无法确认手机 App 与先前 Apifox 的两个空列表使用同一账号、同一数据源及同一时刻,不能直接认定后端计数矛盾。 |
|||
- 后端 `GET /api/v1/auth/me` 的 `liked_users_count` 对应我喜欢的人和 `/api/v1/users/me/liked`;`liked_by_users_count` 对应喜欢我的人和 `/api/v1/users/me/likes`;`matches_count` 对应匹配数。手机 UI 的 `Likes`、`Liked` 实际绑定哪个字段仍需前端确认。 |
|||
- 另两张 Apifox 截图中的 Explore Feed `422` 来自请求参数:`before_id=54` 与非零 `offset=20` 同时传入会冲突;勾选 `before_id` 但留空会触发整数解析错误。前端分页使用 `offset` 时应完全不发送 `before_id`;使用 `before_id` 时应传有效整数并将 `offset` 保持为 `0` 或省略。线上已分别验证两种有效请求可返回 `200`。 |
|||
- 请前端在同一账号、同一环境中提供 `GET /api/v1/auth/me` 的 `id`、上述三个 count,以及 `/users/me/liked` 和 `/users/me/likes` 的请求地址、状态码和响应;隐藏 access token 等凭据。取得这些信息后才能判断是 UI 字段映射、账号混用、缓存,还是接口数据问题。 |
|||
|
|||
### 2026-10-12:应前端要求自行复测 Apifox 所用线上接口 |
|||
|
|||
- 使用现有 QA 账号 ID `54` 和 `55` 登录 `https://melove.youthome.com.cn`,两者的 `GET /api/v1/auth/me` 均返回主动喜欢数 `1`、收到喜欢数 `1`、匹配数 `1`;各自的 `/users/me/liked?limit=20&offset=0` 和 `/users/me/likes?limit=20&offset=0` 均为 `200` 且各有 1 条。线上后端在这些账号上未复现“计数非零但第一页列表为空”。 |
|||
- 对同一 QA 账号 ID `54`,将这两个列表请求改成 `limit=20&offset=20` 后均返回 `200 []`,因为 `offset=20` 跳过前 20 条。此前前端的 Apifox 截图只展示 Auth 页,显示有两个 Params,却没有展示参数值;应首先核查其 `offset` 是否设成 `20`。若总数只有 `4`,该偏移量会使响应为空。 |
|||
- 本地没有 `sakaf` 的登录凭据或前端 App 代码,无法直接核对该手机账号、页面字段映射与 Apifox 所用账号。请前端用手机登录的同一账号重新登录 Apifox,在两个列表请求的 Params 页设置 `limit=20`、`offset=0`,并分别执行 `GET /api/v1/auth/me` 与两个列表请求,提供遮盖凭据后的 ID、三个计数、实际请求 URL、状态码和响应。此次仅执行只读请求,没有修改正式数据或后端代码。 |
|||
|
|||
### 2026-10-12:按前端新要求取消喜欢名单的分页(API 0.6.8,已发布) |
|||
|
|||
- 前端随后提出移除后端喜欢名单的数量限制。`GET /api/v1/users/me/liked` 和 `GET /api/v1/users/me/likes` 已改成返回当前用户所有有效喜欢对象,保持最近操作在前;不再接受分页定义。旧客户端仍传 `limit`、`offset` 时,FastAPI 会忽略这两个额外查询参数并返回完整名单。`GET /api/v1/auth/me` 和 `/api/v1/users/me` 中的喜欢数组仍只展示最近 20 条,三个 count 字段仍为完整总数;帖子、匹配等其他接口继续分页。 |
|||
- `offset=20` 在旧版接口中表示跳过前 20 条,只有 4 条时返回 `200 []` 是正确的分页行为,不是原代码的逻辑错误。新版不分页后,即使旧 Apifox 请求保留 `offset=20`,也会返回完整名单;前端更新接口定义后应删除两个参数。 |
|||
- 已更新 README、中文/英文 API 说明、前端对接清单、Postman 说明和 `docs/openapi-0.6.8.json`;两个接口的 OpenAPI 参数列表为空。测试覆盖 21 条主动/收到喜欢、个人资料 20 条预览以及旧分页参数兼容,本地完整测试 `24 passed`。没有数据库迁移;名单较大时单次响应会相应变大。 |
|||
- 本次改动已发布到正式环境。线上验证确认两个接口返回完整名单,旧请求即使携带 `limit=1&offset=20` 也会返回相同名单;前端应重新导入 OpenAPI 并移除 Apifox 中这两个请求的分页参数。 |
|||
|
|||
### 2026-10-12:Jenkins 构建 #41 发布 API 0.6.8 |
|||
|
|||
- 发布前确认本地与 Gitee `main` 均为提交 `7dc204d`(`feat: expose complete public profiles and like lists`),本地完整自动化测试为 `24 passed`;本次没有数据库结构变化,不执行迁移。 |
|||
- Jenkins Job `1` 的 3 处镜像标签从 `dating-app-api:0.6.7` 更新为 `dating-app-api:0.6.8`,保留数据库预检、容器重建和健康检查,配置中没有一次性迁移块。 |
|||
- Jenkins 构建 `#41/default` 成功,日志确认拉取提交 `7dc204d`、生成 `dating-app-api:0.6.8`,并包含 `DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:注册两个随机测试账号后,目标账号保存 2 个兴趣并创建帖子;`GET /api/v1/users/{user_id}` 返回 `interests` 和新建帖子,且不包含 `email_or_phone`、`status`、喜欢列表、被喜欢列表或匹配记录。 |
|||
- 公网烟测确认 `/api/v1/users/me/liked` 和 `/api/v1/users/me/likes` 均返回对应完整名单;携带旧参数 `?limit=1&offset=20` 时返回结果与无参数请求一致。测试滑动和测试帖子已清理;两个随机烟测账号保留在正式库中。 |
|||
- 正式环境现已升级为 API 0.6.8,Base URL 继续使用 `https://melove.youthome.com.cn`。前端应导入 `docs/openapi-0.6.8.json`,并删除两个喜欢名单请求中的分页参数。 |
|||
|
|||
|
|||
### 2026-10-14:API 0.6.9 作者头像、帖子点赞名单及精简点赞响应 |
|||
|
|||
- `PostAuthorResponse.profile_image_url` 已补充,共用序列化器覆盖帖子创建、Explore、详情、个人资料嵌入的帖子、用户帖子列表、评论新增与列表。复用现有头像 URL 生成逻辑;无头像为 `null`,生产 OSS 签名 URL 有效一小时。 |
|||
- `PostResponse.liked_by_users` 返回 `[{id, full_name}]`,完整名单、最新点赞在前、只含 active 用户。姓名允许 `null`,不公开邮箱/手机号等资料;`like_count` 与名单长度一致,inactive 用户的已有赞不计入公开数量。 |
|||
- POST `/api/v1/explore/posts/{post_id}/like` 响应从完整 `PostResponse` 改为 `PostLikeResponse`:`message: "like successful"`、`like_count`、`liked_by_me`、`liked_by_users`。这是前端需要适配的响应变化,不能用新响应替换完整帖子对象;重复点赞维持幂等行为。 |
|||
- GET 同一 `/like` 地址新增只查询简短名单功能,不分页,不要求调用者已点赞。DELETE 取消点赞保持返回完整 `PostResponse`,其中名单与状态同步更新。 |
|||
- 无数据库迁移;复用现有用户资料、媒体、PostLike 数据。名单较大时响应大小及序列化开销会随人数增加。 |
|||
- 新增两项接口测试,验证有/无头像、签名头像地址、多处帖子响应、评论新增/列表、精简响应字段、重复点赞、多人排序、空名单、取消点赞、inactive 过滤及数量一致性、GET 未登录 401 与帖子不存在 404。本地完整测试 `26 passed in 22.93s`,测试使用临时 SQLite,未操作正式用户或线上数据。 |
|||
- 已生成并验证 `docs/openapi-0.6.9.json`:同一 /like 路径具有 GET/POST/DELETE,响应分别为简短数组/精简结果/完整帖子。`git diff --check` 通过。 |
|||
- 已同步 README、中文/英文 API 说明、前端对接清单、Postman 导入说明。共享 Google 文档补充位置和英文粘贴内容在 `docs/Google文档补充-0.6.9.md`,附中文操作说明;未直接编辑 Google 文档或 Apifox。 |
|||
- 本次代码和新版规范已随提交 `747359b` 推送并发布,正式环境验证结果见下面的 Jenkins 构建 `#42` 记录。 |
|||
|
|||
### 2026-10-15:Jenkins 构建 #42 发布 API 0.6.9 |
|||
|
|||
- 发布前确认本地完整自动化测试为 `26 passed`,OpenAPI 版本和代码均为 0.6.9;本次没有数据库结构变化,不执行迁移。 |
|||
- 代码和文档已提交并推送到 Gitee `main`,提交为 `747359b`(`feat: add post liker summaries and author avatars`)。 |
|||
- Jenkins Job `1` 的 3 处镜像标签从 `dating-app-api:0.6.8` 更新为 `dating-app-api:0.6.9`,保留数据库预检、容器重建和健康检查,配置中没有一次性迁移块。 |
|||
- Jenkins 构建 `#42/default` 成功,日志确认拉取提交 `747359b`、生成 `dating-app-api:0.6.9`,并包含 `DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:帖子作者的 `profile_image_url` 为可直接读取的 OSS 签名 URL;无头像的评论作者返回 `profile_image_url: null`;完整帖子返回 `liked_by_users`。 |
|||
- 点赞 POST 只返回 `message`、`like_count`、`liked_by_me`、`liked_by_users`,重复点赞保持幂等;GET 点赞名单按最新点赞在前返回;取消点赞后完整帖子中的数量、状态和名单同步更新。 |
|||
- 烟测创建的帖子、评论和点赞已随测试帖子删除;3 个随机烟测账号及其中 1 个合成头像保留在正式库中。正式 Base URL 继续使用 `https://melove.youthome.com.cn`,前端应导入 `docs/openapi-0.6.9.json`。 |
|||
|
|||
### 2026-10-15:核对 Dating APP api (3).pdf 与共享文档补充内容 |
|||
|
|||
- 用户提供的 PDF 标注版本为 `0.6.7`,内容覆盖注册、头像/视频、当前用户、资料、位置、刷新 token、Meet、滑动、取消按钮、搜索、Explore、公开资料、公开帖子、创建帖子和帖子媒体;它是旧版快照,不能直接覆盖当前 0.6.9 字段。 |
|||
- 已按 PDF 原有顺序生成可直接复制的 `docs/Google文档完整内容-从Registration-0.6.9.md`,从 Registration API 开始,补入正式 0.6.9 的 `author.profile_image_url`、`liked_by_users`、点赞精简响应、GET 点赞名单和评论作者头像说明,并保留 Cancel Button、Super Like 持续优先级、Explore 分页规则等已确认行为。 |
|||
- 根据当前代码、正式发布记录和需求覆盖清单,蓝色功能项中已完成并发布的包括注册/登录与 token、头像和介绍视频、位置、Meet 排序、pass/like/super_like、Cancel Button、搜索、Explore、公开主页和帖子媒体。安全部分的 HTTPS、JWT、密码哈希和 refresh token 已实现;PDF 中 AES-256-GCM、混合加密、加密 payload 解密中间层和生产密钥管理仍未实现,不能向前端汇报为已完成。 |
|||
- PDF 的创建帖子示例没有 0.6.9 新增的 `liked_by_users` 和作者头像字段;复制时应以新文件为准。PDF 中“Music not supported yet”仍然准确。 |
|||
|
|||
### 2026-10-15:接手交接与现状核验 |
|||
|
|||
- 新接手方按第 7 节流程通读了本文件、`README.md`、`docs/openapi-0.6.9.json`、`docs/Dating-App-API-前端对接清单.md`、`docs/Google文档补充-0.6.9.md` 和 `docs/Google文档完整内容-从Registration-0.6.9.md`,确认当前交付状态仍为正式版本 `0.6.9`、Jenkins 构建 `#42`。 |
|||
- 代码核验:本地 `HEAD` 与 `origin/main` 均为 `3350152`(`docs: record API 0.6.9 production release`),工作区无代码改动;本地完整自动化测试重跑结果为 `26 passed in 26.61s`,与第 6 节记录一致。 |
|||
- 正式环境可连通性核验(只读,未修改线上数据):`POST https://melove.youthome.com.cn/api/v1/auth/login` 使用过短密码返回 `422`,使用不存在的账号返回 `401`;`GET /api/v1/auth/me` 与 `GET /api/v1/feed/questions` 不带 token 均返回 `401`。说明 nginx 到 FastAPI 的转发链路和应用鉴权均正常。 |
|||
- 本次核验发现一处文档与实际部署不一致:公网域名只把 `/api/v1/...` 转发给容器,`https://melove.youthome.com.cn/health`、`/docs` 和 `/` 都落到 nginx 默认欢迎页(`200` 但内容是 `Welcome to nginx!`),不是应用的 `/health` JSON。`docs/Dating-App-API-前端对接清单.md` 的“Health check `GET {{base_url}}/health`”因此会让前端误判。后续二选一处理:请服务器管理员在 nginx 增加 `/health` 转发,或把前端连通性检查改为调用 `/api/v1/...`;当前本地 `http://127.0.0.1:8001/health` 仍返回 `{"status":"ok"}`。 |
|||
- 仓库整理:上一次任务生成的 `docs/Google文档完整内容-从Registration-0.6.9.md` 此前只存在于本地且未提交,本次随本记录一并提交;`.gitignore` 增加 `tmp/`,避免 PDF 渲染出的临时图片和截图被误提交。 |
|||
|
|||
### 2026-10-16:英文同事询问 Chat 章节与“创建聊天链接” |
|||
|
|||
- 用户提供的微信截图(`问题1.png`,带微信翻译)中,英文同事连问三句:`Hello, bro did you update it`(谷歌文档更新了吗)、`And can you explain the chat section`(解释一下聊天部分)、`Can you check the create chat Link`(检查一下创建聊天的链接)。用户已回复“我在谷歌文档中更新了,您可以看一下”。 |
|||
- 定位到原因:交付给共享文档的内容 `docs/Google文档完整内容-从Registration-0.6.9.md` 只写到第 18 项(帖子点赞与评论),**没有 Chat 章节**,所以对方看不到聊天接口;他问的“create chat link”就是创建会话的地址。 |
|||
- 已对照当前代码 `app/main.py` 核验聊天接口:`GET /api/v1/chat/conversations`(会话列表,按最新匹配在前,对方非 active 时跳过,未建会话时 `id` 为 `null`);`POST /api/v1/chat/conversations/{match_id}`(创建或获取会话,无请求体,成功 `201`,重复调用返回同一会话;不带 `match_id` 对 `/api/v1/chat/conversations` 发 POST 返回 `405`;`match_id` 来自滑动响应的 `match_id` 或 `GET /api/v1/matches`,不属于自己的 match 返回 `404`);`GET/POST /api/v1/chat/conversations/{conversation_id}/messages`(`limit` 1–100 默认 50,`before_id` 可选,页内按时间正序;发送 `content` 1–5000,`message_type` 仅 `text`);`DELETE /api/v1/chat/messages/{message_id}`(仅本人可撤回,返回 `content=null`、`is_deleted=true`,否则 `403`/`404`)。本次为只读核对,未修改代码或线上数据。 |
|||
- 已生成可直接粘贴的英文补充:`docs/Google文档补充-Chat章节.md`,包含插入位置(第 18 项之后、`Requirement Coverage` 之前)、`19. Chat` 全部五个接口的 URL / Authentication / Request / Backend Response 示例与规则,以及 `How Each API Link Works` 需补的两条聊天流程。本次未直接编辑在线 Google 文档,内容需由用户粘贴。 |
|||
- 待对方确认的差异:聊天响应里的 `other_user` 目前只有 `id`、`full_name`、`city`、`country`,没有头像地址;如果前端按帖子和评论的做法要求聊天列表/会话显示头像,需要作为新需求开发。 |
|||
|
|||
### 2026-10-16:老板新增每周工作汇报要求并交付本周周报 |
|||
|
|||
- 老板要求从本周起每周日下午 5 点前通过微信提交周工作总结,形式不限,需覆盖本周开展的工作、发现或解决的问题、对项目或产品的思考以及下周工作安排。 |
|||
- 已按交接文档中 2026-10-12 至 2026-10-16 的记录整理周报:两批接口开发并上线(API 0.6.8 公开主页字段与取消喜欢名单分页、API 0.6.9 作者头像与点赞名单及点赞精简响应)、本地测试 24 与 26 项全部通过、Jenkins #41 与 #42 两次发布成功、前端与英文同事的联调支持、文档与交接维护。 |
|||
- 周报同时记录了三类问题与风险:前端参数用法导致的误报(offset 跳页、before_id 留空或与 offset 混用)、对接文档版本落后、公网 /health 与 /docs 未转发到后端;并列出下周计划(跟进 0.6.9 接口导入、处理 /health 转发、确认聊天头像需求、评估加密方案排期、旧测试账号清理需先取得清单或数据库权限)。 |
|||
- 周报成稿在 `docs/周工作总结-2026-10-12至10-16.md`,可直接复制到微信;本次未替用户发送消息。 |
|||
|
|||
### 2026-10-19:按英文同事微信反馈实现 API 0.6.10(已发布) |
|||
|
|||
- 触发来源:工作目录下 1.jpg / 2.png / 3.jpg / 4.png 四张微信截图,同事吴启明(PARVEJ)反馈三点:`For unlike post just return unlike successful`、`you did not give the link for edit comment and post`、`You create post doesn't support image and video together`。 |
|||
- 实现内容(API 0.6.10): |
|||
1. `DELETE /api/v1/explore/posts/{post_id}/like` 从返回完整 PostResponse 改为返回 `PostUnlikeResponse`(`message`、`like_count`、`liked_by_me`、`liked_by_users`),与点赞精简响应保持一致;重复取消保持未点赞状态。 |
|||
2. 新增 `PATCH /api/v1/explore/posts/{post_id}` 编辑自己的帖子(content、content_format、feeling、location;文字帖必须保留文字/心情/地点至少一项),`PATCH /api/v1/explore/comments/{comment_id}` 编辑自己的评论(content 1-2000 字符);非作者 403,不存在 404,非法 422。 |
|||
3. 上传帖子媒体改为允许同一条帖子同时含图片和视频:最多 9 张图片 + 1 个视频,两者同时存在时 `post_type` 为 `mixed`;`position` 参数范围放宽到 0-9。 |
|||
- 无数据库迁移:Post、PostComment 已含 updated_at 等所需字段;复用现有 PostMedia、post_response、post_author_response。 |
|||
- 测试:新增 `test_edit_post_and_comment` 与 `test_post_supports_image_and_video_together`,并收紧 unlike 响应断言;本地完整测试 `28 passed in 22.72s`。 |
|||
- 已生成并验证 `docs/openapi-0.6.10.json`(PATCH 帖子、PATCH 评论、DELETE 点赞返回 PostUnlikeResponse 等契约),`git diff --check` 通过。 |
|||
- 已同步 README、中英文接口说明、前端对接清单、Postman 说明;共享 Google 文档的可粘贴英文内容在 `docs/Google文档补充-0.6.10.md`,附中文操作说明,未直接编辑在线文档或 Apifox。 |
|||
- 发布前复核补了两个边界修复:PATCH 帖子的 `content`、`content_format` 显式传 `null` 现在返回 422,不再写入数据库非空列;删除混用帖子的图片或视频后会按剩余媒体重新计算 `post_type`(`mixed`/`image`/`video`,无媒体但有正文时回到 `text`)。修正后完整测试仍为 `28 passed`。 |
|||
- 当前交付状态:代码与文档已提交并推送到 Gitee `main`,功能提交为 `904ae04`(`feat: add post editing and mixed media uploads`);发布结果见下面的 Jenkins 构建 `#43` 记录。 |
|||
|
|||
### 2026-10-19:Jenkins 构建 #43 发布 API 0.6.10 |
|||
|
|||
- 发布前确认本地完整自动化测试为 `28 passed`,OpenAPI 版本和代码均为 0.6.10;本次没有数据库结构变化,不执行迁移。 |
|||
- Jenkins Job `1` 的 3 处镜像标签从 `dating-app-api:0.6.9` 更新为 `dating-app-api:0.6.10`,保留数据库预检、容器重建和健康检查,配置中没有一次性迁移脚本。 |
|||
- Jenkins 构建 `#43/default` 成功,日志确认拉取提交 `904ae04`、生成 `dating-app-api:0.6.10`,并包含 `DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:`PATCH /api/v1/explore/posts/{post_id}` 可编辑正文、格式、心情和地点,非作者返回 403,`content: null` 返回 422;`PATCH /api/v1/explore/comments/{comment_id}` 可编辑评论,非作者返回 403。 |
|||
- 公网烟测通过:同一帖子成功上传图片和视频后 `post_type` 为 `mixed`,第二段视频返回 409;取消点赞返回 `message`、`like_count`、`liked_by_me`、`liked_by_users`,不含完整帖子,重复取消保持幂等。 |
|||
- 正式环境现已升级为 API 0.6.10,Base URL 继续使用 `https://melove.youthome.com.cn`。测试帖子、评论、点赞和媒体已随测试帖子删除;3 个随机烟测账号保留在正式库中。前端应导入 `docs/openapi-0.6.10.json`。 |
|||
|
|||
### 2026-10-19:英文同事询问 Search 接口说明 |
|||
|
|||
- 同事发来 `Can explain the search link` 和一张 Apifox 截图(保存为工作目录下 5.jpg),截图是 `GET /api/v1/search` 的 Params 页,列出 q、city、country、latitude、longitude、max_distance_km、limit。 |
|||
- 已对照当前代码 `app/main.py` 的 `search_users` 核验参数语义和排序/过滤逻辑,答复见会话;本次只读核对,未修改代码或数据。 |
|||
|
|||
### 2026-10-19:交付共享 Google 文档 0.6.10 纯文本全文 |
|||
|
|||
- 用户提供最新导出 `Dating APP api (6).pdf`(24 页,正文仍标 0.6.9,已含 19 Chat),要求给一份从 Registration API 开始、可直接粘贴的完整内容,并去掉 Markdown 痕迹。 |
|||
- 已生成 `docs/Google文档完整内容-0.6.10.txt`,共 827 行,纯文本、无 `*`、`#`、反引号和分隔线;结构与现有文档一致(1-19 章 + Requirement Coverage + How Each API Link Works + Typical Frontend Order)。 |
|||
- 相对 PDF 的更新:版本改为 0.6.10 并标注 Jenkins 43 已发布;第 12 章 Search 补全 q、city、country、latitude、longitude、max_distance_km、limit 及排序规则;新增 16.1 编辑帖子;第 17 章改为最多 9 图 + 1 视频可混用、position 0-9、post_type 为 mixed;第 18 章取消点赞改为精简响应并补编辑评论、删除评论;覆盖清单和调用流程同步更新。 |
|||
- 本次只生成文档内容,未直接编辑在线 Google 文档。 |
|||
|
|||
### 2026-10-19:按参考模板生成软件著作权申请材料 |
|||
|
|||
- 模板来源:`参考模板(1)` 中的 `申请表信息.txt`、`XXX软件使用说明书.docx`、`XXX软件源程序V1.0.docx`;模板本身是骨架,并附有常见驳回点提示(三处软件名称必须一致、代码不能有大量空行和测试注释、说明书不能只有截图)。 |
|||
- 以本项目服务端为对象生成三件套,软件全称统一为 `MeLOVE交友社交平台服务端软件`,版本 `V1.0`,输出目录 `docs/软著申请/`。 |
|||
- 申请表信息:按模板字段填写开发/运行硬件、Windows 11 开发环境、Linux+Docker 运行平台、Python 语言、MySQL 8/Nginx/OSS 支撑环境、开发目的、面向领域、主要功能和技术特点;源程序量按提交文档实际行数填 2043。 |
|||
- 使用说明书:A4 排版,含封面、文件修订记录、目录、总体功能描述、运行环境、编译环境、安装说明、软件使用、设计理论、软件结束、其他功能,以及主要接口清单附录;共 10 页,正文使用宋体小四、页眉含软件名和版本、页脚页码,并在第 4 章嵌入本地 Swagger 接口文档真实截图。 |
|||
- 源程序:从 `app/` 的实际 Python 源码提取,去除空行和整行注释,长行按 100 字符折行,连续编号,每页 50 行,页眉为软件名+版本+“源程序”,页脚页码;共 2043 行、41 页,A4。 |
|||
- 已用 Word 导出同名 PDF,供直接提交;Word 与 PDF 均已生成并核对页数。软著名称在申请表、说明书页眉、源程序页眉三处保持一致。 |
|||
- 说明:当前说明书截图来自本地 Swagger 接口文档,适合服务端软件;如申请主体希望展示移动端界面,需要补充 App 截图后替换图 1。作者栏暂填“项目组”,公司名称、著作权人等信息需用户补充。 |
|||
|
|||
### 2026-10-19:核对前端“已有 match 但列表为空” |
|||
|
|||
- 用户提供 7.png、8.jpg:同事吴启明(PARVEJ)反馈 `List matches doesn't return anything`、`This already have a match but returning nothing`;Apifox 截图为 `GET /api/v1/matches`,token 的 sub 为用户 80。 |
|||
- 已在正式环境做小范围复现:新建两个账号(ID 89、90),互相 like 后第二次滑动返回 `matched=true, match_id=9`;分别调用 `GET /api/v1/matches?limit=20&offset=0`、`GET /api/v1/users/me/matches?limit=20&offset=0`,两边都正确返回该 match,`matches_count` 从 0 变为 1。说明列表接口和 CASE 关联逻辑在线上正常。 |
|||
- 因此该反馈更可能是请求侧问题:offset 非 0、token 过期或不属于该 match 的账号、另一方账号已停用、match 已被 pass/取消删除。需要对方提供同一 token 下 `GET /api/v1/auth/me` 的 `matches_count` 与 `matches`,以及 `/api/v1/matches` 的完整请求 URL(含参数)和响应。 |
|||
- 备选修复:若确认是 offset 导致,可参照喜欢名单把 `/api/v1/matches` 与 `/api/v1/users/me/matches` 改为不分页返回全部;本次未改代码。 |
|||
- 副作用说明:复现用的账号 89、90 及 match 9 保留在正式库(后端没有删除账号的接口)。 |
|||
|
|||
### 2026-10-19:API 0.6.11 匹配列表取消分页(已发布) |
|||
|
|||
- 前端反馈:`GET /api/v1/auth/me` 显示有 2 条匹配,但 `GET /api/v1/matches` 返回空。两者使用同一 `user_matches` 查询,唯一可能造成差异的是 `/matches` 请求带了非 0 的 `offset`(例如 20)。 |
|||
- 修复:`user_matches` 支持 `limit=None`;`GET /api/v1/matches` 和 `GET /api/v1/users/me/matches` 去掉 `limit`、`offset` 查询参数,直接返回全部有效匹配(最近的在前),旧客户端携带这两个参数会被忽略。`/api/v1/auth/me` 内嵌的 matches 数组仍保持最近 20 条预览,`matches_count` 仍为完整总数。 |
|||
- 测试:在已有匹配测试中补充断言,确认两个匹配接口携带 `?limit=1&offset=20` 时仍返回完整列表;本地完整测试 `28 passed`。 |
|||
- 版本升至 `0.6.11`,已生成并验证 `docs/openapi-0.6.11.json`(两个匹配接口的 parameters 为空);同步 README、中英文接口说明、前端对接清单、Postman 说明。 |
|||
- 共享 Google 文档的可粘贴全文已更新为 `docs/Google文档完整内容-0.6.11.txt`,并新增 9.1 Get Matches 小节说明匹配列表不分页。 |
|||
- 交付状态:代码和文档已提交并推送到 Gitee `main`,功能提交为 `fc520a5`(`feat: return complete match lists without pagination`);发布结果见下面的 Jenkins 构建 `#44` 记录。 |
|||
|
|||
### 2026-10-19:Jenkins 构建 #44 发布 API 0.6.11 |
|||
|
|||
- 发布前确认本地完整自动化测试为 `28 passed`,OpenAPI 版本和代码均为 0.6.11;本次没有数据库结构变化,不执行迁移。 |
|||
- Jenkins Job `1` 的 3 处镜像标签从 `dating-app-api:0.6.10` 更新为 `dating-app-api:0.6.11`,保留数据库预检、容器重建和健康检查,配置中没有一次性迁移脚本。 |
|||
- Jenkins 构建 `#44/default` 成功,日志确认拉取提交 `fc520a5`、生成 `dating-app-api:0.6.11`,并包含 `DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 公网烟测通过:两个新账号互相 like 后生成 match 10;`GET /api/v1/auth/me` 返回 `matches_count=1` 和对应 `matches`;`GET /api/v1/matches?limit=1&offset=20` 与 `GET /api/v1/users/me/matches?limit=1&offset=20` 均返回该匹配,且与不带参数的结果完全一致。 |
|||
- 测试滑动已撤销,match 10 已清理;两个随机烟测账号保留在正式库中。正式环境现已升级为 API 0.6.11,Base URL 继续使用 `https://melove.youthome.com.cn`。前端应导入 `docs/openapi-0.6.11.json`,并删除两个匹配请求中的分页参数。 |
|||
|
|||
### 2026-10-19:用户确认发布 API 0.6.11,公网验证通过 |
|||
|
|||
- 用户表示已导入 OpenAPI 并在正式环境发布 0.6.11。 |
|||
- 公网回归验证(新建账号 ID 93、94,match_id 11):`GET /api/v1/matches?limit=1&offset=20` 返回 200 且包含该 match;`GET /api/v1/users/me/matches?limit=1&offset=20` 同样返回 1 条;`GET /api/v1/auth/me` 的 `matches_count` 为 1。证明旧分页参数已被忽略,修复在正式环境生效。 |
|||
- `/openapi.json` 仍被 nginx 默认页占用,无法从公网直接读取版本号;版本确认以用户发布说明和接口行为为准。 |
|||
- 验证账号 93、94 及 match 11 保留在正式库(无删除账号接口)。 |
|||
|
|||
### 2026-10-21:个人资料新增 8 个板块字段(API 0.6.12,已发布) |
|||
|
|||
- 需求来源:同事微信“Add this information into edit profile / When get me link also should return this”,配图 2-5 为编辑资料页:职业、学历、身高、信仰、吸烟、饮酒、宠物、运动习惯;另有 Display Name、生日年龄、性别、城市、交友目标、简介、兴趣(原有字段)。 |
|||
- 实现:`user_profiles` 新增 8 个可空字段 `occupation`、`education`、`height_cm`、`beliefs`、`smoking`、`drinking`、`pets`、`exercise`;`PATCH /api/v1/users/me` 可只提交需要修改的字段并支持传 null 清空;`GET /api/v1/auth/me` 和 `/api/v1/users/me` 的 profile 返回这些字段,并新增根据 birth_date 计算的 `age`。 |
|||
- 校验:occupation/education/pets/beliefs 最多 120 字符,smoking/drinking/exercise 最多 50 字符,height_cm 范围 100-250。原有 full_name 120、bio 2000 的后端上限保持不变(设计稿显示 30/500,由前端限制)。 |
|||
- 数据库迁移:新增 `sql/migrate_profile_details.sql`,幂等添加 8 个可空列;**必须在部署代码前在正式库执行**。 |
|||
- 测试:新增资料详情写入与 Get Me 返回、age 计算、height_cm 越界 422 的用例;本地完整测试 `29 passed`。 |
|||
- 版本升至 `0.6.12`,生成并验证 `docs/openapi-0.6.12.json`;同步 README、中英文接口说明、前端对接清单、Postman 说明;共享 Google 文档全文更新为 `docs/Google文档完整内容-0.6.12.txt`。 |
|||
- 公开资料(Meet/Search/Match/GET users/{id})本次未加这 8 个字段;如果要在对方的 profile card 上展示,需要下一步扩展 `DiscoveryUserResponse`/公开主页。 |
|||
- 交付状态:已随 `20613f6` 推送并发布,正式验证结果见本文件后面的 Jenkins `#46` 记录。 |
|||
|
|||
### 2026-10-21:复核同事历史需求并补齐匹配移除、评论回复与评论点赞 |
|||
|
|||
- 用户提供 4 张历史/新需求截图,逐条核对: |
|||
1. 互相喜欢后加入 match list、从 likes/liked 列表移除 —— 原先未做,本次已实现。 |
|||
2. 回复评论、点赞评论 —— 原先未做,本次已实现。 |
|||
3. 取消点赞只返回成功、编辑评论/帖子链接、图片视频可混用 —— 已在 0.6.10 完成并发布,本次不改。 |
|||
- 匹配规则:`liked_users` 和 `profile_activity_response` 中的两个喜欢名单、`liked_users_count`、`liked_by_users_count` 均排除已匹配用户;匹配列表不变。 |
|||
- 评论回复:`post_comments` 增加可空 `parent_comment_id` 自关联外键;`CommentCreate` 支持 `parent_comment_id`,父评论必须属于同一帖子,否则 404;`CommentResponse` 增加 `parent_comment_id`、`reply_count`、`like_count`、`liked_by_me`,GET 仍返回扁平列表,由前端按 parent 分组。 |
|||
- 评论点赞:新增 `comment_likes` 表(comment_id + user_id 唯一)和 `POST/DELETE /api/v1/explore/comments/{comment_id}/like`,返回 message、like_count、liked_by_me,重复点赞幂等。 |
|||
- 数据库迁移:新增 `sql/migrate_comment_interactions.sql`(parent 列、索引、外键、comment_likes 表),与 `sql/migrate_profile_details.sql` 一起在部署前执行;`sql/schema.sql` 已同步。 |
|||
- 测试:更新匹配后喜欢名单断言,新增评论回复和评论点赞用例;本地完整测试 `30 passed`。 |
|||
- 版本保持 `0.6.12`,已重新生成 `docs/openapi-0.6.12.json`,同步 README、中英文接口说明、前端对接清单、Postman 说明和 `docs/Google文档完整内容-0.6.12.txt`。 |
|||
|
|||
### 2026-10-21:0.6.12 代码已推送,迁移与 Jenkins 待有权限者执行 |
|||
|
|||
- 已将 0.6.12 的代码、迁移、测试和文档提交为 `20613f6`(`feat: add profile details and comment interactions`)并推送到 Gitee `main`;远端 HEAD 已确认等于 `20613f6`。 |
|||
- 部署阻塞:生产 MySQL(47.116.134.234:3306,用户 dating)需要密码,本机无可用凭据;Jenkins(47.116.134.234:8080)对未登录请求返回 403,当前 Codex 浏览器会话也没有 Jenkins 标签页/登录态。因此本次未能执行迁移,也未能触发构建。 |
|||
- 必须先执行迁移再部署,否则 0.6.12 代码读取 user_profiles 新增列会报 Unknown column: |
|||
1. `sql/migrate_profile_details.sql` |
|||
2. `sql/migrate_comment_interactions.sql` |
|||
- 迁移后 Jenkins Job 1 三处镜像标签改为 `dating-app-api:0.6.12` 并构建,构建成功后验证:GET /auth/me 返回 8 个资料字段和 age、PATCH /users/me 可写、评论可带 parent_comment_id 并有点赞接口、匹配用户不再出现在喜欢名单。 |
|||
|
|||
|
|||
### 2026-10-21:Jenkins 构建 #45/#46 完成 API 0.6.12 迁移与发布 |
|||
|
|||
- 发布前重跑完整自动化测试为 `30 passed`,代码、OpenAPI 和数据库迁移均来自提交 `20613f6`。 |
|||
- 首次构建 `#45` 将镜像和一次性迁移步骤放入 Jenkins,但迁移容器缺少 `-i`,heredoc 未传入容器,Python 读取空输入后直接成功退出;随后新容器替换旧版本,正式注册出现 `500`。这是迁移执行方式问题,不是迁移 SQL 或业务代码错误。 |
|||
- 立即在迁移容器补上 `-i` 并重新构建 `#46`。日志明确包含 `MIGRATION_0612_OK`、`Successfully tagged dating-app-api:0.6.12`、`DATABASE_CONNECTION_OK 1`、`running|healthy`、`DEPLOYMENT_OK` 和 `Finished: SUCCESS`。 |
|||
- 迁移核验通过:`user_profiles` 的 8 个新列存在,`post_comments.parent_comment_id` 存在,`comment_likes` 表存在。构建完成后已从 Jenkins 配置删除一次性迁移块,只保留 0.6.12 镜像构建、数据库预检、容器重建和健康检查。 |
|||
- 公网烟测通过:注册与 Get Me 正常,8 个资料字段可写入;生日 `2000-01-01` 返回 `age=26`;评论可回复,`reply_count` 正确;评论点赞/取消点赞正常。 |
|||
- 公网烟测通过:两个账号互相 like 后形成匹配,双方喜欢与被喜欢名单均排除已匹配用户,`matches_count` 仍为 1。测试匹配、帖子和评论已清理;两个随机烟测账号保留。 |
|||
- 正式环境现已升级为 API 0.6.12,Base URL 继续使用 `https://melove.youthome.com.cn`。前端应导入 `docs/openapi-0.6.12.json`。 |
|||
|
|||
### 2026-10-25:周报与离职交接资料整理 |
|||
|
|||
- 本周周报已整理为 `docs/周工作总结-2026-10-19至10-25.md`,记录 API 0.6.10—0.6.12 的开发、发布、测试和交接状态。 |
|||
- 当前正式后端版本仍为 API 0.6.12,本地完整自动化测试为 `30 passed`,正式环境 0.6.12 已完成迁移、构建和公网烟测。 |
|||
- 按交接要求盘点可提交内容:后端源码、测试、SQL 表结构与迁移、Docker/部署文件、OpenAPI 和接口文档可作为代码交接材料;数据库快照、上传文件、日志、实际密钥和用户数据不放入普通代码仓库。 |
|||
- 目标 Gitea 仓库暂定为 `lishibo/milian`。截至本记录,本机没有可用的 Gitea Personal Access Token,远端 API 返回 `401 token is required`,仓库尚未创建,文件尚未推送。 |
|||
@ -0,0 +1,11 @@ |
|||
fastapi==0.115.6 |
|||
uvicorn[standard]==0.34.0 |
|||
sqlalchemy==2.0.36 |
|||
greenlet==3.0.3 |
|||
pymysql==1.1.1 |
|||
python-multipart==0.0.20 |
|||
oss2==2.18.6 |
|||
python-jose[cryptography]==3.3.0 |
|||
email-validator==2.2.0 |
|||
httpx==0.28.1 |
|||
pytest==8.3.4 |
|||
@ -0,0 +1,7 @@ |
|||
-- 先执行此脚本创建数据库,再启动 FastAPI。 |
|||
CREATE DATABASE IF NOT EXISTS dating_app |
|||
CHARACTER SET utf8mb4 |
|||
COLLATE utf8mb4_unicode_ci; |
|||
|
|||
-- 应用启动时会根据 app/models.py 自动创建业务表。 |
|||
USE dating_app; |
|||
@ -0,0 +1,78 @@ |
|||
USE dating_app; |
|||
|
|||
-- Add the unified login field without deleting legacy data. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users' |
|||
AND column_name = 'email_or_phone' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists = 0, |
|||
'ALTER TABLE users ADD COLUMN email_or_phone VARCHAR(320) NULL AFTER id', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
-- Copy existing accounts into the unified field. Email is normalized to lowercase. |
|||
UPDATE users |
|||
SET email_or_phone = CASE |
|||
WHEN email IS NOT NULL THEN LOWER(TRIM(email)) |
|||
ELSE TRIM(phone) |
|||
END |
|||
WHERE email_or_phone IS NULL; |
|||
|
|||
SET @index_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'users' |
|||
AND column_name = 'email_or_phone' |
|||
AND non_unique = 0 |
|||
); |
|||
SET @statement = IF( |
|||
@index_exists = 0, |
|||
'CREATE UNIQUE INDEX uq_users_email_or_phone ON users (email_or_phone)', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
-- MVP has no OTP step, so previously pending accounts become usable immediately. |
|||
UPDATE users SET status = 'active' WHERE status = 'pending'; |
|||
ALTER TABLE users MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active'; |
|||
|
|||
-- Older schemas may require these legacy columns on INSERT. Keep them compatible |
|||
-- while the application stops using them. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'email_verified' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists > 0, |
|||
'ALTER TABLE users MODIFY COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'phone_verified' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists > 0, |
|||
'ALTER TABLE users MODIFY COLUMN phone_verified BOOLEAN NOT NULL DEFAULT FALSE', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
-- Legacy email, phone, verification columns and verification_codes are intentionally |
|||
-- retained so this migration does not destroy historical data. New code no longer uses them. |
|||
@ -0,0 +1,48 @@ |
|||
USE dating_app; |
|||
|
|||
-- Comment replies: nullable parent comment, self-referencing. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'post_comments' AND COLUMN_NAME = 'parent_comment_id' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE post_comments ADD COLUMN parent_comment_id INT NULL AFTER user_id', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @index_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.STATISTICS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'post_comments' |
|||
AND INDEX_NAME = 'ix_post_comments_parent' |
|||
); |
|||
SET @statement = IF(@index_exists = 0, |
|||
'ALTER TABLE post_comments ADD INDEX ix_post_comments_parent (parent_comment_id)', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @fk_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'post_comments' |
|||
AND CONSTRAINT_NAME = 'fk_post_comments_parent' |
|||
); |
|||
SET @statement = IF(@fk_exists = 0, |
|||
'ALTER TABLE post_comments ADD CONSTRAINT fk_post_comments_parent FOREIGN KEY (parent_comment_id) REFERENCES post_comments(id) ON DELETE CASCADE', |
|||
'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
-- Comment likes. |
|||
CREATE TABLE IF NOT EXISTS comment_likes ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
comment_id INT NOT NULL, |
|||
user_id INT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_comment_like UNIQUE (comment_id, user_id), |
|||
CONSTRAINT fk_comment_likes_comment FOREIGN KEY (comment_id) REFERENCES post_comments(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_comment_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_comment_likes_comment (comment_id), |
|||
INDEX ix_comment_likes_user (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
@ -0,0 +1,36 @@ |
|||
USE dating_app; |
|||
|
|||
-- Country is stored on the profile so discovery can rank same-city users first, |
|||
-- then users in the same country. Matching percentages are calculated from |
|||
-- existing interests and Feed answers, so no persisted score column is needed. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'user_profiles' |
|||
AND column_name = 'country' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN country VARCHAR(120) NULL AFTER city', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @index_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.statistics |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'user_profiles' |
|||
AND index_name = 'ix_user_profiles_city_country' |
|||
); |
|||
SET @statement = IF( |
|||
@index_exists = 0, |
|||
'CREATE INDEX ix_user_profiles_city_country ON user_profiles (city, country)', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,18 @@ |
|||
USE dating_app; |
|||
|
|||
-- Existing users keep a NULL county until they submit an updated location. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'user_profiles' |
|||
AND column_name = 'county' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN county VARCHAR(120) NULL AFTER city', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,52 @@ |
|||
USE dating_app; |
|||
|
|||
-- Keep existing posts readable as plain text; new metadata is optional. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'content_format' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE posts ADD COLUMN content_format VARCHAR(10) NOT NULL DEFAULT ''plain'' AFTER post_type', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'feeling' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE posts ADD COLUMN feeling VARCHAR(50) NULL AFTER content_format', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'location_name' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE posts ADD COLUMN location_name VARCHAR(255) NULL AFTER feeling', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'location_latitude' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE posts ADD COLUMN location_latitude DOUBLE NULL AFTER location_name', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'posts' AND COLUMN_NAME = 'location_longitude' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE posts ADD COLUMN location_longitude DOUBLE NULL AFTER location_latitude', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,83 @@ |
|||
USE dating_app; |
|||
|
|||
-- Optional profile detail fields. Existing users keep NULL until they update their profile. |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'occupation' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN occupation VARCHAR(120) NULL AFTER bio', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'education' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN education VARCHAR(120) NULL AFTER occupation', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'height_cm' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN height_cm INT NULL AFTER education', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'beliefs' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN beliefs VARCHAR(120) NULL AFTER height_cm', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'smoking' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN smoking VARCHAR(50) NULL AFTER beliefs', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'drinking' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN drinking VARCHAR(50) NULL AFTER smoking', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'pets' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN pets VARCHAR(120) NULL AFTER drinking', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
|
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) FROM information_schema.COLUMNS |
|||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_profiles' AND COLUMN_NAME = 'exercise' |
|||
); |
|||
SET @statement = IF(@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN exercise VARCHAR(50) NULL AFTER pets', 'SELECT 1'); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,19 @@ |
|||
USE dating_app; |
|||
|
|||
-- Existing profiles remain nullable for backward compatibility. New email/phone |
|||
-- registrations require one of the four values at the API validation layer. |
|||
SET @column_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.columns |
|||
WHERE table_schema = DATABASE() |
|||
AND table_name = 'user_profiles' |
|||
AND column_name = 'relationship_goal' |
|||
); |
|||
SET @statement = IF( |
|||
@column_exists = 0, |
|||
'ALTER TABLE user_profiles ADD COLUMN relationship_goal VARCHAR(30) NULL AFTER country', |
|||
'SELECT 1' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,20 @@ |
|||
USE dating_app; |
|||
|
|||
-- Replace the named CHECK in one ALTER when the server enforces it. |
|||
-- MySQL 5.7 accepts but does not enforce CHECK constraints. |
|||
SET @direction_check_exists = ( |
|||
SELECT COUNT(*) |
|||
FROM information_schema.TABLE_CONSTRAINTS |
|||
WHERE CONSTRAINT_SCHEMA = DATABASE() |
|||
AND TABLE_NAME = 'swipes' |
|||
AND CONSTRAINT_NAME = 'ck_swipe_direction' |
|||
AND CONSTRAINT_TYPE = 'CHECK' |
|||
); |
|||
SET @statement = IF( |
|||
@direction_check_exists > 0, |
|||
'ALTER TABLE swipes DROP CHECK ck_swipe_direction, ADD CONSTRAINT ck_swipe_direction CHECK (direction IN (''like'', ''pass'', ''super_like''))', |
|||
'ALTER TABLE swipes ADD CONSTRAINT ck_swipe_direction CHECK (direction IN (''like'', ''pass'', ''super_like''))' |
|||
); |
|||
PREPARE migration_statement FROM @statement; |
|||
EXECUTE migration_statement; |
|||
DEALLOCATE PREPARE migration_statement; |
|||
@ -0,0 +1,14 @@ |
|||
USE dating_app; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_interests ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
name VARCHAR(120) NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_user_interest_name UNIQUE (user_id, name), |
|||
CONSTRAINT fk_user_interests_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_user_interests_user (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
-- There is deliberately no maximum row count per user. The API enforces only |
|||
-- the onboarding minimum of two interests when a list is submitted. |
|||
@ -0,0 +1,250 @@ |
|||
USE dating_app; |
|||
|
|||
CREATE TABLE IF NOT EXISTS users ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
email_or_phone VARCHAR(320) UNIQUE, |
|||
password_hash VARCHAR(256), |
|||
status VARCHAR(20) NOT NULL DEFAULT 'active', |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_profiles ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL UNIQUE, |
|||
full_name VARCHAR(120), |
|||
gender VARCHAR(30), |
|||
birth_date VARCHAR(10), |
|||
city VARCHAR(120), |
|||
county VARCHAR(120), |
|||
country VARCHAR(120), |
|||
relationship_goal VARCHAR(30), |
|||
bio TEXT, |
|||
occupation VARCHAR(120), |
|||
education VARCHAR(120), |
|||
height_cm INT, |
|||
beliefs VARCHAR(120), |
|||
smoking VARCHAR(50), |
|||
drinking VARCHAR(50), |
|||
pets VARCHAR(120), |
|||
exercise VARCHAR(50), |
|||
INDEX ix_user_profiles_city_country (city, country), |
|||
CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_interests ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
name VARCHAR(120) NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_user_interest_name UNIQUE (user_id, name), |
|||
CONSTRAINT fk_user_interests_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_user_interests_user (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS auth_identities ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
provider VARCHAR(30) NOT NULL, |
|||
provider_user_id VARCHAR(255) NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_auth_provider_user UNIQUE (provider, provider_user_id), |
|||
CONSTRAINT fk_auth_identities_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_media ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
media_type VARCHAR(20) NOT NULL, |
|||
storage_path VARCHAR(500) NOT NULL, |
|||
original_name VARCHAR(255), |
|||
mime_type VARCHAR(100), |
|||
review_status VARCHAR(20) NOT NULL DEFAULT 'pending', |
|||
created_at DATETIME NOT NULL, |
|||
INDEX ix_user_media_user (user_id), |
|||
CONSTRAINT fk_user_media_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_locations ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL UNIQUE, |
|||
latitude DOUBLE NOT NULL, |
|||
longitude DOUBLE NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
CONSTRAINT fk_user_locations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_sessions ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
refresh_token_hash VARCHAR(256) NOT NULL UNIQUE, |
|||
expires_at DATETIME NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
revoked_at DATETIME NULL, |
|||
CONSTRAINT fk_user_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_user_sessions_user (user_id), |
|||
INDEX ix_user_sessions_expires (expires_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS swipes ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
actor_id INT NOT NULL, |
|||
target_id INT NOT NULL, |
|||
direction VARCHAR(10) NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_swipe_actor_target UNIQUE (actor_id, target_id), |
|||
CONSTRAINT ck_swipe_not_self CHECK (actor_id <> target_id), |
|||
CONSTRAINT ck_swipe_direction CHECK (direction IN ('like', 'pass', 'super_like')), |
|||
CONSTRAINT fk_swipes_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_swipes_target FOREIGN KEY (target_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_swipes_actor (actor_id), |
|||
INDEX ix_swipes_target (target_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS matches ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user1_id INT NOT NULL, |
|||
user2_id INT NOT NULL, |
|||
matched_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_match_user_pair UNIQUE (user1_id, user2_id), |
|||
CONSTRAINT ck_match_canonical_order CHECK (user1_id < user2_id), |
|||
CONSTRAINT fk_matches_user1 FOREIGN KEY (user1_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_matches_user2 FOREIGN KEY (user2_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_matches_user1 (user1_id), |
|||
INDEX ix_matches_user2 (user2_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS posts ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
content TEXT NOT NULL, |
|||
post_type VARCHAR(20) NOT NULL DEFAULT 'text', |
|||
content_format VARCHAR(10) NOT NULL DEFAULT 'plain', |
|||
feeling VARCHAR(50) NULL, |
|||
location_name VARCHAR(255) NULL, |
|||
location_latitude DOUBLE NULL, |
|||
location_longitude DOUBLE NULL, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
CONSTRAINT ck_posts_type CHECK (post_type IN ('text', 'image', 'video')), |
|||
CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_posts_user (user_id), |
|||
INDEX ix_posts_created (created_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS post_media ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
post_id INT NOT NULL, |
|||
media_type VARCHAR(20) NOT NULL, |
|||
storage_path VARCHAR(500) NOT NULL, |
|||
original_name VARCHAR(255), |
|||
mime_type VARCHAR(100) NOT NULL, |
|||
size_bytes INT NOT NULL, |
|||
position INT NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT ck_post_media_type CHECK (media_type IN ('image', 'video')), |
|||
CONSTRAINT fk_post_media_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, |
|||
INDEX ix_post_media_post (post_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS post_likes ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
post_id INT NOT NULL, |
|||
user_id INT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_post_like UNIQUE (post_id, user_id), |
|||
CONSTRAINT fk_post_likes_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_post_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_post_likes_post (post_id), |
|||
INDEX ix_post_likes_user (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS post_comments ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
post_id INT NOT NULL, |
|||
user_id INT NOT NULL, |
|||
parent_comment_id INT NULL, |
|||
content TEXT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
CONSTRAINT fk_post_comments_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_post_comments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_post_comments_parent FOREIGN KEY (parent_comment_id) REFERENCES post_comments(id) ON DELETE CASCADE, |
|||
INDEX ix_post_comments_post (post_id), |
|||
INDEX ix_post_comments_user (user_id), |
|||
INDEX ix_post_comments_parent (parent_comment_id), |
|||
INDEX ix_post_comments_created (created_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS comment_likes ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
comment_id INT NOT NULL, |
|||
user_id INT NOT NULL, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_comment_like UNIQUE (comment_id, user_id), |
|||
CONSTRAINT fk_comment_likes_comment FOREIGN KEY (comment_id) REFERENCES post_comments(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_comment_likes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_comment_likes_comment (comment_id), |
|||
INDEX ix_comment_likes_user (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS conversations ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
match_id INT NOT NULL UNIQUE, |
|||
created_at DATETIME NOT NULL, |
|||
updated_at DATETIME NOT NULL, |
|||
CONSTRAINT fk_conversations_match FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS chat_messages ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
conversation_id INT NOT NULL, |
|||
sender_id INT NOT NULL, |
|||
content TEXT NOT NULL, |
|||
message_type VARCHAR(20) NOT NULL DEFAULT 'text', |
|||
created_at DATETIME NOT NULL, |
|||
deleted_at DATETIME NULL, |
|||
CONSTRAINT ck_chat_messages_type CHECK (message_type IN ('text')), |
|||
CONSTRAINT fk_chat_messages_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_chat_messages_sender FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
INDEX ix_chat_messages_conversation (conversation_id), |
|||
INDEX ix_chat_messages_sender (sender_id), |
|||
INDEX ix_chat_messages_created (created_at) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS feed_questions ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
code VARCHAR(100) NOT NULL UNIQUE, |
|||
question_text VARCHAR(500) NOT NULL, |
|||
question_text_zh VARCHAR(500), |
|||
answer_type VARCHAR(20) NOT NULL DEFAULT 'boolean', |
|||
is_active BOOLEAN NOT NULL DEFAULT TRUE, |
|||
sort_order INT NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL, |
|||
CONSTRAINT ck_feed_questions_type CHECK (answer_type IN ('boolean', 'text')), |
|||
INDEX ix_feed_questions_active (is_active) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
CREATE TABLE IF NOT EXISTS user_question_answers ( |
|||
id INT AUTO_INCREMENT PRIMARY KEY, |
|||
user_id INT NOT NULL, |
|||
question_id INT NOT NULL, |
|||
boolean_answer BOOLEAN NULL, |
|||
text_answer TEXT NULL, |
|||
answered_at DATETIME NOT NULL, |
|||
CONSTRAINT uq_user_question_answer UNIQUE (user_id, question_id), |
|||
CONSTRAINT fk_user_question_answers_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, |
|||
CONSTRAINT fk_user_question_answers_question FOREIGN KEY (question_id) REFERENCES feed_questions(id) ON DELETE CASCADE, |
|||
INDEX ix_user_question_answers_user (user_id), |
|||
INDEX ix_user_question_answers_question (question_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
INSERT IGNORE INTO feed_questions |
|||
(code, question_text, question_text_zh, answer_type, is_active, sort_order, created_at) |
|||
VALUES |
|||
('weekend_movies', 'Do you like watching movies on weekends?', '你喜欢周末看电影吗?', 'boolean', TRUE, 10, NOW()), |
|||
('outdoor_activities', 'Do you enjoy outdoor activities?', '你喜欢户外活动吗?', 'boolean', TRUE, 20, NOW()), |
|||
('quiet_weekends', 'Do you prefer quiet weekends?', '你更喜欢安静的周末吗?', 'boolean', TRUE, 30, NOW()), |
|||
('long_term_relationship', 'Are you looking for a long-term relationship?', '你希望发展长期关系吗?', 'boolean', TRUE, 40, NOW()), |
|||
('ideal_first_date', 'What is your ideal first date?', '你理想中的第一次约会是什么?', 'text', TRUE, 50, NOW()); |
|||
@ -0,0 +1,47 @@ |
|||
param( |
|||
[string]$HostAddress = "127.0.0.1", |
|||
[int]$Port = 8001 |
|||
) |
|||
|
|||
$ErrorActionPreference = "Stop" |
|||
|
|||
function ConvertTo-PlainText([Security.SecureString]$Value) { |
|||
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value) |
|||
try { |
|||
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) |
|||
} |
|||
finally { |
|||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) |
|||
} |
|||
} |
|||
|
|||
Write-Host "Cloud API runtime configuration" |
|||
Write-Host "Secrets are used only by this process and are not written to a file." |
|||
|
|||
$databasePassword = ConvertTo-PlainText (Read-Host "Cloud MySQL password for user dating" -AsSecureString) |
|||
$ossAccessKeyId = Read-Host "Aliyun OSS AccessKey ID" |
|||
$ossAccessKeySecret = ConvertTo-PlainText (Read-Host "Aliyun OSS AccessKey Secret" -AsSecureString) |
|||
$jwtSecret = ConvertTo-PlainText (Read-Host "JWT signing secret" -AsSecureString) |
|||
|
|||
if ([string]::IsNullOrWhiteSpace($databasePassword) -or |
|||
[string]::IsNullOrWhiteSpace($ossAccessKeyId) -or |
|||
[string]::IsNullOrWhiteSpace($ossAccessKeySecret) -or |
|||
[string]::IsNullOrWhiteSpace($jwtSecret)) { |
|||
throw "Database password, OSS credentials and JWT secret are required." |
|||
} |
|||
|
|||
# URI-escape the password because production passwords may contain URL-reserved characters. |
|||
$encodedDatabasePassword = [Uri]::EscapeDataString($databasePassword) |
|||
$env:DATABASE_URL = "mysql+pymysql://dating:$encodedDatabasePassword@47.116.134.234:3306/dating_app?charset=utf8mb4" |
|||
$env:JWT_SECRET = $jwtSecret |
|||
$env:DEV_RETURN_CODES = "false" |
|||
$env:OSS_ENABLED = "true" |
|||
$env:OSS_ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com" |
|||
$env:OSS_ACCESS_KEY_ID = $ossAccessKeyId |
|||
$env:OSS_ACCESS_KEY_SECRET = $ossAccessKeySecret |
|||
$env:OSS_BUCKET_NAME = "bs-qinghe" |
|||
$env:OSS_BASE_PREFIX = "melove/" |
|||
$env:OSS_DOMAIN = "https://oss.baskbuddy.com" |
|||
|
|||
Write-Host "Starting API with cloud MySQL and Aliyun OSS..." |
|||
python -m uvicorn app.main:app --host $HostAddress --port $Port |
|||
File diff suppressed because it is too large
Loading…
Reference in new issue