petstore-backend/README.md

218 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# petstore-backend
宠小它 · 智慧宠物门店服务系统后端Spring Boot 3.2 + JPA + MySQL
## 环境准备
### 必备
- JDK 17
- Maven 3.8+
- MySQL 5.7+ / 8.x
- FFmpeg + ffprobe服务回顾短片生成可选未安装时成片功能不可用但不影响主流程
### 环境变量
| 变量 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `MYSQL_URL` | 生产必填 | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL发布预检要求显式提供 |
| `MYSQL_USERNAME` | 生产必填 | `root` | 数据库用户名;生产使用最小权限专用账号 |
| `MYSQL_PASSWORD` | **是** | 空 | 数据库密码 |
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
| `WECHAT_REDIRECT_URI` | 生产必填 | `http://localhost:8080/api/wechat/callback` | 微信回调;生产必须为真实 HTTPS 地址 |
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`7 天) | session token 有效期 |
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL用于生成媒体绝对 URL |
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
| `HIGHLIGHT_FFMPEG` | 否 | `ffmpeg` | ffmpeg 可执行路径 |
| `HIGHLIGHT_FFPROBE` | 否 | `ffprobe` | ffprobe 可执行路径 |
| `HIGHLIGHT_MAX_DURATION_SEC` | 否 | `300` | 成片最大总秒数 |
| `HIGHLIGHT_BGM_PATH` | 否 | 空 | 商用授权 BGM 绝对路径;留空不混音 |
| `SMS_UNIVERSAL_CODE` | **生产必须为空** | `123456`dev/ 空production | 演示万能验证码production profile 自动关闭 |
| `SPRING_JPA_HIBERNATE_DDL_AUTO` | 否 | `validate`production | 生产只能为 `validate`;禁止自动改表 |
| `SPRING_JPA_SHOW_SQL` | 否 | `false` | 生产 SQL 日志开关,必须为 `false` |
| `LOG_LEVEL_PETSTORE` | 否 | `info` | `com.petstore` 包日志级别 |
> ⚠️ **密钥轮换**`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
## 本地开发
```bash
# 1. 准备本地 MySQL创建数据库
mysql -uroot -p -e "CREATE DATABASE petstore DEFAULT CHARSET utf8mb4"
# 2. 配置环境变量(或复制 application-example.yml 为 application.yml 并填写)
export MYSQL_PASSWORD=your_local_password
# 3. 启动
mvn spring-boot:run
# 4. 验证
curl http://localhost:8080/api/store/list
```
## 数据库迁移
⚠️ **生产库变更必须走 SQL/Flyway/Liquibase 或人工审核 SQL禁止依赖 `ddl-auto:update` 自动改表**
### P0 稳定化批次迁移要点
1. **`uk_report_appointment` 唯一约束上线前需清理重复 `appointment_id`**
```sql
-- 检查是否存在重复
SELECT appointment_id, COUNT(*) c FROM t_report WHERE deleted = 0 GROUP BY appointment_id HAVING c > 1;
-- 清理:保留最新一条,其余软删
-- (具体清理 SQL 由 DBA 按数据情况编写)
```
清理后再启动应用,让 JPA `ddl-auto:update` 创建唯一约束;否则应用启动会失败。
2. **历史图片 URL 修复**(旧版启动期自动执行,现已移除):
```sql
UPDATE t_report SET before_photo = REPLACE(before_photo, 'http://localhost:8080/2026/', '/api/upload/image/2026/') WHERE before_photo LIKE 'http://localhost:8080/2026/%';
UPDATE t_report SET after_photo = REPLACE(after_photo, 'http://localhost:8080/2026/', '/api/upload/image/2026/') WHERE after_photo LIKE 'http://localhost:8080/2026/%';
```
仅需在生产库执行一次dev 库若无历史数据可跳过。
3. **`t_report.appointment_id` 列**P0 口径要求报告必须绑定预约,但历史 DDL 曾将该列改为 `NULL`。新环境由 JPA 实体 `@UniqueConstraint` 创建约束;老环境若该列允许 NULL 且无重复,可直接加唯一约束。
4. **预约/报告身份字段拆分**:升级到 2026-08-01 之后的版本前,生产库必须先执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_split_service_identity.sql
```
脚本新增 `customer_user_id`、`created_by_user_id`、`author_staff_id` 并回填可确定的历史数据末尾三个验证查询必须留档`unresolved_assisted_appointments` 0 时需人工确认禁止把旧员工 `user_id` 冒认成客户
5. **门店客户主档**身份拆分验证完成后紧接着执行
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_store_customer.sql
```
脚本创建 `t_store_customer`从预约和报告留资回填本店客户关系末尾 `appointments_without_store_customer`、`leads_without_store_customer`、`store_customers_linked_to_non_customer` 必须全部为 0
6. **业务事件落库**客户主档验证完成后执行
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_business_event.sql
```
脚本创建不可变 `t_business_event` 并回填可确认的历史事实末尾四个验证计数必须全部为 0报告打开和服务开始没有可靠历史数据不做猜测性回填
7. **真实预约容量**业务事件验证完成后执行
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_booking_capacity.sql
```
脚本新增门店并发容量服务时长预约时长快照和连续排班占用末尾容量时长和快照验证计数必须全部为 0
8. **报告显式确认发送状态**预约容量验证完成后执行
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_report_send_status.sql
```
脚本把历史报告统一标记为 `unknown`迁移后的新报告默认 `unsent`不得推测历史发送事实末尾三项状态与回执一致性验证必须全部为 0
9. **门店开通、员工邀请与操作审计**报告发送状态验证完成后执行
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_store_onboarding.sql
```
脚本把历史门店开通状态标为不可推断的 `unknown`建立只保存 token SHA-256 的一次性员工邀请和不可变低敏审计表末尾六项状态/回执一致性验证必须全部为 0不得恢复 legacy 永久邀请码注册
### production profile
```bash
java -jar petstore-backend.jar --spring.profiles.active=production
```
production profile
- `ddl-auto=validate`只校验不改表
- `show-sql=false`
- `SMS_UNIVERSAL_CODE` 默认空关闭万能验证码
- 不执行开发期默认服务初始化启动和预检不会写入业务数据
- `/actuator/health/liveness` `/actuator/health/readiness` 提供编排探针响应不暴露内部细节
### 生产只读预检
完成备份和六个迁移后以最终生产环境变量运行
```bash
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
BUILD_FIRST=1 deploy/release-preflight.sh
```
脚本先校验生产配置上传目录Java FFmpeg再启动无 Web `production` profile执行 JPA schema `validate` 24 项只读数据不变量检查通过后退出它不会执行迁移也不会修复数据
## 测试
```bash
mvn test
```
最小测试基线P0 稳定化批次
- `SessionTokenServiceTest`签发/校验/篡改/过期
- `ReportServiceTest`appointmentId 必填重复报告doingdone
- `ReportControllerTest`公开字段裁剪照片必填业务码重复报告业务码
- `AppointmentServiceTest`状态机非法迁移
## RC 发布门禁
发布前必须依次执行并全部通过
```bash
# 1. FFmpeg 可用
ffmpeg -version
ffprobe -version
# 2. 上传资源可访问(替换 <known-file> 为已上传的测试文件)
curl -I "$APP_BASE_URL/api/upload/image/<known-file>"
# 期望HTTP/1.1 200
# 3. 后端测试
mvn -f backend/pom.xml test
# 期望BUILD SUCCESS且不再输出 "No tests to run"
# 4. 前端构建(见 frontend/README.md
npm --prefix frontend install
npm --prefix frontend run build:h5
npm --prefix frontend run build:mp-weixin
# 5. 上线后只读冒烟
API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
```
### RC 检查项
- [ ] `APP_BASE_URL` 已配置且后端可生成可访问媒体 URL
- [ ] `PETSTORE_SESSION_SECRET` 已通过环境变量覆盖 `dev-change-me`
- [ ] `MYSQL_PASSWORD` / `WECHAT_APPSECRET` 等敏感项通过环境变量注入未硬编码
- [ ] `HIGHLIGHT_FFMPEG` / `HIGHLIGHT_FFPROBE` 指向可用二进制
- [ ] `HIGHLIGHT_MAX_DURATION_SEC` / `HIGHLIGHT_BGM_PATH` 按运营配置
- [ ] 上传目录 `UPLOAD_PATH` 存在且进程有读写权限
- [ ] Nginx `client_max_body_size` 200MB `spring.servlet.multipart.max-file-size` 一致
- [ ] 启动使用 `--spring.profiles.active=production`
- [ ] `uk_report_appointment` 唯一约束上线前已清理重复数据
- [ ] 历史图片 URL 已执行一次性修复 SQL
- [ ] 已执行 `20260801_split_service_identity.sql`并留存未解析预约/报告验证结果
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`三项主档验证计数均为 0
- [ ] 已执行 `20260801_create_business_event.sql`四项事件回填验证计数均为 0
- [ ] 已执行 `20260801_create_booking_capacity.sql`容量与时长验证计数均为 0
- [ ] 已执行 `20260801_create_report_send_status.sql`历史 `unknown` 与三项回执一致性验证均为 0
- [ ] 已执行 `20260802_create_store_onboarding.sql`历史开通状态与六项邀请/回执验证均为 0
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
- [ ] readiness 与上线后只读冒烟全部通过
- [ ] 已暴露凭据DB 密码微信 AppSecret已轮换
## 安全说明
- **最小鉴权上下文**HMAC session token`SessionTokenService`+ `AuthInterceptor`并在每次受保护请求核验活跃账号的 role/storeId 未变化删除员工会立即使旧会话失效
- **公开接口白名单** `AuthInterceptor.PUBLIC_PATTERNS`其余 `/api/**` 要求 `Authorization: Bearer <token>`
- **跨店/跨用户边界**所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`不信任请求体里的身份字段
- **公开报告页**`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`日志只记 token SHA-256 8 hash
- **回访池脱敏**`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`只返回 `wechatBound` + 脱敏 id
- **上传路径保护**`FileController` 归一化路径 + 扩展名/MIME 校验拒绝 `..` 逃逸
- **员工邀请**原始 256-bit token 只在创建响应返回一次数据库只存 SHA-256邀请绑定微信核验手机号限时一次使用可撤销
- **操作审计**门店开通/设置和员工权限操作写入不可变低敏 AuditLogmetadata 禁止 PII凭证和原始请求体
## 参与贡献
1. Fork 本仓库
2. 新建 `feat_xxx` 分支
3. 提交代码使用 conventional commits`feat` / `fix` / `chore` / `docs` / `refactor` / `test`
4. 新建 Pull Request