Compare commits
34 Commits
bd20adc700
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8089c06a73 | |||
| 0569585434 | |||
| 7f1dd33097 | |||
| 19e8cba59f | |||
| 99a3c1f8ab | |||
| 40bac85543 | |||
| 101ac0fbbc | |||
| 263cd88451 | |||
| 10a43f876c | |||
| 8330885b06 | |||
| 3c671fcaf5 | |||
| a97814ac1c | |||
| 64c9b72fd1 | |||
| d026f2f63d | |||
| 00f7dbdb69 | |||
| 3b27f9fcbe | |||
| d8303bf446 | |||
| 4c2653c643 | |||
| 825dde3928 | |||
| 8fbf44472d | |||
| 58576f8ebc | |||
| 0eae1c9bec | |||
| 49299fb4eb | |||
| 0d81c38fc6 | |||
| b38b0d83bb | |||
| 6d47c5a2b6 | |||
| b22eaede28 | |||
| 6528a06c28 | |||
| 3f6e8187cf | |||
| ab0265c17b | |||
| 8a799719ff | |||
| 8bdaf53222 | |||
| 4dc3dcdfa3 | |||
| 43ab6c5827 |
@@ -0,0 +1,55 @@
|
|||||||
|
# Gitea Actions 门禁:与 docs/development/git-workflow.md 的本地门禁完全同一条命令。
|
||||||
|
#
|
||||||
|
# 启用前提(服务器侧,一次性):
|
||||||
|
# 1. Gitea ≥ 1.19 且管理端开启 Actions(app.ini: [actions] ENABLED=true,
|
||||||
|
# 仓库 Settings → Actions 启用)。
|
||||||
|
# 2. 注册一个 act_runner,且 runner 所在主机具备:
|
||||||
|
# - Docker(Testcontainers 需要,跑 postgres:18 一次性容器)
|
||||||
|
# - 标签 ubuntu-latest 映射到含 bash/git 的镜像,或 host 模式执行
|
||||||
|
# 3. 服务器无法访问 github.com:本工作流不引用任何外部 action——
|
||||||
|
# checkout 手动从本 Gitea 实例克隆,JDK 用 apt 安装,全链路无 GitHub 依赖。
|
||||||
|
#
|
||||||
|
# 未启用 Actions 时本文件无副作用。
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
# 同分支连推多个提交时,自动取消旧的排队/进行中 run,只跑最新
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# 不用 actions/checkout:Gitea 1.20+ 的 DEFAULT_ACTIONS_URL 仅接受
|
||||||
|
# github/self,自定义镜像源不生效,action 会回落到 github.com(服务器不可达)。
|
||||||
|
# 改为手动从本 Gitea 实例克隆,零外部依赖。
|
||||||
|
- name: Checkout (manual)
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
AUTH_URL=$(echo "${{ github.server_url }}" | sed "s#https://#https://oauth2:${{ github.token }}@#")
|
||||||
|
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
|
||||||
|
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
|
||||||
|
git checkout -q refs/ci-head
|
||||||
|
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
|
||||||
|
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
|
||||||
|
- name: Secret scan
|
||||||
|
run: sh scripts/check-secrets.sh --all
|
||||||
|
# 不用 actions/setup-java:它从 github.com 下载 JDK 二进制,
|
||||||
|
# 服务器无法访问 GitHub(实测 ETIMEDOUT)。改用 apt 装 OpenJDK 17,
|
||||||
|
# 并优先切换到腾讯云镜像源(runner 在腾讯云,内网加速)。
|
||||||
|
- name: Install JDK 17 via apt
|
||||||
|
run: |
|
||||||
|
sed -i 's|http://archive.ubuntu.com|https://mirrors.cloud.tencent.com|g; s|http://security.ubuntu.com|https://mirrors.cloud.tencent.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||||
|
sed -i 's|http://archive.ubuntu.com|https://mirrors.cloud.tencent.com|g; s|http://security.ubuntu.com|https://mirrors.cloud.tencent.com|g' /etc/apt/sources.list.d/*.sources 2>/dev/null || true
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends openjdk-17-jdk-headless
|
||||||
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
java -version
|
||||||
|
# 与本地门禁同一命令;BUILD SUCCESS 且 0 失败方可合入(git-workflow.md)
|
||||||
|
- run: ./mvnw -B clean test
|
||||||
@@ -9,3 +9,7 @@ target/
|
|||||||
# Local service configuration (copy the committed .sample to application.yml)
|
# Local service configuration (copy the committed .sample to application.yml)
|
||||||
patbond-*/src/main/resources/application.yml
|
patbond-*/src/main/resources/application.yml
|
||||||
!patbond-*/src/main/resources/application.yml.sample
|
!patbond-*/src/main/resources/application.yml.sample
|
||||||
|
|
||||||
|
# Docker compose 本地机密(deploy/init-secrets.sh 生成,绝不入库)
|
||||||
|
.env
|
||||||
|
deploy/keys/
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ Patbond API is a Spring Boot multi-module backend.
|
|||||||
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
|
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
|
||||||
- `patbond-user`: user service for user creation, profile lookup, and password verification.
|
- `patbond-user`: user service for user creation, profile lookup, and password verification.
|
||||||
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
|
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
|
||||||
|
- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only.
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- Java 17 (build baseline; use JDK 17 for release builds)
|
- Java 17 (build baseline; use JDK 17 for release builds)
|
||||||
- Spring Boot 3.5.16
|
- Spring Boot 3.5.16
|
||||||
- Spring Cloud 2025.0.3 (OpenFeign only)
|
- Spring Cloud 2025.0.3 (OpenFeign only)
|
||||||
- PostgreSQL 16 + Flyway (patbond-user owns the `identity`/`media` schemas)
|
- PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas)
|
||||||
- Maven (use the committed Maven Wrapper `./mvnw`)
|
- Maven (use the committed Maven Wrapper `./mvnw`)
|
||||||
|
|
||||||
## Build and Test
|
## Build and Test
|
||||||
@@ -22,7 +23,7 @@ Patbond API is a Spring Boot multi-module backend.
|
|||||||
./mvnw clean test
|
./mvnw clean test
|
||||||
```
|
```
|
||||||
|
|
||||||
Integration tests start a disposable `postgres:16` via Testcontainers, so a
|
Integration tests start a disposable `postgres:18` via Testcontainers, so a
|
||||||
running Docker daemon is required (no local PostgreSQL installation or
|
running Docker daemon is required (no local PostgreSQL installation or
|
||||||
credentials are needed).
|
credentials are needed).
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
Running `patbond-user` requires a reachable PostgreSQL 16 database; Flyway
|
Running `patbond-user` requires a reachable PostgreSQL 18 database; Flyway
|
||||||
applies the versioned migrations in
|
applies the versioned migrations in
|
||||||
`patbond-user/src/main/resources/db/migration` automatically on startup.
|
`patbond-user/src/main/resources/db/migration` automatically on startup.
|
||||||
Development seed data (`db/dev`, regions reference rows) is opt-in via a dev
|
Development seed data (`db/dev`, regions reference rows) is opt-in via a dev
|
||||||
@@ -49,6 +50,8 @@ cp patbond-user/src/main/resources/application.yml.sample \
|
|||||||
patbond-user/src/main/resources/application.yml
|
patbond-user/src/main/resources/application.yml
|
||||||
cp patbond-auth/src/main/resources/application.yml.sample \
|
cp patbond-auth/src/main/resources/application.yml.sample \
|
||||||
patbond-auth/src/main/resources/application.yml
|
patbond-auth/src/main/resources/application.yml
|
||||||
|
cp patbond-pet/src/main/resources/application.yml.sample \
|
||||||
|
patbond-pet/src/main/resources/application.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the shared module once, then run each service in its own terminal:
|
Install the shared module once, then run each service in its own terminal:
|
||||||
@@ -59,13 +62,24 @@ Install the shared module once, then run each service in its own terminal:
|
|||||||
./mvnw -pl patbond-auth spring-boot:run
|
./mvnw -pl patbond-auth spring-boot:run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Running the services also needs an RS256 key pair for access tokens (the
|
||||||
|
private key signs in `patbond-auth`, the public key verifies in
|
||||||
|
`patbond-user`; neither is committed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
|
||||||
|
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
||||||
|
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # patbond-auth
|
||||||
|
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # patbond-user
|
||||||
|
```
|
||||||
|
|
||||||
Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml`
|
Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml`
|
||||||
keeps `./mvnw clean test` self-contained on a clean checkout.
|
keeps `./mvnw clean test` self-contained on a clean checkout.
|
||||||
|
|
||||||
Smoke check:
|
Smoke check:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8081/auth/register \
|
curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"username":"demo_user","password":"secret123"}'
|
-d '{"username":"demo_user","password":"secret123"}'
|
||||||
```
|
```
|
||||||
@@ -80,31 +94,73 @@ curl -X POST http://127.0.0.1:8081/auth/register \
|
|||||||
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user |
|
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user |
|
||||||
| `PATBOND_DB_USER` | `patbond` | patbond-user |
|
| `PATBOND_DB_USER` | `patbond` | patbond-user |
|
||||||
| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user |
|
| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user |
|
||||||
|
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | both (shared secret for `/internal/**`; inject a strong random value outside local dev) |
|
||||||
|
| `PATBOND_JWT_PRIVATE_KEY` | _(none, required)_ | patbond-auth (RS256 private key: PEM path or inline PEM) |
|
||||||
|
| `PATBOND_JWT_PUBLIC_KEY` | _(none)_ | patbond-user (RS256 public key: PEM path or inline PEM) |
|
||||||
|
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth (access token lifetime, ADR-003) |
|
||||||
|
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user (refresh token lifetime, ADR-003) |
|
||||||
|
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user (login failures before lockout) |
|
||||||
|
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user (failure counting window) |
|
||||||
|
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user (lock duration) |
|
||||||
|
|
||||||
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
||||||
committed `.sample`); never commit secrets to the samples.
|
committed `.sample`); never commit secrets to the samples.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
MVP 编排(ADR-007):`postgres:18`(数据落 volume)+ 两个无状态应用容器。
|
||||||
|
配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,
|
||||||
|
`PATBOND_*` 环境变量注入实际值。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌;产物被 .gitignore 忽略)
|
||||||
|
./deploy/init-secrets.sh
|
||||||
|
|
||||||
|
# 2. 构建可执行 jar
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
|
||||||
|
|
||||||
|
# 3. 启动(首次会构建镜像)
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 冒烟
|
||||||
|
curl -s -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"demo_user","password":"secret123"}'
|
||||||
|
|
||||||
|
# 停止(-v 同时删除数据库数据)
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供
|
||||||
|
`/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
### Auth Service
|
### Auth Service
|
||||||
|
|
||||||
- Application name: `patbond-auth`
|
- Application name: `patbond-auth`
|
||||||
- Default port: `8081`
|
- Default port: `8081`
|
||||||
- Register: `POST /auth/register`
|
- Register: `POST /api/v1/auth/register`
|
||||||
- Login: `POST /auth/login`
|
- Login: `POST /api/v1/auth/login`
|
||||||
|
- Refresh (rotates the refresh token): `POST /api/v1/auth/refresh`
|
||||||
|
- Logout (revokes the current session): `POST /api/v1/auth/logout`
|
||||||
|
|
||||||
### User Service
|
### User Service
|
||||||
|
|
||||||
- Application name: `patbond-user`
|
- Application name: `patbond-user`
|
||||||
- Default port: `8082`
|
- Default port: `8082`
|
||||||
|
- Current user profile: `GET /api/v1/me` (Bearer access token, verified locally with the RS256 public key)
|
||||||
|
- Internal (require `X-Internal-Token`):
|
||||||
- Create user: `POST /internal/users`
|
- Create user: `POST /internal/users`
|
||||||
- Verify password: `POST /internal/users/verify-password`
|
- Verify password: `POST /internal/users/verify-password`
|
||||||
- Get user by id: `GET /internal/users/{id}`
|
- Get user by id: `GET /internal/users/{id}`
|
||||||
- Get user by username: `GET /internal/users/by-username/{username}`
|
- Get user by username: `GET /internal/users/by-username/{username}`
|
||||||
|
- Sessions: `POST /internal/sessions`, `POST /internal/sessions/refresh`, `POST /internal/sessions/revoke`
|
||||||
|
|
||||||
> Note: user data is persisted in PostgreSQL (`identity.users` /
|
> Note: user data is persisted in PostgreSQL (`identity.users` /
|
||||||
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
|
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
|
||||||
> the application). Errors follow the `{code, message, data}` envelope with
|
> the application). Refresh sessions live in `identity.auth_sessions` (SHA-256
|
||||||
> stable business codes and matching HTTP statuses. Verifiable JWT tokens,
|
> digests only, rotation on every refresh, token-family revocation on reuse,
|
||||||
> refresh sessions, and `/internal` access control are planned in iteration 1
|
> ADR-003). Errors follow the `{code, message, data}` envelope with stable
|
||||||
> follow-up tasks.
|
> business codes and matching HTTP statuses; the public contract is documented
|
||||||
|
> in `patbond-doc/docs/api/openapi.yaml`.
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 生成 docker compose 运行所需的本地机密:RS256 密钥对 + .env(DB 口令、内部令牌)。
|
||||||
|
# 产物全部被 .gitignore 忽略,绝不入库;重复执行是幂等的(已存在则不覆盖)。
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mkdir -p deploy/keys
|
||||||
|
if [ ! -f deploy/keys/jwt-private.pem ]; then
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/jwt-private.pem
|
||||||
|
echo "已生成 deploy/keys/jwt-private.pem"
|
||||||
|
fi
|
||||||
|
openssl pkey -in deploy/keys/jwt-private.pem -pubout -out deploy/keys/jwt-public.pem
|
||||||
|
echo "已生成 deploy/keys/jwt-public.pem"
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
{
|
||||||
|
echo "PATBOND_DB_PASSWORD=$(openssl rand -hex 16)"
|
||||||
|
echo "PATBOND_INTERNAL_TOKEN=$(openssl rand -hex 32)"
|
||||||
|
} > .env
|
||||||
|
echo "已生成 .env(随机 DB 口令与内部令牌)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# M3(ADR-016/021):MinIO 根凭证。按键幂等追加,兼容已有 .env;
|
||||||
|
# 凭证只存在于被 gitignore 的 .env 中,绝不入库。
|
||||||
|
if ! grep -q '^PATBOND_MINIO_ROOT_USER=' .env; then
|
||||||
|
echo "PATBOND_MINIO_ROOT_USER=patbond-minio-$(openssl rand -hex 4)" >> .env
|
||||||
|
echo "已追加 PATBOND_MINIO_ROOT_USER 到 .env"
|
||||||
|
fi
|
||||||
|
if ! grep -q '^PATBOND_MINIO_ROOT_PASSWORD=' .env; then
|
||||||
|
echo "PATBOND_MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)" >> .env
|
||||||
|
echo "已追加 PATBOND_MINIO_ROOT_PASSWORD 到 .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 容器内以 uid 10001 运行,密钥需可读
|
||||||
|
chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem
|
||||||
|
echo "OK:deploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
|
||||||
|
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Patbond MVP 编排(ADR-007):应用容器无状态,PostgreSQL 数据落 volume。
|
||||||
|
# 使用步骤见 Readme.md「Docker Compose」一节:
|
||||||
|
# 1) ./deploy/init-secrets.sh 生成 RS256 密钥对与 .env(均不入库)
|
||||||
|
# 2) JAVA_HOME=... ./mvnw -DskipTests package
|
||||||
|
# 3) docker compose up -d --build
|
||||||
|
#
|
||||||
|
# 配置来源:容器内挂载各服务的 application.yml.sample 作为配置文件,
|
||||||
|
# 其中的 ${PATBOND_*} 占位由下方 environment 注入 —— 与本机运行同一套约定。
|
||||||
|
name: patbond
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PATBOND_DB_NAME:-patbond}
|
||||||
|
POSTGRES_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
POSTGRES_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
volumes:
|
||||||
|
# postgres:18 官方镜像的挂载点是 /var/lib/postgresql(含版本子目录)
|
||||||
|
- pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${PATBOND_DB_USER:-patbond} -d ${PATBOND_DB_NAME:-patbond}"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
|
||||||
|
|
||||||
|
# M3(ADR-016):自托管 MinIO 对象存储。镜像 tag 与集成测试的 MinIO
|
||||||
|
# Testcontainer 钉同一版本(三环境零分叉);对象数据落 volume(ADR-007,
|
||||||
|
# 应用容器保持无状态)。桶初始化由 user 服务启动时执行(ensureBucket,
|
||||||
|
# 本地/compose/CI 同一条路径),无需 mc 初始化容器。9000 端口必须对客户端
|
||||||
|
# 可达:预签名直传/读取 URL 都直接指向 MinIO,不经应用服务器。
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: server /data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
MINIO_ROOT_PASSWORD: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_MINIO_PORT:-9000}:9000"
|
||||||
|
|
||||||
|
user:
|
||||||
|
build: ./patbond-user
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||||
|
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
# ADR-016/017:media 上传流程在 user 服务。服务自身走内网端点;
|
||||||
|
# 预签名 URL 按 PATBOND_MINIO_PUBLIC_ENDPOINT 签发(默认本机回环,
|
||||||
|
# 真机联调/生产改为客户端可达地址)。
|
||||||
|
PATBOND_MINIO_ENDPOINT: http://minio:9000
|
||||||
|
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
|
||||||
|
PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_MINIO_BUCKET: ${PATBOND_MINIO_BUCKET:-patbond-media}
|
||||||
|
volumes:
|
||||||
|
- ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
# MVP 直连暴露 8082 供客户端访问 /api/v1/me;/internal/** 已有服务间鉴权,
|
||||||
|
# 规模化阶段应由网关统一入口并停止直接暴露本端口。
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_USER_PORT:-8082}:8082"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
auth:
|
||||||
|
build: ./patbond-auth
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_USER_SERVICE_URL: http://user:8082
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PRIVATE_KEY: /run/patbond/keys/jwt-private.pem
|
||||||
|
volumes:
|
||||||
|
- ./patbond-auth/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_AUTH_PORT:-8081}:8081"
|
||||||
|
depends_on:
|
||||||
|
- user
|
||||||
|
|
||||||
|
# M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds
|
||||||
|
# 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由
|
||||||
|
# user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。
|
||||||
|
pet:
|
||||||
|
build: ./patbond-pet
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||||
|
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
volumes:
|
||||||
|
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_PET_PORT:-8083}:8083"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
user:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# M3(ADR-017):社区服务(Feed/帖子/评论/互动)。骨架起 /api/v1/** 即接
|
||||||
|
# RS256 校验(与 user/pet 同一公钥);只读写 community schema。与 user 共库;
|
||||||
|
# Flyway 迁移链(V1..V5,含 community 基线)由 user 服务统一执行,故依赖
|
||||||
|
# user 先起,保证 community schema 已就绪。
|
||||||
|
community:
|
||||||
|
build: ./patbond-community
|
||||||
|
environment:
|
||||||
|
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||||
|
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||||
|
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
# 作者公开资料(D3-9 方案 B):走 user 服务 /internal 批量接口,
|
||||||
|
# 服务间共享密钥与 auth/user 同一值。
|
||||||
|
PATBOND_USER_SERVICE_URL: http://user:8082
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
# 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一
|
||||||
|
# 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
|
||||||
|
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
|
||||||
|
PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
|
volumes:
|
||||||
|
- ./patbond-community/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
ports:
|
||||||
|
- "${PATBOND_COMMUNITY_PORT:-8084}:8084"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
user:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
minio-data:
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime image only — build the jar first: ./mvnw -pl patbond-auth -am package
|
||||||
|
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
RUN useradd --system --uid 10001 patbond
|
||||||
|
USER patbond
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/patbond-auth-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -35,11 +35,52 @@
|
|||||||
<groupId>org.springframework.cloud</groupId>
|
<groupId>org.springframework.cloud</groupId>
|
||||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Real HTTP client for Feign: the JDK HttpURLConnection default
|
||||||
|
returns a null error stream on 401 replies to streamed POSTs, so
|
||||||
|
downstream error envelopes (40100/40102…) were unreadable and
|
||||||
|
collapsed to 503 (found by AuthE2eIntegrationTest). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openfeign</groupId>
|
||||||
|
<artifactId>feign-hc5</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Access token issuing (RS256): jjwt is not in the Boot BOM, version
|
||||||
|
pinned here and in patbond-user in step. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- End-to-end vertical test: boots the real user service (against a
|
||||||
|
Testcontainers postgres:18) in the same JVM and drives the full
|
||||||
|
register → login → me → refresh → logout flow over HTTP. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-user</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -47,6 +88,22 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<!-- No spring-boot-starter-parent in this build, so the
|
||||||
|
executable-jar repackaging must be bound explicitly. -->
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Keep the plain jar as the main artifact so other
|
||||||
|
modules can depend on this one (patbond-auth's
|
||||||
|
E2E test does); the runnable fat jar gets the
|
||||||
|
-exec classifier and is what the Dockerfile ships. -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package com.patbond.patbond.auth;
|
package com.patbond.patbond.auth;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.config.FeignInternalConfig;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
|
||||||
@EnableFeignClients
|
@EnableFeignClients(defaultConfiguration = FeignInternalConfig.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class AuthApplication {
|
public class AuthApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.patbond.patbond.auth.client;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.RefreshSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.SessionTokens;
|
||||||
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session lifecycle API of patbond-user, the identity schema owner. The
|
||||||
|
* X-Internal-Token header is attached by the interceptor in FeignConfig.
|
||||||
|
*/
|
||||||
|
@FeignClient(name = "patbond-user-sessions", url = "${patbond.user-service.url}")
|
||||||
|
public interface SessionClient {
|
||||||
|
|
||||||
|
@PostMapping("/internal/sessions")
|
||||||
|
ApiResponse<SessionTokens> create(@RequestBody CreateSessionRequest request);
|
||||||
|
|
||||||
|
@PostMapping("/internal/sessions/refresh")
|
||||||
|
ApiResponse<SessionTokens> refresh(@RequestBody RefreshSessionRequest request);
|
||||||
|
|
||||||
|
@PostMapping("/internal/sessions/revoke")
|
||||||
|
ApiResponse<Void> revoke(@RequestBody RevokeSessionRequest request);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.patbond.patbond.common.response.ApiResponse;
|
|||||||
import feign.Response;
|
import feign.Response;
|
||||||
import feign.Util;
|
import feign.Util;
|
||||||
import feign.codec.ErrorDecoder;
|
import feign.codec.ErrorDecoder;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ import java.io.IOException;
|
|||||||
*/
|
*/
|
||||||
public class ApiErrorDecoder implements ErrorDecoder {
|
public class ApiErrorDecoder implements ErrorDecoder {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ApiErrorDecoder.class);
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ApiErrorDecoder(ObjectMapper objectMapper) {
|
public ApiErrorDecoder(ObjectMapper objectMapper) {
|
||||||
@@ -35,8 +39,10 @@ public class ApiErrorDecoder implements ErrorDecoder {
|
|||||||
return new BusinessException(envelope.getCode(), response.status(), envelope.getMessage());
|
return new BusinessException(envelope.getCode(), response.status(), envelope.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException | RuntimeException ignored) {
|
} catch (IOException | RuntimeException e) {
|
||||||
// Not a Patbond envelope; fall through to the generic error below.
|
// Not a Patbond envelope; report as DOWNSTREAM_UNAVAILABLE below.
|
||||||
|
// The body itself is not logged (it may echo request data).
|
||||||
|
log.warn("Undecodable {} reply from {}: {}", response.status(), methodKey, e.toString());
|
||||||
}
|
}
|
||||||
return new BusinessException(ErrorCode.DOWNSTREAM_UNAVAILABLE);
|
return new BusinessException(ErrorCode.DOWNSTREAM_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package com.patbond.patbond.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security knobs of the auth service. ADR-003 mandates configurable token
|
||||||
|
* lifetimes; the committed default is the ADR value (access 15 minutes).
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "patbond")
|
||||||
|
public class AuthSecurityProperties {
|
||||||
|
|
||||||
|
/** Shared secret sent as X-Internal-Token on every call to patbond-user. */
|
||||||
|
private String internalToken;
|
||||||
|
|
||||||
|
private final Jwt jwt = new Jwt();
|
||||||
|
|
||||||
|
public String getInternalToken() {
|
||||||
|
return internalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInternalToken(String internalToken) {
|
||||||
|
this.internalToken = internalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Jwt getJwt() {
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Jwt {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RS256 private key (PKCS#8): either inline PEM (starts with
|
||||||
|
* -----BEGIN) or a filesystem path. Injected via environment
|
||||||
|
* variable; the key never enters the repository.
|
||||||
|
*/
|
||||||
|
private String privateKey;
|
||||||
|
|
||||||
|
/** Access token lifetime (ADR-003: 15 minutes). */
|
||||||
|
private Duration accessTtl = Duration.ofMinutes(15);
|
||||||
|
|
||||||
|
private String issuer = "patbond-auth";
|
||||||
|
|
||||||
|
public String getPrivateKey() {
|
||||||
|
return privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrivateKey(String privateKey) {
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getAccessTtl() {
|
||||||
|
return accessTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessTtl(Duration accessTtl) {
|
||||||
|
this.accessTtl = accessTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIssuer() {
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIssuer(String issuer) {
|
||||||
|
this.issuer = issuer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.patbond.patbond.auth.config;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import feign.codec.ErrorDecoder;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class FeignConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
|
|
||||||
return new ApiErrorDecoder(objectMapper);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.patbond.patbond.auth.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import feign.RequestInterceptor;
|
||||||
|
import feign.codec.ErrorDecoder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered via {@code @EnableFeignClients(defaultConfiguration = …)} so
|
||||||
|
* these beans land INSIDE each Feign child context. Deliberately not
|
||||||
|
* annotated with @Configuration: a component-scanned ErrorDecoder only
|
||||||
|
* reaches the parent context, where the child's own @ConditionalOnMissingBean
|
||||||
|
* default shadows it — downstream business errors would silently collapse to
|
||||||
|
* 503 on real HTTP calls (caught by AuthE2eIntegrationTest).
|
||||||
|
*/
|
||||||
|
public class FeignInternalConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-raises downstream {code, message} envelopes as BusinessException so
|
||||||
|
* the user service's business code and HTTP status reach the client
|
||||||
|
* unchanged (audit issue M1).
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
|
||||||
|
return new ApiErrorDecoder(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presents the shared service secret on every call to patbond-user. */
|
||||||
|
@Bean
|
||||||
|
public RequestInterceptor internalTokenInterceptor(AuthSecurityProperties properties) {
|
||||||
|
return template -> template.header("X-Internal-Token", properties.getInternalToken());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.patbond.patbond.auth.config;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.security.JwtSigner;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-context security wiring; the Feign-specific beans live in
|
||||||
|
* {@link FeignInternalConfig} (see the note there).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtSigner jwtSigner(AuthSecurityProperties properties) {
|
||||||
|
return new JwtSigner(
|
||||||
|
properties.getJwt().getPrivateKey(),
|
||||||
|
properties.getJwt().getAccessTtl(),
|
||||||
|
properties.getJwt().getIssuer());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,23 @@ package com.patbond.patbond.auth.controller;
|
|||||||
|
|
||||||
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
||||||
import com.patbond.patbond.auth.dto.LoginRequest;
|
import com.patbond.patbond.auth.dto.LoginRequest;
|
||||||
|
import com.patbond.patbond.auth.dto.LogoutRequest;
|
||||||
|
import com.patbond.patbond.auth.dto.RefreshRequest;
|
||||||
import com.patbond.patbond.auth.dto.RegisterRequest;
|
import com.patbond.patbond.auth.dto.RegisterRequest;
|
||||||
import com.patbond.patbond.auth.service.AuthService;
|
import com.patbond.patbond.auth.service.AuthService;
|
||||||
import com.patbond.patbond.common.response.ApiResponse;
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** Public auth endpoints under the /api/v1 prefix (development-plan 6.2). */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/auth")
|
@RequestMapping("/api/v1/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
@@ -22,12 +28,44 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request,
|
||||||
return ApiResponse.success(authService.register(request));
|
HttpServletRequest httpRequest) {
|
||||||
|
return ApiResponse.success(authService.register(request, clientInfo(httpRequest)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request,
|
||||||
return ApiResponse.success(authService.login(request));
|
HttpServletRequest httpRequest) {
|
||||||
|
return ApiResponse.success(authService.login(request, clientInfo(httpRequest)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ApiResponse<AuthTokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||||
|
return ApiResponse.success(authService.refresh(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ApiResponse<Void> logout(
|
||||||
|
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||||
|
@Valid @RequestBody LogoutRequest request) {
|
||||||
|
authService.logout(authorization, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
||||||
|
String deviceId = truncate(request.getHeader("X-Device-Id"), 128);
|
||||||
|
String userAgent = truncate(request.getHeader(HttpHeaders.USER_AGENT), 512);
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
String ip = forwarded != null && !forwarded.isBlank()
|
||||||
|
? forwarded.split(",")[0].trim()
|
||||||
|
: request.getRemoteAddr();
|
||||||
|
return new AuthService.ClientInfo(deviceId, userAgent, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncate(String value, int maxLength) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.length() > maxLength ? value.substring(0, maxLength) : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,35 @@
|
|||||||
package com.patbond.patbond.auth.dto;
|
package com.patbond.patbond.auth.dto;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frozen public contract for register/login/refresh responses — exactly
|
||||||
|
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
|
||||||
|
* refreshTokenExpiresAt}; timestamps serialize as ISO 8601 with offset.
|
||||||
|
*/
|
||||||
public class AuthTokenResponse {
|
public class AuthTokenResponse {
|
||||||
|
|
||||||
|
private UUID userId;
|
||||||
private String tokenType;
|
private String tokenType;
|
||||||
private String accessToken;
|
private String accessToken;
|
||||||
private LocalDateTime expiresAt;
|
private OffsetDateTime accessTokenExpiresAt;
|
||||||
private UUID userId;
|
private String refreshToken;
|
||||||
private String username;
|
private OffsetDateTime refreshTokenExpiresAt;
|
||||||
private String nickname;
|
|
||||||
|
|
||||||
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
|
public AuthTokenResponse(UUID userId, String tokenType,
|
||||||
UUID userId, String username, String nickname) {
|
String accessToken, OffsetDateTime accessTokenExpiresAt,
|
||||||
|
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
|
||||||
|
this.userId = userId;
|
||||||
this.tokenType = tokenType;
|
this.tokenType = tokenType;
|
||||||
this.accessToken = accessToken;
|
this.accessToken = accessToken;
|
||||||
this.expiresAt = expiresAt;
|
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
||||||
this.userId = userId;
|
this.refreshToken = refreshToken;
|
||||||
this.username = username;
|
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||||
this.nickname = nickname;
|
}
|
||||||
|
|
||||||
|
public UUID getUserId() {
|
||||||
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getTokenType() {
|
public String getTokenType() {
|
||||||
@@ -30,19 +40,15 @@ public class AuthTokenResponse {
|
|||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDateTime getExpiresAt() {
|
public OffsetDateTime getAccessTokenExpiresAt() {
|
||||||
return expiresAt;
|
return accessTokenExpiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getUserId() {
|
public String getRefreshToken() {
|
||||||
return userId;
|
return refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUsername() {
|
public OffsetDateTime getRefreshTokenExpiresAt() {
|
||||||
return username;
|
return refreshTokenExpiresAt;
|
||||||
}
|
|
||||||
|
|
||||||
public String getNickname() {
|
|
||||||
return nickname;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.patbond.patbond.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class LogoutRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "refreshToken 不能为空")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.patbond.patbond.auth.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class RefreshRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "refreshToken 不能为空")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.patbond.patbond.auth.security;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
|
||||||
|
import java.security.interfaces.RSAPrivateCrtKey;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues (and, for logout, verifies) RS256 access tokens. Claims: sub = user
|
||||||
|
* UUID, jti = the id minted by patbond-user and stored on the session row
|
||||||
|
* (auth_sessions.access_token_jti), sid = session UUID, plus iss/iat/exp.
|
||||||
|
* Resource services verify these tokens locally with the public key only —
|
||||||
|
* the private key never leaves this service.
|
||||||
|
*/
|
||||||
|
public class JwtSigner {
|
||||||
|
|
||||||
|
public static final String SESSION_ID_CLAIM = "sid";
|
||||||
|
|
||||||
|
private final RSAPrivateCrtKey privateKey;
|
||||||
|
private final Duration accessTtl;
|
||||||
|
private final String issuer;
|
||||||
|
private final JwtParser parser;
|
||||||
|
|
||||||
|
public record AccessToken(String token, OffsetDateTime expiresAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtSigner(String privateKeyLocation, Duration accessTtl, String issuer) {
|
||||||
|
if (privateKeyLocation == null || privateKeyLocation.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"patbond.jwt.private-key 未配置:请生成 RS256 密钥对并通过环境变量注入"
|
||||||
|
+ "(见 application.yml.sample)");
|
||||||
|
}
|
||||||
|
this.privateKey = RsaPrivateKeyLoader.load(privateKeyLocation);
|
||||||
|
this.accessTtl = accessTtl;
|
||||||
|
this.issuer = issuer;
|
||||||
|
this.parser = Jwts.parser()
|
||||||
|
.verifyWith(RsaPrivateKeyLoader.derivePublicKey(privateKey))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccessToken sign(UUID userId, UUID sessionId, String jti) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiresAt = now.plus(accessTtl);
|
||||||
|
String token = Jwts.builder()
|
||||||
|
.id(jti)
|
||||||
|
.subject(userId.toString())
|
||||||
|
.issuer(issuer)
|
||||||
|
.claim(SESSION_ID_CLAIM, sessionId.toString())
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(expiresAt))
|
||||||
|
.signWith(privateKey, Jwts.SIG.RS256)
|
||||||
|
.compact();
|
||||||
|
return new AccessToken(token, OffsetDateTime.ofInstant(expiresAt, ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||||
|
*/
|
||||||
|
public Claims verify(String token) {
|
||||||
|
try {
|
||||||
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.patbond.patbond.auth.security;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.interfaces.RSAPrivateCrtKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.security.spec.RSAPublicKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the RS256 signing key from either inline PEM content (value starts
|
||||||
|
* with -----BEGIN, e.g. injected through an environment variable) or a
|
||||||
|
* filesystem path to a PEM file. Only the PKCS#8 form produced by
|
||||||
|
* `openssl genpkey` is supported. The matching public key is derived from
|
||||||
|
* the CRT parameters, so this service needs no second configuration value.
|
||||||
|
*/
|
||||||
|
public final class RsaPrivateKeyLoader {
|
||||||
|
|
||||||
|
private RsaPrivateKeyLoader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RSAPrivateCrtKey load(String pemOrPath) {
|
||||||
|
String pem = pemOrPath.trim();
|
||||||
|
if (!pem.startsWith("-----BEGIN")) {
|
||||||
|
try {
|
||||||
|
pem = Files.readString(Path.of(pem));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException("无法读取 JWT 私钥文件: " + pemOrPath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String base64 = pem
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.replaceAll("\\s", "");
|
||||||
|
try {
|
||||||
|
byte[] der = Base64.getDecoder().decode(base64);
|
||||||
|
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||||
|
return (RSAPrivateCrtKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
|
||||||
|
} catch (IllegalArgumentException | ClassCastException
|
||||||
|
| NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||||
|
throw new IllegalStateException("JWT 私钥不是有效的 PEM(PKCS#8) RSA 私钥", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RSAPublicKey derivePublicKey(RSAPrivateCrtKey privateKey) {
|
||||||
|
try {
|
||||||
|
return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(
|
||||||
|
new RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPublicExponent()));
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||||
|
throw new IllegalStateException("无法从 RSA 私钥推导公钥", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,53 @@
|
|||||||
package com.patbond.patbond.auth.service;
|
package com.patbond.patbond.auth.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.client.SessionClient;
|
||||||
import com.patbond.patbond.auth.client.UserClient;
|
import com.patbond.patbond.auth.client.UserClient;
|
||||||
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
||||||
import com.patbond.patbond.auth.dto.LoginRequest;
|
import com.patbond.patbond.auth.dto.LoginRequest;
|
||||||
|
import com.patbond.patbond.auth.dto.LogoutRequest;
|
||||||
|
import com.patbond.patbond.auth.dto.RefreshRequest;
|
||||||
import com.patbond.patbond.auth.dto.RegisterRequest;
|
import com.patbond.patbond.auth.dto.RegisterRequest;
|
||||||
|
import com.patbond.patbond.auth.security.JwtSigner;
|
||||||
import com.patbond.patbond.common.error.BusinessException;
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
import com.patbond.patbond.common.error.ErrorCode;
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
import com.patbond.patbond.common.response.ApiResponse;
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.RefreshSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.SessionTokens;
|
||||||
import com.patbond.patbond.common.user.CreateUserRequest;
|
import com.patbond.patbond.common.user.CreateUserRequest;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin public entry for authentication (per the adopted architecture from
|
||||||
|
* the iteration-1 technical assessment): credentials and sessions live in
|
||||||
|
* patbond-user, the identity schema owner; this service validates input,
|
||||||
|
* orchestrates the internal calls, and signs RS256 access tokens.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
private final UserClient userClient;
|
/** Device metadata forwarded to the session record (observability only). */
|
||||||
|
public record ClientInfo(String deviceId, String userAgent, String ipAddress) {
|
||||||
public AuthService(UserClient userClient) {
|
|
||||||
this.userClient = userClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthTokenResponse register(RegisterRequest request) {
|
private final UserClient userClient;
|
||||||
|
private final SessionClient sessionClient;
|
||||||
|
private final JwtSigner jwtSigner;
|
||||||
|
|
||||||
|
public AuthService(UserClient userClient, SessionClient sessionClient, JwtSigner jwtSigner) {
|
||||||
|
this.userClient = userClient;
|
||||||
|
this.sessionClient = sessionClient;
|
||||||
|
this.jwtSigner = jwtSigner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthTokenResponse register(RegisterRequest request, ClientInfo clientInfo) {
|
||||||
ApiResponse<UserProfile> response = userClient.createUser(new CreateUserRequest(
|
ApiResponse<UserProfile> response = userClient.createUser(new CreateUserRequest(
|
||||||
request.getUsername(),
|
request.getUsername(),
|
||||||
request.getPassword(),
|
request.getPassword(),
|
||||||
@@ -33,28 +55,64 @@ public class AuthService {
|
|||||||
request.getPhone()
|
request.getPhone()
|
||||||
));
|
));
|
||||||
UserProfile user = requireData(response, "注册失败");
|
UserProfile user = requireData(response, "注册失败");
|
||||||
return buildToken(user.getId(), user.getUsername(), user.getNickname());
|
return openSession(user.getId(), clientInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthTokenResponse login(LoginRequest request) {
|
public AuthTokenResponse login(LoginRequest request, ClientInfo clientInfo) {
|
||||||
ApiResponse<VerifyPasswordResponse> response = userClient.verifyPassword(
|
ApiResponse<VerifyPasswordResponse> response = userClient.verifyPassword(
|
||||||
new VerifyPasswordRequest(request.getUsername(), request.getPassword())
|
new VerifyPasswordRequest(request.getUsername(), request.getPassword())
|
||||||
);
|
);
|
||||||
VerifyPasswordResponse user = requireData(response, "登录失败");
|
VerifyPasswordResponse user = requireData(response, "登录失败");
|
||||||
return buildToken(user.getUserId(), user.getUsername(), user.getNickname());
|
return openSession(user.getUserId(), clientInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AuthTokenResponse buildToken(UUID userId, String username, String nickname) {
|
/** ADR-003: every refresh rotates the refresh token (handled downstream). */
|
||||||
|
public AuthTokenResponse refresh(RefreshRequest request) {
|
||||||
|
SessionTokens tokens = requireData(
|
||||||
|
sessionClient.refresh(new RefreshSessionRequest(request.getRefreshToken())),
|
||||||
|
"刷新失败");
|
||||||
|
return assemble(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-003: logout revokes only the current session. The user is taken
|
||||||
|
* from the verified access token, so a caller can only revoke their own
|
||||||
|
* session; an invalid or expired access token answers 40101.
|
||||||
|
*/
|
||||||
|
public void logout(String authorizationHeader, LogoutRequest request) {
|
||||||
|
Claims claims = requireBearer(authorizationHeader);
|
||||||
|
UUID userId = UUID.fromString(claims.getSubject());
|
||||||
|
requireSuccess(sessionClient.revoke(
|
||||||
|
new RevokeSessionRequest(userId, request.getRefreshToken())), "退出失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
||||||
|
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
||||||
|
userId, clientInfo.deviceId(), clientInfo.userAgent(), clientInfo.ipAddress())),
|
||||||
|
"创建会话失败");
|
||||||
|
return assemble(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthTokenResponse assemble(SessionTokens tokens) {
|
||||||
|
JwtSigner.AccessToken accessToken =
|
||||||
|
jwtSigner.sign(tokens.getUserId(), tokens.getSessionId(), tokens.getJti());
|
||||||
return new AuthTokenResponse(
|
return new AuthTokenResponse(
|
||||||
|
tokens.getUserId(),
|
||||||
"Bearer",
|
"Bearer",
|
||||||
UUID.randomUUID().toString().replace("-", ""),
|
accessToken.token(),
|
||||||
LocalDateTime.now().plusHours(2),
|
accessToken.expiresAt(),
|
||||||
userId,
|
tokens.getRefreshToken(),
|
||||||
username,
|
tokens.getRefreshTokenExpiresAt()
|
||||||
nickname
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Claims requireBearer(String authorizationHeader) {
|
||||||
|
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
|
||||||
|
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
return jwtSigner.verify(authorizationHeader.substring("Bearer ".length()).trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downstream business failures arrive as BusinessException via the Feign
|
* Downstream business failures arrive as BusinessException via the Feign
|
||||||
* ErrorDecoder and never reach this method; this only guards against a
|
* ErrorDecoder and never reach this method; this only guards against a
|
||||||
@@ -67,4 +125,12 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
return response.getData();
|
return response.getData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same guard for envelopes that legitimately carry no data (revoke). */
|
||||||
|
private void requireSuccess(ApiResponse<Void> response, String defaultMessage) {
|
||||||
|
if (response == null || !response.isSuccess()) {
|
||||||
|
String message = response == null || response.getMessage() == null ? defaultMessage : response.getMessage();
|
||||||
|
throw new BusinessException(ErrorCode.INTERNAL_ERROR, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,18 @@ spring:
|
|||||||
patbond:
|
patbond:
|
||||||
user-service:
|
user-service:
|
||||||
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
|
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
|
||||||
|
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
|
||||||
|
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
|
||||||
|
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
|
||||||
|
jwt:
|
||||||
|
# RS256 私钥(PKCS#8),用于签发 access token;对应公钥配置给 patbond-user。
|
||||||
|
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||||
|
# 生成密钥对(私钥绝不提交进仓库):
|
||||||
|
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
|
||||||
|
# openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
||||||
|
# 然后:export PATBOND_JWT_PRIVATE_KEY=/path/to/jwt-private.pem
|
||||||
|
# 未配置时服务启动失败(fail-fast)。
|
||||||
|
private-key: ${PATBOND_JWT_PRIVATE_KEY:}
|
||||||
|
# ADR-003:access token 15 分钟,可配置。
|
||||||
|
access-ttl: ${PATBOND_ACCESS_TTL:15m}
|
||||||
|
issuer: patbond-auth
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.patbond.patbond.auth;
|
package com.patbond.patbond.auth;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||||
|
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||||
class AuthApplicationTests {
|
class AuthApplicationTests {
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void jwtKey(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {
|
||||||
// Verifies the auth service starts with the committed application.yml:
|
// Verifies the auth service starts with the committed configuration:
|
||||||
// the Feign client resolves patbond.user-service.url from the config
|
// the Feign clients resolve patbond.user-service.url without Nacos or
|
||||||
// default without Nacos or a running user service (ADR-002).
|
// a running user service (ADR-002), and the JwtSigner comes up from
|
||||||
|
// an injected private key (here: generated per test run).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.patbond.patbond.auth;
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath;
|
||||||
|
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||||
|
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||||
|
import com.patbond.patbond.user.UserApplication;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end vertical over real HTTP: this class boots the actual user
|
||||||
|
* service (Flyway V1 on a clean Testcontainers postgres:18) in the same JVM
|
||||||
|
* and drives the full M1 acceptance flow through the auth service —
|
||||||
|
* register → me → refresh (rotation) → reuse rejected + family revoked →
|
||||||
|
* login → logout → refresh dead. Also pins 40101 for expired/forged access
|
||||||
|
* tokens, 401 for /internal without the service credential, and the login
|
||||||
|
* failure lockout.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||||
|
class AuthE2eIntegrationTest {
|
||||||
|
|
||||||
|
private static final String INTERNAL_TOKEN = "e2e-internal-token";
|
||||||
|
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
|
||||||
|
private static ConfigurableApplicationContext userApp;
|
||||||
|
private static String userBaseUrl;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
|
||||||
|
POSTGRES.start();
|
||||||
|
userApp = new SpringApplicationBuilder(UserApplication.class).run(
|
||||||
|
"--server.port=0",
|
||||||
|
"--spring.application.name=patbond-user",
|
||||||
|
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||||
|
"--spring.datasource.username=" + POSTGRES.getUsername(),
|
||||||
|
"--spring.datasource.password=" + POSTGRES.getPassword(),
|
||||||
|
"--patbond.internal-token=" + INTERNAL_TOKEN,
|
||||||
|
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
|
||||||
|
"--patbond.login-lock.max-failures=3");
|
||||||
|
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
|
||||||
|
|
||||||
|
registry.add("patbond.user-service.url", () -> userBaseUrl);
|
||||||
|
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
|
||||||
|
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void shutdown() {
|
||||||
|
if (userApp != null) {
|
||||||
|
userApp.close();
|
||||||
|
}
|
||||||
|
POSTGRES.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> postJson(String url, String body, String bearerToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
if (bearerToken != null) {
|
||||||
|
headers.setBearerAuth(bearerToken);
|
||||||
|
}
|
||||||
|
return restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> getWithBearer(String url, String bearerToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
if (bearerToken != null) {
|
||||||
|
headers.setBearerAuth(bearerToken);
|
||||||
|
}
|
||||||
|
return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String register(String username, String phone) {
|
||||||
|
ResponseEntity<String> response = postJson("/api/v1/auth/register",
|
||||||
|
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
|
||||||
|
.formatted(username, phone), null);
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
return response.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> login(String username, String password) {
|
||||||
|
return postJson("/api/v1/auth/login",
|
||||||
|
"{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> refresh(String refreshToken) {
|
||||||
|
return postJson("/api/v1/auth/refresh",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullAuthVerticalFlow() {
|
||||||
|
// Register: frozen contract shape, ISO 8601 timestamps with offset.
|
||||||
|
String registered = register("e2e_alice", "+8613800000501");
|
||||||
|
String userId = JsonPath.read(registered, "$.data.userId");
|
||||||
|
String accessToken = JsonPath.read(registered, "$.data.accessToken");
|
||||||
|
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||||
|
assertThat((String) JsonPath.read(registered, "$.data.tokenType")).isEqualTo("Bearer");
|
||||||
|
OffsetDateTime accessExpiry =
|
||||||
|
OffsetDateTime.parse(JsonPath.read(registered, "$.data.accessTokenExpiresAt"));
|
||||||
|
OffsetDateTime refreshExpiry =
|
||||||
|
OffsetDateTime.parse(JsonPath.read(registered, "$.data.refreshTokenExpiresAt"));
|
||||||
|
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
|
assertThat(accessExpiry).isAfter(now.plusMinutes(13)).isBefore(now.plusMinutes(17));
|
||||||
|
assertThat(refreshExpiry).isAfter(now.plusDays(29));
|
||||||
|
|
||||||
|
// Me on the user service, authenticated purely by local RS256 verification.
|
||||||
|
ResponseEntity<String> me = getWithBearer(userBaseUrl + "/api/v1/me", accessToken);
|
||||||
|
assertThat(me.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat((String) JsonPath.read(me.getBody(), "$.data.userId")).isEqualTo(userId);
|
||||||
|
assertThat((String) JsonPath.read(me.getBody(), "$.data.username")).isEqualTo("e2e_alice");
|
||||||
|
assertThat((String) JsonPath.read(me.getBody(), "$.data.phone")).isEqualTo("+8613800000501");
|
||||||
|
assertThat((String) JsonPath.read(me.getBody(), "$.data.createdAt")).contains("T");
|
||||||
|
|
||||||
|
// Refresh rotates the pair.
|
||||||
|
ResponseEntity<String> rotated = refresh(refreshToken);
|
||||||
|
assertThat(rotated.getStatusCode().value()).isEqualTo(200);
|
||||||
|
String rotatedRefresh = JsonPath.read(rotated.getBody(), "$.data.refreshToken");
|
||||||
|
assertThat(rotatedRefresh).isNotEqualTo(refreshToken);
|
||||||
|
assertThat((String) JsonPath.read(rotated.getBody(), "$.data.userId")).isEqualTo(userId);
|
||||||
|
|
||||||
|
// Replaying the rotated-away token is rejected and kills the family …
|
||||||
|
ResponseEntity<String> reuse = refresh(refreshToken);
|
||||||
|
assertThat(reuse.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(reuse.getBody(), "$.code")).isEqualTo(40102);
|
||||||
|
|
||||||
|
// … including the freshly rotated token.
|
||||||
|
ResponseEntity<String> familyDead = refresh(rotatedRefresh);
|
||||||
|
assertThat(familyDead.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(familyDead.getBody(), "$.code")).isEqualTo(40102);
|
||||||
|
|
||||||
|
// Login again (new family), then logout revokes that session.
|
||||||
|
ResponseEntity<String> reLogin = login("e2e_alice", "secret123");
|
||||||
|
assertThat(reLogin.getStatusCode().value()).isEqualTo(200);
|
||||||
|
String access2 = JsonPath.read(reLogin.getBody(), "$.data.accessToken");
|
||||||
|
String refresh2 = JsonPath.read(reLogin.getBody(), "$.data.refreshToken");
|
||||||
|
|
||||||
|
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
|
||||||
|
assertThat(logout.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat((int) JsonPath.read(logout.getBody(), "$.code")).isZero();
|
||||||
|
|
||||||
|
ResponseEntity<String> afterLogout = refresh(refresh2);
|
||||||
|
assertThat(afterLogout.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(afterLogout.getBody(), "$.code")).isEqualTo(40102);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredAndForgedAccessTokensAnswer40101() {
|
||||||
|
String registered = register("e2e_bob", "+8613800000502");
|
||||||
|
String userId = JsonPath.read(registered, "$.data.userId");
|
||||||
|
|
||||||
|
String expired = signToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(-1));
|
||||||
|
ResponseEntity<String> expiredMe = getWithBearer(userBaseUrl + "/api/v1/me", expired);
|
||||||
|
assertThat(expiredMe.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(expiredMe.getBody(), "$.code")).isEqualTo(40101);
|
||||||
|
|
||||||
|
String forged = signToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||||
|
ResponseEntity<String> forgedMe = getWithBearer(userBaseUrl + "/api/v1/me", forged);
|
||||||
|
assertThat(forgedMe.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(forgedMe.getBody(), "$.code")).isEqualTo(40101);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void internalEndpointsRejectCallsWithoutTheServiceCredential() {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
|
userBaseUrl + "/internal/users", HttpMethod.POST,
|
||||||
|
new HttpEntity<>("{\"username\":\"e2e_intruder\",\"password\":\"secret123\"}", headers),
|
||||||
|
String.class);
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedLoginFailuresLockTheAccount() {
|
||||||
|
register("e2e_carol", "+8613800000503");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
ResponseEntity<String> wrong = login("e2e_carol", "wrong-password");
|
||||||
|
assertThat(wrong.getStatusCode().value()).isEqualTo(401);
|
||||||
|
assertThat((int) JsonPath.read(wrong.getBody(), "$.code")).isEqualTo(40100);
|
||||||
|
}
|
||||||
|
ResponseEntity<String> locked = login("e2e_carol", "secret123");
|
||||||
|
assertThat(locked.getStatusCode().value()).isEqualTo(423);
|
||||||
|
assertThat((int) JsonPath.read(locked.getBody(), "$.code")).isEqualTo(42300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutOnOneDeviceKeepsOtherDevicesLoggedIn() {
|
||||||
|
String device1 = register("e2e_dave", "+8613800000504");
|
||||||
|
String refresh1 = JsonPath.read(device1, "$.data.refreshToken");
|
||||||
|
|
||||||
|
ResponseEntity<String> device2 = login("e2e_dave", "secret123");
|
||||||
|
String access2 = JsonPath.read(device2.getBody(), "$.data.accessToken");
|
||||||
|
String refresh2 = JsonPath.read(device2.getBody(), "$.data.refreshToken");
|
||||||
|
|
||||||
|
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
|
||||||
|
assertThat(logout.getStatusCode().value()).isEqualTo(200);
|
||||||
|
|
||||||
|
// Device 2's session is gone, device 1 refreshes on unaffected.
|
||||||
|
assertThat(refresh(refresh2).getStatusCode().value()).isEqualTo(401);
|
||||||
|
ResponseEntity<String> stillAlive = refresh(refresh1);
|
||||||
|
assertThat(stillAlive.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat((int) JsonPath.read(stillAlive.getBody(), "$.code")).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String signToken(java.security.PrivateKey key, String userId, Duration ttl) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.id(UUID.randomUUID().toString())
|
||||||
|
.subject(userId)
|
||||||
|
.issuer("patbond-auth")
|
||||||
|
.claim("sid", UUID.randomUUID().toString())
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plus(ttl)))
|
||||||
|
.signWith(key, Jwts.SIG.RS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
package com.patbond.patbond.auth.contract;
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath;
|
||||||
|
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||||
|
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||||
|
import com.patbond.patbond.user.UserApplication;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Order;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T3-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
|
||||||
|
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照
|
||||||
|
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
|
||||||
|
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
|
||||||
|
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
|
||||||
|
*
|
||||||
|
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
|
||||||
|
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
|
||||||
|
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
|
||||||
|
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
@SpringBootTest(
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||||
|
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||||
|
class AuthContractConformanceTest {
|
||||||
|
|
||||||
|
private static final String INTERNAL_TOKEN = "contract-internal-token";
|
||||||
|
|
||||||
|
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||||
|
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
|
||||||
|
|
||||||
|
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
|
||||||
|
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
/** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
|
||||||
|
private static final List<String> AUTH_OPERATIONS = List.of(
|
||||||
|
"POST /api/v1/auth/register",
|
||||||
|
"POST /api/v1/auth/login",
|
||||||
|
"POST /api/v1/auth/refresh",
|
||||||
|
"POST /api/v1/auth/logout",
|
||||||
|
"GET /api/v1/me",
|
||||||
|
"POST /api/v1/events");
|
||||||
|
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
|
||||||
|
private static ConfigurableApplicationContext userApp;
|
||||||
|
private static String userBaseUrl;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
|
||||||
|
POSTGRES.start();
|
||||||
|
userApp = new SpringApplicationBuilder(UserApplication.class).run(
|
||||||
|
"--server.port=0",
|
||||||
|
"--spring.application.name=patbond-user",
|
||||||
|
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||||
|
"--spring.datasource.username=" + POSTGRES.getUsername(),
|
||||||
|
"--spring.datasource.password=" + POSTGRES.getPassword(),
|
||||||
|
"--patbond.internal-token=" + INTERNAL_TOKEN,
|
||||||
|
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
|
||||||
|
"--patbond.login-lock.max-failures=3");
|
||||||
|
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
|
||||||
|
|
||||||
|
registry.add("patbond.user-service.url", () -> userBaseUrl);
|
||||||
|
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
|
||||||
|
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void shutdown() {
|
||||||
|
if (userApp != null) {
|
||||||
|
userApp.close();
|
||||||
|
}
|
||||||
|
POSTGRES.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 校验骨架 ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 真实发请求,断言 HTTP 状态并将响应体对照冻结契约严格校验;通过后把
|
||||||
|
* (操作, 状态码) 记入覆盖表。url 为相对路径时打 auth 服务,绝对 URL
|
||||||
|
* (userBaseUrl 前缀)打 user 服务。
|
||||||
|
*/
|
||||||
|
private String verified(HttpMethod method, String url, String pathTemplate,
|
||||||
|
String body, String bearerToken, int expectedStatus) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
if (body != null) {
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
}
|
||||||
|
if (bearerToken != null) {
|
||||||
|
headers.setBearerAuth(bearerToken);
|
||||||
|
}
|
||||||
|
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||||
|
url, method, new HttpEntity<>(body, headers), byte[].class);
|
||||||
|
String responseBody = response.getBody() == null
|
||||||
|
? "" : new String(response.getBody(), StandardCharsets.UTF_8);
|
||||||
|
assertThat(response.getStatusCode().value())
|
||||||
|
.as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, responseBody)
|
||||||
|
.isEqualTo(expectedStatus);
|
||||||
|
List<String> drift = VALIDATOR.validateResponse(
|
||||||
|
method.name(), pathTemplate, expectedStatus, responseBody);
|
||||||
|
assertThat(drift)
|
||||||
|
.as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, expectedStatus)
|
||||||
|
.isEmpty();
|
||||||
|
COVERED.add(method.name() + " " + pathTemplate + " " + expectedStatus);
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
|
||||||
|
private String verifiedError(HttpMethod method, String url, String pathTemplate,
|
||||||
|
String body, String bearerToken, int status, int bizCode) {
|
||||||
|
String responseBody = verified(method, url, pathTemplate, body, bearerToken, status);
|
||||||
|
assertThat((Integer) JsonPath.read(responseBody, "$.code"))
|
||||||
|
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
|
||||||
|
.isEqualTo(bizCode);
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String register(String username, String phone) {
|
||||||
|
return verified(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||||
|
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
|
||||||
|
.formatted(username, phone),
|
||||||
|
null, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用正确私钥签任意 subject 的 access token(404 场景等)。 */
|
||||||
|
private static String signToken(PrivateKey key, String subject, Duration ttl) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Jwts.builder()
|
||||||
|
.id(UUID.randomUUID().toString())
|
||||||
|
.subject(subject)
|
||||||
|
.issuer("patbond-auth")
|
||||||
|
.claim("sid", UUID.randomUUID().toString())
|
||||||
|
.issuedAt(Date.from(now))
|
||||||
|
.expiration(Date.from(now.plus(ttl)))
|
||||||
|
.signWith(key, Jwts.SIG.RS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 成功路径 ------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void registerRefreshLogoutSuccessShapes() {
|
||||||
|
String registered = register("contract_auth_alice", "+8613800000601");
|
||||||
|
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||||
|
|
||||||
|
// refresh 轮换出新令牌对
|
||||||
|
String rotated = verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
|
||||||
|
String rotatedAccess = JsonPath.read(rotated, "$.data.accessToken");
|
||||||
|
String rotatedRefresh = JsonPath.read(rotated, "$.data.refreshToken");
|
||||||
|
|
||||||
|
// logout 撤销当前会话
|
||||||
|
verified(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(rotatedRefresh), rotatedAccess, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void loginAndMeSuccessShapes() {
|
||||||
|
register("contract_auth_bob", "+8613800000602");
|
||||||
|
String loggedIn = verified(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||||
|
"{\"username\":\"contract_auth_bob\",\"password\":\"secret123\"}", null, 200);
|
||||||
|
String accessToken = JsonPath.read(loggedIn, "$.data.accessToken");
|
||||||
|
|
||||||
|
String me = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||||
|
null, accessToken, 200);
|
||||||
|
assertThat((String) JsonPath.read(me, "$.data.username")).isEqualTo("contract_auth_bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(3)
|
||||||
|
void trackEventsSuccessShape() {
|
||||||
|
// 匿名合法批次:202 + 与请求等长的逐条结果
|
||||||
|
String body = verified(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||||
|
"""
|
||||||
|
{"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "auth_register_started",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "%s",
|
||||||
|
"sessionId": "%s",
|
||||||
|
"clientTs": "2026-09-08T10:00:00Z",
|
||||||
|
"appVersion": "1.0.0+1",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14",
|
||||||
|
"props": {"entryPoint": "onboarding"}
|
||||||
|
}]}
|
||||||
|
""".formatted(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()),
|
||||||
|
null, 202);
|
||||||
|
assertThat((String) JsonPath.read(body, "$.data.results[0].status")).isEqualTo("accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 错误信封 ------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(4)
|
||||||
|
void validationErrorsAnswer40000() {
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||||
|
"{}", null, 400, 40000);
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||||
|
"{}", null, 400, 40000);
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||||
|
"{}", null, 400, 40000);
|
||||||
|
|
||||||
|
// logout 400:token 合法但 body 缺 refreshToken
|
||||||
|
String registered = register("contract_auth_carol", "+8613800000603");
|
||||||
|
String accessToken = JsonPath.read(registered, "$.data.accessToken");
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||||
|
"{}", accessToken, 400, 40000);
|
||||||
|
|
||||||
|
// events 400:空批次整批拒绝
|
||||||
|
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||||
|
"{\"events\":[]}", null, 400, 40000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(5)
|
||||||
|
void conflictAndCredentialErrorsMatchContract() {
|
||||||
|
register("contract_auth_dave", "+8613800000604");
|
||||||
|
|
||||||
|
// register 409:用户名占用 40900 / 手机号占用 40901(同一单元格的两种业务码)
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||||
|
"{\"username\":\"contract_auth_dave\",\"phone\":\"+8613800000605\",\"password\":\"secret123\"}",
|
||||||
|
null, 409, 40900);
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||||
|
"{\"username\":\"contract_auth_dave2\",\"phone\":\"+8613800000604\",\"password\":\"secret123\"}",
|
||||||
|
null, 409, 40901);
|
||||||
|
|
||||||
|
// login 401:密码错误 40100
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||||
|
"{\"username\":\"contract_auth_dave\",\"password\":\"wrong-pass\"}",
|
||||||
|
null, 401, 40100);
|
||||||
|
|
||||||
|
// refresh 401:已轮换 token 重放 40102
|
||||||
|
String registered = register("contract_auth_erin", "+8613800000606");
|
||||||
|
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||||
|
verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||||
|
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 401, 40102);
|
||||||
|
|
||||||
|
// logout 401:缺 access token
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||||
|
"{\"refreshToken\":\"whatever\"}", null, 401, 40101);
|
||||||
|
|
||||||
|
// me 401:无 token;404:合法签名但用户不存在(40400)
|
||||||
|
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||||
|
null, null, 401, 40101);
|
||||||
|
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||||
|
UUID.randomUUID().toString(), Duration.ofMinutes(15));
|
||||||
|
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||||
|
null, ghostToken, 404, 40400);
|
||||||
|
|
||||||
|
// events 401:携带了 Authorization 但 token 无效
|
||||||
|
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||||
|
"{\"events\":[]}", "not-a-token", 401, 40101);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(6)
|
||||||
|
void loginLockoutAnswers42300() {
|
||||||
|
register("contract_auth_locked", "+8613800000607");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||||
|
"{\"username\":\"contract_auth_locked\",\"password\":\"wrong-pass\"}",
|
||||||
|
null, 401, 40100);
|
||||||
|
}
|
||||||
|
// 窗口内失败达到阈值(测试将阈值降为 3):即使密码正确也锁定
|
||||||
|
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||||
|
"{\"username\":\"contract_auth_locked\",\"password\":\"secret123\"}",
|
||||||
|
null, 423, 42300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结快照守卫:与 pet 侧同一纪律——正典契约升版时必须同步复制新快照
|
||||||
|
* 并更新期望值,忘记同步在 CI 立即变红。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(98)
|
||||||
|
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||||
|
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
|
||||||
|
assertThat(CONTRACT.paths()).hasSize(31);
|
||||||
|
assertThat(CONTRACT.operations()).hasSize(43);
|
||||||
|
assertThat(CONTRACT.schemas()).hasSize(72);
|
||||||
|
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
|
||||||
|
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
|
||||||
|
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Order(99)
|
||||||
|
void everyDeclaredResponseCellIsExercised() {
|
||||||
|
List<String> missing = new ArrayList<>();
|
||||||
|
for (String op : AUTH_OPERATIONS) {
|
||||||
|
for (int status : CONTRACT.responseStatuses(op)) {
|
||||||
|
String cell = op + " " + status;
|
||||||
|
if (!COVERED.contains(cell)) {
|
||||||
|
missing.add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.patbond.patbond.auth.contract;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static com.patbond.patbond.auth.contract.OpenApiContract.cast;
|
||||||
|
import static com.patbond.patbond.auth.contract.OpenApiContract.list;
|
||||||
|
import static com.patbond.patbond.auth.contract.OpenApiContract.map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an actual HTTP response against the frozen contract, strictly:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>the operation and the status must be declared;</li>
|
||||||
|
* <li>required fields must be present; a null value needs {@code nullable};</li>
|
||||||
|
* <li>fields the schema does not declare are rejected (this is what catches
|
||||||
|
* a renamed or newly leaked field — plain OpenAPI semantics would allow
|
||||||
|
* extra properties, but the frozen contract is "exactly these fields");</li>
|
||||||
|
* <li>types, enum membership, uuid / date-time / date formats and
|
||||||
|
* min/max(Length) bounds are checked.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* Behavioural semantics (state machines, anti-enumeration, permission logic)
|
||||||
|
* stay with the existing integration tests — this class only pins structure.
|
||||||
|
*/
|
||||||
|
final class ContractValidator {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final OpenApiContract contract;
|
||||||
|
|
||||||
|
ContractValidator(OpenApiContract contract) {
|
||||||
|
this.contract = contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return drift findings, empty when the response conforms; each entry is
|
||||||
|
* a human-readable "where: what" line
|
||||||
|
*/
|
||||||
|
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
String opKey = method + " " + pathTemplate;
|
||||||
|
Map<String, Object> op = contract.operation(opKey);
|
||||||
|
if (op == null) {
|
||||||
|
errors.add("契约未声明该操作: " + opKey);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
Object respNode = map(op, "responses").get(String.valueOf(status));
|
||||||
|
if (respNode == null) {
|
||||||
|
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
|
||||||
|
if (content == null) {
|
||||||
|
return errors; // response declared without a body
|
||||||
|
}
|
||||||
|
Map<String, Object> schema = map(map(content, "application/json"), "schema");
|
||||||
|
if (schema == null) {
|
||||||
|
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
JsonNode node;
|
||||||
|
try {
|
||||||
|
node = MAPPER.readTree(body);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
validate(schema, node, "$", errors);
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
|
||||||
|
Map<String, Object> schema = effectiveSchema(rawSchema);
|
||||||
|
if (node == null || node.isMissingNode()) {
|
||||||
|
errors.add(loc + ": 字段缺失");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.isNull()) {
|
||||||
|
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
|
||||||
|
errors.add(loc + ": 为 null,但契约未声明 nullable");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Object> allowed = list(schema, "enum");
|
||||||
|
if (allowed != null && !enumMatches(allowed, node)) {
|
||||||
|
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + " 内");
|
||||||
|
}
|
||||||
|
String type = (String) schema.get("type");
|
||||||
|
if (type == null) {
|
||||||
|
type = schema.containsKey("properties") ? "object" : null;
|
||||||
|
}
|
||||||
|
if (type == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case "object" -> validateObject(schema, node, loc, errors);
|
||||||
|
case "array" -> validateArray(schema, node, loc, errors);
|
||||||
|
case "string" -> validateString(schema, node, loc, errors);
|
||||||
|
case "integer" -> {
|
||||||
|
if (!node.isIntegralNumber()) {
|
||||||
|
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
|
||||||
|
} else {
|
||||||
|
checkRange(schema, node.decimalValue(), loc, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "number" -> {
|
||||||
|
if (!node.isNumber()) {
|
||||||
|
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
|
||||||
|
} else {
|
||||||
|
checkRange(schema, node.decimalValue(), loc, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "boolean" -> {
|
||||||
|
if (!node.isBoolean()) {
|
||||||
|
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
|
||||||
|
* pattern into one plain schema (branch keys first, sibling keys — e.g.
|
||||||
|
* the outer {@code nullable} — win). The frozen contract only ever uses
|
||||||
|
* single-branch allOf, so a shallow merge is exact; overlapping
|
||||||
|
* {@code properties} across branches would need a deep merge and are not
|
||||||
|
* supported.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
|
||||||
|
Map<String, Object> schema = contract.resolve(rawSchema);
|
||||||
|
List<Object> allOf = list(schema, "allOf");
|
||||||
|
if (allOf == null) {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
Map<String, Object> merged = new LinkedHashMap<>();
|
||||||
|
for (Object branch : allOf) {
|
||||||
|
merged.putAll(effectiveSchema(cast(branch)));
|
||||||
|
}
|
||||||
|
schema.forEach((key, value) -> {
|
||||||
|
if (!"allOf".equals(key)) {
|
||||||
|
merged.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||||
|
if (!node.isObject()) {
|
||||||
|
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> props = map(schema, "properties");
|
||||||
|
List<Object> required = list(schema, "required");
|
||||||
|
if (required != null) {
|
||||||
|
for (Object r : required) {
|
||||||
|
if (!node.has((String) r)) {
|
||||||
|
errors.add(loc + "." + r + ": 契约必填字段缺失");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object additional = schema.get("additionalProperties");
|
||||||
|
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||||
|
while (fields.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> field = fields.next();
|
||||||
|
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
|
||||||
|
if (propSchema != null) {
|
||||||
|
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
|
||||||
|
} else if (!open) {
|
||||||
|
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||||
|
if (!node.isArray()) {
|
||||||
|
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> items = map(schema, "items");
|
||||||
|
if (items == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int i = 0;
|
||||||
|
for (JsonNode element : node) {
|
||||||
|
validate(items, element, loc + "[" + i++ + "]", errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
|
||||||
|
if (!node.isTextual()) {
|
||||||
|
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String value = node.asText();
|
||||||
|
String format = (String) schema.get("format");
|
||||||
|
if (format != null) {
|
||||||
|
try {
|
||||||
|
switch (format) {
|
||||||
|
case "uuid" -> {
|
||||||
|
if (value.length() != 36) {
|
||||||
|
throw new IllegalArgumentException("非规范 UUID 长度");
|
||||||
|
}
|
||||||
|
java.util.UUID.fromString(value);
|
||||||
|
}
|
||||||
|
case "date-time" -> OffsetDateTime.parse(value);
|
||||||
|
case "date" -> LocalDate.parse(value);
|
||||||
|
default -> { /* password 等纯标注格式不校验 */ }
|
||||||
|
}
|
||||||
|
} catch (IllegalArgumentException | DateTimeParseException e) {
|
||||||
|
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
|
||||||
|
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
|
||||||
|
}
|
||||||
|
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
|
||||||
|
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
|
||||||
|
if (schema.get("minimum") instanceof Number min
|
||||||
|
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
|
||||||
|
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
|
||||||
|
}
|
||||||
|
if (schema.get("maximum") instanceof Number max
|
||||||
|
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
|
||||||
|
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
|
||||||
|
if (node.isTextual()) {
|
||||||
|
return allowed.contains(node.asText());
|
||||||
|
}
|
||||||
|
if (node.isIntegralNumber()) {
|
||||||
|
long v = node.longValue();
|
||||||
|
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.patbond.patbond.auth.contract;
|
||||||
|
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||||
|
* {@code /contract/openapi-v1.3.0.yaml}.
|
||||||
|
*
|
||||||
|
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
|
||||||
|
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
|
||||||
|
* snapshot is a byte-identical copy taken at freeze time, and this class is
|
||||||
|
* the module-local copy of the pet module's contract framework (same
|
||||||
|
* per-module duplication discipline as BearerAuthFilter). Whenever the
|
||||||
|
* canonical contract changes, copy it into every framework-carrying module
|
||||||
|
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
|
||||||
|
* new version's file name and update each conformance test (expected version
|
||||||
|
* + snapshot counts). The guard test on {@code info.version} makes a forgotten
|
||||||
|
* sync fail loudly in CI instead of silently testing against a stale
|
||||||
|
* contract.
|
||||||
|
*
|
||||||
|
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
||||||
|
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
||||||
|
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
|
||||||
|
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
|
||||||
|
* {@link ContractValidator}) — no oneOf/anyOf.
|
||||||
|
*/
|
||||||
|
final class OpenApiContract {
|
||||||
|
|
||||||
|
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
|
||||||
|
|
||||||
|
private static final Set<String> HTTP_METHODS =
|
||||||
|
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||||
|
|
||||||
|
private final Map<String, Object> root;
|
||||||
|
|
||||||
|
private OpenApiContract(Map<String, Object> root) {
|
||||||
|
this.root = root;
|
||||||
|
}
|
||||||
|
|
||||||
|
static OpenApiContract load() {
|
||||||
|
try (InputStream in = Objects.requireNonNull(
|
||||||
|
OpenApiContract.class.getResourceAsStream(RESOURCE),
|
||||||
|
"契约快照缺失: " + RESOURCE)) {
|
||||||
|
return new OpenApiContract(new Yaml().load(in));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String version() {
|
||||||
|
return (String) map(root, "info").get("version");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> paths() {
|
||||||
|
return map(root, "paths");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> schemas() {
|
||||||
|
return map(map(root, "components"), "schemas");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All declared operations as "METHOD pathTemplate" (insertion order). */
|
||||||
|
Set<String> operations() {
|
||||||
|
Set<String> ops = new LinkedHashSet<>();
|
||||||
|
paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
|
||||||
|
if (HTTP_METHODS.contains(method)) {
|
||||||
|
ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
|
||||||
|
Set<String> operationsTagged(Set<String> tags) {
|
||||||
|
Set<String> ops = new LinkedHashSet<>();
|
||||||
|
for (String key : operations()) {
|
||||||
|
List<Object> opTags = list(operation(key), "tags");
|
||||||
|
if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
|
||||||
|
ops.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declared response statuses of an operation, as ints. */
|
||||||
|
Set<Integer> responseStatuses(String operationKey) {
|
||||||
|
Set<Integer> statuses = new LinkedHashSet<>();
|
||||||
|
map(operation(operationKey), "responses")
|
||||||
|
.keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single 2xx status the operation declares. */
|
||||||
|
int successStatus(String operationKey) {
|
||||||
|
return responseStatuses(operationKey).stream()
|
||||||
|
.filter(s -> s >= 200 && s < 300)
|
||||||
|
.reduce((a, b) -> {
|
||||||
|
throw new IllegalStateException("多个 2xx 响应: " + operationKey);
|
||||||
|
})
|
||||||
|
.orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operation object for "METHOD pathTemplate", or null when undeclared. */
|
||||||
|
Map<String, Object> operation(String operationKey) {
|
||||||
|
String[] parts = operationKey.split(" ", 2);
|
||||||
|
Map<String, Object> pathItem = map(paths(), parts[1]);
|
||||||
|
return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows local $ref chains; non-ref maps come back unchanged. */
|
||||||
|
Map<String, Object> resolve(Map<String, Object> node) {
|
||||||
|
while (node != null && node.get("$ref") instanceof String ref) {
|
||||||
|
if (!ref.startsWith("#/")) {
|
||||||
|
throw new IllegalStateException("仅支持本地 $ref: " + ref);
|
||||||
|
}
|
||||||
|
Map<String, Object> cur = root;
|
||||||
|
for (String seg : ref.substring(2).split("/")) {
|
||||||
|
cur = map(cur, seg);
|
||||||
|
if (cur == null) {
|
||||||
|
throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = cur;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static Map<String, Object> cast(Object o) {
|
||||||
|
return (Map<String, Object>) o;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> map(Map<String, Object> m, String key) {
|
||||||
|
return m == null ? null : cast(m.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
static List<Object> list(Map<String, Object> m, String key) {
|
||||||
|
return m == null ? null : (List<Object>) m.get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
-47
@@ -1,74 +1,138 @@
|
|||||||
package com.patbond.patbond.auth.controller;
|
package com.patbond.patbond.auth.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.client.SessionClient;
|
||||||
import com.patbond.patbond.auth.client.UserClient;
|
import com.patbond.patbond.auth.client.UserClient;
|
||||||
|
import com.patbond.patbond.auth.security.JwtSigner;
|
||||||
|
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||||
|
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||||
import com.patbond.patbond.common.error.BusinessException;
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
import com.patbond.patbond.common.error.ErrorCode;
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
import com.patbond.patbond.common.response.ApiResponse;
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||||
|
import com.patbond.patbond.common.session.SessionTokens;
|
||||||
import com.patbond.patbond.common.user.UserProfile;
|
import com.patbond.patbond.common.user.UserProfile;
|
||||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import feign.Request;
|
import feign.Request;
|
||||||
import feign.Response;
|
import feign.Response;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.matchesPattern;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MockMvc tests for the auth endpoints. The Feign UserClient is replaced with
|
* MockMvc tests for the public /api/v1/auth endpoints. The Feign clients are
|
||||||
* a Mockito mock so no user-service process is required. Downstream business
|
* Mockito mocks, so no user-service process is required; downstream business
|
||||||
* failures are simulated as the BusinessException the ApiErrorDecoder raises,
|
* failures are simulated as the BusinessException the ApiErrorDecoder raises.
|
||||||
* so these tests pin the FIXED error contract: 409/401/400 pass through to
|
* These tests pin the FROZEN response contract: data carries exactly
|
||||||
* the client with stable business codes instead of collapsing to 400/500
|
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
|
||||||
* (audit issue M1); transport-level Feign failures answer 503.
|
* refreshTokenExpiresAt}, timestamps are ISO 8601 with offset, and error
|
||||||
|
* codes pass through unchanged (40100/40102/40900/42300…).
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
class AuthControllerTest {
|
class AuthControllerTest {
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private MockMvc mockMvc;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private UserClient userClient;
|
|
||||||
|
|
||||||
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
||||||
|
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
|
||||||
|
/** ISO 8601 with a UTC offset, e.g. 2026-09-04T12:34:56.789Z or …+00:00. */
|
||||||
|
private static final String ISO_OFFSET_PATTERN =
|
||||||
|
"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})";
|
||||||
|
private static final String JWT_PATTERN =
|
||||||
|
"[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+";
|
||||||
|
|
||||||
private static final String REGISTER_BODY = """
|
private static final String REGISTER_BODY = """
|
||||||
{"username":"alice","password":"secret123","nickname":"Alice","phone":"+8613800138000"}
|
{"username":"alice","password":"secret123","phone":"+8613800138000"}
|
||||||
""";
|
""";
|
||||||
private static final String LOGIN_BODY = """
|
private static final String LOGIN_BODY = """
|
||||||
{"username":"alice","password":"secret123"}
|
{"username":"alice","password":"secret123"}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@Test
|
@Autowired
|
||||||
void registerReturnsTokenWhenUserServiceSucceeds() throws Exception {
|
private MockMvc mockMvc;
|
||||||
UserProfile profile = new UserProfile(USER_ID, "alice", "Alice", null, OffsetDateTime.now());
|
|
||||||
when(userClient.createUser(any())).thenReturn(ApiResponse.success(profile));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/register")
|
@Autowired
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
private JwtSigner jwtSigner;
|
||||||
.content(REGISTER_BODY))
|
|
||||||
|
@MockitoBean
|
||||||
|
private UserClient userClient;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private SessionClient sessionClient;
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void jwtKey(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionTokens sessionTokens() {
|
||||||
|
return new SessionTokens(SESSION_ID, USER_ID, "jti-1", "refresh-token-1",
|
||||||
|
OffsetDateTime.now(ZoneOffset.UTC).plusDays(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerReturnsTheFrozenTokenContract() throws Exception {
|
||||||
|
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
|
||||||
|
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
|
||||||
|
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
|
||||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||||
.andExpect(jsonPath("$.data.username").value("alice"));
|
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||||
|
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||||
|
.andExpect(jsonPath("$.data.accessTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
|
||||||
|
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"))
|
||||||
|
.andExpect(jsonPath("$.data.refreshTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
|
||||||
|
// Frozen contract: exactly these six fields, nothing else.
|
||||||
|
.andExpect(jsonPath("$.data.username").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.nickname").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.data.expiresAt").doesNotExist())
|
||||||
|
// Envelope is exactly {code, message, data}: the derived
|
||||||
|
// isSuccess() getter must not leak onto the wire.
|
||||||
|
.andExpect(jsonPath("$.success").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerForwardsDeviceIdHeaderToTheSessionRecord() throws Exception {
|
||||||
|
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
|
||||||
|
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
|
||||||
|
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.header("X-Device-Id", "pixel-8-of-alice")
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content(REGISTER_BODY))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateSessionRequest> captor =
|
||||||
|
ArgumentCaptor.forClass(CreateSessionRequest.class);
|
||||||
|
verify(sessionClient).create(captor.capture());
|
||||||
|
assertThat(captor.getValue().getDeviceId()).isEqualTo("pixel-8-of-alice");
|
||||||
|
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -76,9 +140,7 @@ class AuthControllerTest {
|
|||||||
when(userClient.createUser(any()))
|
when(userClient.createUser(any()))
|
||||||
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
|
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(REGISTER_BODY))
|
|
||||||
.andExpect(status().isConflict())
|
.andExpect(status().isConflict())
|
||||||
.andExpect(jsonPath("$.code").value(40900))
|
.andExpect(jsonPath("$.code").value(40900))
|
||||||
.andExpect(jsonPath("$.message").value("用户名已存在"));
|
.andExpect(jsonPath("$.message").value("用户名已存在"));
|
||||||
@@ -86,8 +148,7 @@ class AuthControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registerRejectsInvalidPayloadWithoutCallingUserService() throws Exception {
|
void registerRejectsInvalidPayloadWithoutCallingUserService() throws Exception {
|
||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value(40000));
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
@@ -95,8 +156,7 @@ class AuthControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void registerRejectsNonE164Phone() throws Exception {
|
void registerRejectsNonE164Phone() throws Exception {
|
||||||
mockMvc.perform(post("/auth/register")
|
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("""
|
.content("""
|
||||||
{"username":"alice","password":"secret123","phone":"13800138000"}
|
{"username":"alice","password":"secret123","phone":"13800138000"}
|
||||||
"""))
|
"""))
|
||||||
@@ -105,17 +165,17 @@ class AuthControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginReturnsTokenWhenPasswordVerified() throws Exception {
|
void loginReturnsTokenPairWhenPasswordVerified() throws Exception {
|
||||||
when(userClient.verifyPassword(any()))
|
when(userClient.verifyPassword(any()))
|
||||||
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", "Alice")));
|
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", null)));
|
||||||
|
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(LOGIN_BODY))
|
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()));
|
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||||
|
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -123,18 +183,25 @@ class AuthControllerTest {
|
|||||||
when(userClient.verifyPassword(any()))
|
when(userClient.verifyPassword(any()))
|
||||||
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
|
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(LOGIN_BODY))
|
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
.andExpect(jsonPath("$.code").value(40100))
|
.andExpect(jsonPath("$.code").value(40100))
|
||||||
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
|
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginPropagatesAccountLockAs423() throws Exception {
|
||||||
|
when(userClient.verifyPassword(any()))
|
||||||
|
.thenThrow(new BusinessException(ErrorCode.LOGIN_LOCKED));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||||
|
.andExpect(status().is(423))
|
||||||
|
.andExpect(jsonPath("$.code").value(42300));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loginRejectsBlankCredentials() throws Exception {
|
void loginRejectsBlankCredentials() throws Exception {
|
||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"username\":\"\",\"password\":\"\"}"))
|
.content("{\"username\":\"\",\"password\":\"\"}"))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value(40000));
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
@@ -148,10 +215,88 @@ class AuthControllerTest {
|
|||||||
Response.builder().status(502).request(request).build());
|
Response.builder().status(502).request(request).build());
|
||||||
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
|
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
|
||||||
|
|
||||||
mockMvc.perform(post("/auth/login")
|
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(LOGIN_BODY))
|
|
||||||
.andExpect(status().isServiceUnavailable())
|
.andExpect(status().isServiceUnavailable())
|
||||||
.andExpect(jsonPath("$.code").value(50300));
|
.andExpect(jsonPath("$.code").value(50300));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshReturnsARotatedTokenPair() throws Exception {
|
||||||
|
when(sessionClient.refresh(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
|
||||||
|
.content("{\"refreshToken\":\"old-refresh-token\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||||
|
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||||
|
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||||
|
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshPropagatesInvalidatedTokenAs40102() throws Exception {
|
||||||
|
when(sessionClient.refresh(any()))
|
||||||
|
.thenThrow(new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
|
||||||
|
.content("{\"refreshToken\":\"reused-refresh-token\"}"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40102));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshRejectsMissingToken() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON).content("{}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutRevokesTheCurrentSessionWithAValidAccessToken() throws Exception {
|
||||||
|
when(sessionClient.revoke(any())).thenReturn(ApiResponse.success(null));
|
||||||
|
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout").token();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/logout")
|
||||||
|
.header("Authorization", "Bearer " + accessToken)
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0));
|
||||||
|
|
||||||
|
ArgumentCaptor<RevokeSessionRequest> captor = ArgumentCaptor.forClass(RevokeSessionRequest.class);
|
||||||
|
verify(sessionClient).revoke(captor.capture());
|
||||||
|
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
|
||||||
|
assertThat(captor.getValue().getRefreshToken()).isEqualTo("refresh-token-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutWithoutAuthorizationHeaderAnswers40101() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/v1/auth/logout").contentType(APPLICATION_JSON)
|
||||||
|
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutWithGarbageAccessTokenAnswers40101() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/v1/auth/logout")
|
||||||
|
.header("Authorization", "Bearer not.a.jwt")
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logoutRejectsMissingRefreshToken() throws Exception {
|
||||||
|
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout2").token();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/logout")
|
||||||
|
.header("Authorization", "Bearer " + accessToken)
|
||||||
|
.contentType(APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.patbond.patbond.auth.security;
|
||||||
|
|
||||||
|
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class JwtSignerTest {
|
||||||
|
|
||||||
|
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
||||||
|
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
|
||||||
|
|
||||||
|
private JwtSigner signer(Duration ttl) {
|
||||||
|
return new JwtSigner(TestJwtKeys.privatePem(), ttl, "patbond-auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signedTokenCarriesTheExpectedClaimsAndVerifies() {
|
||||||
|
JwtSigner signer = signer(Duration.ofMinutes(15));
|
||||||
|
JwtSigner.AccessToken accessToken = signer.sign(USER_ID, SESSION_ID, "jti-123");
|
||||||
|
|
||||||
|
Claims claims = signer.verify(accessToken.token());
|
||||||
|
assertThat(claims.getSubject()).isEqualTo(USER_ID.toString());
|
||||||
|
assertThat(claims.getId()).isEqualTo("jti-123");
|
||||||
|
assertThat(claims.get(JwtSigner.SESSION_ID_CLAIM, String.class))
|
||||||
|
.isEqualTo(SESSION_ID.toString());
|
||||||
|
assertThat(claims.getIssuer()).isEqualTo("patbond-auth");
|
||||||
|
assertThat(accessToken.expiresAt())
|
||||||
|
.isAfter(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(14))
|
||||||
|
.isBefore(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expiredTokenIsRejectedWith40101() {
|
||||||
|
JwtSigner expiredSigner = signer(Duration.ofMinutes(-1));
|
||||||
|
String token = expiredSigner.sign(USER_ID, SESSION_ID, "jti-exp").token();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> expiredSigner.verify(token))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenSignedWithAForeignKeyIsRejected() {
|
||||||
|
String forged = new JwtSigner(TestJwtKeys.privatePem(TestJwtKeys.WRONG_KEY_PAIR),
|
||||||
|
Duration.ofMinutes(15), "patbond-auth")
|
||||||
|
.sign(USER_ID, SESSION_ID, "jti-forged").token();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> signer(Duration.ofMinutes(15)).verify(forged))
|
||||||
|
.isInstanceOf(BusinessException.class)
|
||||||
|
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tamperedTokenIsRejected() {
|
||||||
|
JwtSigner signer = signer(Duration.ofMinutes(15));
|
||||||
|
String token = signer.sign(USER_ID, SESSION_ID, "jti-tamper").token();
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
String tampered = parts[0] + "." + parts[1].substring(0, parts[1].length() - 2) + "aa." + parts[2];
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> signer.verify(tampered))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingPrivateKeyFailsFastAtConstruction() {
|
||||||
|
assertThatThrownBy(() -> new JwtSigner("", Duration.ofMinutes(15), "patbond-auth"))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("patbond.jwt.private-key");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.patbond.patbond.auth.support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth service has no database; JDBC and Flyway are only on the test
|
||||||
|
* classpath because the end-to-end test boots the real user service in the
|
||||||
|
* same JVM. Auth-only Spring contexts must exclude their auto-configuration
|
||||||
|
* or they fail for lack of a DataSource.
|
||||||
|
*/
|
||||||
|
public final class SpringTestSupport {
|
||||||
|
|
||||||
|
public static final String EXCLUDE_JDBC_AUTOCONFIG = "spring.autoconfigure.exclude="
|
||||||
|
+ "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,"
|
||||||
|
+ "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration";
|
||||||
|
|
||||||
|
private SpringTestSupport() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.patbond.patbond.auth.support;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||||
|
* key material (git-workflow: no credentials in the repository) — every test
|
||||||
|
* run mints a fresh pair and injects the PEM via {@code @DynamicPropertySource}.
|
||||||
|
*/
|
||||||
|
public final class TestJwtKeys {
|
||||||
|
|
||||||
|
public static final KeyPair KEY_PAIR = generate();
|
||||||
|
/** A second pair, for tokens the services must reject. */
|
||||||
|
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||||
|
|
||||||
|
private TestJwtKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String privatePem() {
|
||||||
|
return privatePem(KEY_PAIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String privatePem(KeyPair keyPair) {
|
||||||
|
return "-----BEGIN PRIVATE KEY-----\n"
|
||||||
|
+ Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded())
|
||||||
|
+ "\n-----END PRIVATE KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String publicPem() {
|
||||||
|
return "-----BEGIN PUBLIC KEY-----\n"
|
||||||
|
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||||
|
+ "\n-----END PUBLIC KEY-----";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyPair generate() {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Test-only configuration: keeps @SpringBootTest self-contained on a clean
|
# Test-only configuration: keeps @SpringBootTest self-contained on a clean
|
||||||
# checkout, where the git-ignored application.yml does not exist yet.
|
# checkout, where the git-ignored application.yml does not exist yet. The JWT
|
||||||
|
# private key is generated at runtime per test class and injected through
|
||||||
|
# @DynamicPropertySource — no key material is committed.
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: patbond-auth
|
name: patbond-auth
|
||||||
@@ -7,3 +9,4 @@ spring:
|
|||||||
patbond:
|
patbond:
|
||||||
user-service:
|
user-service:
|
||||||
url: http://127.0.0.1:8082
|
url: http://127.0.0.1:8082
|
||||||
|
internal-token: test-internal-token
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,12 @@
|
|||||||
<groupId>jakarta.validation</groupId>
|
<groupId>jakarta.validation</groupId>
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Annotations only (no databind): keeps the wire shape of the shared
|
||||||
|
envelope under the contract module's control. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-annotations</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -11,9 +11,29 @@ public enum ErrorCode {
|
|||||||
|
|
||||||
VALIDATION_ERROR(40000, 400, "参数校验失败"),
|
VALIDATION_ERROR(40000, 400, "参数校验失败"),
|
||||||
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
|
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
|
||||||
|
TOKEN_INVALID(40101, 401, "token 无效或过期"),
|
||||||
|
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
|
||||||
|
PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"),
|
||||||
|
POST_ACCESS_DENIED(40301, 403, "无权限执行该操作"),
|
||||||
USER_NOT_FOUND(40400, 404, "用户不存在"),
|
USER_NOT_FOUND(40400, 404, "用户不存在"),
|
||||||
|
PET_NOT_FOUND(40401, 404, "宠物不存在"),
|
||||||
|
RECORD_NOT_FOUND(40402, 404, "记录不存在"),
|
||||||
|
POST_NOT_FOUND(40403, 404, "帖子不存在"),
|
||||||
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
||||||
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
||||||
|
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||||
|
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||||
|
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
|
||||||
|
IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"),
|
||||||
|
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
|
||||||
|
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
|
||||||
|
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
|
||||||
|
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
||||||
|
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
|
||||||
|
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
|
||||||
|
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
|
||||||
|
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
|
||||||
|
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.patbond.patbond.common.response;
|
package com.patbond.patbond.common.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
private Integer code;
|
private Integer code;
|
||||||
@@ -23,6 +25,11 @@ public class ApiResponse<T> {
|
|||||||
return new ApiResponse<>(code, message, null);
|
return new ApiResponse<>(code, message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived convenience only — not part of the frozen wire contract
|
||||||
|
* {@code {code, message, data}}, hence never serialized.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
public boolean isSuccess() {
|
public boolean isSuccess() {
|
||||||
return Integer.valueOf(0).equals(code);
|
return Integer.valueOf(0).equals(code);
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.patbond.patbond.common.session;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal contract: patbond-auth asks patbond-user (the identity schema
|
||||||
|
* owner) to open a refresh session for a just-authenticated user. Device
|
||||||
|
* metadata is optional observability data for the multi-device session list.
|
||||||
|
*/
|
||||||
|
public class CreateSessionRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "userId 不能为空")
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Size(max = 128, message = "deviceId 长度不能超过128位")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
@Size(max = 512, message = "userAgent 长度不能超过512位")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Size(max = 45, message = "ipAddress 长度不能超过45位")
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
public CreateSessionRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateSessionRequest(UUID userId, String deviceId, String userAgent, String ipAddress) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(UUID userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceId(String deviceId) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.patbond.patbond.common.session;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** Internal contract: rotate a refresh session (ADR-003). */
|
||||||
|
public class RefreshSessionRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "refreshToken 不能为空")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
public RefreshSessionRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RefreshSessionRequest(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.patbond.patbond.common.session;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal contract: logout — revoke the one session holding this refresh
|
||||||
|
* token, scoped to the user taken from the verified access token so a token
|
||||||
|
* from another account cannot be revoked (ADR-003: logout revokes only the
|
||||||
|
* current session; other devices stay logged in).
|
||||||
|
*/
|
||||||
|
public class RevokeSessionRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "userId 不能为空")
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@NotBlank(message = "refreshToken 不能为空")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
public RevokeSessionRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RevokeSessionRequest(UUID userId, String refreshToken) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(UUID userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.patbond.patbond.common.session;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal contract: the session material patbond-user hands back to
|
||||||
|
* patbond-auth after creating or rotating a session. The refresh token is the
|
||||||
|
* only plaintext copy that ever exists (the database stores its SHA-256
|
||||||
|
* digest); the jti is minted here so auth can embed it in the access token
|
||||||
|
* it signs, matching auth_sessions.access_token_jti without a second call.
|
||||||
|
*/
|
||||||
|
public class SessionTokens {
|
||||||
|
|
||||||
|
private UUID sessionId;
|
||||||
|
private UUID userId;
|
||||||
|
private String jti;
|
||||||
|
private String refreshToken;
|
||||||
|
private OffsetDateTime refreshTokenExpiresAt;
|
||||||
|
|
||||||
|
public SessionTokens() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionTokens(UUID sessionId, UUID userId, String jti,
|
||||||
|
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.jti = jti;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getSessionId() {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionId(UUID sessionId) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(UUID userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJti() {
|
||||||
|
return jti;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setJti(String jti) {
|
||||||
|
this.jti = jti;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getRefreshTokenExpiresAt() {
|
||||||
|
return refreshTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshTokenExpiresAt(OffsetDateTime refreshTokenExpiresAt) {
|
||||||
|
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime image only — build the jar first: ./mvnw -pl patbond-community -am package
|
||||||
|
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
RUN useradd --system --uid 10001 patbond
|
||||||
|
USER patbond
|
||||||
|
WORKDIR /app
|
||||||
|
COPY target/patbond-community-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||||
|
EXPOSE 8084
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-api</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>patbond-community</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>patbond-community</name>
|
||||||
|
<description>Community feed, posts and interactions service for Patbond (ADR-017)</description>
|
||||||
|
|
||||||
|
<!-- M3 first-wave skeleton: RS256 bearer auth on /api/v1/** (same JWT
|
||||||
|
verification stack as patbond-user/pet), community schema access via
|
||||||
|
JDBC. Flyway remains absent — the migration chain is owned by
|
||||||
|
patbond-user. -->
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-common</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Author public profiles come from patbond-user's /internal batch
|
||||||
|
API (D3-9 方案 B), static direct URL per ADR-002. feign-hc5 for
|
||||||
|
the same reason as patbond-auth: the JDK default client loses
|
||||||
|
error bodies on some replies. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openfeign</groupId>
|
||||||
|
<artifactId>feign-hc5</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Read-side media URL signing only (presigned GET is a local SigV4
|
||||||
|
computation): this service never talks to the object store, the
|
||||||
|
media write flow stays in patbond-user (ADR-016/017). Version
|
||||||
|
managed by the root pom's awssdk bom. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
|
<artifactId>s3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||||
|
the Boot BOM, version pinned in step with patbond-user/auth/pet. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Tests need the community schema. The migration chain (V1..V5) is
|
||||||
|
owned by patbond-user (single flyway_schema_history); pulling its
|
||||||
|
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
|
||||||
|
auto-config apply the same chain to the disposable container.
|
||||||
|
Production wiring is unchanged: this module still ships without
|
||||||
|
Flyway and the chain runs in patbond-user's startup path (same
|
||||||
|
mechanism as patbond-pet). -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.patbond.patbond</groupId>
|
||||||
|
<artifactId>patbond-user</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<!-- No spring-boot-starter-parent in this build, so the
|
||||||
|
executable-jar repackaging must be bound explicitly. -->
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>repackage</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<!-- Keep the plain jar as the main artifact so other
|
||||||
|
modules can depend on this one; the runnable fat
|
||||||
|
jar gets the -exec classifier and is what the
|
||||||
|
Dockerfile ships (same pattern as user/auth/pet). -->
|
||||||
|
<classifier>exec</classifier>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.config.CommunityFeignConfig;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community feed, posts and interactions service (M3, ADR-017: the community
|
||||||
|
* domain lives in its own Maven module on :8084). Configuration wiring,
|
||||||
|
* datasource, RS256 bearer auth on /api/v1/**, the post lifecycle (T3-04)
|
||||||
|
* and the public feed (T3-05). The module only reads and writes the
|
||||||
|
* community schema (plus the ADR-017 read-only media.assets exception);
|
||||||
|
* author public profiles come from patbond-user's /internal batch API over
|
||||||
|
* Feign (D3-9 方案 B).
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableFeignClients(defaultConfiguration = CommunityFeignConfig.class)
|
||||||
|
public class CommunityApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CommunityApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package com.patbond.patbond.community.access;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-schema visibility probe for posts.pet_id references. Semantics
|
||||||
|
* follow patbond-pet's PetAccessService anti-enumeration rule: a pet the
|
||||||
|
* caller has no pet_owners row for is indistinguishable from a nonexistent
|
||||||
|
* one — both answer 404/40401. Any role (owner/caregiver/viewer) may
|
||||||
|
* reference a visible pet from a post; referencing needs no write power
|
||||||
|
* over the pet itself.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PetVisibilityGateway {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public PetVisibilityGateway(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40401 when the pet is invisible to the caller */
|
||||||
|
public void requireVisible(UUID userId, UUID petId) {
|
||||||
|
boolean visible = jdbcClient.sql("""
|
||||||
|
SELECT 1
|
||||||
|
FROM pet_health.pets p
|
||||||
|
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
||||||
|
WHERE p.id = :petId AND p.status <> 'deleted'
|
||||||
|
""")
|
||||||
|
.param("userId", userId)
|
||||||
|
.param("petId", petId)
|
||||||
|
.query(Integer.class)
|
||||||
|
.optional()
|
||||||
|
.isPresent();
|
||||||
|
if (!visible) {
|
||||||
|
throw new BusinessException(ErrorCode.PET_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.patbond.patbond.community.access;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existence probe into identity.users for write gates that reference a
|
||||||
|
* user (follow target, comment @-reply target). Same-database read-only
|
||||||
|
* access under the ADR-017 exception — the user_follows/comments foreign
|
||||||
|
* keys already bind these schemas together, and a WRITE gate cannot ride
|
||||||
|
* the Feign profile path, whose degradation deliberately cannot tell
|
||||||
|
* "absent" from "unreachable". A soft-deleted (注销) user counts as absent.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class UserExistenceGateway {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public UserExistenceGateway(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsActive(UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT EXISTS (SELECT 1 FROM identity.users
|
||||||
|
WHERE id = :id AND deleted_at IS NULL)
|
||||||
|
""")
|
||||||
|
.param("id", userId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch public-profile API of patbond-user, the identity schema owner
|
||||||
|
* (D3-9 方案 B; static direct URL per ADR-002). The X-Internal-Token header
|
||||||
|
* is attached by the interceptor in CommunityFeignConfig. Unknown or
|
||||||
|
* deleted ids are silently absent from the reply. {@code primary = false}
|
||||||
|
* only matters to tests (lets a stub take precedence); in production this
|
||||||
|
* is the sole candidate.
|
||||||
|
*/
|
||||||
|
@FeignClient(name = "patbond-user-profiles", url = "${patbond.user-service.url}", primary = false)
|
||||||
|
public interface AuthorProfileClient {
|
||||||
|
|
||||||
|
/** @param ids comma-separated user ids, at most 50 per call */
|
||||||
|
@GetMapping("/internal/users/profiles")
|
||||||
|
ApiResponse<List<AuthorProfileDto>> profiles(@RequestParam("ids") String ids);
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape of one profile in patbond-user's /internal/users/profiles
|
||||||
|
* reply (D3-9 方案 B): display name (nickname→username fallback already
|
||||||
|
* applied by the owning service) plus the avatar asset pointer. The avatar
|
||||||
|
* arrives as an id, not a URL — this service resolves it against
|
||||||
|
* media.assets (ADR-017 read-only exception) and signs a fresh presigned
|
||||||
|
* GET per response, so nothing cached here ever holds an expiring URL.
|
||||||
|
*/
|
||||||
|
public record AuthorProfileDto(UUID userId, String nickname, UUID avatarAssetId) {
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetGateway;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetRef;
|
||||||
|
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author public-profile lookup (D3-9 方案 B): a batch Feign call to
|
||||||
|
* patbond-user's /internal/users/profiles behind a short-TTL in-process
|
||||||
|
* cache, plus local avatar resolution.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Batch, never loop</b> — one call per ≤50 distinct cache-missed
|
||||||
|
* authors (a feed page has ≤20 cards, so normally exactly one call,
|
||||||
|
* and none on a warm cache).</li>
|
||||||
|
* <li><b>Avatar</b> — travels as an asset id; resolved to bucket/key via
|
||||||
|
* the ADR-017 read-only media.assets exception (ready assets only)
|
||||||
|
* and signed fresh per response, so the cache stores no expiring
|
||||||
|
* URL.</li>
|
||||||
|
* <li><b>Degradation</b> — ANY lookup failure (user service down, slow,
|
||||||
|
* or answering an error) logs one warning and leaves the ids
|
||||||
|
* unresolved; callers render the id-only summary. Failures are never
|
||||||
|
* cached, so the next request retries; the feed never 5xxes over a
|
||||||
|
* profile lookup.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AuthorProfileGateway {
|
||||||
|
|
||||||
|
private static final int MAX_BATCH = 50;
|
||||||
|
/** Expired entries are pruned opportunistically past this size. */
|
||||||
|
private static final int PRUNE_THRESHOLD = 10_000;
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthorProfileGateway.class);
|
||||||
|
|
||||||
|
private final AuthorProfileClient client;
|
||||||
|
private final MediaAssetGateway mediaAssetGateway;
|
||||||
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
|
private final AuthorProfileProperties properties;
|
||||||
|
private final ConcurrentHashMap<UUID, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public AuthorProfileGateway(AuthorProfileClient client, MediaAssetGateway mediaAssetGateway,
|
||||||
|
MediaUrlSigner mediaUrlSigner, AuthorProfileProperties properties) {
|
||||||
|
this.client = client;
|
||||||
|
this.mediaAssetGateway = mediaAssetGateway;
|
||||||
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summaries for the given authors, avatar URLs signed fresh. Ids that
|
||||||
|
* could not be resolved (lookup degraded, or the user no longer exists)
|
||||||
|
* are absent — callers fall back to
|
||||||
|
* {@link AuthorSummaryResponse#idOnly}.
|
||||||
|
*/
|
||||||
|
public Map<UUID, AuthorSummaryResponse> summarize(Collection<UUID> userIds) {
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
long now = System.nanoTime();
|
||||||
|
Map<UUID, AuthorRef> resolved = new HashMap<>();
|
||||||
|
List<UUID> misses = new ArrayList<>();
|
||||||
|
for (UUID id : new LinkedHashSet<>(userIds)) {
|
||||||
|
CacheEntry entry = cache.get(id);
|
||||||
|
if (entry != null && entry.expiresAtNanos() - now > 0) {
|
||||||
|
resolved.put(id, entry.ref());
|
||||||
|
} else {
|
||||||
|
misses.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!misses.isEmpty()) {
|
||||||
|
fetchInto(resolved, misses, now);
|
||||||
|
}
|
||||||
|
Map<UUID, AuthorSummaryResponse> summaries = new HashMap<>();
|
||||||
|
resolved.forEach((id, ref) -> summaries.put(id, new AuthorSummaryResponse(
|
||||||
|
id, ref.nickname(), mediaUrlSigner.signGet(ref.avatarBucket(), ref.avatarObjectKey()))));
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchInto(Map<UUID, AuthorRef> resolved, List<UUID> misses, long now) {
|
||||||
|
List<AuthorProfileDto> profiles = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < misses.size(); i += MAX_BATCH) {
|
||||||
|
List<UUID> chunk = misses.subList(i, Math.min(i + MAX_BATCH, misses.size()));
|
||||||
|
ApiResponse<List<AuthorProfileDto>> reply = client.profiles(
|
||||||
|
chunk.stream().map(UUID::toString).collect(Collectors.joining(",")));
|
||||||
|
if (reply != null && reply.getData() != null) {
|
||||||
|
profiles.addAll(reply.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Chunks fetched before the failure still count below.
|
||||||
|
log.warn("作者公开资料获取失败,本次响应对未解析作者降级为 authorId 保底: {}",
|
||||||
|
e.toString());
|
||||||
|
}
|
||||||
|
if (profiles.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<UUID> assetIds = profiles.stream()
|
||||||
|
.map(AuthorProfileDto::avatarAssetId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<UUID, MediaAssetRef> assets = assetIds.isEmpty()
|
||||||
|
? Map.of()
|
||||||
|
: mediaAssetGateway.findByIds(assetIds);
|
||||||
|
long expiresAt = now + properties.getCacheTtl().toNanos();
|
||||||
|
for (AuthorProfileDto profile : profiles) {
|
||||||
|
MediaAssetRef asset = profile.avatarAssetId() == null
|
||||||
|
? null
|
||||||
|
: assets.get(profile.avatarAssetId());
|
||||||
|
boolean ready = asset != null && "ready".equals(asset.status());
|
||||||
|
AuthorRef ref = new AuthorRef(profile.nickname(),
|
||||||
|
ready ? asset.bucket() : null,
|
||||||
|
ready ? asset.objectKey() : null);
|
||||||
|
cache.put(profile.userId(), new CacheEntry(ref, expiresAt));
|
||||||
|
resolved.put(profile.userId(), ref);
|
||||||
|
}
|
||||||
|
if (cache.size() > PRUNE_THRESHOLD) {
|
||||||
|
cache.values().removeIf(entry -> entry.expiresAtNanos() - now <= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AuthorRef(String nickname, String avatarBucket, String avatarObjectKey) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CacheEntry(AuthorRef ref, long expiresAtNanos) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knobs of the author-profile lookup (D3-9 方案 B): a short in-process TTL
|
||||||
|
* cache in front of patbond-user's /internal batch API. 60 s is the frozen
|
||||||
|
* default — long enough to absorb feed scrolling and refresh bursts,
|
||||||
|
* short enough that a nickname/avatar change propagates within a minute.
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "patbond.author-profile")
|
||||||
|
public class AuthorProfileProperties {
|
||||||
|
|
||||||
|
/** How long one resolved profile stays in the in-process cache. */
|
||||||
|
private Duration cacheTtl = Duration.ofSeconds(60);
|
||||||
|
|
||||||
|
public Duration getCacheTtl() {
|
||||||
|
return cacheTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCacheTtl(Duration value) {
|
||||||
|
this.cacheTtl = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import feign.Request;
|
||||||
|
import feign.RequestInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign child-context beans, registered via
|
||||||
|
* {@code @EnableFeignClients(defaultConfiguration = …)} — deliberately not
|
||||||
|
* a @Configuration, same reasoning as patbond-auth's FeignInternalConfig
|
||||||
|
* (a component-scanned bean would land in the parent context and be
|
||||||
|
* shadowed by the child's defaults).
|
||||||
|
*
|
||||||
|
* <p>No ErrorDecoder on purpose: the only Feign consumer here is the author
|
||||||
|
* profile lookup, whose gateway degrades on ANY failure instead of
|
||||||
|
* propagating it — a downstream business error is as much "no profile" as a
|
||||||
|
* connection refusal. Timeouts are tight because this call sits on the feed
|
||||||
|
* read path: a hung patbond-user must cost one bounded stall, not an
|
||||||
|
* unbounded one (connection refused already fails fast on its own).</p>
|
||||||
|
*/
|
||||||
|
public class CommunityFeignConfig {
|
||||||
|
|
||||||
|
/** Presents the shared service secret on every call to patbond-user. */
|
||||||
|
@Bean
|
||||||
|
public RequestInterceptor internalTokenInterceptor(CommunitySecurityProperties properties) {
|
||||||
|
return template -> template.header("X-Internal-Token", properties.getInternalToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Request.Options feignOptions() {
|
||||||
|
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security knobs of the community service: the RS256 public key for
|
||||||
|
* verifying access tokens issued by patbond-auth (same contract as
|
||||||
|
* patbond-user/pet's {@code patbond.jwt.public-key}), and the shared
|
||||||
|
* service secret presented on outbound /internal/** calls to patbond-user
|
||||||
|
* (D3-9 方案 B author-profile lookups — this service still exposes no
|
||||||
|
* /internal routes of its own).
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "patbond")
|
||||||
|
public class CommunitySecurityProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared secret sent as X-Internal-Token on calls to patbond-user's
|
||||||
|
* /internal/** API; must equal the value patbond-user expects.
|
||||||
|
*/
|
||||||
|
private String internalToken;
|
||||||
|
|
||||||
|
private final Jwt jwt = new Jwt();
|
||||||
|
|
||||||
|
public String getInternalToken() {
|
||||||
|
return internalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInternalToken(String value) {
|
||||||
|
this.internalToken = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Jwt getJwt() {
|
||||||
|
return jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Jwt {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RS256 public key for verifying access tokens signed by
|
||||||
|
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
|
||||||
|
* filesystem path. The private key never reaches this service.
|
||||||
|
*/
|
||||||
|
private String publicKey;
|
||||||
|
|
||||||
|
public String getPublicKey() {
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicKey(String publicKey) {
|
||||||
|
this.publicKey = publicKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integer fields must reject decimal input (development-plan 4.3) — Jackson's
|
||||||
|
* default is to silently truncate 45.5 to 45 in an integer field, which
|
||||||
|
* would corrupt values instead of rejecting them. Same setting as the other
|
||||||
|
* services so all envelopes behave identically.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
|
||||||
|
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.media.CommunityMediaProperties;
|
||||||
|
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-side media wiring: a presigned-GET signer over the same MinIO
|
||||||
|
* configuration patbond-user uses (ADR-016). Bean destruction closes the
|
||||||
|
* underlying presigner.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(CommunityMediaProperties.class)
|
||||||
|
public class MediaConfig {
|
||||||
|
|
||||||
|
@Bean(destroyMethod = "close")
|
||||||
|
public MediaUrlSigner mediaUrlSigner(CommunityMediaProperties properties) {
|
||||||
|
return new MediaUrlSigner(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileProperties;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.security.JwtVerifier;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires bearer authentication for /api/v1/** without pulling in
|
||||||
|
* spring-security — the same single-filter pattern patbond-user and
|
||||||
|
* patbond-pet use. The /health probe stays outside /api/v1 and therefore
|
||||||
|
* unauthenticated.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtVerifier jwtVerifier(CommunitySecurityProperties properties) {
|
||||||
|
return new JwtVerifier(properties.getJwt().getPublicKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
|
||||||
|
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||||
|
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||||
|
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||||
|
registration.addUrlPatterns("/api/v1/*");
|
||||||
|
registration.setOrder(20);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CommentResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreateCommentRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.CommentService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat comment endpoints (T3-07). Delete rides the top-level short path
|
||||||
|
* (commentId is globally unique — the pets-domain precedent); create
|
||||||
|
* carries a MANDATORY Idempotency-Key (ADR-019). All permission and error
|
||||||
|
* semantics live in CommentService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentService commentService;
|
||||||
|
|
||||||
|
public CommentController(CommentService commentService) {
|
||||||
|
this.commentService = commentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/posts/{postId}/comments")
|
||||||
|
public ApiResponse<CursorPage<CommentResponse>> list(
|
||||||
|
@PathVariable UUID postId,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(commentService.list(postId, limit, cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/v1/posts/{postId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ApiResponse<CommentResponse> create(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId,
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
|
@Valid @RequestBody CreateCommentRequest request) {
|
||||||
|
return ApiResponse.success(commentService.create(userId, postId, idempotencyKey, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/comments/{commentId}")
|
||||||
|
public ApiResponse<Void> delete(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID commentId) {
|
||||||
|
commentService.delete(userId, commentId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.FeedCardResponse;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.FeedService;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public feed endpoint (T3-05). Authenticated like every /api/v1 route —
|
||||||
|
* the viewer identity feeds likedByMe/bookmarkedByMe; the feed content
|
||||||
|
* itself is the same for everyone (published + public only).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class FeedController {
|
||||||
|
|
||||||
|
private final FeedService feedService;
|
||||||
|
|
||||||
|
public FeedController(FeedService feedService) {
|
||||||
|
this.feedService = feedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/feed")
|
||||||
|
public ApiResponse<CursorPage<FeedCardResponse>> feed(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(feedService.list(userId, limit, cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.FollowStateResponse;
|
||||||
|
import com.patbond.patbond.community.dto.FollowStatsResponse;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.FollowService;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ADR-018 minimal follow surface (T3-07): idempotent follow/unfollow
|
||||||
|
* plus the numbers endpoint. Follower/following LISTS are deliberately not
|
||||||
|
* in M3.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class FollowController {
|
||||||
|
|
||||||
|
private final FollowService followService;
|
||||||
|
|
||||||
|
public FollowController(FollowService followService) {
|
||||||
|
this.followService = followService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/v1/users/{userId}/follow")
|
||||||
|
public ApiResponse<FollowStateResponse> follow(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
|
||||||
|
@PathVariable UUID userId) {
|
||||||
|
return ApiResponse.success(followService.follow(callerId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/users/{userId}/follow")
|
||||||
|
public ApiResponse<FollowStateResponse> unfollow(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
|
||||||
|
@PathVariable UUID userId) {
|
||||||
|
return ApiResponse.success(followService.unfollow(callerId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/users/{userId}/follow-stats")
|
||||||
|
public ApiResponse<FollowStatsResponse> stats(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
|
||||||
|
@PathVariable UUID userId) {
|
||||||
|
return ApiResponse.success(followService.stats(callerId, userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness/readiness probe for the skeleton phase: confirms the service is
|
||||||
|
* up and that its datasource can reach the shared PostgreSQL. Deliberately
|
||||||
|
* outside /api/v1 so it stays unauthenticated (same reasoning as compose's
|
||||||
|
* pg_isready: infrastructure probes carry no business data).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public HealthController(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public ApiResponse<Map<String, String>> health() {
|
||||||
|
String db;
|
||||||
|
try {
|
||||||
|
jdbcClient.sql("SELECT 1").query(Integer.class).single();
|
||||||
|
db = "up";
|
||||||
|
} catch (Exception e) {
|
||||||
|
db = "down";
|
||||||
|
}
|
||||||
|
return ApiResponse.success(Map.of("status", "ok", "db", db));
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.BookmarkStateResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.FeedCardResponse;
|
||||||
|
import com.patbond.patbond.community.dto.LikeStateResponse;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.FeedService;
|
||||||
|
import com.patbond.patbond.community.service.InteractionService;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary post interactions (T3-06): PUT/DELETE idempotent like and
|
||||||
|
* bookmark, each answering the authoritative terminal state, plus the
|
||||||
|
* my-bookmarks list whose items reuse the feed card shape.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class InteractionController {
|
||||||
|
|
||||||
|
private final InteractionService interactionService;
|
||||||
|
private final FeedService feedService;
|
||||||
|
|
||||||
|
public InteractionController(InteractionService interactionService, FeedService feedService) {
|
||||||
|
this.interactionService = interactionService;
|
||||||
|
this.feedService = feedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/v1/posts/{postId}/like")
|
||||||
|
public ApiResponse<LikeStateResponse> like(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
return ApiResponse.success(interactionService.like(userId, postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/posts/{postId}/like")
|
||||||
|
public ApiResponse<LikeStateResponse> unlike(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
return ApiResponse.success(interactionService.unlike(userId, postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/v1/posts/{postId}/bookmark")
|
||||||
|
public ApiResponse<BookmarkStateResponse> bookmark(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
return ApiResponse.success(interactionService.bookmark(userId, postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/posts/{postId}/bookmark")
|
||||||
|
public ApiResponse<BookmarkStateResponse> unbookmark(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
return ApiResponse.success(interactionService.unbookmark(userId, postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/me/bookmarks")
|
||||||
|
public ApiResponse<CursorPage<FeedCardResponse>> myBookmarks(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(feedService.listBookmarked(userId, limit, cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreatePostRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.PostResponse;
|
||||||
|
import com.patbond.patbond.community.dto.UpdatePostRequest;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.PostService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post lifecycle endpoints (T3-04). Idempotency-Key is MANDATORY on create
|
||||||
|
* (ADR-019 — deliberately different from the pets domain's optional key;
|
||||||
|
* a missing header answers 400/40000). Publishing is a PATCH state
|
||||||
|
* transition, not a separate endpoint. All permission and error semantics
|
||||||
|
* live in PostService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class PostController {
|
||||||
|
|
||||||
|
private final PostService postService;
|
||||||
|
|
||||||
|
public PostController(PostService postService) {
|
||||||
|
this.postService = postService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/v1/posts")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ApiResponse<PostResponse> create(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||||
|
@Valid @RequestBody CreatePostRequest request) {
|
||||||
|
return ApiResponse.success(postService.create(userId, idempotencyKey, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/posts/{postId}")
|
||||||
|
public ApiResponse<PostResponse> get(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
return ApiResponse.success(postService.get(userId, postId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/api/v1/posts/{postId}")
|
||||||
|
public ApiResponse<PostResponse> update(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId,
|
||||||
|
@Valid @RequestBody UpdatePostRequest request) {
|
||||||
|
return ApiResponse.success(postService.update(userId, postId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/v1/posts/{postId}")
|
||||||
|
public ApiResponse<Void> delete(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID postId) {
|
||||||
|
postService.delete(userId, postId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/me/posts")
|
||||||
|
public ApiResponse<CursorPage<PostResponse>> listMine(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author public summary embedded in post/feed/comment responses (D3-9).
|
||||||
|
* {@code nickname} carries the nickname→username fallback applied by
|
||||||
|
* patbond-user, so clients never assemble a display name themselves;
|
||||||
|
* {@code avatarUrl} is a fresh presigned GET (null when the author has no
|
||||||
|
* ready avatar, or when object storage is unconfigured — clients show a
|
||||||
|
* placeholder). The degraded shape — profile service unreachable, or the
|
||||||
|
* author since deleted — keeps only {@code userId} and nulls the rest
|
||||||
|
* (authorId 保底:the feed never 5xxes over a profile lookup).
|
||||||
|
*/
|
||||||
|
public record AuthorSummaryResponse(UUID userId, String nickname, String avatarUrl) {
|
||||||
|
|
||||||
|
/** The degraded / tombstone shape: id only, client renders placeholders. */
|
||||||
|
public static AuthorSummaryResponse idOnly(UUID userId) {
|
||||||
|
return new AuthorSummaryResponse(userId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
/** Authoritative post-write bookmark state — isomorphic to {@link LikeStateResponse}. */
|
||||||
|
public record BookmarkStateResponse(boolean bookmarked, long bookmarkCount) {
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One flat comment (T3-07 定型): the author and the optional @-reply target
|
||||||
|
* both travel as the D3-9 AuthorSummary shape, resolved through the same
|
||||||
|
* batch profile gateway as posts, so a degraded profile service renders
|
||||||
|
* id-only summaries here too and never fails the request.
|
||||||
|
*/
|
||||||
|
public record CommentResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID postId,
|
||||||
|
AuthorSummaryResponse author,
|
||||||
|
AuthorSummaryResponse replyToUser,
|
||||||
|
String content,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/posts/{postId}/comments. Content width mirrors
|
||||||
|
* ck_comments_content (1~2000 after trim); {@code replyToUserId} is the
|
||||||
|
* optional flat @-reply target (single level, no parentCommentId — ADR-018
|
||||||
|
* rules out nested threads).
|
||||||
|
*/
|
||||||
|
public class CreateCommentRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "content 不能为空")
|
||||||
|
@Size(max = 2000, message = "content 最长 2000 字符")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** Optional @-reply target; must be an existing active user (40406). */
|
||||||
|
private UUID replyToUserId;
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getReplyToUserId() {
|
||||||
|
return replyToUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReplyToUserId(UUID replyToUserId) {
|
||||||
|
this.replyToUserId = replyToUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/posts. Field widths mirror the ck_posts_* constraints;
|
||||||
|
* category and status enums are the write-side whitelists (ai_creation is an
|
||||||
|
* M4 read-side reservation and hidden/archived are operational states with
|
||||||
|
* no open endpoint, D3-7).
|
||||||
|
*/
|
||||||
|
public class CreatePostRequest {
|
||||||
|
|
||||||
|
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@NotBlank(message = "content 不能为空")
|
||||||
|
@Size(max = 10000, message = "content 最长 10000 字符")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Pattern(regexp = "draft|published", message = "status 仅支持 draft/published")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** Optional pet reference; must be a pet visible to the caller (40401). */
|
||||||
|
private UUID petId;
|
||||||
|
|
||||||
|
@Size(max = 9, message = "media 最多 9 张图")
|
||||||
|
@Valid
|
||||||
|
private List<PostMediaAttachRequest> media;
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategory(String category) {
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getPetId() {
|
||||||
|
return petId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPetId(UUID petId) {
|
||||||
|
this.petId = petId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PostMediaAttachRequest> getMedia() {
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMedia(List<PostMediaAttachRequest> media) {
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor-pagination envelope body — the pagination canon of the whole API
|
||||||
|
* (openapi v1.2.0 通用约定): {@code nextCursor} is null exactly when
|
||||||
|
* {@code hasMore} is false.
|
||||||
|
*/
|
||||||
|
public record CursorPage<T>(
|
||||||
|
List<T> items,
|
||||||
|
String nextCursor,
|
||||||
|
boolean hasMore) {
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One public-feed card (T3-05 定型, the FeedCard freeze input): the Post
|
||||||
|
* shape trimmed for list rendering — content cut to a 200-code-point
|
||||||
|
* preview, the media set reduced to the cover item plus a count, counts
|
||||||
|
* read from the posts table's denormalized columns. {@code coverImage} is
|
||||||
|
* null exactly for text-only posts (T3-04 guarantees a unique is_cover row
|
||||||
|
* whenever media exist); {@code publishedAt} is never null here (the feed
|
||||||
|
* predicate admits published posts only).
|
||||||
|
*/
|
||||||
|
public record FeedCardResponse(
|
||||||
|
UUID id,
|
||||||
|
AuthorSummaryResponse author,
|
||||||
|
String category,
|
||||||
|
String title,
|
||||||
|
String contentPreview,
|
||||||
|
PostMediaItemResponse coverImage,
|
||||||
|
int mediaCount,
|
||||||
|
long likeCount,
|
||||||
|
long commentCount,
|
||||||
|
long bookmarkCount,
|
||||||
|
boolean likedByMe,
|
||||||
|
boolean bookmarkedByMe,
|
||||||
|
OffsetDateTime publishedAt) {
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative post-write follow state; {@code followerCount} is the
|
||||||
|
* TARGET user's follower count (real-time COUNT — user_follows has no
|
||||||
|
* denormalized counter column, and the double index keeps both directions
|
||||||
|
* cheap).
|
||||||
|
*/
|
||||||
|
public record FollowStateResponse(boolean following, long followerCount) {
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/users/{userId}/follow-stats — the ADR-018 minimal "numbers"
|
||||||
|
* endpoint. {@code followedByMe} is the caller's view; asking about oneself
|
||||||
|
* yields false (a self-follow row cannot exist, ck_user_follows_self).
|
||||||
|
*/
|
||||||
|
public record FollowStatsResponse(long followerCount, long followingCount, boolean followedByMe) {
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative post-write like state (草案定型): a PUT answers
|
||||||
|
* {@code liked=true} and a DELETE {@code liked=false} regardless of whether
|
||||||
|
* the call changed anything; {@code likeCount} is the count as of this
|
||||||
|
* write's transaction, the value optimistic clients reconcile against.
|
||||||
|
*/
|
||||||
|
public record LikeStateResponse(boolean liked, long likeCount) {
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attached image in a create/update request. The asset must be owned by
|
||||||
|
* the caller and {@code status='ready'} (T3-03 联调协议), enforced in
|
||||||
|
* PostService against a read-only view of media.assets.
|
||||||
|
*/
|
||||||
|
public class PostMediaAttachRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "assetId 不能为空")
|
||||||
|
private UUID assetId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 0-based position. Either every item carries a position (together
|
||||||
|
* forming exactly 0..n-1) or none does (array order applies) — a mix is
|
||||||
|
* a 40000.
|
||||||
|
*/
|
||||||
|
@Min(value = 0, message = "position 最小为 0")
|
||||||
|
@Max(value = 8, message = "position 最大为 8")
|
||||||
|
private Integer position;
|
||||||
|
|
||||||
|
/** At most one true per post (uq_post_media_cover); none → position 0. */
|
||||||
|
private Boolean isCover;
|
||||||
|
|
||||||
|
@Size(max = 300, message = "caption 最长 300 字符")
|
||||||
|
private String caption;
|
||||||
|
|
||||||
|
public UUID getAssetId() {
|
||||||
|
return assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAssetId(UUID assetId) {
|
||||||
|
this.assetId = assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPosition() {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPosition(Integer position) {
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsCover() {
|
||||||
|
return isCover;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsCover(Boolean isCover) {
|
||||||
|
this.isCover = isCover;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCaption() {
|
||||||
|
return caption;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCaption(String caption) {
|
||||||
|
this.caption = caption;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One attached image in a post response. {@code url} is a presigned GET URL
|
||||||
|
* signed per response (T3-03: the bucket stays private, clients never
|
||||||
|
* persist it); null when object storage is unconfigured in this service.
|
||||||
|
*/
|
||||||
|
public record PostMediaItemResponse(
|
||||||
|
UUID assetId,
|
||||||
|
int position,
|
||||||
|
boolean isCover,
|
||||||
|
String url,
|
||||||
|
Integer widthPx,
|
||||||
|
Integer heightPx,
|
||||||
|
String caption) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full post shape (detail / my-posts list / write responses). The T3-04
|
||||||
|
* {@code authorId} placeholder is gone: {@code author} is the D3-9
|
||||||
|
* AuthorSummary, degraded to its id-only shape when the profile lookup is
|
||||||
|
* unavailable (contract deviation #1 closed by T3-05). Trimmed fields
|
||||||
|
* (region/generationJob/topics …) do not appear at all (ADR-018 + ADR-010
|
||||||
|
* precedent).
|
||||||
|
*/
|
||||||
|
public record PostResponse(
|
||||||
|
UUID id,
|
||||||
|
AuthorSummaryResponse author,
|
||||||
|
UUID petId,
|
||||||
|
String category,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
String status,
|
||||||
|
String visibility,
|
||||||
|
List<PostMediaItemResponse> media,
|
||||||
|
long likeCount,
|
||||||
|
long commentCount,
|
||||||
|
long bookmarkCount,
|
||||||
|
boolean likedByMe,
|
||||||
|
boolean bookmarkedByMe,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime updatedAt,
|
||||||
|
OffsetDateTime publishedAt,
|
||||||
|
int version) {
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package com.patbond.patbond.community.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.PositiveOrZero;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/posts/{postId}. Partial update: an absent (or null) field is
|
||||||
|
* left unchanged — no clearing back to null (M2 惯例). {@code version} is
|
||||||
|
* mandatory, it is the optimistic lock. {@code status} accepts only
|
||||||
|
* "published": draft→published is the single open state transition (发布即
|
||||||
|
* 状态迁移, no separate /publish endpoint); publishing an already-published
|
||||||
|
* post is a no-op. {@code media}, when present, replaces the whole set
|
||||||
|
* (整组替换).
|
||||||
|
*/
|
||||||
|
public class UpdatePostRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "version 不能为空")
|
||||||
|
@PositiveOrZero(message = "version 必须为非负整数")
|
||||||
|
private Integer version;
|
||||||
|
|
||||||
|
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Size(min = 1, max = 10000, message = "content 长度须在 1~10000 字符")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
private UUID petId;
|
||||||
|
|
||||||
|
@Pattern(regexp = "published", message = "status 仅支持 published(唯一开放的状态迁移)")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Size(max = 9, message = "media 最多 9 张图")
|
||||||
|
@Valid
|
||||||
|
private List<PostMediaAttachRequest> media;
|
||||||
|
|
||||||
|
public Integer getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVersion(Integer version) {
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategory(String category) {
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getPetId() {
|
||||||
|
return petId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPetId(UUID petId) {
|
||||||
|
this.petId = petId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PostMediaAttachRequest> getMedia() {
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMedia(List<PostMediaAttachRequest> media) {
|
||||||
|
this.media = media;
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.patbond.patbond.community.media;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-side subset of the media object-storage configuration (the write
|
||||||
|
* side — upload flow, whitelists — lives in patbond-user's MediaProperties).
|
||||||
|
* This service only signs GET URLs, a purely local SigV4 computation, so no
|
||||||
|
* bucket/HEAD client is needed. Values reuse the same PATBOND_MINIO_* /
|
||||||
|
* PATBOND_MEDIA_* environment variables as patbond-user, keeping one set of
|
||||||
|
* knobs per deployment (ADR-016/021).
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "patbond.media")
|
||||||
|
public class CommunityMediaProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint presigned GET URLs are issued against — the address CLIENTS
|
||||||
|
* can reach. Empty means media is unconfigured for this service:
|
||||||
|
* responses carry {@code url: null} (same degradation precedent as the
|
||||||
|
* missing JWT public key).
|
||||||
|
*/
|
||||||
|
private String publicEndpoint = "";
|
||||||
|
|
||||||
|
/** S3 access key; injected via environment, never committed (ADR-021). */
|
||||||
|
private String accessKey = "";
|
||||||
|
|
||||||
|
/** S3 secret key; injected via environment, never committed (ADR-021). */
|
||||||
|
private String secretKey = "";
|
||||||
|
|
||||||
|
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
|
||||||
|
private String region = "us-east-1";
|
||||||
|
|
||||||
|
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
|
||||||
|
private Duration downloadTtl = Duration.ofHours(1);
|
||||||
|
|
||||||
|
public String getPublicEndpoint() {
|
||||||
|
return publicEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPublicEndpoint(String publicEndpoint) {
|
||||||
|
this.publicEndpoint = publicEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAccessKey() {
|
||||||
|
return accessKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setter 形参名取 value:check-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
|
||||||
|
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
|
||||||
|
public void setAccessKey(String value) {
|
||||||
|
this.accessKey = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecretKey() {
|
||||||
|
return secretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecretKey(String value) {
|
||||||
|
this.secretKey = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRegion() {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRegion(String region) {
|
||||||
|
this.region = region;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Duration getDownloadTtl() {
|
||||||
|
return downloadTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDownloadTtl(Duration downloadTtl) {
|
||||||
|
this.downloadTtl = downloadTtl;
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.patbond.patbond.community.media;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only cross-schema access to media.assets — the community side of the
|
||||||
|
* T3-03 联调协议 (business references accept only assets owned by the caller
|
||||||
|
* with status='ready'). Same-database read was chosen over an internal HTTP
|
||||||
|
* call to patbond-user (ADR-017 precedent: author data is likewise a
|
||||||
|
* cross-schema read while the schemas share one database; splitting the
|
||||||
|
* database later moves both to internal APIs together). This class never
|
||||||
|
* writes media.assets — the media state machine belongs to patbond-user.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class MediaAssetGateway {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public MediaAssetGateway(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<UUID, MediaAssetRef> findByIds(Collection<UUID> ids) {
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT id, owner_user_id, status, bucket, object_key, width_px, height_px
|
||||||
|
FROM media.assets
|
||||||
|
WHERE id IN (:ids)
|
||||||
|
""")
|
||||||
|
.param("ids", List.copyOf(ids))
|
||||||
|
.query(MediaAssetGateway::mapRef)
|
||||||
|
.list()
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.toMap(MediaAssetRef::id, Function.identity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MediaAssetRef mapRef(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new MediaAssetRef(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("owner_user_id", UUID.class),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getString("bucket"),
|
||||||
|
rs.getString("object_key"),
|
||||||
|
rs.getObject("width_px", Integer.class),
|
||||||
|
rs.getObject("height_px", Integer.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.patbond.patbond.community.media;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view of one media.assets row — exactly the columns the post
|
||||||
|
* domain needs for attach validation and response URL signing.
|
||||||
|
*/
|
||||||
|
public record MediaAssetRef(
|
||||||
|
UUID id,
|
||||||
|
UUID ownerUserId,
|
||||||
|
String status,
|
||||||
|
String bucket,
|
||||||
|
String objectKey,
|
||||||
|
Integer widthPx,
|
||||||
|
Integer heightPx) {
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package com.patbond.patbond.community.media;
|
||||||
|
|
||||||
|
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||||
|
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||||
|
import software.amazon.awssdk.regions.Region;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs presigned GET URLs for media objects referenced by posts (T3-03
|
||||||
|
* 定型:private bucket + presigned GET, TTL configurable, signed fresh on
|
||||||
|
* every response — clients never persist the URL). Presigning is a local
|
||||||
|
* SigV4 computation against the public endpoint; this service never talks
|
||||||
|
* to the object store itself. Path-style addressing is forced because MinIO
|
||||||
|
* has no wildcard DNS for virtual-host-style buckets (same as
|
||||||
|
* patbond-user's S3ObjectStorage). When unconfigured, {@link #signGet}
|
||||||
|
* returns null and post responses degrade to {@code url: null}.
|
||||||
|
*/
|
||||||
|
public class MediaUrlSigner implements AutoCloseable {
|
||||||
|
|
||||||
|
private final CommunityMediaProperties properties;
|
||||||
|
private final S3Presigner presigner;
|
||||||
|
|
||||||
|
public MediaUrlSigner(CommunityMediaProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
if (properties.getPublicEndpoint().isBlank()) {
|
||||||
|
this.presigner = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.presigner = S3Presigner.builder()
|
||||||
|
.endpointOverride(URI.create(properties.getPublicEndpoint()))
|
||||||
|
.region(Region.of(properties.getRegion()))
|
||||||
|
.credentialsProvider(StaticCredentialsProvider.create(
|
||||||
|
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
|
||||||
|
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return a presigned GET URL, or null when storage is unconfigured */
|
||||||
|
public String signGet(String bucket, String objectKey) {
|
||||||
|
if (presigner == null || bucket == null || objectKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||||
|
.signatureDuration(properties.getDownloadTtl())
|
||||||
|
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
|
||||||
|
.build())
|
||||||
|
.url()
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (presigner != null) {
|
||||||
|
presigner.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package com.patbond.patbond.community.repository;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.support.CommentCursor;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* community.comments access. Post visibility and authorship decisions live
|
||||||
|
* in CommentService; every query here filters on the comment's own state
|
||||||
|
* only (status='visible' is the single liveness predicate — 'hidden' has no
|
||||||
|
* producing endpoint in M3 and 'deleted' pairs with deleted_at,
|
||||||
|
* ck_comments_deleted).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class CommentRepository {
|
||||||
|
|
||||||
|
private static final String SELECT_COMMENT = """
|
||||||
|
SELECT c.id, c.post_id, c.author_user_id, c.reply_to_user_id, c.content,
|
||||||
|
c.status, c.request_hash, c.created_at, c.deleted_at
|
||||||
|
FROM community.comments c
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public CommentRepository(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one comment; the conflict target is the (author_user_id,
|
||||||
|
* client_request_id) unique constraint, so a keyed replay is a no-op and
|
||||||
|
* the caller settles retry-vs-mismatch on the stored request_hash
|
||||||
|
* (ADR-019, same shape as posts).
|
||||||
|
*
|
||||||
|
* @return rows inserted — 0 means this author already used the key
|
||||||
|
*/
|
||||||
|
public int insertComment(UUID id, UUID postId, UUID authorUserId, UUID replyToUserId,
|
||||||
|
String content, String clientRequestId, byte[] requestHash) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.comments
|
||||||
|
(id, post_id, author_user_id, reply_to_user_id, content,
|
||||||
|
client_request_id, request_hash)
|
||||||
|
VALUES (:id, :postId, :authorUserId, :replyToUserId, :content,
|
||||||
|
:clientRequestId, :requestHash)
|
||||||
|
ON CONFLICT (author_user_id, client_request_id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("replyToUserId", replyToUserId)
|
||||||
|
.param("content", content)
|
||||||
|
.param("clientRequestId", clientRequestId)
|
||||||
|
.param("requestHash", requestHash)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
|
||||||
|
public Optional<CommentRow> findByAuthorAndClientRequestId(UUID authorUserId,
|
||||||
|
String clientRequestId) {
|
||||||
|
return jdbcClient.sql(SELECT_COMMENT
|
||||||
|
+ " WHERE c.author_user_id = :authorUserId"
|
||||||
|
+ " AND c.client_request_id = :clientRequestId")
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("clientRequestId", clientRequestId)
|
||||||
|
.query(CommentRepository::mapComment)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the visible row for the delete transition: concurrent deletes
|
||||||
|
* of the same comment serialize here, so the status flip — and with it
|
||||||
|
* the comment_count decrement — happens exactly once.
|
||||||
|
*/
|
||||||
|
public Optional<CommentRow> lockVisibleById(UUID id) {
|
||||||
|
return jdbcClient.sql(SELECT_COMMENT + " WHERE c.id = :id AND c.status = 'visible' FOR UPDATE")
|
||||||
|
.param("id", id)
|
||||||
|
.query(CommentRepository::mapComment)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The soft-delete transition; deleted_at pairs with status (ck_comments_deleted). */
|
||||||
|
public int softDelete(UUID id) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.comments
|
||||||
|
SET status = 'deleted', deleted_at = now()
|
||||||
|
WHERE id = :id AND status = 'visible'
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of a post's visible comments in (created_at DESC, id DESC) —
|
||||||
|
* the exact key of ix_comments_post_created. The caller asks for
|
||||||
|
* limit+1 rows to learn whether more exist.
|
||||||
|
*/
|
||||||
|
public List<CommentRow> pageByPost(UUID postId, CommentCursor after, int limitPlusOne) {
|
||||||
|
String sql = SELECT_COMMENT + " WHERE c.post_id = :postId AND c.status = 'visible'";
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (c.created_at, c.id) < (:cursorCreatedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY c.created_at DESC, c.id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorCreatedAt", after.createdAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(CommentRepository::mapComment).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommentRow mapComment(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new CommentRow(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("post_id", UUID.class),
|
||||||
|
rs.getObject("author_user_id", UUID.class),
|
||||||
|
rs.getObject("reply_to_user_id", UUID.class),
|
||||||
|
rs.getString("content"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getBytes("request_hash"),
|
||||||
|
rs.getObject("created_at", OffsetDateTime.class),
|
||||||
|
rs.getObject("deleted_at", OffsetDateTime.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One comments row; requestHash carries the ADR-019 replay comparison. */
|
||||||
|
public record CommentRow(
|
||||||
|
UUID id,
|
||||||
|
UUID postId,
|
||||||
|
UUID authorUserId,
|
||||||
|
UUID replyToUserId,
|
||||||
|
String content,
|
||||||
|
String status,
|
||||||
|
byte[] requestHash,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime deletedAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
package com.patbond.patbond.community.repository;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* community.post_likes / post_bookmarks / user_follows access, plus the
|
||||||
|
* denormalized counter writes on community.posts. The invariant every
|
||||||
|
* caller must hold (工单验收硬项): a counter column moves IN THE SAME
|
||||||
|
* TRANSACTION as its relation row, and only by the number of rows the
|
||||||
|
* relation write actually changed — {@code ON CONFLICT DO NOTHING} inserts
|
||||||
|
* and conditional deletes report that number, so concurrent duplicates
|
||||||
|
* converge on the composite primary key and never double-count.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class InteractionRepository {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public InteractionRepository(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interaction gate: likes, bookmarks and comments attach to the
|
||||||
|
* PUBLIC face of a post only — published and live. Drafts (the
|
||||||
|
* author's own included), hidden/archived and soft-deleted posts all
|
||||||
|
* fail this probe and answer the byte-identical 404/40403.
|
||||||
|
*/
|
||||||
|
public boolean isInteractable(UUID postId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT EXISTS (SELECT 1 FROM community.posts
|
||||||
|
WHERE id = :id AND status = 'published'
|
||||||
|
AND deleted_at IS NULL)
|
||||||
|
""")
|
||||||
|
.param("id", postId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows inserted — 0 when the like already existed */
|
||||||
|
public int insertLike(UUID postId, UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.post_likes (post_id, user_id)
|
||||||
|
VALUES (:postId, :userId)
|
||||||
|
ON CONFLICT (post_id, user_id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("userId", userId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows deleted — 0 when there was nothing to cancel */
|
||||||
|
public int deleteLike(UUID postId, UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
DELETE FROM community.post_likes
|
||||||
|
WHERE post_id = :postId AND user_id = :userId
|
||||||
|
""")
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("userId", userId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows inserted — 0 when the bookmark already existed */
|
||||||
|
public int insertBookmark(UUID postId, UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.post_bookmarks (post_id, user_id)
|
||||||
|
VALUES (:postId, :userId)
|
||||||
|
ON CONFLICT (post_id, user_id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("userId", userId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows deleted — 0 when there was nothing to cancel */
|
||||||
|
public int deleteBookmark(UUID postId, UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
DELETE FROM community.post_bookmarks
|
||||||
|
WHERE post_id = :postId AND user_id = :userId
|
||||||
|
""")
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("userId", userId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves like_count by delta and returns the resulting value — the
|
||||||
|
* authoritative count the write response carries. Callers pass the row
|
||||||
|
* count their relation write reported; a zero delta must instead read
|
||||||
|
* via {@link #likeCount} so a no-op replay takes no row lock and does
|
||||||
|
* not touch updated_at.
|
||||||
|
*/
|
||||||
|
public long bumpLikeCount(UUID postId, int delta) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.posts SET like_count = like_count + :delta
|
||||||
|
WHERE id = :id
|
||||||
|
RETURNING like_count
|
||||||
|
""")
|
||||||
|
.param("id", postId)
|
||||||
|
.param("delta", delta)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long bumpBookmarkCount(UUID postId, int delta) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.posts SET bookmark_count = bookmark_count + :delta
|
||||||
|
WHERE id = :id
|
||||||
|
RETURNING bookmark_count
|
||||||
|
""")
|
||||||
|
.param("id", postId)
|
||||||
|
.param("delta", delta)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long bumpCommentCount(UUID postId, int delta) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.posts SET comment_count = comment_count + :delta
|
||||||
|
WHERE id = :id
|
||||||
|
RETURNING comment_count
|
||||||
|
""")
|
||||||
|
.param("id", postId)
|
||||||
|
.param("delta", delta)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long likeCount(UUID postId) {
|
||||||
|
return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id")
|
||||||
|
.param("id", postId)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long bookmarkCount(UUID postId) {
|
||||||
|
return jdbcClient.sql("SELECT bookmark_count FROM community.posts WHERE id = :id")
|
||||||
|
.param("id", postId)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows inserted — 0 when the follow already existed */
|
||||||
|
public int insertFollow(UUID followerUserId, UUID followeeUserId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.user_follows (follower_user_id, followee_user_id)
|
||||||
|
VALUES (:follower, :followee)
|
||||||
|
ON CONFLICT (follower_user_id, followee_user_id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("follower", followerUserId)
|
||||||
|
.param("followee", followeeUserId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return rows deleted — 0 when there was nothing to cancel */
|
||||||
|
public int deleteFollow(UUID followerUserId, UUID followeeUserId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
DELETE FROM community.user_follows
|
||||||
|
WHERE follower_user_id = :follower AND followee_user_id = :followee
|
||||||
|
""")
|
||||||
|
.param("follower", followerUserId)
|
||||||
|
.param("followee", followeeUserId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real-time follower count of a user — ix_user_follows_followee. */
|
||||||
|
public long countFollowers(UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT count(*) FROM community.user_follows
|
||||||
|
WHERE followee_user_id = :userId
|
||||||
|
""")
|
||||||
|
.param("userId", userId)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real-time following count of a user — the primary key prefix. */
|
||||||
|
public long countFollowing(UUID userId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT count(*) FROM community.user_follows
|
||||||
|
WHERE follower_user_id = :userId
|
||||||
|
""")
|
||||||
|
.param("userId", userId)
|
||||||
|
.query(Long.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean followExists(UUID followerUserId, UUID followeeUserId) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT EXISTS (SELECT 1 FROM community.user_follows
|
||||||
|
WHERE follower_user_id = :follower
|
||||||
|
AND followee_user_id = :followee)
|
||||||
|
""")
|
||||||
|
.param("follower", followerUserId)
|
||||||
|
.param("followee", followeeUserId)
|
||||||
|
.query(Boolean.class)
|
||||||
|
.single();
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
package com.patbond.patbond.community.repository;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.support.BookmarkCursor;
|
||||||
|
import com.patbond.patbond.community.support.FeedCursor;
|
||||||
|
import com.patbond.patbond.community.support.PostCursor;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* community.posts / community.post_media access. Visibility and authorship
|
||||||
|
* decisions live in PostService — every query here is still explicitly
|
||||||
|
* scoped (viewer-dependent flags are parameters, never session state).
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class PostRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full post projection: row columns plus the two viewer-relative
|
||||||
|
* flags, each a primary-key probe into its relation table (post_likes /
|
||||||
|
* post_bookmarks composite PKs), so no N+1 and no separate round trip.
|
||||||
|
*/
|
||||||
|
private static final String SELECT_POST = """
|
||||||
|
SELECT p.id, p.author_user_id, p.pet_id, p.category, p.title, p.content,
|
||||||
|
p.status, p.visibility, p.like_count, p.comment_count, p.bookmark_count,
|
||||||
|
p.created_at, p.updated_at, p.published_at, p.deleted_at, p.version, p.request_hash,
|
||||||
|
EXISTS (SELECT 1 FROM community.post_likes pl
|
||||||
|
WHERE pl.post_id = p.id AND pl.user_id = :viewerId) AS liked_by_me,
|
||||||
|
EXISTS (SELECT 1 FROM community.post_bookmarks pb
|
||||||
|
WHERE pb.post_id = p.id AND pb.user_id = :viewerId) AS bookmarked_by_me
|
||||||
|
FROM community.posts p
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public PostRepository(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one post; {@code ON CONFLICT ON CONSTRAINT
|
||||||
|
* uq_posts_author_idempotency DO NOTHING} makes a keyed replay a no-op —
|
||||||
|
* the caller then loads the first-write row by (author, key) and settles
|
||||||
|
* the retry-vs-mismatch question on request_hash (ADR-019).
|
||||||
|
*
|
||||||
|
* @return rows inserted — 0 means this author already used the key
|
||||||
|
*/
|
||||||
|
public int insertPost(UUID id, UUID authorUserId, UUID petId, String category, String title,
|
||||||
|
String content, String status, OffsetDateTime publishedAt,
|
||||||
|
String idempotencyKey, byte[] requestHash) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO community.posts
|
||||||
|
(id, author_user_id, pet_id, category, title, content, status,
|
||||||
|
published_at, idempotency_key, request_hash)
|
||||||
|
VALUES (:id, :authorUserId, :petId, :category, :title, :content, :status,
|
||||||
|
:publishedAt, :idempotencyKey, :requestHash)
|
||||||
|
ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("petId", petId)
|
||||||
|
.param("category", category)
|
||||||
|
.param("title", title)
|
||||||
|
.param("content", content)
|
||||||
|
.param("status", status)
|
||||||
|
.param("publishedAt", publishedAt)
|
||||||
|
.param("idempotencyKey", idempotencyKey)
|
||||||
|
.param("requestHash", requestHash)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
|
||||||
|
public Optional<PostRow> findByAuthorAndIdempotencyKey(UUID authorUserId, String idempotencyKey,
|
||||||
|
UUID viewerId) {
|
||||||
|
return jdbcClient.sql(SELECT_POST
|
||||||
|
+ " WHERE p.author_user_id = :authorUserId AND p.idempotency_key = :idempotencyKey")
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("idempotencyKey", idempotencyKey)
|
||||||
|
.param("viewerId", viewerId)
|
||||||
|
.query(PostRepository::mapPost)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any live (not soft-deleted) row by id; visibility is the service's call. */
|
||||||
|
public Optional<PostRow> findLiveById(UUID id, UUID viewerId) {
|
||||||
|
return jdbcClient.sql(SELECT_POST + " WHERE p.id = :id AND p.deleted_at IS NULL")
|
||||||
|
.param("id", id)
|
||||||
|
.param("viewerId", viewerId)
|
||||||
|
.query(PostRepository::mapPost)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the live row for a write (PATCH/DELETE): concurrent writers
|
||||||
|
* serialize here, so the later one sees the earlier one's version bump
|
||||||
|
* and fails its version condition deterministically.
|
||||||
|
*/
|
||||||
|
public Optional<LockedPost> lockLiveById(UUID id) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT id, author_user_id, pet_id, category, title, content, status,
|
||||||
|
published_at, version
|
||||||
|
FROM community.posts
|
||||||
|
WHERE id = :id AND deleted_at IS NULL
|
||||||
|
FOR UPDATE
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.query((rs, rowNum) -> new LockedPost(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("author_user_id", UUID.class),
|
||||||
|
rs.getObject("pet_id", UUID.class),
|
||||||
|
rs.getString("category"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("content"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getObject("published_at", OffsetDateTime.class),
|
||||||
|
rs.getInt("version")))
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optimistic-locked merge write: one conditional UPDATE, version
|
||||||
|
* bumped only when the expected version still stands.
|
||||||
|
*
|
||||||
|
* @return rows updated — 0 means the version went stale
|
||||||
|
*/
|
||||||
|
public int updatePost(UUID id, int expectedVersion, UUID petId, String category, String title,
|
||||||
|
String content, String status, OffsetDateTime publishedAt) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.posts
|
||||||
|
SET pet_id = :petId, category = :category, title = :title,
|
||||||
|
content = :content, status = :status, published_at = :publishedAt,
|
||||||
|
version = version + 1
|
||||||
|
WHERE id = :id AND deleted_at IS NULL AND version = :expectedVersion
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("expectedVersion", expectedVersion)
|
||||||
|
.param("petId", petId)
|
||||||
|
.param("category", category)
|
||||||
|
.param("title", title)
|
||||||
|
.param("content", content)
|
||||||
|
.param("status", status)
|
||||||
|
.param("publishedAt", publishedAt)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete (deleted_at is THE deletion marker everywhere). A deleted
|
||||||
|
* published post must also leave status='published' to satisfy
|
||||||
|
* ck_posts_publish_state, so it is parked as 'archived'; drafts keep
|
||||||
|
* their status. Once deleted the post answers 404 on every read path,
|
||||||
|
* so the parked status is internal bookkeeping only (D3-7 定型).
|
||||||
|
*/
|
||||||
|
public int softDelete(UUID id) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
UPDATE community.posts
|
||||||
|
SET deleted_at = now(),
|
||||||
|
status = CASE WHEN status = 'published' THEN 'archived' ELSE status END,
|
||||||
|
version = version + 1
|
||||||
|
WHERE id = :id AND deleted_at IS NULL
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the author's own posts in (created_at DESC, id DESC) — the
|
||||||
|
* exact key of ix_posts_author_created. Soft-deleted rows never appear;
|
||||||
|
* hidden/archived are excluded (the contract exposes draft/published
|
||||||
|
* only). The caller asks for limit+1 rows to learn whether more exist.
|
||||||
|
*/
|
||||||
|
public List<PostRow> pageByAuthor(UUID authorUserId, String statusFilter, PostCursor after,
|
||||||
|
int limitPlusOne) {
|
||||||
|
String sql = SELECT_POST + """
|
||||||
|
WHERE p.author_user_id = :authorUserId AND p.deleted_at IS NULL
|
||||||
|
AND p.status IN ('draft', 'published')
|
||||||
|
""";
|
||||||
|
if (statusFilter != null) {
|
||||||
|
sql += " AND p.status = :statusFilter";
|
||||||
|
}
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (p.created_at, p.id) < (:cursorCreatedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY p.created_at DESC, p.id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("authorUserId", authorUserId)
|
||||||
|
.param("viewerId", authorUserId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (statusFilter != null) {
|
||||||
|
spec = spec.param("statusFilter", statusFilter);
|
||||||
|
}
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorCreatedAt", after.createdAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(PostRepository::mapPost).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One public-feed page in (published_at DESC, id DESC) — the exact key
|
||||||
|
* and predicate of the ix_posts_feed partial index. The explicit
|
||||||
|
* {@code deleted_at IS NULL} is belt-and-braces: softDelete parks
|
||||||
|
* published rows as 'archived', so status='published' already implies
|
||||||
|
* live (ck_posts_publish_state), and the planner still matches the
|
||||||
|
* partial index. The caller asks for limit+1 rows to learn whether more
|
||||||
|
* exist.
|
||||||
|
*/
|
||||||
|
public List<PostRow> pageFeed(UUID viewerId, FeedCursor after, int limitPlusOne) {
|
||||||
|
String sql = SELECT_POST + """
|
||||||
|
WHERE p.status = 'published' AND p.visibility = 'public'
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
""";
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (p.published_at, p.id) < (:cursorPublishedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY p.published_at DESC, p.id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("viewerId", viewerId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorPublishedAt", after.publishedAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(PostRepository::mapPost).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One my-bookmarks page in (bookmarks.created_at DESC, post_id DESC) —
|
||||||
|
* the exact key of ix_post_bookmarks_user_created. Bookmarked posts
|
||||||
|
* that turned invisible (deleted, hidden/archived, non-public) are
|
||||||
|
* filtered INSIDE the keyset query(草案「静默剔除」定型): the cursor
|
||||||
|
* keys on the relation row, so dropped posts cost nothing to
|
||||||
|
* pagination correctness. The caller asks for limit+1 rows to learn
|
||||||
|
* whether more exist.
|
||||||
|
*/
|
||||||
|
public List<BookmarkedPostRow> pageBookmarked(UUID userId, BookmarkCursor after,
|
||||||
|
int limitPlusOne) {
|
||||||
|
String sql = """
|
||||||
|
SELECT p.id, p.author_user_id, p.pet_id, p.category, p.title, p.content,
|
||||||
|
p.status, p.visibility, p.like_count, p.comment_count, p.bookmark_count,
|
||||||
|
p.created_at, p.updated_at, p.published_at, p.deleted_at, p.version, p.request_hash,
|
||||||
|
EXISTS (SELECT 1 FROM community.post_likes pl
|
||||||
|
WHERE pl.post_id = p.id AND pl.user_id = :viewerId) AS liked_by_me,
|
||||||
|
true AS bookmarked_by_me,
|
||||||
|
b.created_at AS bookmarked_at
|
||||||
|
FROM community.post_bookmarks b
|
||||||
|
JOIN community.posts p ON p.id = b.post_id
|
||||||
|
WHERE b.user_id = :viewerId
|
||||||
|
AND p.status = 'published' AND p.visibility = 'public' AND p.deleted_at IS NULL
|
||||||
|
""";
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (b.created_at, b.post_id) < (:cursorBookmarkedAt, :cursorPostId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY b.created_at DESC, b.post_id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("viewerId", userId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorBookmarkedAt", after.bookmarkedAt())
|
||||||
|
.param("cursorPostId", after.postId());
|
||||||
|
}
|
||||||
|
return spec.query((rs, rowNum) -> new BookmarkedPostRow(
|
||||||
|
mapPost(rs, rowNum),
|
||||||
|
rs.getObject("bookmarked_at", OffsetDateTime.class))).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
|
||||||
|
jdbcClient.sql("""
|
||||||
|
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
|
||||||
|
VALUES (:postId, :position, :assetId, :isCover, :caption)
|
||||||
|
""")
|
||||||
|
.param("postId", postId)
|
||||||
|
.param("position", position)
|
||||||
|
.param("assetId", assetId)
|
||||||
|
.param("isCover", isCover)
|
||||||
|
.param("caption", caption)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-set replacement (PATCH media 整组替换): clear, then re-insert. */
|
||||||
|
public void deleteMedia(UUID postId) {
|
||||||
|
jdbcClient.sql("DELETE FROM community.post_media WHERE post_id = :postId")
|
||||||
|
.param("postId", postId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media of many posts in one query (position order within each post),
|
||||||
|
* joined with media.assets for the response-side url/dimension fields.
|
||||||
|
*/
|
||||||
|
public List<PostMediaRow> findMediaByPostIds(Collection<UUID> postIds) {
|
||||||
|
if (postIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT pm.post_id, pm.position, pm.asset_id, pm.is_cover, pm.caption,
|
||||||
|
a.bucket, a.object_key, a.width_px, a.height_px
|
||||||
|
FROM community.post_media pm
|
||||||
|
JOIN media.assets a ON a.id = pm.asset_id
|
||||||
|
WHERE pm.post_id IN (:postIds)
|
||||||
|
ORDER BY pm.post_id, pm.position
|
||||||
|
""")
|
||||||
|
.param("postIds", List.copyOf(postIds))
|
||||||
|
.query((rs, rowNum) -> new PostMediaRow(
|
||||||
|
rs.getObject("post_id", UUID.class),
|
||||||
|
rs.getInt("position"),
|
||||||
|
rs.getObject("asset_id", UUID.class),
|
||||||
|
rs.getBoolean("is_cover"),
|
||||||
|
rs.getString("caption"),
|
||||||
|
rs.getString("bucket"),
|
||||||
|
rs.getString("object_key"),
|
||||||
|
rs.getObject("width_px", Integer.class),
|
||||||
|
rs.getObject("height_px", Integer.class)))
|
||||||
|
.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PostRow mapPost(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new PostRow(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("author_user_id", UUID.class),
|
||||||
|
rs.getObject("pet_id", UUID.class),
|
||||||
|
rs.getString("category"),
|
||||||
|
rs.getString("title"),
|
||||||
|
rs.getString("content"),
|
||||||
|
rs.getString("status"),
|
||||||
|
rs.getString("visibility"),
|
||||||
|
rs.getLong("like_count"),
|
||||||
|
rs.getLong("comment_count"),
|
||||||
|
rs.getLong("bookmark_count"),
|
||||||
|
rs.getBoolean("liked_by_me"),
|
||||||
|
rs.getBoolean("bookmarked_by_me"),
|
||||||
|
rs.getObject("created_at", OffsetDateTime.class),
|
||||||
|
rs.getObject("updated_at", OffsetDateTime.class),
|
||||||
|
rs.getObject("published_at", OffsetDateTime.class),
|
||||||
|
rs.getObject("deleted_at", OffsetDateTime.class),
|
||||||
|
rs.getInt("version"),
|
||||||
|
rs.getBytes("request_hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full projection of one post as seen by a given viewer. */
|
||||||
|
public record PostRow(
|
||||||
|
UUID id,
|
||||||
|
UUID authorUserId,
|
||||||
|
UUID petId,
|
||||||
|
String category,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
String status,
|
||||||
|
String visibility,
|
||||||
|
long likeCount,
|
||||||
|
long commentCount,
|
||||||
|
long bookmarkCount,
|
||||||
|
boolean likedByMe,
|
||||||
|
boolean bookmarkedByMe,
|
||||||
|
OffsetDateTime createdAt,
|
||||||
|
OffsetDateTime updatedAt,
|
||||||
|
OffsetDateTime publishedAt,
|
||||||
|
OffsetDateTime deletedAt,
|
||||||
|
int version,
|
||||||
|
byte[] requestHash) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row image under FOR UPDATE, the merge base of a PATCH. */
|
||||||
|
public record LockedPost(
|
||||||
|
UUID id,
|
||||||
|
UUID authorUserId,
|
||||||
|
UUID petId,
|
||||||
|
String category,
|
||||||
|
String title,
|
||||||
|
String content,
|
||||||
|
String status,
|
||||||
|
OffsetDateTime publishedAt,
|
||||||
|
int version) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One post_media row joined with its asset's storage location. */
|
||||||
|
public record PostMediaRow(
|
||||||
|
UUID postId,
|
||||||
|
int position,
|
||||||
|
UUID assetId,
|
||||||
|
boolean isCover,
|
||||||
|
String caption,
|
||||||
|
String bucket,
|
||||||
|
String objectKey,
|
||||||
|
Integer widthPx,
|
||||||
|
Integer heightPx) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bookmarked post plus the relation row's timestamp (the page key). */
|
||||||
|
public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer authentication for /api/v1/** community endpoints. Verifies the
|
||||||
|
* RS256 signature locally with the auth service's public key and exposes the
|
||||||
|
* authenticated user id as a request attribute. Missing, forged or expired
|
||||||
|
* tokens all answer 401/40101 without detail. Third copy of the user/pet
|
||||||
|
* filter — sinking the shared pure-Java parts into patbond-common stays a
|
||||||
|
* separate decision (iteration-3/02 P9, not ratified in ADR-016~021).
|
||||||
|
*/
|
||||||
|
public class BearerAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
/** Request attribute holding the authenticated user's UUID. */
|
||||||
|
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
|
||||||
|
|
||||||
|
private final JwtVerifier jwtVerifier;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||||
|
this.jwtVerifier = jwtVerifier;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return !request.getRequestURI().startsWith("/api/v1/");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header == null || !header.startsWith("Bearer ")) {
|
||||||
|
reject(response, ErrorCode.TOKEN_INVALID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
|
||||||
|
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
reject(response, ErrorCode.TOKEN_INVALID);
|
||||||
|
return;
|
||||||
|
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||||
|
log.error("Access token verification unavailable: {}", e.getMessage());
|
||||||
|
reject(response, ErrorCode.INTERNAL_ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
|
||||||
|
response.setStatus(errorCode.getHttpStatus());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
objectMapper.writeValue(response.getWriter(),
|
||||||
|
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies RS256 access tokens issued by patbond-auth against the configured
|
||||||
|
* public key. The key is optional at startup so contexts that never serve
|
||||||
|
* protected routes (most tests) can boot without one; any verification
|
||||||
|
* attempt without a key fails loudly as a server misconfiguration instead of
|
||||||
|
* being reported to the client as an authentication problem.
|
||||||
|
*/
|
||||||
|
public class JwtVerifier {
|
||||||
|
|
||||||
|
private final JwtParser parser;
|
||||||
|
|
||||||
|
public JwtVerifier(String publicKeyLocation) {
|
||||||
|
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
|
||||||
|
? null
|
||||||
|
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the verified claims
|
||||||
|
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||||
|
*/
|
||||||
|
public Claims verify(String token) {
|
||||||
|
if (parser == null) {
|
||||||
|
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.patbond.patbond.community.security;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads an RSA public key for JWT signature verification. Accepts either
|
||||||
|
* inline PEM or a filesystem path; the inline form (environment variable
|
||||||
|
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
|
||||||
|
* because mounted secrets beat files-in-the-image.
|
||||||
|
*/
|
||||||
|
public final class RsaPublicKeyLoader {
|
||||||
|
|
||||||
|
private RsaPublicKeyLoader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PublicKey load(String pemOrPath) {
|
||||||
|
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
|
||||||
|
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
|
||||||
|
.replaceAll("\\s", "");
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(stripped);
|
||||||
|
try {
|
||||||
|
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||||
|
throw new IllegalArgumentException("Invalid RSA public key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readFile(String path) {
|
||||||
|
try {
|
||||||
|
return Files.readString(Path.of(path));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalArgumentException("Cannot read public key from " + path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
package com.patbond.patbond.community.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.community.access.UserExistenceGateway;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileGateway;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CommentResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreateCommentRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.repository.CommentRepository;
|
||||||
|
import com.patbond.patbond.community.repository.CommentRepository.CommentRow;
|
||||||
|
import com.patbond.patbond.community.repository.InteractionRepository;
|
||||||
|
import com.patbond.patbond.community.support.CommentCursor;
|
||||||
|
import com.patbond.patbond.community.support.RequestHashes;
|
||||||
|
import com.patbond.patbond.community.support.UuidV7;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat comments (T3-07 定型). The semantics fixed here are T3-10 freeze
|
||||||
|
* input:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Interaction surface</b> — comments attach to the PUBLIC face of
|
||||||
|
* a post only: published and live. A draft (its author included),
|
||||||
|
* hidden/archived or soft-deleted post answers the byte-identical
|
||||||
|
* 404/40403 on every comment path — 互动域不区分「作者的草稿」.</li>
|
||||||
|
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory,
|
||||||
|
* stored as client_request_id next to the normalized request hash;
|
||||||
|
* same key + same payload returns the first comment (201 again),
|
||||||
|
* different payload 40905, keys scoped per author. A replay hitting
|
||||||
|
* a since-deleted first comment answers 404/40404 (T3-04 §2.4
|
||||||
|
* 同一先例).</li>
|
||||||
|
* <li><b>Delete</b> — the comment's author only(D3-7 拍板:帖主删他人
|
||||||
|
* 评论首版不做); a non-author on a visible comment gets 403/40301,
|
||||||
|
* everything invisible (absent, deleted, its post invisible) merges
|
||||||
|
* into 404/40404. comment_count moves -1 in the same transaction,
|
||||||
|
* exactly once — the FOR UPDATE lock serializes double deletes.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CommentService {
|
||||||
|
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
private final InteractionRepository interactionRepository;
|
||||||
|
private final UserExistenceGateway userExistenceGateway;
|
||||||
|
private final AuthorProfileGateway authorProfileGateway;
|
||||||
|
|
||||||
|
public CommentService(CommentRepository commentRepository,
|
||||||
|
InteractionRepository interactionRepository,
|
||||||
|
UserExistenceGateway userExistenceGateway,
|
||||||
|
AuthorProfileGateway authorProfileGateway) {
|
||||||
|
this.commentRepository = commentRepository;
|
||||||
|
this.interactionRepository = interactionRepository;
|
||||||
|
this.userExistenceGateway = userExistenceGateway;
|
||||||
|
this.authorProfileGateway = authorProfileGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CursorPage<CommentResponse> list(UUID postId, int limit, String cursor) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
CommentCursor after = cursor == null ? null : CommentCursor.decode(cursor);
|
||||||
|
List<CommentRow> rows = commentRepository.pageByPost(postId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<CommentRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new CommentCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assemble(page), nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CommentResponse create(UUID userId, UUID postId, String idempotencyKey,
|
||||||
|
CreateCommentRequest request) {
|
||||||
|
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||||
|
String content = requireContent(request.getContent());
|
||||||
|
requireInteractable(postId);
|
||||||
|
if (request.getReplyToUserId() != null
|
||||||
|
&& !userExistenceGateway.existsActive(request.getReplyToUserId())) {
|
||||||
|
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] requestHash = RequestHashes.sha256(
|
||||||
|
canonicalize(postId, content, request.getReplyToUserId()));
|
||||||
|
UUID id = UuidV7.generate();
|
||||||
|
int inserted = commentRepository.insertComment(id, postId, userId,
|
||||||
|
request.getReplyToUserId(), content, key, requestHash);
|
||||||
|
if (inserted == 0) {
|
||||||
|
CommentRow first = commentRepository.findByAuthorAndClientRequestId(userId, key)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
if (!Arrays.equals(first.requestHash(), requestHash)) {
|
||||||
|
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
|
||||||
|
}
|
||||||
|
if (first.deletedAt() != null) {
|
||||||
|
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return assemble(List.of(first)).get(0);
|
||||||
|
}
|
||||||
|
interactionRepository.bumpCommentCount(postId, 1);
|
||||||
|
CommentRow row = commentRepository.lockVisibleById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
return assemble(List.of(row)).get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID userId, UUID commentId) {
|
||||||
|
CommentRow comment = commentRepository.lockVisibleById(commentId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND));
|
||||||
|
if (!interactionRepository.isInteractable(comment.postId())) {
|
||||||
|
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!comment.authorUserId().equals(userId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
|
||||||
|
}
|
||||||
|
commentRepository.softDelete(commentId);
|
||||||
|
interactionRepository.bumpCommentCount(comment.postId(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireInteractable(UUID postId) {
|
||||||
|
if (!interactionRepository.isInteractable(postId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeIdempotencyKey(String idempotencyKey) {
|
||||||
|
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
|
||||||
|
if (key.isEmpty() || key.length() > 128) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Idempotency-Key 必带且长度须在 1~128 字符");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireContent(String content) {
|
||||||
|
String trimmed = content == null ? "" : content.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.length() > 2000) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~2000 字符");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
|
||||||
|
private static String canonicalize(UUID postId, String content, UUID replyToUserId) {
|
||||||
|
return "comment.v1\n" + postId + '\n'
|
||||||
|
+ (replyToUserId == null ? "" : replyToUserId) + '\n'
|
||||||
|
+ content + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CommentResponse> assemble(List<CommentRow> rows) {
|
||||||
|
Set<UUID> userIds = new HashSet<>();
|
||||||
|
for (CommentRow row : rows) {
|
||||||
|
userIds.add(row.authorUserId());
|
||||||
|
if (row.replyToUserId() != null) {
|
||||||
|
userIds.add(row.replyToUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<UUID, AuthorSummaryResponse> profiles = authorProfileGateway.summarize(userIds);
|
||||||
|
return rows.stream().map(row -> new CommentResponse(
|
||||||
|
row.id(),
|
||||||
|
row.postId(),
|
||||||
|
profiles.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
|
row.replyToUserId() == null ? null
|
||||||
|
: profiles.getOrDefault(row.replyToUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.replyToUserId())),
|
||||||
|
row.content(),
|
||||||
|
row.createdAt())).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package com.patbond.patbond.community.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileGateway;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.FeedCardResponse;
|
||||||
|
import com.patbond.patbond.community.dto.PostMediaItemResponse;
|
||||||
|
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.BookmarkedPostRow;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.PostRow;
|
||||||
|
import com.patbond.patbond.community.support.BookmarkCursor;
|
||||||
|
import com.patbond.patbond.community.support.FeedCursor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public feed (T3-05): keyset pagination over the ix_posts_feed key
|
||||||
|
* (published_at DESC, id DESC), cards assembled from the posts row (counts
|
||||||
|
* come from the denormalized like/comment/bookmark_count columns — the
|
||||||
|
* writers of T3-06/T3-07 maintain them in the same transaction as the
|
||||||
|
* relation rows), the cover media item, the viewer's liked/bookmarked flags
|
||||||
|
* and the D3-9 author summary. Everything is batch: one page query, one
|
||||||
|
* media query, at most one profile call — no per-card work.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class FeedService {
|
||||||
|
|
||||||
|
/** Frozen preview rule: the first 200 Unicode code points, verbatim. */
|
||||||
|
static final int PREVIEW_CODE_POINTS = 200;
|
||||||
|
|
||||||
|
private final PostRepository postRepository;
|
||||||
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
|
private final AuthorProfileGateway authorProfileGateway;
|
||||||
|
|
||||||
|
public FeedService(PostRepository postRepository, MediaUrlSigner mediaUrlSigner,
|
||||||
|
AuthorProfileGateway authorProfileGateway) {
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
|
this.authorProfileGateway = authorProfileGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CursorPage<FeedCardResponse> list(UUID viewerId, int limit, String cursor) {
|
||||||
|
FeedCursor after = cursor == null ? null : FeedCursor.decode(cursor);
|
||||||
|
List<PostRow> rows = postRepository.pageFeed(viewerId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new FeedCursor(page.get(limit - 1).publishedAt(), page.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assembleCards(page), nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* My-bookmarks page (T3-07): the item IS the feed card(草案定型:项
|
||||||
|
* 形态复用 Feed 卡片), the order and cursor key on the bookmark
|
||||||
|
* relation row, and posts that turned invisible since bookmarking are
|
||||||
|
* silently dropped inside the page query — the same public-face
|
||||||
|
* predicate the feed uses, so a card here never breaks the
|
||||||
|
* publishedAt-non-null invariant.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CursorPage<FeedCardResponse> listBookmarked(UUID userId, int limit, String cursor) {
|
||||||
|
BookmarkCursor after = cursor == null ? null : BookmarkCursor.decode(cursor);
|
||||||
|
List<BookmarkedPostRow> rows = postRepository.pageBookmarked(userId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<BookmarkedPostRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new BookmarkCursor(page.get(limit - 1).bookmarkedAt(),
|
||||||
|
page.get(limit - 1).post().id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assembleCards(page.stream().map(BookmarkedPostRow::post).toList()),
|
||||||
|
nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FeedCardResponse> assembleCards(List<PostRow> rows) {
|
||||||
|
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
|
||||||
|
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.groupingBy(PostMediaRow::postId));
|
||||||
|
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
|
||||||
|
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
|
||||||
|
return rows.stream().map(row -> {
|
||||||
|
List<PostMediaRow> media = mediaByPost.getOrDefault(row.id(), List.of());
|
||||||
|
return new FeedCardResponse(
|
||||||
|
row.id(),
|
||||||
|
authors.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
|
row.category(),
|
||||||
|
row.title(),
|
||||||
|
preview(row.content()),
|
||||||
|
coverOf(media),
|
||||||
|
media.size(),
|
||||||
|
row.likeCount(),
|
||||||
|
row.commentCount(),
|
||||||
|
row.bookmarkCount(),
|
||||||
|
row.likedByMe(),
|
||||||
|
row.bookmarkedByMe(),
|
||||||
|
row.publishedAt());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The is_cover row (unique per post, and present whenever media exist —
|
||||||
|
* T3-04 §2.6 sets it on position 0 when the author picked none).
|
||||||
|
*/
|
||||||
|
private PostMediaItemResponse coverOf(List<PostMediaRow> media) {
|
||||||
|
return media.stream()
|
||||||
|
.filter(PostMediaRow::isCover)
|
||||||
|
.findFirst()
|
||||||
|
.map(m -> new PostMediaItemResponse(
|
||||||
|
m.assetId(),
|
||||||
|
m.position(),
|
||||||
|
m.isCover(),
|
||||||
|
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
|
||||||
|
m.widthPx(),
|
||||||
|
m.heightPx(),
|
||||||
|
m.caption()))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview = the first {@value #PREVIEW_CODE_POINTS} code points of the
|
||||||
|
* stored content, cut on a code-point boundary (no surrogate is ever
|
||||||
|
* split), no ellipsis appended — whether the card is a truncation is
|
||||||
|
* the client's call via {@code contentPreview.length} vs its own
|
||||||
|
* rendering, and the full text always comes from the detail endpoint.
|
||||||
|
*/
|
||||||
|
static String preview(String content) {
|
||||||
|
if (content.codePointCount(0, content.length()) <= PREVIEW_CODE_POINTS) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content.substring(0, content.offsetByCodePoints(0, PREVIEW_CODE_POINTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package com.patbond.patbond.community.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.community.access.UserExistenceGateway;
|
||||||
|
import com.patbond.patbond.community.dto.FollowStateResponse;
|
||||||
|
import com.patbond.patbond.community.dto.FollowStatsResponse;
|
||||||
|
import com.patbond.patbond.community.repository.InteractionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ADR-018 minimal follow surface: follow/unfollow (PUT/DELETE
|
||||||
|
* idempotent on the composite primary key, ADR-019) plus the follow-stats
|
||||||
|
* numbers. Counts are real-time COUNTs — user_follows carries no
|
||||||
|
* denormalized counters, and both directions ride an index. The target
|
||||||
|
* must be an existing active user (404/40406, absent and 注销 merged);
|
||||||
|
* following oneself is 422/42204 on PUT (ck_user_follows_self is the
|
||||||
|
* database backstop), while DELETE stays a plain idempotent no-op — a
|
||||||
|
* self-follow row cannot exist, so the authoritative false is the truth.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class FollowService {
|
||||||
|
|
||||||
|
private final InteractionRepository interactionRepository;
|
||||||
|
private final UserExistenceGateway userExistenceGateway;
|
||||||
|
|
||||||
|
public FollowService(InteractionRepository interactionRepository,
|
||||||
|
UserExistenceGateway userExistenceGateway) {
|
||||||
|
this.interactionRepository = interactionRepository;
|
||||||
|
this.userExistenceGateway = userExistenceGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public FollowStateResponse follow(UUID userId, UUID targetUserId) {
|
||||||
|
if (userId.equals(targetUserId)) {
|
||||||
|
throw new BusinessException(ErrorCode.FOLLOW_RULE_VIOLATION);
|
||||||
|
}
|
||||||
|
requireActive(targetUserId);
|
||||||
|
interactionRepository.insertFollow(userId, targetUserId);
|
||||||
|
return new FollowStateResponse(true, interactionRepository.countFollowers(targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public FollowStateResponse unfollow(UUID userId, UUID targetUserId) {
|
||||||
|
requireActive(targetUserId);
|
||||||
|
interactionRepository.deleteFollow(userId, targetUserId);
|
||||||
|
return new FollowStateResponse(false, interactionRepository.countFollowers(targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public FollowStatsResponse stats(UUID viewerId, UUID targetUserId) {
|
||||||
|
requireActive(targetUserId);
|
||||||
|
return new FollowStatsResponse(
|
||||||
|
interactionRepository.countFollowers(targetUserId),
|
||||||
|
interactionRepository.countFollowing(targetUserId),
|
||||||
|
interactionRepository.followExists(viewerId, targetUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireActive(UUID targetUserId) {
|
||||||
|
if (!userExistenceGateway.existsActive(targetUserId)) {
|
||||||
|
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package com.patbond.patbond.community.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.community.dto.BookmarkStateResponse;
|
||||||
|
import com.patbond.patbond.community.dto.LikeStateResponse;
|
||||||
|
import com.patbond.patbond.community.repository.InteractionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary post interactions (T3-06, ADR-019): PUT/DELETE are idempotent by
|
||||||
|
* construction — the relation row's composite primary key is the
|
||||||
|
* idempotency key, the counter column moves in the same transaction and
|
||||||
|
* only by the number of rows the relation write actually changed, so
|
||||||
|
* concurrent duplicates converge (N concurrent PUTs land exactly one row
|
||||||
|
* and exactly +1) and every response carries the authoritative terminal
|
||||||
|
* state. The interaction gate is the post's public face: anything not
|
||||||
|
* published-and-live answers the byte-identical 404/40403 on PUT and
|
||||||
|
* DELETE alike.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class InteractionService {
|
||||||
|
|
||||||
|
private final InteractionRepository interactionRepository;
|
||||||
|
|
||||||
|
public InteractionService(InteractionRepository interactionRepository) {
|
||||||
|
this.interactionRepository = interactionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LikeStateResponse like(UUID userId, UUID postId) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
int inserted = interactionRepository.insertLike(postId, userId);
|
||||||
|
long count = inserted > 0
|
||||||
|
? interactionRepository.bumpLikeCount(postId, inserted)
|
||||||
|
: interactionRepository.likeCount(postId);
|
||||||
|
return new LikeStateResponse(true, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LikeStateResponse unlike(UUID userId, UUID postId) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
int deleted = interactionRepository.deleteLike(postId, userId);
|
||||||
|
long count = deleted > 0
|
||||||
|
? interactionRepository.bumpLikeCount(postId, -deleted)
|
||||||
|
: interactionRepository.likeCount(postId);
|
||||||
|
return new LikeStateResponse(false, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BookmarkStateResponse bookmark(UUID userId, UUID postId) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
int inserted = interactionRepository.insertBookmark(postId, userId);
|
||||||
|
long count = inserted > 0
|
||||||
|
? interactionRepository.bumpBookmarkCount(postId, inserted)
|
||||||
|
: interactionRepository.bookmarkCount(postId);
|
||||||
|
return new BookmarkStateResponse(true, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public BookmarkStateResponse unbookmark(UUID userId, UUID postId) {
|
||||||
|
requireInteractable(postId);
|
||||||
|
int deleted = interactionRepository.deleteBookmark(postId, userId);
|
||||||
|
long count = deleted > 0
|
||||||
|
? interactionRepository.bumpBookmarkCount(postId, -deleted)
|
||||||
|
: interactionRepository.bookmarkCount(postId);
|
||||||
|
return new BookmarkStateResponse(false, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireInteractable(UUID postId) {
|
||||||
|
if (!interactionRepository.isInteractable(postId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
package com.patbond.patbond.community.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.community.access.PetVisibilityGateway;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileGateway;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CreatePostRequest;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
|
||||||
|
import com.patbond.patbond.community.dto.PostMediaItemResponse;
|
||||||
|
import com.patbond.patbond.community.dto.PostResponse;
|
||||||
|
import com.patbond.patbond.community.dto.UpdatePostRequest;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetGateway;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetRef;
|
||||||
|
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.LockedPost;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
|
||||||
|
import com.patbond.patbond.community.repository.PostRepository.PostRow;
|
||||||
|
import com.patbond.patbond.community.support.PostCursor;
|
||||||
|
import com.patbond.patbond.community.support.RequestHashes;
|
||||||
|
import com.patbond.patbond.community.support.UuidV7;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post lifecycle use-cases (T3-04): draft/edit/publish/soft-delete/detail/
|
||||||
|
* my-posts. The permission and error semantics implemented here are the
|
||||||
|
* T3-10 freeze input:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Visibility</b> — published is visible to every authenticated
|
||||||
|
* user; draft only to its author; hidden/archived (operational
|
||||||
|
* states, D3-7) and soft-deleted answer 404/40403 to EVERYONE, the
|
||||||
|
* author included. Every invisible case is byte-identical
|
||||||
|
* (anti-enumeration).</li>
|
||||||
|
* <li><b>403 vs 404</b> — 403/40301 goes only to callers the post is
|
||||||
|
* VISIBLE to (non-author PATCH/DELETE of a published post); anything
|
||||||
|
* invisible is 404/40403, never 403.</li>
|
||||||
|
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory;
|
||||||
|
* same key + same normalized payload returns the first write (201
|
||||||
|
* again), same key + different payload answers 409/40905, keys are
|
||||||
|
* scoped per author (two users may reuse a key).</li>
|
||||||
|
* <li><b>Publish</b> — a PATCH carrying {@code status: published}; the
|
||||||
|
* only open transition is draft→published (publishedAt written once);
|
||||||
|
* re-publishing a published post is a no-op. published→draft does not
|
||||||
|
* exist (the request enum rejects it as 40000).</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PostService {
|
||||||
|
|
||||||
|
private static final int MAX_MEDIA = 9;
|
||||||
|
|
||||||
|
private final PostRepository postRepository;
|
||||||
|
private final MediaAssetGateway mediaAssetGateway;
|
||||||
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
|
private final PetVisibilityGateway petVisibilityGateway;
|
||||||
|
private final AuthorProfileGateway authorProfileGateway;
|
||||||
|
|
||||||
|
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
|
||||||
|
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway,
|
||||||
|
AuthorProfileGateway authorProfileGateway) {
|
||||||
|
this.postRepository = postRepository;
|
||||||
|
this.mediaAssetGateway = mediaAssetGateway;
|
||||||
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
|
this.petVisibilityGateway = petVisibilityGateway;
|
||||||
|
this.authorProfileGateway = authorProfileGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PostResponse create(UUID userId, String idempotencyKey, CreatePostRequest request) {
|
||||||
|
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||||
|
String title = requireTitleOrNull(request.getTitle());
|
||||||
|
String content = requireContent(request.getContent());
|
||||||
|
String category = request.getCategory() == null ? "general" : request.getCategory();
|
||||||
|
String status = request.getStatus() == null ? "draft" : request.getStatus();
|
||||||
|
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
|
||||||
|
|
||||||
|
if (request.getPetId() != null) {
|
||||||
|
petVisibilityGateway.requireVisible(userId, request.getPetId());
|
||||||
|
}
|
||||||
|
validateAssets(userId, media);
|
||||||
|
|
||||||
|
byte[] requestHash = RequestHashes.sha256(
|
||||||
|
canonicalize(category, status, title, content, request.getPetId(), media));
|
||||||
|
|
||||||
|
UUID id = UuidV7.generate();
|
||||||
|
OffsetDateTime publishedAt = "published".equals(status) ? OffsetDateTime.now() : null;
|
||||||
|
int inserted = postRepository.insertPost(id, userId, request.getPetId(), category, title,
|
||||||
|
content, status, publishedAt, key, requestHash);
|
||||||
|
if (inserted == 0) {
|
||||||
|
// The author used this key before (or a concurrent retry won the
|
||||||
|
// race): settle retry-vs-mismatch on the stored request_hash.
|
||||||
|
PostRow first = postRepository.findByAuthorAndIdempotencyKey(userId, key, userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
if (!Arrays.equals(first.requestHash(), requestHash)) {
|
||||||
|
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
|
||||||
|
}
|
||||||
|
if (first.deletedAt() != null) {
|
||||||
|
// The first write was deleted meanwhile — the resource the
|
||||||
|
// retry asks about is gone, same anti-enumeration 404.
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return assembleOne(first);
|
||||||
|
}
|
||||||
|
for (NormalizedMedia item : media) {
|
||||||
|
postRepository.insertMedia(id, item.position(), item.assetId(), item.isCover(),
|
||||||
|
item.caption());
|
||||||
|
}
|
||||||
|
PostRow row = postRepository.findLiveById(id, userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
return assembleOne(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PostResponse get(UUID userId, UUID postId) {
|
||||||
|
PostRow row = postRepository.findLiveById(postId, userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
|
||||||
|
if (!visibleTo(row.status(), row.authorUserId(), userId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return assembleOne(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PostResponse update(UUID userId, UUID postId, UpdatePostRequest request) {
|
||||||
|
LockedPost current = requireAuthorEditable(userId, postId);
|
||||||
|
if (request.getVersion() != current.version()) {
|
||||||
|
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
String title = request.getTitle() != null
|
||||||
|
? requireTitleOrNull(request.getTitle())
|
||||||
|
: current.title();
|
||||||
|
String content = request.getContent() != null
|
||||||
|
? requireContent(request.getContent())
|
||||||
|
: current.content();
|
||||||
|
String category = request.getCategory() != null ? request.getCategory() : current.category();
|
||||||
|
UUID petId = current.petId();
|
||||||
|
if (request.getPetId() != null) {
|
||||||
|
petVisibilityGateway.requireVisible(userId, request.getPetId());
|
||||||
|
petId = request.getPetId();
|
||||||
|
}
|
||||||
|
|
||||||
|
String status = current.status();
|
||||||
|
OffsetDateTime publishedAt = current.publishedAt();
|
||||||
|
if ("published".equals(request.getStatus()) && "draft".equals(current.status())) {
|
||||||
|
// The single open transition: draft→published, publishedAt
|
||||||
|
// written exactly once (ck_posts_publish_state). Publishing an
|
||||||
|
// already-published post falls through as a no-op.
|
||||||
|
status = "published";
|
||||||
|
publishedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getMedia() != null) {
|
||||||
|
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
|
||||||
|
validateAssets(userId, media);
|
||||||
|
postRepository.deleteMedia(postId);
|
||||||
|
for (NormalizedMedia item : media) {
|
||||||
|
postRepository.insertMedia(postId, item.position(), item.assetId(), item.isCover(),
|
||||||
|
item.caption());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = postRepository.updatePost(postId, request.getVersion(), petId, category,
|
||||||
|
title, content, status, publishedAt);
|
||||||
|
if (updated == 0) {
|
||||||
|
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||||
|
}
|
||||||
|
PostRow row = postRepository.findLiveById(postId, userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
return assembleOne(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(UUID userId, UUID postId) {
|
||||||
|
requireAuthorEditable(userId, postId);
|
||||||
|
postRepository.softDelete(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CursorPage<PostResponse> listMine(UUID userId, String status, int limit, String cursor) {
|
||||||
|
if (status != null && !status.equals("draft") && !status.equals("published")) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "status 仅支持 draft/published");
|
||||||
|
}
|
||||||
|
PostCursor after = cursor == null ? null : PostCursor.decode(cursor);
|
||||||
|
List<PostRow> rows = postRepository.pageByAuthor(userId, status, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new PostCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assemble(page), nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared write gate of PATCH/DELETE: locks the live row, then walks
|
||||||
|
* the 403/404 boundary — invisible (absent, deleted, hidden/archived,
|
||||||
|
* someone else's draft) → 40403; visible but not the author's
|
||||||
|
* (published, someone else's) → 40301.
|
||||||
|
*/
|
||||||
|
private LockedPost requireAuthorEditable(UUID userId, UUID postId) {
|
||||||
|
LockedPost current = postRepository.lockLiveById(postId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
|
||||||
|
boolean visible = visibleTo(current.status(), current.authorUserId(), userId);
|
||||||
|
if (!visible) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!current.authorUserId().equals(userId)) {
|
||||||
|
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visibility matrix (T3-10 freeze input): published → everyone;
|
||||||
|
* draft → author only; hidden/archived → no one, the author included
|
||||||
|
* (no operational state leaks through the M3 contract, whose status
|
||||||
|
* enum stays [draft, published]).
|
||||||
|
*/
|
||||||
|
private static boolean visibleTo(String status, UUID authorUserId, UUID viewerId) {
|
||||||
|
return switch (status) {
|
||||||
|
case "published" -> true;
|
||||||
|
case "draft" -> authorUserId.equals(viewerId);
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- create-side normalization ----------
|
||||||
|
|
||||||
|
private static String normalizeIdempotencyKey(String idempotencyKey) {
|
||||||
|
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
|
||||||
|
if (key.isEmpty() || key.length() > 128) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Idempotency-Key 必带且长度须在 1~128 字符");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provided title must survive trimming (ck_posts_title width) — a
|
||||||
|
* whitespace-only title is a 40000, NOT a clear-to-null: PATCH does not
|
||||||
|
* support clearing optional fields back to null (M2 惯例), and create
|
||||||
|
* stays symmetric.
|
||||||
|
*/
|
||||||
|
private static String requireTitleOrNull(String title) {
|
||||||
|
if (title == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = title.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.length() > 120) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "title 长度须在 1~120 字符");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireContent(String content) {
|
||||||
|
String trimmed = content == null ? "" : content.trim();
|
||||||
|
if (trimmed.isEmpty() || trimmed.length() > 10000) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~10000 字符");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves positions and the cover flag: either every item names a
|
||||||
|
* position (together exactly 0..n-1) or none does (array order); at
|
||||||
|
* most one isCover=true (uq_post_media_cover), none → position 0 gets
|
||||||
|
* the flag (草案预设:全 false 服务端取 position 0).
|
||||||
|
*/
|
||||||
|
private static List<NormalizedMedia> normalizeMedia(List<PostMediaAttachRequest> requested) {
|
||||||
|
if (requested == null || requested.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
long withPosition = requested.stream().filter(m -> m.getPosition() != null).count();
|
||||||
|
if (withPosition != 0 && withPosition != requested.size()) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"media position 须全部提供或全部省略");
|
||||||
|
}
|
||||||
|
long covers = requested.stream().filter(m -> Boolean.TRUE.equals(m.getIsCover())).count();
|
||||||
|
if (covers > 1) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "isCover 至多一个");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NormalizedMedia> items = new ArrayList<>(requested.size());
|
||||||
|
Set<Integer> seenPositions = new HashSet<>();
|
||||||
|
Set<UUID> seenAssets = new HashSet<>();
|
||||||
|
for (int i = 0; i < requested.size(); i++) {
|
||||||
|
PostMediaAttachRequest m = requested.get(i);
|
||||||
|
int position = m.getPosition() != null ? m.getPosition() : i;
|
||||||
|
if (!seenPositions.add(position) || position >= requested.size()) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"media position 须为 0 起连续且不重复");
|
||||||
|
}
|
||||||
|
if (!seenAssets.add(m.getAssetId())) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 中 assetId 重复");
|
||||||
|
}
|
||||||
|
items.add(new NormalizedMedia(m.getAssetId(), position,
|
||||||
|
Boolean.TRUE.equals(m.getIsCover()), trimOrNull(m.getCaption())));
|
||||||
|
}
|
||||||
|
items.sort((a, b) -> Integer.compare(a.position(), b.position()));
|
||||||
|
if (covers == 0) {
|
||||||
|
items.set(0, items.get(0).asCover());
|
||||||
|
}
|
||||||
|
if (items.size() > MAX_MEDIA) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 最多 9 张图");
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The T3-03 联调协议 on the referencing side: an asset that does not
|
||||||
|
* exist, is not the caller's or is deleted answers 404/40405 (one merged
|
||||||
|
* anti-enumeration case); the caller's own asset in uploading/failed
|
||||||
|
* answers 422/42203.
|
||||||
|
*/
|
||||||
|
private void validateAssets(UUID userId, List<NormalizedMedia> media) {
|
||||||
|
if (media.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<UUID, MediaAssetRef> assets = mediaAssetGateway.findByIds(
|
||||||
|
media.stream().map(NormalizedMedia::assetId).collect(Collectors.toSet()));
|
||||||
|
for (NormalizedMedia item : media) {
|
||||||
|
MediaAssetRef ref = assets.get(item.assetId());
|
||||||
|
if (ref == null || !userId.equals(ref.ownerUserId()) || "deleted".equals(ref.status())) {
|
||||||
|
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!"ready".equals(ref.status())) {
|
||||||
|
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
|
||||||
|
private static String canonicalize(String category, String status, String title, String content,
|
||||||
|
UUID petId, List<NormalizedMedia> media) {
|
||||||
|
StringBuilder sb = new StringBuilder("post.v1\n")
|
||||||
|
.append(category).append('\n')
|
||||||
|
.append(status).append('\n')
|
||||||
|
.append(title == null ? "" : title).append('\n')
|
||||||
|
.append(content).append('\n')
|
||||||
|
.append(petId == null ? "" : petId).append('\n');
|
||||||
|
for (NormalizedMedia item : media) {
|
||||||
|
sb.append(item.assetId()).append(':').append(item.position()).append(':')
|
||||||
|
.append(item.isCover()).append(':')
|
||||||
|
.append(item.caption() == null ? "" : item.caption()).append('\n');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimOrNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- response assembly ----------
|
||||||
|
|
||||||
|
private PostResponse assembleOne(PostRow row) {
|
||||||
|
return assemble(List.of(row)).get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PostResponse> assemble(List<PostRow> rows) {
|
||||||
|
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
|
||||||
|
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.groupingBy(PostMediaRow::postId));
|
||||||
|
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
|
||||||
|
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
|
||||||
|
return rows.stream().map(row -> new PostResponse(
|
||||||
|
row.id(),
|
||||||
|
authors.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
|
row.petId(),
|
||||||
|
row.category(),
|
||||||
|
row.title(),
|
||||||
|
row.content(),
|
||||||
|
row.status(),
|
||||||
|
row.visibility(),
|
||||||
|
mediaByPost.getOrDefault(row.id(), List.of()).stream()
|
||||||
|
.map(m -> new PostMediaItemResponse(
|
||||||
|
m.assetId(),
|
||||||
|
m.position(),
|
||||||
|
m.isCover(),
|
||||||
|
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
|
||||||
|
m.widthPx(),
|
||||||
|
m.heightPx(),
|
||||||
|
m.caption()))
|
||||||
|
.toList(),
|
||||||
|
row.likeCount(),
|
||||||
|
row.commentCount(),
|
||||||
|
row.bookmarkCount(),
|
||||||
|
row.likedByMe(),
|
||||||
|
row.bookmarkedByMe(),
|
||||||
|
row.createdAt(),
|
||||||
|
row.updatedAt(),
|
||||||
|
row.publishedAt(),
|
||||||
|
row.version())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NormalizedMedia(UUID assetId, int position, boolean isCover, String caption) {
|
||||||
|
|
||||||
|
NormalizedMedia asCover() {
|
||||||
|
return new NormalizedMedia(assetId, position, true, caption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor of the my-bookmarks list (bookmarks.created_at DESC,
|
||||||
|
* post_id DESC — the exact key of ix_post_bookmarks_user_created). The key
|
||||||
|
* lives on the RELATION row, not the post: a bookmarked post that later
|
||||||
|
* turns invisible is filtered inside the same keyset query, so pages stay
|
||||||
|
* complete and the cursor never points at a value the client saw filtered.
|
||||||
|
* Encoding is the shared base64url("epochMicros:id") shape.
|
||||||
|
*/
|
||||||
|
public record BookmarkCursor(OffsetDateTime bookmarkedAt, UUID postId) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(bookmarkedAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ bookmarkedAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + postId).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static BookmarkCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID postId = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime bookmarkedAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new BookmarkCursor(bookmarkedAt, postId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor of a post's comment list (created_at DESC, id DESC — the
|
||||||
|
* exact key of ix_comments_post_created), same encoding as
|
||||||
|
* {@link PostCursor}: base64url("epochMicros:id"), next page selects
|
||||||
|
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by
|
||||||
|
* id and rows are neither lost nor repeated across page boundaries.
|
||||||
|
*/
|
||||||
|
public record CommentCursor(OffsetDateTime createdAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ createdAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static CommentCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime createdAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new CommentCursor(createdAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor of the public feed (published_at DESC, id DESC — the exact
|
||||||
|
* key of ix_posts_feed), same encoding as {@link PostCursor}:
|
||||||
|
* base64url("epochMicros:id"), next page selects
|
||||||
|
* {@code (published_at, id) < (cursor)} so ties on published_at are broken
|
||||||
|
* by id and rows are neither lost nor repeated across page boundaries.
|
||||||
|
* timestamptz carries microseconds, so the micros encoding is lossless.
|
||||||
|
*/
|
||||||
|
public record FeedCursor(OffsetDateTime publishedAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(publishedAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ publishedAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static FeedCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime publishedAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new FeedCursor(publishedAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor for the my-posts list (created_at DESC, id DESC — the exact
|
||||||
|
* key of ix_posts_author_created), isomorphic to patbond-pet's EventCursor:
|
||||||
|
* base64url("epochMicros:id"), next page selects
|
||||||
|
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by id
|
||||||
|
* and rows are neither lost nor repeated across page boundaries.
|
||||||
|
*/
|
||||||
|
public record PostCursor(OffsetDateTime createdAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ createdAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static PostCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime createdAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new PostCursor(createdAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 of the canonical form of a create request (ADR-019: creation-type
|
||||||
|
* writes carry a mandatory Idempotency-Key and the request body's hash is
|
||||||
|
* stored next to it — a keyed retry with the same payload returns the first
|
||||||
|
* write, a different payload answers 40905). Hashing the NORMALIZED command
|
||||||
|
* (trimmed fields, defaults applied, media positions resolved) rather than
|
||||||
|
* the raw bytes makes the comparison insensitive to JSON formatting while
|
||||||
|
* still catching every semantic difference. 32 bytes, matching
|
||||||
|
* ck_posts_idempotency's octet_length(request_hash) = 32.
|
||||||
|
*/
|
||||||
|
public final class RequestHashes {
|
||||||
|
|
||||||
|
private RequestHashes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] sha256(String canonical) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(canonical.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
|
||||||
|
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
|
||||||
|
* B-tree page churn low on uuid primary keys; the database DEFAULT
|
||||||
|
* gen_random_uuid() remains the fallback for rows not inserted through the
|
||||||
|
* application. Third copy after patbond-user/pet — the services deploy
|
||||||
|
* independently and patbond-common stays contract-only.
|
||||||
|
*/
|
||||||
|
public final class UuidV7 {
|
||||||
|
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private UuidV7() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UUID generate() {
|
||||||
|
long timestampMs = System.currentTimeMillis();
|
||||||
|
long randA = RANDOM.nextLong() & 0x0FFFL;
|
||||||
|
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
|
||||||
|
|
||||||
|
long msb = (timestampMs << 16) | 0x7000L | randA;
|
||||||
|
long lsb = 0x8000000000000000L | randB;
|
||||||
|
return new UUID(msb, lsb);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.patbond.patbond.community.web;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single place that turns exceptions into the {code, message, data} envelope
|
||||||
|
* with a matching HTTP status (development-plan 6.1) — same contract as the
|
||||||
|
* user/auth/pet handlers. Unexpected exceptions are logged in full but never
|
||||||
|
* leak internals to the client.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||||
|
return ResponseEntity.status(e.getHttpStatus())
|
||||||
|
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||||
|
String message = e.getBindingResult().getFieldErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(FieldError::getDefaultMessage)
|
||||||
|
.orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||||
|
return failure(ErrorCode.VALIDATION_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
|
||||||
|
ConstraintViolationException.class, HandlerMethodValidationException.class,
|
||||||
|
MissingRequestHeaderException.class})
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
|
||||||
|
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
|
||||||
|
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
|
||||||
|
log.error("Unhandled exception", e);
|
||||||
|
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
|
||||||
|
return ResponseEntity.status(errorCode.getHttpStatus())
|
||||||
|
.body(ApiResponse.failure(errorCode.getCode(), message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
server:
|
||||||
|
port: ${PATBOND_COMMUNITY_PORT:8084}
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: patbond-community
|
||||||
|
datasource:
|
||||||
|
# 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 community schema
|
||||||
|
# (作者公开资料按 D3-9 方案 B 走 user 的 /internal 批量接口,后续波次落地)。
|
||||||
|
# Flyway 迁移链(V1..V5,含 community 基线)由 patbond-user 启动时统一执行,
|
||||||
|
# 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆。
|
||||||
|
url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond}
|
||||||
|
username: ${PATBOND_DB_USER:patbond}
|
||||||
|
password: ${PATBOND_DB_PASSWORD:patbond}
|
||||||
|
|
||||||
|
# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 patbond-user/pet 同一约定。
|
||||||
|
patbond:
|
||||||
|
jwt:
|
||||||
|
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
|
||||||
|
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||||
|
# 私钥只给 patbond-auth,绝不入库。
|
||||||
|
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||||
|
# 作者公开资料来源(D3-9 方案 B):patbond-user 的 /internal 批量接口,
|
||||||
|
# ADR-002 静态直连。不可达时 Feed/详情照常返回,作者摘要降级为仅 userId。
|
||||||
|
user-service:
|
||||||
|
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
|
||||||
|
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
|
||||||
|
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
|
||||||
|
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
|
||||||
|
author-profile:
|
||||||
|
# 作者公开资料的进程内缓存 TTL:昵称/头像变更最迟一分钟可见。
|
||||||
|
cache-ttl: ${PATBOND_AUTHOR_PROFILE_CACHE_TTL:60s}
|
||||||
|
media:
|
||||||
|
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
|
||||||
|
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
|
||||||
|
# 环境变量与 patbond-user 共用同一组(一套部署一套旋钮)。
|
||||||
|
# public-endpoint 为空时服务照常启动,帖子响应中 media[].url 为 null。
|
||||||
|
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
|
||||||
|
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
|
||||||
|
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
|
||||||
|
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context smoke test: the skeleton boots against a clean postgres:18 with
|
||||||
|
* the full V1..V5 migration chain applied from the test classpath.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class CommunityApplicationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Testcontainers setup: a disposable postgres:18 (the production
|
||||||
|
* target version) wired into the Spring context via @ServiceConnection.
|
||||||
|
* The production module carries no Flyway (the single migration chain is
|
||||||
|
* owned by patbond-user), but the TEST classpath adds patbond-user's jar
|
||||||
|
* plus Flyway, so Boot applies the full V1..V5 chain — including the
|
||||||
|
* community schema these tests exercise — to the fresh container exactly
|
||||||
|
* as the shared database gets it in production (same mechanism as
|
||||||
|
* patbond-pet).
|
||||||
|
*/
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ServiceConnection
|
||||||
|
PostgreSQLContainer<?> postgresContainer() {
|
||||||
|
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.support.CommunityTestData;
|
||||||
|
import com.patbond.patbond.community.support.TestJwtKeys;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real Feign wiring against an in-test HTTP server standing in for
|
||||||
|
* patbond-user: static URL resolution, the X-Internal-Token interceptor,
|
||||||
|
* query-string batching, envelope decoding, avatar resolution through
|
||||||
|
* media.assets plus URL signing — and degradation when the downstream
|
||||||
|
* answers an error. (The /internal endpoint itself is tested in the
|
||||||
|
* patbond-user module; the DB-backed stub covers the service-level tests.)
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class AuthorProfileClientWireTest {
|
||||||
|
|
||||||
|
private static final HttpServer SERVER;
|
||||||
|
private static final AtomicReference<String> RESPONSE_BODY = new AtomicReference<>("");
|
||||||
|
private static final AtomicInteger RESPONSE_STATUS = new AtomicInteger(200);
|
||||||
|
private static final AtomicReference<String> SEEN_TOKEN = new AtomicReference<>();
|
||||||
|
private static final AtomicReference<String> SEEN_QUERY = new AtomicReference<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
SERVER = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
SERVER.createContext("/internal/users/profiles", exchange -> {
|
||||||
|
SEEN_TOKEN.set(exchange.getRequestHeaders().getFirst("X-Internal-Token"));
|
||||||
|
SEEN_QUERY.set(exchange.getRequestURI().getRawQuery());
|
||||||
|
byte[] body = RESPONSE_BODY.get().getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(RESPONSE_STATUS.get(), body.length);
|
||||||
|
try (OutputStream out = exchange.getResponseBody()) {
|
||||||
|
out.write(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
SERVER.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void properties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||||
|
registry.add("patbond.user-service.url",
|
||||||
|
() -> "http://127.0.0.1:" + SERVER.getAddress().getPort());
|
||||||
|
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
|
||||||
|
registry.add("patbond.media.access-key", () -> "test-access-key");
|
||||||
|
registry.add("patbond.media.secret-key", () -> "test-secret-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopServer() {
|
||||||
|
SERVER.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AuthorProfileGateway gateway;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetServer() {
|
||||||
|
RESPONSE_STATUS.set(200);
|
||||||
|
RESPONSE_BODY.set("{\"code\":0,\"message\":\"success\",\"data\":[]}");
|
||||||
|
SEEN_TOKEN.set(null);
|
||||||
|
SEEN_QUERY.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID newUser() {
|
||||||
|
return CommunityTestData.insertUser(jdbcClient,
|
||||||
|
"w" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void presentsTheServiceSecretAndBatchesIdsIntoOneQuery() {
|
||||||
|
UUID userA = UUID.randomUUID();
|
||||||
|
UUID userB = UUID.randomUUID();
|
||||||
|
RESPONSE_BODY.set("""
|
||||||
|
{"code":0,"message":"success","data":[
|
||||||
|
{"userId":"%s","nickname":"小白","avatarAssetId":null}
|
||||||
|
]}""".formatted(userA));
|
||||||
|
|
||||||
|
Map<UUID, AuthorSummaryResponse> summaries =
|
||||||
|
gateway.summarize(java.util.List.of(userA, userB));
|
||||||
|
|
||||||
|
assertThat(SEEN_TOKEN.get()).isEqualTo("test-internal-token");
|
||||||
|
String ids = URLDecoder.decode(SEEN_QUERY.get(), StandardCharsets.UTF_8)
|
||||||
|
.replaceFirst("^ids=", "");
|
||||||
|
assertThat(ids.split(",")).containsExactlyInAnyOrder(
|
||||||
|
userA.toString(), userB.toString());
|
||||||
|
assertThat(summaries).containsOnlyKeys(userA);
|
||||||
|
assertThat(summaries.get(userA).nickname()).isEqualTo("小白");
|
||||||
|
assertThat(summaries.get(userA).avatarUrl()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesTheAvatarAssetLocallyAndSignsTheUrl() {
|
||||||
|
UUID owner = newUser();
|
||||||
|
UUID assetId = CommunityTestData.insertReadyAsset(jdbcClient, owner);
|
||||||
|
RESPONSE_BODY.set("""
|
||||||
|
{"code":0,"message":"success","data":[
|
||||||
|
{"userId":"%s","nickname":"有头像","avatarAssetId":"%s"}
|
||||||
|
]}""".formatted(owner, assetId));
|
||||||
|
|
||||||
|
AuthorSummaryResponse summary = gateway.summarize(java.util.List.of(owner)).get(owner);
|
||||||
|
assertThat(summary.nickname()).isEqualTo("有头像");
|
||||||
|
assertThat(summary.avatarUrl())
|
||||||
|
.contains(assetId.toString())
|
||||||
|
.contains("X-Amz-Signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aDownstreamErrorDegradesToNoSummaries() {
|
||||||
|
RESPONSE_STATUS.set(500);
|
||||||
|
RESPONSE_BODY.set("{\"code\":50000,\"message\":\"boom\",\"data\":null}");
|
||||||
|
assertThat(gateway.summarize(java.util.List.of(UUID.randomUUID()))).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user