# 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` | 微信回调 | | `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) | | `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 自动关闭 | | `JPA_DDL_AUTO` | 否 | `update`(dev)/ `validate`(production) | JPA DDL 模式 | | `JPA_SHOW_SQL` | 否 | `false` | SQL 日志 | | `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 -u -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 -u -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 -u -p petstore < db/migrations/20260801_create_business_event.sql ``` 脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0;报告打开和服务开始没有可靠历史数据,不做猜测性回填。 ### production profile ```bash java -jar petstore-backend.jar --spring.profiles.active=production ``` production profile 下: - `ddl-auto=validate`(只校验,不改表) - `show-sql=false` - `SMS_UNIVERSAL_CODE` 默认空(关闭万能验证码) ## 测试 ```bash mvn test ``` 最小测试基线(P0 稳定化批次): - `SessionTokenServiceTest`:签发/校验/篡改/过期 - `ReportServiceTest`:appointmentId 必填、重复报告、doing→done - `ReportControllerTest`:公开字段裁剪、照片必填业务码、重复报告业务码 - `AppointmentServiceTest`:状态机非法迁移 ## RC 发布门禁 发布前必须依次执行并全部通过: ```bash # 1. FFmpeg 可用 ffmpeg -version ffprobe -version # 2. 上传资源可访问(替换 为已上传的测试文件) curl -I "$APP_BASE_URL/api/upload/image/" # 期望: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 ``` ### 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 - [ ] 已暴露凭据(DB 密码、微信 AppSecret)已轮换 ## 安全说明 - **最小鉴权上下文**:HMAC session token(`SessionTokenService`)+ `AuthInterceptor`,不引入完整 Spring Security。 - **公开接口白名单**:见 `AuthInterceptor.PUBLIC_PATTERNS`;其余 `/api/**` 要求 `Authorization: Bearer `。 - **跨店/跨用户边界**:所有 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 校验,拒绝 `..` 逃逸。 ## 参与贡献 1. Fork 本仓库 2. 新建 `feat_xxx` 分支 3. 提交代码(使用 conventional commits:`feat` / `fix` / `chore` / `docs` / `refactor` / `test`) 4. 新建 Pull Request