Compare commits
No commits in common. "main" and "be63a3a3771d83d2961cfdc788e353147a407aee" have entirely different histories.
main
...
be63a3a377
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,7 +1,2 @@
|
||||
target/
|
||||
uploads/
|
||||
.DS_Store
|
||||
.idea/
|
||||
*.iml
|
||||
# 本地密钥,勿提交;用 application-example.yml 复制
|
||||
src/main/resources/application.yml
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# petstore-backend
|
||||
|
||||
#### Description
|
||||
宠小它
|
||||
宠伴生活馆
|
||||
|
||||
#### Software Architecture
|
||||
Software architecture description
|
||||
|
||||
252
README.md
252
README.md
@ -1,239 +1,37 @@
|
||||
# petstore-backend
|
||||
|
||||
宠小它 · 智慧宠物门店服务系统后端(Spring Boot 3.2 + JPA + MySQL)。
|
||||
#### 介绍
|
||||
宠伴生活馆
|
||||
|
||||
## 环境准备
|
||||
#### 软件架构
|
||||
软件架构说明
|
||||
|
||||
### 必备
|
||||
|
||||
- JDK 17
|
||||
- Maven 3.8+
|
||||
- MySQL 5.7+ / 8.x
|
||||
- FFmpeg + ffprobe(服务回顾短片生成;可选,未安装时成片功能不可用但不影响主流程)
|
||||
#### 安装教程
|
||||
|
||||
### 环境变量
|
||||
1. xxxx
|
||||
2. xxxx
|
||||
3. xxxx
|
||||
|
||||
| 变量 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `MYSQL_URL` | 生产必填 | `jdbc:mysql://127.0.0.1:3306/petstore?...` | 数据库 JDBC URL;发布预检要求显式提供 |
|
||||
| `MYSQL_USERNAME` | 生产必填 | `root` | 数据库用户名;生产使用最小权限专用账号 |
|
||||
| `MYSQL_PASSWORD` | **是** | 空 | 数据库密码 |
|
||||
| `WECHAT_APPID` | 生产必填 | 空 | 微信小程序 AppID |
|
||||
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
|
||||
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
|
||||
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期;生产限制为 300~2592000 秒 |
|
||||
| `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` 包日志级别 |
|
||||
#### 使用说明
|
||||
|
||||
CORS 只由全局 `CorsFilter` 和 `CORS_ALLOWED_ORIGINS` 控制;Controller 不得声明 `@CrossOrigin`。生产只读 smoke 必须同时提供白名单中的 `SMOKE_ALLOWED_ORIGIN`,并验证一个非白名单 Origin 返回 403。
|
||||
1. xxxx
|
||||
2. xxxx
|
||||
3. xxxx
|
||||
|
||||
微信出站调用的日志只允许记录 HTTP 状态码、微信错误码和异常类型;禁止记录完整响应体、手机号、access_token、AppSecret、一次性 code 或可能包含带凭据 URI 的异常消息。
|
||||
|
||||
Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。
|
||||
|
||||
> ⚠️ **密钥轮换**:`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 永久邀请码注册。
|
||||
|
||||
10. **StoreCustomer 合并别名与事实时间线连续性**:门店开通迁移验证完成后执行:
|
||||
```bash
|
||||
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_store_customer_timeline.sql
|
||||
```
|
||||
脚本为被合并的软删除客户投影增加同店 canonical 指针。既有 `BusinessEvent` 保持不可变,时间线通过主档与别名共同读取历史事实。末尾两项别名状态/目标验证必须全部为 0。
|
||||
|
||||
11. **可执行回访任务与真实再次预约归因**:客户时间线迁移验证完成后执行:
|
||||
```bash
|
||||
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_follow_up_task.sql
|
||||
```
|
||||
脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。
|
||||
|
||||
### 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` 和 31 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
mvn test
|
||||
```
|
||||
|
||||
试点期的聚合指标使用只读脚本 `db/queries/pilot_metrics.sql`。执行前必须在当前数据库 session 显式填写 3~5 个 `@pilot_store_ids`,并确认统一的 `@pilot_from/@pilot_to`;默认门店集合为 `0`,不会意外汇总全部门店或历史。输出不包含客户或员工直接标识。只能由只读账号/只读副本执行,完整发布仍以 31 项 production preflight 为数据门禁。
|
||||
|
||||
最小测试基线(P0 稳定化批次):
|
||||
- `SessionTokenServiceTest`:签发/校验/篡改/过期
|
||||
- `ReportServiceTest`:appointmentId 必填、重复报告、doing→done
|
||||
- `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
|
||||
- [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0
|
||||
- [ ] 已执行 `20260802_create_follow_up_task.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。
|
||||
- **回访执行权限**:`/api/admin/follow-up-tasks` 仅本店 boss/staff;列表中的完整手机号只用于已认证员工实际联系,不写入 BusinessEvent、AuditLog、时间线或工作台预览。
|
||||
- **上传路径保护**:`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。
|
||||
- **员工邀请**:原始 256-bit token 只在创建响应返回一次,数据库只存 SHA-256;邀请绑定微信核验手机号、限时、一次使用、可撤销。
|
||||
- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLog,metadata 禁止 PII、凭证和原始请求体。
|
||||
|
||||
## 参与贡献
|
||||
#### 参与贡献
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 新建 `feat_xxx` 分支
|
||||
3. 提交代码(使用 conventional commits:`feat` / `fix` / `chore` / `docs` / `refactor` / `test`)
|
||||
2. 新建 Feat_xxx 分支
|
||||
3. 提交代码
|
||||
4. 新建 Pull Request
|
||||
|
||||
|
||||
#### 特技
|
||||
|
||||
1. 使用 Readme\_XXX.md 来支持不同的语言,例如 Readme\_en.md, Readme\_zh.md
|
||||
2. Gitee 官方博客 [blog.gitee.com](https://blog.gitee.com)
|
||||
3. 你可以 [https://gitee.com/explore](https://gitee.com/explore) 这个地址来了解 Gitee 上的优秀开源项目
|
||||
4. [GVP](https://gitee.com/gvp) 全称是 Gitee 最有价值开源项目,是综合评定出的优秀开源项目
|
||||
5. Gitee 官方提供的使用手册 [https://gitee.com/help](https://gitee.com/help)
|
||||
6. Gitee 封面人物是一档用来展示 Gitee 会员风采的栏目 [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/)
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
-- Petstore Phase 0:真实预约容量模型
|
||||
-- 前置:20260801_split_service_identity.sql、20260801_create_store_customer.sql、
|
||||
-- 20260801_create_business_event.sql 已执行。
|
||||
|
||||
ALTER TABLE t_service_type
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 60 COMMENT '预计服务时长,30分钟粒度';
|
||||
|
||||
ALTER TABLE t_appointment
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 60 COMMENT '下单时服务时长快照';
|
||||
|
||||
ALTER TABLE t_schedule_block
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 30 COMMENT '连续占用时长,30分钟粒度';
|
||||
|
||||
ALTER TABLE t_store
|
||||
ADD COLUMN booking_capacity INT NOT NULL DEFAULT 1 COMMENT '门店并发接待数';
|
||||
|
||||
-- 系统默认服务时长;未知服务保留 60 分钟安全值。
|
||||
UPDATE t_service_type
|
||||
SET duration_minutes = CASE name
|
||||
WHEN '洗澡' THEN 60
|
||||
WHEN '美容' THEN 120
|
||||
WHEN '洗澡+美容' THEN 150
|
||||
WHEN '剪指甲' THEN 30
|
||||
WHEN '驱虫' THEN 30
|
||||
ELSE 60
|
||||
END;
|
||||
|
||||
-- 历史预约形成时没有时长快照,只能按当时服务名称采用稳定默认值回填。
|
||||
UPDATE t_appointment
|
||||
SET duration_minutes = CASE service_type
|
||||
WHEN '洗澡' THEN 60
|
||||
WHEN '美容' THEN 120
|
||||
WHEN '洗澡+美容' THEN 150
|
||||
WHEN '剪指甲' THEN 30
|
||||
WHEN '驱虫' THEN 30
|
||||
ELSE 60
|
||||
END;
|
||||
|
||||
-- 验证:以下四项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_service_duration_count
|
||||
FROM t_service_type
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_appointment_duration_count
|
||||
FROM t_appointment
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_block_duration_count
|
||||
FROM t_schedule_block
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_store_capacity_count
|
||||
FROM t_store
|
||||
WHERE booking_capacity < 1 OR booking_capacity > 10;
|
||||
@ -1,190 +0,0 @@
|
||||
-- Petstore Phase 0: persist immutable business events for funnels and timelines.
|
||||
-- Target: MySQL. Run after 20260801_create_store_customer.sql.
|
||||
-- One-time migration; back up the database before execution.
|
||||
|
||||
CREATE TABLE t_business_event (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(36) NOT NULL COMMENT '随机事件 ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '过去时业务事件类型',
|
||||
event_version INT NOT NULL DEFAULT 1,
|
||||
store_id BIGINT NOT NULL,
|
||||
store_customer_id BIGINT NULL COMMENT '门店客户稳定关系,可空',
|
||||
aggregate_type VARCHAR(32) NOT NULL,
|
||||
aggregate_id BIGINT NOT NULL,
|
||||
actor_user_id BIGINT NULL,
|
||||
actor_role VARCHAR(16) NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
occurred_at DATETIME(3) NOT NULL,
|
||||
metadata_json TEXT NOT NULL COMMENT '仅低敏白名单维度 JSON',
|
||||
idempotency_key VARCHAR(128) NULL,
|
||||
create_time DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_business_event_event_id (event_id),
|
||||
UNIQUE KEY uk_business_event_idempotency (idempotency_key),
|
||||
KEY idx_be_store_type_time (store_id, event_type, occurred_at),
|
||||
KEY idx_be_aggregate_time (aggregate_type, aggregate_id, occurred_at),
|
||||
KEY idx_be_store_customer_time (store_customer_id, occurred_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变业务事件';
|
||||
|
||||
-- 历史预约只回填可确定的“预约已创建”,不猜测无法还原的开始服务时间。
|
||||
INSERT INTO t_business_event (
|
||||
event_id, event_type, event_version, store_id, store_customer_id,
|
||||
aggregate_type, aggregate_id, actor_user_id, actor_role, source,
|
||||
occurred_at, metadata_json, idempotency_key, create_time
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'appointment_created',
|
||||
1,
|
||||
a.store_id,
|
||||
sc.id,
|
||||
'appointment',
|
||||
a.id,
|
||||
a.created_by_user_id,
|
||||
actor.role,
|
||||
CASE
|
||||
WHEN a.created_by_user_id IS NULL THEN 'migration'
|
||||
WHEN a.created_by_user_id = a.customer_user_id THEN 'customer'
|
||||
ELSE 'admin'
|
||||
END,
|
||||
COALESCE(a.create_time, a.appointment_time, NOW()),
|
||||
CASE
|
||||
WHEN a.created_by_user_id IS NULL
|
||||
THEN '{"bookingOrigin":"migration"}'
|
||||
WHEN a.created_by_user_id = a.customer_user_id
|
||||
THEN '{"bookingOrigin":"customer"}'
|
||||
ELSE '{"bookingOrigin":"admin"}'
|
||||
END,
|
||||
CONCAT('appointment_created:', a.id),
|
||||
NOW()
|
||||
FROM t_appointment a
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = a.store_id
|
||||
AND sc.customer_user_id = a.customer_user_id
|
||||
AND sc.deleted = 0
|
||||
LEFT JOIN t_user actor
|
||||
ON actor.id = a.created_by_user_id
|
||||
AND actor.deleted = 0
|
||||
WHERE a.deleted = 0
|
||||
AND a.store_id IS NOT NULL;
|
||||
|
||||
-- 历史报告提交是可靠事实;不保存 report_token 或其 hash。
|
||||
INSERT INTO t_business_event (
|
||||
event_id, event_type, event_version, store_id, store_customer_id,
|
||||
aggregate_type, aggregate_id, actor_user_id, actor_role, source,
|
||||
occurred_at, metadata_json, idempotency_key, create_time
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'report_submitted',
|
||||
1,
|
||||
r.store_id,
|
||||
sc.id,
|
||||
'report',
|
||||
r.id,
|
||||
r.author_staff_id,
|
||||
actor.role,
|
||||
CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END,
|
||||
COALESCE(r.create_time, NOW()),
|
||||
'{}',
|
||||
CONCAT('report_submitted:', r.id),
|
||||
NOW()
|
||||
FROM t_report r
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = r.store_id
|
||||
AND sc.customer_user_id = r.customer_user_id
|
||||
AND sc.deleted = 0
|
||||
LEFT JOIN t_user actor
|
||||
ON actor.id = r.author_staff_id
|
||||
AND actor.deleted = 0
|
||||
WHERE r.deleted = 0
|
||||
AND r.store_id IS NOT NULL;
|
||||
|
||||
-- 有报告的完成时间可由报告创建时间可靠近似;无报告的历史 done 不做猜测。
|
||||
INSERT INTO t_business_event (
|
||||
event_id, event_type, event_version, store_id, store_customer_id,
|
||||
aggregate_type, aggregate_id, actor_user_id, actor_role, source,
|
||||
occurred_at, metadata_json, idempotency_key, create_time
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'service_completed',
|
||||
1,
|
||||
r.store_id,
|
||||
sc.id,
|
||||
'appointment',
|
||||
r.appointment_id,
|
||||
r.author_staff_id,
|
||||
actor.role,
|
||||
CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END,
|
||||
COALESCE(r.create_time, NOW()),
|
||||
'{}',
|
||||
CONCAT('service_completed:', r.appointment_id),
|
||||
NOW()
|
||||
FROM t_report r
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = r.store_id
|
||||
AND sc.customer_user_id = r.customer_user_id
|
||||
AND sc.deleted = 0
|
||||
LEFT JOIN t_user actor
|
||||
ON actor.id = r.author_staff_id
|
||||
AND actor.deleted = 0
|
||||
WHERE r.deleted = 0
|
||||
AND r.store_id IS NOT NULL
|
||||
AND r.appointment_id IS NOT NULL;
|
||||
|
||||
-- 历史留资每条只形成一个 lead_submitted 事实;不复制手机号、IP、openid 或退订 token。
|
||||
INSERT INTO t_business_event (
|
||||
event_id, event_type, event_version, store_id, store_customer_id,
|
||||
aggregate_type, aggregate_id, actor_user_id, actor_role, source,
|
||||
occurred_at, metadata_json, idempotency_key, create_time
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'lead_submitted',
|
||||
1,
|
||||
l.store_id,
|
||||
sc.id,
|
||||
'report_lead',
|
||||
l.id,
|
||||
NULL,
|
||||
NULL,
|
||||
'public_report',
|
||||
COALESCE(l.consent_at, l.create_time, l.update_time, NOW()),
|
||||
'{"repeatSubmit":false}',
|
||||
CONCAT('lead_submitted:', l.id),
|
||||
NOW()
|
||||
FROM t_report_lead l
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = l.store_id
|
||||
AND sc.phone = l.phone
|
||||
AND sc.deleted = 0
|
||||
WHERE l.store_id IS NOT NULL;
|
||||
|
||||
-- Verification queries. All four counts should be 0 before application release.
|
||||
SELECT COUNT(*) AS appointments_without_created_event
|
||||
FROM t_appointment a
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('appointment_created:', a.id)
|
||||
WHERE a.deleted = 0
|
||||
AND a.store_id IS NOT NULL
|
||||
AND e.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS reports_without_submitted_event
|
||||
FROM t_report r
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('report_submitted:', r.id)
|
||||
WHERE r.deleted = 0
|
||||
AND r.store_id IS NOT NULL
|
||||
AND e.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS leads_without_submitted_event
|
||||
FROM t_report_lead l
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('lead_submitted:', l.id)
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND e.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS events_without_store
|
||||
FROM t_business_event
|
||||
WHERE store_id IS NULL;
|
||||
@ -1,40 +0,0 @@
|
||||
-- Petstore Phase 0:报告显式确认发送状态
|
||||
-- 前置:20260801_create_booking_capacity.sql 已执行。
|
||||
-- 语义:历史报告无法可靠证明是否发送,统一标记 unknown;迁移后新报告默认 unsent。
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN send_status VARCHAR(16) NULL COMMENT 'unknown | unsent | sent';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN sent_at DATETIME NULL COMMENT '首次显式确认发送时间';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN sent_by_user_id BIGINT NULL COMMENT '首次确认发送的门店员工';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN send_channel VARCHAR(16) NULL COMMENT 'wechat | qr | other';
|
||||
|
||||
UPDATE t_report
|
||||
SET send_status = 'unknown';
|
||||
|
||||
ALTER TABLE t_report
|
||||
MODIFY COLUMN send_status VARCHAR(16) NOT NULL DEFAULT 'unsent' COMMENT 'unknown | unsent | sent';
|
||||
|
||||
CREATE INDEX idx_report_store_send_time
|
||||
ON t_report (store_id, send_status, create_time);
|
||||
|
||||
-- 验证:以下三项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_report_send_status_count
|
||||
FROM t_report
|
||||
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent');
|
||||
|
||||
SELECT COUNT(*) AS sent_report_without_receipt_count
|
||||
FROM t_report
|
||||
WHERE send_status = 'sent'
|
||||
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'));
|
||||
|
||||
SELECT COUNT(*) AS unconfirmed_report_with_receipt_count
|
||||
FROM t_report
|
||||
WHERE send_status IN ('unknown', 'unsent')
|
||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL);
|
||||
@ -1,138 +0,0 @@
|
||||
-- Petstore Phase 0: create stable store-customer relationship master.
|
||||
-- Target: MySQL. Run after 20260801_split_service_identity.sql.
|
||||
-- One-time migration; back up the database before execution.
|
||||
|
||||
CREATE TABLE t_store_customer (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
store_id BIGINT NOT NULL COMMENT '门店 ID',
|
||||
customer_user_id BIGINT NULL COMMENT '全局 customer 账号,留资客户可空',
|
||||
phone VARCHAR(20) NULL COMMENT '本店最后确认的联系手机号',
|
||||
display_name VARCHAR(64) NULL COMMENT '门店视角客户称呼',
|
||||
source VARCHAR(32) NOT NULL COMMENT '首次来源',
|
||||
first_contact_at DATETIME NULL COMMENT '首次接触时间',
|
||||
last_contact_at DATETIME NULL COMMENT '最近接触时间',
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_store_customer_user (store_id, customer_user_id),
|
||||
UNIQUE KEY uk_store_customer_phone (store_id, phone),
|
||||
KEY idx_store_customer_active_update (store_id, deleted, update_time),
|
||||
KEY idx_store_customer_last_contact (store_id, last_contact_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店客户关系主档';
|
||||
|
||||
-- 历史预约客户建档。旧数据无法精确确定首次是自助或代客,统一记为 appointment。
|
||||
INSERT INTO t_store_customer (
|
||||
store_id,
|
||||
customer_user_id,
|
||||
phone,
|
||||
display_name,
|
||||
source,
|
||||
first_contact_at,
|
||||
last_contact_at,
|
||||
create_time,
|
||||
update_time,
|
||||
deleted
|
||||
)
|
||||
SELECT
|
||||
a.store_id,
|
||||
a.customer_user_id,
|
||||
MAX(u.phone),
|
||||
MAX(COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '客户')),
|
||||
'appointment',
|
||||
MIN(COALESCE(a.create_time, a.appointment_time, NOW())),
|
||||
MAX(COALESCE(a.update_time, a.create_time, a.appointment_time, NOW())),
|
||||
NOW(),
|
||||
NOW(),
|
||||
0
|
||||
FROM t_appointment a
|
||||
JOIN t_user u
|
||||
ON u.id = a.customer_user_id
|
||||
AND u.deleted = 0
|
||||
AND u.role = 'customer'
|
||||
WHERE a.deleted = 0
|
||||
AND a.store_id IS NOT NULL
|
||||
AND a.customer_user_id IS NOT NULL
|
||||
GROUP BY a.store_id, a.customer_user_id;
|
||||
|
||||
-- 报告留资建档。若同店同手机已由预约建档,只更新最近接触时间并保留首次来源。
|
||||
INSERT INTO t_store_customer (
|
||||
store_id,
|
||||
customer_user_id,
|
||||
phone,
|
||||
display_name,
|
||||
source,
|
||||
first_contact_at,
|
||||
last_contact_at,
|
||||
create_time,
|
||||
update_time,
|
||||
deleted
|
||||
)
|
||||
SELECT
|
||||
l.store_id,
|
||||
MAX(CASE WHEN u.role = 'customer' AND u.deleted = 0 THEN u.id ELSE NULL END),
|
||||
l.phone,
|
||||
MAX(CASE
|
||||
WHEN u.role = 'customer' AND u.deleted = 0
|
||||
THEN COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '留资客户')
|
||||
ELSE '留资客户'
|
||||
END),
|
||||
'report_lead',
|
||||
MIN(COALESCE(l.create_time, NOW())),
|
||||
MAX(COALESCE(l.update_time, l.create_time, NOW())),
|
||||
NOW(),
|
||||
NOW(),
|
||||
0
|
||||
FROM t_report_lead l
|
||||
LEFT JOIN t_user u
|
||||
ON u.phone = l.phone
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND l.phone IS NOT NULL
|
||||
AND l.phone <> ''
|
||||
GROUP BY l.store_id, l.phone
|
||||
ON DUPLICATE KEY UPDATE
|
||||
customer_user_id = COALESCE(t_store_customer.customer_user_id, VALUES(customer_user_id)),
|
||||
phone = COALESCE(t_store_customer.phone, VALUES(phone)),
|
||||
display_name = CASE
|
||||
WHEN t_store_customer.display_name IS NULL OR t_store_customer.display_name = ''
|
||||
THEN VALUES(display_name)
|
||||
ELSE t_store_customer.display_name
|
||||
END,
|
||||
first_contact_at = LEAST(
|
||||
COALESCE(t_store_customer.first_contact_at, VALUES(first_contact_at)),
|
||||
VALUES(first_contact_at)
|
||||
),
|
||||
last_contact_at = GREATEST(
|
||||
COALESCE(t_store_customer.last_contact_at, VALUES(last_contact_at)),
|
||||
VALUES(last_contact_at)
|
||||
),
|
||||
update_time = NOW(),
|
||||
deleted = 0;
|
||||
|
||||
-- Verification queries. All three counts should be 0 before application release.
|
||||
SELECT COUNT(*) AS appointments_without_store_customer
|
||||
FROM t_appointment a
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = a.store_id
|
||||
AND sc.customer_user_id = a.customer_user_id
|
||||
AND sc.deleted = 0
|
||||
WHERE a.deleted = 0
|
||||
AND a.customer_user_id IS NOT NULL
|
||||
AND sc.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS leads_without_store_customer
|
||||
FROM t_report_lead l
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = l.store_id
|
||||
AND sc.phone = l.phone
|
||||
AND sc.deleted = 0
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND l.phone IS NOT NULL
|
||||
AND l.phone <> ''
|
||||
AND sc.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS store_customers_linked_to_non_customer
|
||||
FROM t_store_customer sc
|
||||
JOIN t_user u ON u.id = sc.customer_user_id
|
||||
WHERE sc.deleted = 0
|
||||
AND (u.deleted <> 0 OR u.role <> 'customer');
|
||||
@ -1,59 +0,0 @@
|
||||
-- Petstore Phase 0: split customer / creator / service staff / report author identity.
|
||||
-- Target: MySQL. One-time migration; back up the database before execution.
|
||||
-- Existing legacy columns remain for compatibility and are not dropped here.
|
||||
|
||||
ALTER TABLE t_appointment
|
||||
ADD COLUMN customer_user_id BIGINT NULL COMMENT '被服务客户账号',
|
||||
ADD COLUMN created_by_user_id BIGINT NULL COMMENT '预约创建账号';
|
||||
|
||||
CREATE INDEX idx_appt_customer_status_time
|
||||
ON t_appointment (customer_user_id, status, appointment_time);
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN customer_user_id BIGINT NULL COMMENT '报告归属客户账号',
|
||||
ADD COLUMN author_staff_id BIGINT NULL COMMENT '报告提交门店账号';
|
||||
|
||||
CREATE INDEX idx_report_customer_time
|
||||
ON t_report (customer_user_id, create_time);
|
||||
|
||||
CREATE INDEX idx_report_author_time
|
||||
ON t_report (author_staff_id, create_time);
|
||||
|
||||
-- Appointment backfill priority:
|
||||
-- 1. pet.owner_user_id (strongest customer ownership evidence)
|
||||
-- 2. legacy user_id only when that account is a customer
|
||||
-- Legacy staff-created rows without pet ownership remain NULL for manual correction.
|
||||
UPDATE t_appointment a
|
||||
LEFT JOIN t_pet p
|
||||
ON p.id = a.pet_id AND p.deleted = 0
|
||||
LEFT JOIN t_user legacy_user
|
||||
ON legacy_user.id = a.user_id AND legacy_user.deleted = 0
|
||||
SET a.customer_user_id = COALESCE(
|
||||
a.customer_user_id,
|
||||
p.owner_user_id,
|
||||
CASE WHEN legacy_user.role = 'customer' THEN a.user_id ELSE NULL END
|
||||
),
|
||||
a.created_by_user_id = COALESCE(a.created_by_user_id, a.user_id)
|
||||
WHERE a.customer_user_id IS NULL OR a.created_by_user_id IS NULL;
|
||||
|
||||
-- Report user_id historically means report author/service staff.
|
||||
-- Customer ownership must come from the linked appointment, never from report.user_id.
|
||||
UPDATE t_report r
|
||||
LEFT JOIN t_appointment a
|
||||
ON a.id = r.appointment_id AND a.deleted = 0
|
||||
SET r.customer_user_id = COALESCE(r.customer_user_id, a.customer_user_id),
|
||||
r.author_staff_id = COALESCE(r.author_staff_id, r.user_id)
|
||||
WHERE r.customer_user_id IS NULL OR r.author_staff_id IS NULL;
|
||||
|
||||
-- Verification queries. unresolved_assisted_appointments must be reviewed manually.
|
||||
SELECT COUNT(*) AS unresolved_assisted_appointments
|
||||
FROM t_appointment
|
||||
WHERE deleted = 0 AND customer_user_id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS reports_without_customer
|
||||
FROM t_report
|
||||
WHERE deleted = 0 AND customer_user_id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS reports_without_author
|
||||
FROM t_report
|
||||
WHERE deleted = 0 AND author_staff_id IS NULL;
|
||||
@ -1,164 +0,0 @@
|
||||
-- Petstore Phase 1:可执行回访任务与再次预约归因
|
||||
-- 前置:20260802_create_store_customer_timeline.sql 已执行。
|
||||
-- ReportLead 保留宠主同意/来源事实;FollowUpTask 承载门店操作状态。
|
||||
|
||||
CREATE TABLE t_follow_up_task (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
store_id BIGINT NOT NULL,
|
||||
store_customer_id BIGINT NOT NULL,
|
||||
source_lead_id BIGINT NOT NULL,
|
||||
source_report_id BIGINT NULL,
|
||||
status VARCHAR(16) NOT NULL COMMENT 'pending | in_progress | completed | canceled',
|
||||
outcome VARCHAR(24) NULL COMMENT 'rebooked | not_interested | invalid_contact | unsubscribed',
|
||||
due_date DATE NOT NULL,
|
||||
assigned_user_id BIGINT NULL,
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
last_attempt_outcome VARCHAR(24) NULL COMMENT 'no_answer | follow_later',
|
||||
last_contacted_at DATETIME NULL,
|
||||
last_contacted_by_user_id BIGINT NULL,
|
||||
closed_at DATETIME NULL,
|
||||
closed_by_user_id BIGINT NULL,
|
||||
rebooked_appointment_id BIGINT NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uk_follow_up_task_public_id UNIQUE (task_id),
|
||||
INDEX idx_follow_up_store_status_due (store_id, status, due_date),
|
||||
INDEX idx_follow_up_customer_status (store_customer_id, status),
|
||||
INDEX idx_follow_up_lead_status (source_lead_id, status),
|
||||
INDEX idx_follow_up_rebook (rebooked_appointment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店回访执行任务';
|
||||
|
||||
-- 只为仍待处理的历史留资建立开放任务;不得猜测已发送/取消记录的真实结果。
|
||||
INSERT INTO t_follow_up_task (
|
||||
task_id, store_id, store_customer_id, source_lead_id, source_report_id,
|
||||
status, outcome, due_date, assigned_user_id, attempt_count,
|
||||
last_attempt_outcome, last_contacted_at, last_contacted_by_user_id,
|
||||
closed_at, closed_by_user_id, rebooked_appointment_id,
|
||||
create_time, update_time, version
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
l.store_id,
|
||||
sc.id,
|
||||
l.id,
|
||||
l.report_id,
|
||||
'pending',
|
||||
NULL,
|
||||
COALESCE(l.remind_date, CURRENT_DATE),
|
||||
NULL,
|
||||
0,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
COALESCE(l.create_time, l.update_time, NOW()),
|
||||
COALESCE(l.update_time, l.create_time, NOW()),
|
||||
0
|
||||
FROM t_report_lead l
|
||||
JOIN t_store_customer sc
|
||||
ON sc.store_id = l.store_id
|
||||
AND sc.phone = l.phone
|
||||
AND sc.deleted = 0
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND l.remind_status = 'pending';
|
||||
|
||||
-- 历史开放任务形成可靠的 task_created 事实;不复制手机号、IP、token 或微信标识。
|
||||
INSERT INTO t_business_event (
|
||||
event_id, event_type, event_version, store_id, store_customer_id,
|
||||
aggregate_type, aggregate_id, actor_user_id, actor_role, source,
|
||||
occurred_at, metadata_json, idempotency_key, create_time
|
||||
)
|
||||
SELECT
|
||||
UUID(),
|
||||
'follow_up_created',
|
||||
1,
|
||||
t.store_id,
|
||||
t.store_customer_id,
|
||||
'follow_up_task',
|
||||
t.id,
|
||||
NULL,
|
||||
NULL,
|
||||
'migration',
|
||||
t.create_time,
|
||||
CONCAT('{"dueDate":"', t.due_date, '"}'),
|
||||
CONCAT('follow_up_created:', t.id),
|
||||
NOW()
|
||||
FROM t_follow_up_task t;
|
||||
|
||||
-- 验证:以下五项必须全部为 0。
|
||||
SELECT COUNT(*) AS pending_leads_without_open_task_count
|
||||
FROM t_report_lead l
|
||||
LEFT JOIN t_follow_up_task t
|
||||
ON t.source_lead_id = l.id
|
||||
AND t.status IN ('pending', 'in_progress')
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND l.remind_status = 'pending'
|
||||
AND t.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS invalid_follow_up_customer_scope_count
|
||||
FROM t_follow_up_task t
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.id = t.store_customer_id
|
||||
AND sc.store_id = t.store_id
|
||||
LEFT JOIN t_store_customer canonical
|
||||
ON canonical.id = CASE
|
||||
WHEN sc.deleted = 0 THEN sc.id
|
||||
ELSE sc.merged_into_store_customer_id
|
||||
END
|
||||
AND canonical.store_id = t.store_id
|
||||
AND canonical.deleted = 0
|
||||
AND canonical.merged_into_store_customer_id IS NULL
|
||||
WHERE sc.id IS NULL OR canonical.id IS NULL;
|
||||
|
||||
SELECT COUNT(*) AS invalid_follow_up_state_count
|
||||
FROM t_follow_up_task t
|
||||
WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled')
|
||||
OR (t.status IN ('pending', 'in_progress')
|
||||
AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL
|
||||
OR t.rebooked_appointment_id IS NOT NULL))
|
||||
OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL)
|
||||
OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL)
|
||||
OR (t.status = 'completed'
|
||||
AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact')
|
||||
OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL
|
||||
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL
|
||||
OR t.attempt_count < 1))
|
||||
OR (t.status = 'canceled'
|
||||
AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL
|
||||
OR t.closed_by_user_id IS NOT NULL))
|
||||
OR (t.last_attempt_outcome IS NOT NULL
|
||||
AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later')
|
||||
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL))
|
||||
OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL)
|
||||
OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL)
|
||||
OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM t_appointment a
|
||||
WHERE a.id = t.rebooked_appointment_id
|
||||
AND a.store_id = t.store_id
|
||||
AND a.deleted = 0
|
||||
))
|
||||
OR t.attempt_count < 0;
|
||||
|
||||
SELECT COUNT(*) AS duplicate_open_follow_up_task_count
|
||||
FROM (
|
||||
SELECT source_lead_id
|
||||
FROM t_follow_up_task
|
||||
WHERE status IN ('pending', 'in_progress')
|
||||
GROUP BY source_lead_id
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicated;
|
||||
|
||||
SELECT COUNT(*) AS follow_up_tasks_without_created_event_count
|
||||
FROM t_follow_up_task t
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('follow_up_created:', t.id)
|
||||
AND e.event_type = 'follow_up_created'
|
||||
AND e.store_id = t.store_id
|
||||
AND e.aggregate_type = 'follow_up_task'
|
||||
AND e.aggregate_id = t.id
|
||||
WHERE e.id IS NULL;
|
||||
@ -1,26 +0,0 @@
|
||||
-- Petstore Phase 0:StoreCustomer 合并别名与事实时间线连续性
|
||||
-- 前置:20260802_create_store_onboarding.sql 已执行。
|
||||
-- 语义:BusinessEvent 不改写;被合并的软删除主档通过同店 canonical 指针保留历史事实归属。
|
||||
|
||||
ALTER TABLE t_store_customer
|
||||
ADD COLUMN merged_into_store_customer_id BIGINT NULL
|
||||
COMMENT '软删除客户投影并入的同店 canonical StoreCustomer ID';
|
||||
|
||||
CREATE INDEX idx_store_customer_merge
|
||||
ON t_store_customer (store_id, merged_into_store_customer_id);
|
||||
|
||||
-- 验证:以下两项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_store_customer_alias_state_count
|
||||
FROM t_store_customer sc
|
||||
WHERE (sc.deleted = 0 AND sc.merged_into_store_customer_id IS NOT NULL)
|
||||
OR sc.id = sc.merged_into_store_customer_id;
|
||||
|
||||
SELECT COUNT(*) AS invalid_store_customer_alias_target_count
|
||||
FROM t_store_customer sc_alias
|
||||
LEFT JOIN t_store_customer canonical
|
||||
ON canonical.id = sc_alias.merged_into_store_customer_id
|
||||
AND canonical.store_id = sc_alias.store_id
|
||||
AND canonical.deleted = 0
|
||||
AND canonical.merged_into_store_customer_id IS NULL
|
||||
WHERE sc_alias.merged_into_store_customer_id IS NOT NULL
|
||||
AND canonical.id IS NULL;
|
||||
@ -1,89 +0,0 @@
|
||||
-- Petstore Phase 0:门店开通状态、一次性员工邀请与不可变操作审计
|
||||
-- 前置:20260801_create_report_send_status.sql 已执行。
|
||||
-- 语义:历史门店开通状态不可推断,统一为 unknown;新门店由应用写 in_progress。
|
||||
|
||||
ALTER TABLE t_store
|
||||
ADD COLUMN onboarding_status VARCHAR(16) NULL COMMENT 'unknown | in_progress | completed';
|
||||
|
||||
ALTER TABLE t_store
|
||||
ADD COLUMN onboarding_completed_at DATETIME NULL COMMENT '老板显式完成开通时间';
|
||||
|
||||
ALTER TABLE t_store
|
||||
ADD COLUMN onboarding_completed_by_user_id BIGINT NULL COMMENT '显式完成开通的老板用户';
|
||||
|
||||
UPDATE t_store
|
||||
SET onboarding_status = 'unknown';
|
||||
|
||||
ALTER TABLE t_store
|
||||
MODIFY COLUMN onboarding_status VARCHAR(16) NOT NULL DEFAULT 'in_progress'
|
||||
COMMENT 'unknown | in_progress | completed';
|
||||
|
||||
CREATE TABLE t_staff_invitation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
invitation_id VARCHAR(36) NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT '原始 token 的 SHA-256;原始 token 不落库',
|
||||
store_id BIGINT NOT NULL,
|
||||
invited_name VARCHAR(64) NOT NULL,
|
||||
invited_phone VARCHAR(20) NOT NULL COMMENT '仅服务端用于微信核验匹配;客户端只返回脱敏值',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending | accepted | revoked | expired',
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_by_user_id BIGINT NOT NULL,
|
||||
accepted_by_user_id BIGINT NULL,
|
||||
accepted_at DATETIME NULL,
|
||||
revoked_by_user_id BIGINT NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
CONSTRAINT uk_staff_invitation_public_id UNIQUE (invitation_id),
|
||||
CONSTRAINT uk_staff_invitation_token_hash UNIQUE (token_hash),
|
||||
INDEX idx_staff_invite_store_status (store_id, status, expires_at),
|
||||
INDEX idx_staff_invite_phone (store_id, invited_phone, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='限时、一次性员工邀请';
|
||||
|
||||
CREATE TABLE t_audit_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
audit_id VARCHAR(36) NOT NULL,
|
||||
store_id BIGINT NOT NULL,
|
||||
actor_user_id BIGINT NULL,
|
||||
actor_role VARCHAR(16) NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target_type VARCHAR(32) NOT NULL,
|
||||
target_id VARCHAR(64) NULL,
|
||||
outcome VARCHAR(16) NOT NULL,
|
||||
metadata_json TEXT NOT NULL COMMENT '服务端白名单低敏标量 JSON',
|
||||
occurred_at DATETIME NOT NULL,
|
||||
create_time DATETIME NOT NULL,
|
||||
CONSTRAINT uk_audit_log_audit_id UNIQUE (audit_id),
|
||||
INDEX idx_audit_store_time (store_id, occurred_at),
|
||||
INDEX idx_audit_action_time (action, occurred_at),
|
||||
INDEX idx_audit_target (target_type, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变操作审计';
|
||||
|
||||
-- 验证:以下六项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_store_onboarding_status_count
|
||||
FROM t_store
|
||||
WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed');
|
||||
|
||||
SELECT COUNT(*) AS completed_onboarding_without_receipt_count
|
||||
FROM t_store
|
||||
WHERE onboarding_status = 'completed'
|
||||
AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL);
|
||||
|
||||
SELECT COUNT(*) AS incomplete_onboarding_with_receipt_count
|
||||
FROM t_store
|
||||
WHERE onboarding_status IN ('unknown', 'in_progress')
|
||||
AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL);
|
||||
|
||||
SELECT COUNT(*) AS invalid_staff_invitation_status_count
|
||||
FROM t_staff_invitation
|
||||
WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired');
|
||||
|
||||
SELECT COUNT(*) AS inconsistent_staff_invitation_acceptance_count
|
||||
FROM t_staff_invitation
|
||||
WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL))
|
||||
OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL));
|
||||
|
||||
SELECT COUNT(*) AS inconsistent_staff_invitation_revocation_count
|
||||
FROM t_staff_invitation
|
||||
WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL))
|
||||
OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL));
|
||||
@ -1,27 +0,0 @@
|
||||
# 数据库迁移
|
||||
|
||||
当前项目尚未接入 Flyway/Liquibase。本目录存放经过评审的一次性 MySQL 迁移脚本;生产执行规则:
|
||||
|
||||
1. 先完成数据库备份并记录备份位置。
|
||||
2. 在测试库执行脚本,核对文件末尾的验证查询。
|
||||
3. 由发布负责人在生产低峰期执行,同一个脚本不得重复执行。
|
||||
4. 验证通过后再启动使用新字段的 backend 版本。
|
||||
5. 迁移脚本只允许向前兼容;本阶段不得删除 legacy 字段。
|
||||
|
||||
`20260801_split_service_identity.sql` 为身份语义拆分迁移:新增客户、创建人和报告作者字段,保留 `user_id` / `assigned_user_id` 兼容旧版本。
|
||||
|
||||
`20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。
|
||||
|
||||
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
|
||||
|
||||
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
|
||||
|
||||
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
|
||||
|
||||
`20260802_create_store_onboarding.sql` 必须在报告发送状态迁移之后执行:为历史门店写入不可推断的 `unknown` 开通状态,建立只存 token 摘要的限时员工邀请与低敏不可变操作审计。脚本末尾六个验证计数必须为 0;不得用 legacy `invite_code` 恢复公开员工注册。
|
||||
|
||||
`20260802_create_store_customer_timeline.sql` 必须在门店开通迁移之后执行:为被合并的软删除 StoreCustomer 投影增加同店 canonical 指针。既有 BusinessEvent 不改写,事实时间线同时读取 canonical 与别名的历史事件;脚本末尾两个验证计数必须为 0。
|
||||
|
||||
`20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。
|
||||
|
||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
@ -1,374 +0,0 @@
|
||||
-- Petstore Pilot Metrics v2(MySQL 5.7+/8.x,只读)
|
||||
-- 默认时间窗为最近 30 天,但默认门店集合为 0(不返回业务数据)。
|
||||
-- 执行人必须把 @pilot_store_ids 显式改为 3~5 个试点门店内部 ID,例如 '101,205,309'。
|
||||
-- 输出仅含 store_id、计数、比率和时效,不含客户/员工 PII。
|
||||
|
||||
SET @pilot_to = DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY);
|
||||
SET @pilot_from = DATE_SUB(@pilot_to, INTERVAL 30 DAY);
|
||||
SET @pilot_store_ids = '0';
|
||||
|
||||
-- 1. 核心服务与报告漏斗。[from, to),各事实按稳定 aggregate_id 去重。
|
||||
-- 比率只认同窗且时间顺序正确的 cohort,避免跨窗口事件把比率推到 100% 以上。
|
||||
SELECT
|
||||
e.store_id,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'appointment_created' THEN e.aggregate_id END) AS appointment_created_count,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'service_started' THEN e.aggregate_id END) AS service_started_count,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'service_completed' THEN e.aggregate_id END) AS complete_service_count,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'report_submitted' THEN e.aggregate_id END) AS report_submitted_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'report_sent'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM t_business_event submitted
|
||||
WHERE submitted.store_id = e.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = e.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END) AS report_sent_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN e.event_type IN ('report_opened', 'report_reopened')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM t_business_event sent
|
||||
JOIN t_business_event submitted
|
||||
ON submitted.store_id = sent.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = sent.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= sent.occurred_at
|
||||
WHERE sent.store_id = e.store_id
|
||||
AND sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.aggregate_id = e.aggregate_id
|
||||
AND sent.occurred_at >= @pilot_from
|
||||
AND sent.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END) AS report_opened_count,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'lead_submitted' THEN e.aggregate_id END) AS lead_submitted_count,
|
||||
COUNT(DISTINCT CASE WHEN e.event_type = 'rebook_created' THEN e.aggregate_id END) AS rebook_created_count,
|
||||
ROUND(
|
||||
100 * COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'service_completed'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM t_business_event started
|
||||
WHERE started.store_id = e.store_id
|
||||
AND started.event_type = 'service_started'
|
||||
AND started.aggregate_type = 'appointment'
|
||||
AND started.aggregate_id = e.aggregate_id
|
||||
AND started.occurred_at >= @pilot_from
|
||||
AND started.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END)
|
||||
/ NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'service_started' THEN e.aggregate_id END), 0),
|
||||
1
|
||||
) AS service_completion_rate_pct,
|
||||
ROUND(
|
||||
100 * COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'report_sent'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM t_business_event submitted
|
||||
WHERE submitted.store_id = e.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = e.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END)
|
||||
/ NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'report_submitted' THEN e.aggregate_id END), 0),
|
||||
1
|
||||
) AS report_sent_rate_pct,
|
||||
ROUND(
|
||||
100 * COUNT(DISTINCT CASE
|
||||
WHEN e.event_type IN ('report_opened', 'report_reopened')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM t_business_event sent
|
||||
JOIN t_business_event submitted
|
||||
ON submitted.store_id = sent.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = sent.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= sent.occurred_at
|
||||
WHERE sent.store_id = e.store_id
|
||||
AND sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.aggregate_id = e.aggregate_id
|
||||
AND sent.occurred_at >= @pilot_from
|
||||
AND sent.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END)
|
||||
/ NULLIF(COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'report_sent'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM t_business_event submitted
|
||||
WHERE submitted.store_id = e.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = e.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END), 0),
|
||||
1
|
||||
) AS report_open_rate_pct,
|
||||
ROUND(
|
||||
100 * COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'lead_submitted'
|
||||
AND lead_fact.report_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM t_business_event opened
|
||||
JOIN t_business_event sent
|
||||
ON sent.store_id = opened.store_id
|
||||
AND sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.aggregate_id = opened.aggregate_id
|
||||
AND sent.occurred_at >= @pilot_from
|
||||
AND sent.occurred_at <= opened.occurred_at
|
||||
JOIN t_business_event submitted
|
||||
ON submitted.store_id = sent.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = sent.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= sent.occurred_at
|
||||
WHERE opened.store_id = e.store_id
|
||||
AND opened.event_type IN ('report_opened', 'report_reopened')
|
||||
AND opened.aggregate_type = 'report'
|
||||
AND opened.aggregate_id = lead_fact.report_id
|
||||
AND opened.occurred_at >= @pilot_from
|
||||
AND opened.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN lead_fact.report_id
|
||||
END)
|
||||
/ NULLIF(COUNT(DISTINCT CASE
|
||||
WHEN e.event_type IN ('report_opened', 'report_reopened')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM t_business_event sent
|
||||
JOIN t_business_event submitted
|
||||
ON submitted.store_id = sent.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = sent.aggregate_id
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at <= sent.occurred_at
|
||||
WHERE sent.store_id = e.store_id
|
||||
AND sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.aggregate_id = e.aggregate_id
|
||||
AND sent.occurred_at >= @pilot_from
|
||||
AND sent.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN e.aggregate_id
|
||||
END), 0),
|
||||
1
|
||||
) AS opened_to_lead_rate_pct,
|
||||
ROUND(
|
||||
100 * COUNT(DISTINCT CASE
|
||||
WHEN e.event_type = 'rebook_created'
|
||||
AND rebook.source_lead_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM t_business_event submitted_lead
|
||||
WHERE submitted_lead.store_id = e.store_id
|
||||
AND submitted_lead.event_type = 'lead_submitted'
|
||||
AND submitted_lead.aggregate_type = 'report_lead'
|
||||
AND submitted_lead.aggregate_id = rebook.source_lead_id
|
||||
AND submitted_lead.occurred_at >= @pilot_from
|
||||
AND submitted_lead.occurred_at <= e.occurred_at
|
||||
)
|
||||
THEN rebook.source_lead_id
|
||||
END)
|
||||
/ NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'lead_submitted' THEN e.aggregate_id END), 0),
|
||||
1
|
||||
) AS lead_to_rebook_rate_pct
|
||||
FROM t_business_event e
|
||||
LEFT JOIN t_report_lead lead_fact
|
||||
ON e.event_type = 'lead_submitted'
|
||||
AND lead_fact.id = e.aggregate_id
|
||||
AND lead_fact.store_id = e.store_id
|
||||
LEFT JOIN t_follow_up_task rebook
|
||||
ON e.event_type = 'rebook_created'
|
||||
AND rebook.rebooked_appointment_id = e.aggregate_id
|
||||
AND rebook.store_id = e.store_id
|
||||
WHERE e.occurred_at >= @pilot_from
|
||||
AND e.occurred_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(e.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
GROUP BY e.store_id
|
||||
ORDER BY e.store_id;
|
||||
|
||||
-- 2. 报告发送时效:只纳入已经完整观察 24 小时的提交 cohort。
|
||||
SELECT
|
||||
submitted.store_id,
|
||||
COUNT(*) AS matured_submitted_report_count,
|
||||
SUM(CASE
|
||||
WHEN sent.occurred_at IS NOT NULL
|
||||
AND sent.occurred_at >= submitted.occurred_at
|
||||
AND sent.occurred_at <= DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR)
|
||||
THEN 1 ELSE 0
|
||||
END) AS sent_within_24h_count,
|
||||
ROUND(
|
||||
100 * SUM(CASE
|
||||
WHEN sent.occurred_at IS NOT NULL
|
||||
AND sent.occurred_at >= submitted.occurred_at
|
||||
AND sent.occurred_at <= DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR)
|
||||
THEN 1 ELSE 0
|
||||
END) / NULLIF(COUNT(*), 0),
|
||||
1
|
||||
) AS sent_within_24h_rate_pct
|
||||
FROM t_business_event submitted
|
||||
LEFT JOIN t_business_event sent
|
||||
ON sent.store_id = submitted.store_id
|
||||
AND sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.aggregate_id = submitted.aggregate_id
|
||||
WHERE submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.occurred_at >= @pilot_from
|
||||
AND submitted.occurred_at < DATE_SUB(@pilot_to, INTERVAL 24 HOUR)
|
||||
AND FIND_IN_SET(CAST(submitted.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
GROUP BY submitted.store_id
|
||||
ORDER BY submitted.store_id;
|
||||
|
||||
-- 3. 回访执行质量:以任务 due_date 落在窗口内为队列口径;退订不计员工完成。
|
||||
SELECT
|
||||
t.store_id,
|
||||
COUNT(*) AS due_follow_up_count,
|
||||
SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) AS staff_completed_count,
|
||||
SUM(CASE WHEN t.status = 'canceled' AND t.outcome = 'unsubscribed' THEN 1 ELSE 0 END) AS unsubscribed_count,
|
||||
SUM(CASE WHEN t.status IN ('pending', 'in_progress') THEN 1 ELSE 0 END) AS still_open_count,
|
||||
SUM(CASE
|
||||
WHEN t.status IN ('pending', 'in_progress') AND t.due_date < CURRENT_DATE
|
||||
THEN 1 ELSE 0
|
||||
END) AS overdue_open_count,
|
||||
SUM(CASE
|
||||
WHEN t.status = 'completed'
|
||||
AND t.closed_at < DATE_ADD(t.due_date, INTERVAL 3 DAY)
|
||||
THEN 1 ELSE 0
|
||||
END) AS completed_by_due_plus_2d_count,
|
||||
ROUND(
|
||||
100 * SUM(CASE
|
||||
WHEN t.status = 'completed'
|
||||
AND t.closed_at < DATE_ADD(t.due_date, INTERVAL 3 DAY)
|
||||
THEN 1 ELSE 0
|
||||
END) / NULLIF(SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
1
|
||||
) AS on_time_completion_rate_pct,
|
||||
ROUND(AVG(t.attempt_count), 2) AS average_attempt_count,
|
||||
SUM(CASE WHEN t.outcome = 'rebooked' THEN 1 ELSE 0 END) AS rebooked_task_count,
|
||||
SUM(CASE WHEN t.outcome = 'not_interested' THEN 1 ELSE 0 END) AS not_interested_count,
|
||||
SUM(CASE WHEN t.outcome = 'invalid_contact' THEN 1 ELSE 0 END) AS invalid_contact_count
|
||||
FROM t_follow_up_task t
|
||||
WHERE t.due_date >= DATE(@pilot_from)
|
||||
AND t.due_date < DATE(@pilot_to)
|
||||
AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
GROUP BY t.store_id
|
||||
ORDER BY t.store_id;
|
||||
|
||||
-- 4. 约 200 次完整服务样本闸门:严格使用试点窗口,按店 + 最后一行试点店合计。
|
||||
SELECT
|
||||
e.store_id,
|
||||
COUNT(DISTINCT e.aggregate_id) AS complete_service_count,
|
||||
MIN(e.occurred_at) AS first_complete_service_at,
|
||||
MAX(e.occurred_at) AS latest_complete_service_at
|
||||
FROM t_business_event e
|
||||
WHERE e.event_type = 'service_completed'
|
||||
AND e.aggregate_type = 'appointment'
|
||||
AND e.occurred_at >= @pilot_from
|
||||
AND e.occurred_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(e.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
GROUP BY e.store_id WITH ROLLUP;
|
||||
|
||||
-- 5. 指标可靠性反例:四项均须为 0;完整发布还必须通过 31 项 production preflight。
|
||||
SELECT COUNT(*) AS completed_services_without_submitted_report_event_count
|
||||
FROM t_business_event completed
|
||||
LEFT JOIN t_report r
|
||||
ON r.appointment_id = completed.aggregate_id
|
||||
AND r.store_id = completed.store_id
|
||||
AND r.deleted = 0
|
||||
LEFT JOIN t_business_event submitted
|
||||
ON submitted.store_id = completed.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = r.id
|
||||
WHERE completed.event_type = 'service_completed'
|
||||
AND completed.aggregate_type = 'appointment'
|
||||
AND (r.id IS NULL OR submitted.id IS NULL)
|
||||
AND completed.occurred_at >= @pilot_from
|
||||
AND completed.occurred_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(completed.store_id AS CHAR), @pilot_store_ids) > 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_rebook_attribution_count
|
||||
FROM t_follow_up_task t
|
||||
LEFT JOIN t_appointment a
|
||||
ON a.id = t.rebooked_appointment_id
|
||||
AND a.store_id = t.store_id
|
||||
AND a.deleted = 0
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('rebook_created:', t.rebooked_appointment_id)
|
||||
AND e.event_type = 'rebook_created'
|
||||
AND e.store_id = t.store_id
|
||||
AND e.aggregate_type = 'appointment'
|
||||
AND e.aggregate_id = t.rebooked_appointment_id
|
||||
WHERE t.outcome = 'rebooked'
|
||||
AND (a.id IS NULL OR e.id IS NULL)
|
||||
AND t.closed_at >= @pilot_from
|
||||
AND t.closed_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0;
|
||||
|
||||
SELECT COUNT(*) AS duplicate_open_follow_up_task_count
|
||||
FROM (
|
||||
SELECT t.store_id, t.source_lead_id
|
||||
FROM t_follow_up_task t
|
||||
WHERE t.status IN ('pending', 'in_progress')
|
||||
AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
GROUP BY t.store_id, t.source_lead_id
|
||||
HAVING COUNT(*) > 1
|
||||
) duplicated;
|
||||
|
||||
SELECT SUM(anomaly_count) AS invalid_metric_event_order_count
|
||||
FROM (
|
||||
SELECT COUNT(*) AS anomaly_count
|
||||
FROM t_business_event sent
|
||||
JOIN t_business_event submitted
|
||||
ON submitted.store_id = sent.store_id
|
||||
AND submitted.event_type = 'report_submitted'
|
||||
AND submitted.aggregate_type = 'report'
|
||||
AND submitted.aggregate_id = sent.aggregate_id
|
||||
WHERE sent.event_type = 'report_sent'
|
||||
AND sent.aggregate_type = 'report'
|
||||
AND sent.occurred_at < submitted.occurred_at
|
||||
AND sent.occurred_at >= @pilot_from
|
||||
AND sent.occurred_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(sent.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT COUNT(*) AS anomaly_count
|
||||
FROM t_business_event rebooked
|
||||
JOIN t_follow_up_task task
|
||||
ON task.store_id = rebooked.store_id
|
||||
AND task.rebooked_appointment_id = rebooked.aggregate_id
|
||||
JOIN t_business_event submitted_lead
|
||||
ON submitted_lead.store_id = rebooked.store_id
|
||||
AND submitted_lead.event_type = 'lead_submitted'
|
||||
AND submitted_lead.aggregate_type = 'report_lead'
|
||||
AND submitted_lead.aggregate_id = task.source_lead_id
|
||||
WHERE rebooked.event_type = 'rebook_created'
|
||||
AND rebooked.aggregate_type = 'appointment'
|
||||
AND rebooked.occurred_at < submitted_lead.occurred_at
|
||||
AND rebooked.occurred_at >= @pilot_from
|
||||
AND rebooked.occurred_at < @pilot_to
|
||||
AND FIND_IN_SET(CAST(rebooked.store_id AS CHAR), @pilot_store_ids) > 0
|
||||
) anomalies;
|
||||
@ -1,7 +0,0 @@
|
||||
# 反代到 Spring Boot 时加入(server 或 location /api/ 内)
|
||||
# 默认 client_max_body_size 仅 1m,会导致大视频返回 HTTP 413,小程序提示「文件过大」
|
||||
|
||||
client_max_body_size 200m;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
@ -1,71 +0,0 @@
|
||||
# Dedicated Petstore server blocks. Replace domains/certificate paths before use.
|
||||
# Keep these blocks separate from Gitea/GitLab configurations on a shared host.
|
||||
|
||||
# Deliberately log $uri instead of $request_uri/$request so report and invitation
|
||||
# tokens carried in query strings never enter Nginx access logs.
|
||||
log_format petstore_safe '$remote_addr - $remote_user [$time_local] '
|
||||
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
|
||||
'"$http_referer" "$http_user_agent"';
|
||||
|
||||
upstream petstore_backend_8080 {
|
||||
server 127.0.0.1:8080;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.petstore.invalid;
|
||||
ssl_certificate /etc/letsencrypt/live/api.petstore.invalid/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.petstore.invalid/privkey.pem;
|
||||
|
||||
client_max_body_size 200m;
|
||||
access_log /var/log/nginx/petstore-api.access.log petstore_safe;
|
||||
|
||||
location / {
|
||||
proxy_pass http://petstore_backend_8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name admin.petstore.invalid;
|
||||
root /opt/petstore/admin/current;
|
||||
index index.html;
|
||||
ssl_certificate /etc/letsencrypt/live/admin.petstore.invalid/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/admin.petstore.invalid/privkey.pem;
|
||||
access_log /var/log/nginx/petstore-admin.access.log petstore_safe;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://petstore_backend_8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
location /uploads/ {
|
||||
proxy_pass http://petstore_backend_8080;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name report.petstore.invalid;
|
||||
root /opt/petstore/frontend-h5/current;
|
||||
index index.html;
|
||||
ssl_certificate /etc/letsencrypt/live/report.petstore.invalid/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/report.petstore.invalid/privkey.pem;
|
||||
access_log /var/log/nginx/petstore-report.access.log petstore_safe;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
# Copy to /etc/petstore/backend.env with mode 0600. Never commit real values.
|
||||
SPRING_PROFILES_ACTIVE=production
|
||||
SPRING_JPA_HIBERNATE_DDL_AUTO=validate
|
||||
SPRING_JPA_SHOW_SQL=false
|
||||
|
||||
MYSQL_URL='jdbc:mysql://db-host:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai'
|
||||
MYSQL_USERNAME=
|
||||
MYSQL_PASSWORD=
|
||||
|
||||
WECHAT_APPID=
|
||||
WECHAT_APPSECRET=
|
||||
PETSTORE_SESSION_SECRET=
|
||||
PETSTORE_SESSION_TTL_SECONDS=604800
|
||||
SMS_UNIVERSAL_CODE=
|
||||
|
||||
APP_BASE_URL=https://api.petstore.invalid
|
||||
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
|
||||
UPLOAD_PATH=/var/lib/petstore/uploads
|
||||
|
||||
HIGHLIGHT_FFMPEG=/usr/bin/ffmpeg
|
||||
HIGHLIGHT_FFPROBE=/usr/bin/ffprobe
|
||||
HIGHLIGHT_MAX_DURATION_SEC=300
|
||||
HIGHLIGHT_BGM_PATH=
|
||||
HIGHLIGHT_FONT_PATH=
|
||||
@ -1,24 +0,0 @@
|
||||
[Unit]
|
||||
Description=Petstore backend
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=petstore
|
||||
Group=petstore
|
||||
WorkingDirectory=/opt/petstore/backend/current
|
||||
EnvironmentFile=/etc/petstore/backend.env
|
||||
ExecStart=/usr/bin/java -jar /opt/petstore/backend/current/petstore-backend.jar
|
||||
SuccessExitStatus=143
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/petstore/uploads
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
API_ORIGIN="${API_ORIGIN:-}"
|
||||
SMOKE_ALLOWED_ORIGIN="${SMOKE_ALLOWED_ORIGIN:-}"
|
||||
SMOKE_BLOCKED_ORIGIN="${SMOKE_BLOCKED_ORIGIN:-https://blocked.petstore.invalid}"
|
||||
SMOKE_BEARER_TOKEN="${SMOKE_BEARER_TOKEN:-}"
|
||||
|
||||
[[ "$API_ORIGIN" == https://* ]] || {
|
||||
printf 'SMOKE FAIL: API_ORIGIN must be an https origin\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$API_ORIGIN" != *localhost* && "$API_ORIGIN" != *127.0.0.1* && "$API_ORIGIN" != *.invalid* ]] || {
|
||||
printf 'SMOKE FAIL: API_ORIGIN must use a production domain\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$API_ORIGIN" != *.example* && "$API_ORIGIN" != *.test* ]] || {
|
||||
printf 'SMOKE FAIL: API_ORIGIN must not use a reserved example/test domain\n' >&2
|
||||
exit 1
|
||||
}
|
||||
API_ORIGIN="${API_ORIGIN%/}"
|
||||
|
||||
[[ "$SMOKE_ALLOWED_ORIGIN" == https://* ]] || {
|
||||
printf 'SMOKE FAIL: SMOKE_ALLOWED_ORIGIN must be an https origin\n' >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$SMOKE_ALLOWED_ORIGIN" != *localhost* && "$SMOKE_ALLOWED_ORIGIN" != *127.0.0.1* \
|
||||
&& "$SMOKE_ALLOWED_ORIGIN" != *.invalid* && "$SMOKE_ALLOWED_ORIGIN" != *.example* \
|
||||
&& "$SMOKE_ALLOWED_ORIGIN" != *.test* ]] || {
|
||||
printf 'SMOKE FAIL: SMOKE_ALLOWED_ORIGIN must use a production domain\n' >&2
|
||||
exit 1
|
||||
}
|
||||
SMOKE_ALLOWED_ORIGIN="${SMOKE_ALLOWED_ORIGIN%/}"
|
||||
[[ "$SMOKE_BLOCKED_ORIGIN" != "$SMOKE_ALLOWED_ORIGIN" ]] || {
|
||||
printf 'SMOKE FAIL: blocked and allowed origins must differ\n' >&2
|
||||
exit 1
|
||||
}
|
||||
python3 -c 'import sys,urllib.parse
|
||||
for raw in sys.argv[1:]:
|
||||
u=urllib.parse.urlsplit(raw)
|
||||
assert u.scheme == "https" and u.hostname and not u.username and not u.password
|
||||
assert u.path in ("", "/") and not u.query and not u.fragment' \
|
||||
"$SMOKE_ALLOWED_ORIGIN" "$SMOKE_BLOCKED_ORIGIN" || {
|
||||
printf 'SMOKE FAIL: CORS smoke values must be explicit https origins\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
json_assert_status_up() {
|
||||
python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("status") == "UP", d; print("UP")'
|
||||
}
|
||||
|
||||
printf '1/7 liveness: '
|
||||
curl --fail --silent --show-error "$API_ORIGIN/actuator/health/liveness" | json_assert_status_up
|
||||
|
||||
printf '2/7 readiness: '
|
||||
curl --fail --silent --show-error "$API_ORIGIN/actuator/health/readiness" | json_assert_status_up
|
||||
|
||||
STORES="$(curl --fail --silent --show-error "$API_ORIGIN/api/store/list")"
|
||||
STORE_ID="$(printf '%s' "$STORES" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; rows=d.get("data") or []; assert rows,"no pilot store"; print(rows[0]["id"])')"
|
||||
printf '3/7 public store list: PASS\n'
|
||||
|
||||
SERVICES="$(curl --fail --silent --show-error "$API_ORIGIN/api/service-type/list?storeId=$STORE_ID")"
|
||||
SERVICE_NAME="$(printf '%s' "$SERVICES" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; rows=d.get("data") or []; assert rows,"no service type"; assert all(int(x.get("durationMinutes") or 0)>=30 for x in rows),rows; print(rows[0]["name"])')"
|
||||
printf '4/7 service duration contract: PASS\n'
|
||||
|
||||
if date -v+1d +%F >/dev/null 2>&1; then
|
||||
TOMORROW="$(date -v+1d +%F)"
|
||||
else
|
||||
TOMORROW="$(date -d tomorrow +%F)"
|
||||
fi
|
||||
curl --fail --silent --show-error --get "$API_ORIGIN/api/appointment/available-slots" \
|
||||
--data-urlencode "storeId=$STORE_ID" \
|
||||
--data-urlencode "date=$TOMORROW" \
|
||||
--data-urlencode "serviceType=$SERVICE_NAME" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; data=d["data"]; assert int(data["durationMinutes"])>=30; assert int(data["bookingCapacity"])>=1; assert isinstance(data.get("slots"),list); print("5/7 capacity slot contract: PASS")'
|
||||
|
||||
curl --fail --silent --show-error --dump-header - --output /dev/null \
|
||||
-H "Origin: $SMOKE_ALLOWED_ORIGIN" \
|
||||
"$API_ORIGIN/api/store/list" \
|
||||
| SMOKE_EXPECTED_ORIGIN="$SMOKE_ALLOWED_ORIGIN" python3 -c 'import os,sys; expected=os.environ["SMOKE_EXPECTED_ORIGIN"].lower(); headers=sys.stdin.read().lower(); assert f"access-control-allow-origin: {expected}" in headers,headers; assert "access-control-allow-origin: *" not in headers,headers; print("6/7 allowed CORS origin: PASS")'
|
||||
|
||||
BLOCKED_STATUS="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
-H "Origin: $SMOKE_BLOCKED_ORIGIN" \
|
||||
"$API_ORIGIN/api/store/list")"
|
||||
[[ "$BLOCKED_STATUS" == "403" ]] || {
|
||||
printf 'SMOKE FAIL: blocked CORS origin returned HTTP %s\n' "$BLOCKED_STATUS" >&2
|
||||
exit 1
|
||||
}
|
||||
printf '7/7 blocked CORS origin: PASS\n'
|
||||
|
||||
if [[ -n "$SMOKE_BEARER_TOKEN" ]]; then
|
||||
AUTH_HEADER="Authorization: Bearer $SMOKE_BEARER_TOKEN"
|
||||
curl --fail --silent --show-error -H "$AUTH_HEADER" "$API_ORIGIN/api/admin/store" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; assert int(d["data"]["bookingCapacity"])>=1; print("protected admin store: PASS")'
|
||||
curl --fail --silent --show-error -H "$AUTH_HEADER" "$API_ORIGIN/api/appointment/list?page=1&pageSize=5" \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d.get("code")==200,d; print("protected appointment list: PASS")'
|
||||
else
|
||||
printf 'Protected smoke SKIPPED: SMOKE_BEARER_TOKEN not provided\n'
|
||||
fi
|
||||
|
||||
printf 'Production read-only smoke PASS\n'
|
||||
@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
JAR_PATH="${JAR_PATH:-$BACKEND_DIR/target/petstore-backend-1.0.0.jar}"
|
||||
JAVA_BIN="${JAVA_BIN:-java}"
|
||||
BUILD_FIRST="${BUILD_FIRST:-1}"
|
||||
|
||||
fail() {
|
||||
printf 'PRECHECK FAIL: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
[[ -n "${!name:-}" ]] || fail "missing environment variable: $name"
|
||||
}
|
||||
|
||||
for name in MYSQL_URL MYSQL_USERNAME MYSQL_PASSWORD WECHAT_APPID WECHAT_APPSECRET \
|
||||
PETSTORE_SESSION_SECRET APP_BASE_URL UPLOAD_PATH CORS_ALLOWED_ORIGINS; do
|
||||
require_env "$name"
|
||||
done
|
||||
|
||||
[[ "$APP_BASE_URL" == https://* ]] || fail "APP_BASE_URL must use https"
|
||||
[[ "$APP_BASE_URL" != *localhost* ]] || fail "APP_BASE_URL must not use localhost"
|
||||
[[ "$APP_BASE_URL" != *.invalid* ]] || fail "APP_BASE_URL must not use a placeholder domain"
|
||||
[[ "$APP_BASE_URL" != *.example* && "$APP_BASE_URL" != *.test* ]] \
|
||||
|| fail "APP_BASE_URL must not use a reserved example/test domain"
|
||||
[[ ${#PETSTORE_SESSION_SECRET} -ge 32 ]] || fail "PETSTORE_SESSION_SECRET must be at least 32 characters"
|
||||
[[ "$PETSTORE_SESSION_SECRET" != "dev-change-me" ]] || fail "PETSTORE_SESSION_SECRET must not use the default"
|
||||
SESSION_TTL_SECONDS="${PETSTORE_SESSION_TTL_SECONDS:-604800}"
|
||||
[[ "$SESSION_TTL_SECONDS" =~ ^[0-9]{1,10}$ ]] \
|
||||
|| fail "PETSTORE_SESSION_TTL_SECONDS must be an integer"
|
||||
(( SESSION_TTL_SECONDS >= 300 && SESSION_TTL_SECONDS <= 2592000 )) \
|
||||
|| fail "PETSTORE_SESSION_TTL_SECONDS must be between 300 and 2592000"
|
||||
[[ -z "${SMS_UNIVERSAL_CODE:-}" ]] || fail "SMS_UNIVERSAL_CODE must be empty in production"
|
||||
[[ "${SPRING_JPA_HIBERNATE_DDL_AUTO:-validate}" == "validate" ]] || fail "production ddl-auto must be validate"
|
||||
[[ "${SPRING_JPA_SHOW_SQL:-false}" == "false" ]] || fail "production show-sql must be false"
|
||||
[[ "$UPLOAD_PATH" == /* ]] || fail "UPLOAD_PATH must be absolute"
|
||||
[[ -d "$UPLOAD_PATH" && -r "$UPLOAD_PATH" && -w "$UPLOAD_PATH" ]] \
|
||||
|| fail "UPLOAD_PATH must exist and be readable/writable by the service user"
|
||||
|
||||
IFS=',' read -r -a cors_origins <<< "$CORS_ALLOWED_ORIGINS"
|
||||
[[ ${#cors_origins[@]} -gt 0 ]] || fail "CORS_ALLOWED_ORIGINS must not be empty"
|
||||
for origin in "${cors_origins[@]}"; do
|
||||
origin="${origin#${origin%%[![:space:]]*}}"
|
||||
origin="${origin%${origin##*[![:space:]]}}"
|
||||
[[ "$origin" == https://* && "$origin" != *'*'* \
|
||||
&& "$origin" != *localhost* && "$origin" != *127.0.0.1* && "$origin" != *.invalid* ]] \
|
||||
|| fail "every CORS origin must be an explicit https origin"
|
||||
[[ "$origin" != *.example* && "$origin" != *.test* ]] \
|
||||
|| fail "CORS origins must not use reserved example/test domains"
|
||||
done
|
||||
|
||||
command_ready() {
|
||||
local binary="$1"
|
||||
if [[ "$binary" == */* ]]; then
|
||||
[[ -x "$binary" ]]
|
||||
else
|
||||
command -v "$binary" >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
FFMPEG_BINARY="${HIGHLIGHT_FFMPEG:-ffmpeg}"
|
||||
FFPROBE_BINARY="${HIGHLIGHT_FFPROBE:-ffprobe}"
|
||||
command_ready "$FFMPEG_BINARY" || fail "HIGHLIGHT_FFMPEG is not executable"
|
||||
command_ready "$FFPROBE_BINARY" || fail "HIGHLIGHT_FFPROBE is not executable"
|
||||
command -v "$JAVA_BIN" >/dev/null 2>&1 || fail "JAVA_BIN is not executable"
|
||||
|
||||
if [[ "$BUILD_FIRST" == "1" ]]; then
|
||||
(cd "$BACKEND_DIR" && mvn -q -DskipTests package)
|
||||
fi
|
||||
[[ -r "$JAR_PATH" ]] || fail "backend jar not found: $JAR_PATH"
|
||||
|
||||
printf 'Static production precheck PASS\n'
|
||||
printf 'Running read-only Spring/JPA/database invariant preflight...\n'
|
||||
exec "$JAVA_BIN" -jar "$JAR_PATH" \
|
||||
--spring.profiles.active=production \
|
||||
--spring.main.web-application-type=none \
|
||||
--app.preflight-only=true
|
||||
16
pom.xml
16
pom.xml
@ -16,7 +16,7 @@
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>petstore-backend</name>
|
||||
<description>宠小它 后端服务|智慧宠物门店服务系统</description>
|
||||
<description>宠伴生活馆 后端服务</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
@ -46,20 +46,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@ -6,29 +6,25 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@SpringBootApplication
|
||||
@RequiredArgsConstructor
|
||||
public class PetstoreApplication {
|
||||
public static void main(String[] args) {
|
||||
System.out.println(">>> working dir: " + System.getProperty("user.dir"));
|
||||
SpringApplication.run(PetstoreApplication.class, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动期仅初始化系统默认服务类型。
|
||||
*
|
||||
* <p>历史版本曾在此执行 {@code ALTER TABLE t_report MODIFY COLUMN appointment_id BIGINT NULL}
|
||||
* 与历史图片 URL {@code UPDATE},这些属于一次性数据迁移,不应在每次启动时执行:
|
||||
* <ul>
|
||||
* <li>{@code appointment_id} 允许 NULL 与 P0「报告必须绑定预约」口径冲突,已通过实体层 + 服务层兜底收口,无需 DDL 改列。</li>
|
||||
* <li>历史图片 URL 修复应走版本化迁移脚本(SQL/Flyway/Liquibase),见 backend/README.md「数据库迁移」。</li>
|
||||
* </ul>
|
||||
* 启动期不再改表结构,便于审计与生产回滚。
|
||||
*/
|
||||
@Bean
|
||||
@Profile("!production")
|
||||
CommandLineRunner initRunner(ServiceTypeService serviceTypeService) {
|
||||
return args -> serviceTypeService.initDefaults();
|
||||
CommandLineRunner initRunner(ServiceTypeService serviceTypeService, JdbcTemplate jdbc) {
|
||||
return args -> {
|
||||
serviceTypeService.initDefaults();
|
||||
// appointment_id 改为允许 NULL(支持不挂预约直接填报告)
|
||||
jdbc.execute("ALTER TABLE t_report MODIFY COLUMN appointment_id BIGINT NULL");
|
||||
// 修复旧图片URL:/2026/xxx → /api/upload/image/2026/xxx
|
||||
jdbc.execute("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/%'");
|
||||
jdbc.execute("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/%'");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
package com.petstore.auth;
|
||||
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.StoreMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 鉴权拦截器:解析 {@code Authorization: Bearer ...},校验 session token,
|
||||
* 成功后写入 {@link CurrentUserContext},afterCompletion 清理。
|
||||
*
|
||||
* <p>Public allowlist 命中的路径直接放行(登录、注册、公开报告、上传资源、门店/服务类型/号源等)。
|
||||
* 其余 {@code /api/**} 要求 Bearer token。
|
||||
*
|
||||
* <p>不引入完整 Spring Security;fail-open 仅限配置缺失/内部异常,token 校验失败一律 401。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final SessionTokenService sessionTokenService;
|
||||
private final UserMapper userMapper;
|
||||
private final StoreMapper storeMapper;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
||||
/**
|
||||
* Public allowlist:未登录可访问。
|
||||
* - 登录 / 注册 / 短信
|
||||
* - 门店列表 / 门店详情 / 服务类型列表 / 号源
|
||||
* - 公开报告(token 查询、留资、退订、打开埋点、寄语)
|
||||
* - 上传资源读取(图片/视频)
|
||||
*/
|
||||
private static final List<String> PUBLIC_PATTERNS = List.of(
|
||||
"POST /api/user/login",
|
||||
"POST /api/user/wx-phone-login",
|
||||
"POST /api/user/register-boss",
|
||||
"POST /api/user/register-staff",
|
||||
"POST /api/onboarding/register-boss",
|
||||
"GET /api/staff-invitations/preview",
|
||||
"POST /api/staff-invitations/accept",
|
||||
"POST /api/sms/send",
|
||||
"GET /api/store/list",
|
||||
"GET /api/store/get",
|
||||
"GET /api/service-type/list",
|
||||
"GET /api/appointment/available-slots",
|
||||
"GET /api/report/get",
|
||||
"GET /api/report/*/suggestion",
|
||||
"POST /api/report/*/reminder",
|
||||
"POST /api/report/*/testimonial",
|
||||
"POST /api/report/unsubscribe",
|
||||
"POST /api/report/open-track",
|
||||
"GET /api/upload/image/**",
|
||||
"GET /api/upload/legacy/**",
|
||||
"GET /uploads/**"
|
||||
);
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase();
|
||||
String path = request.getRequestURI();
|
||||
if (isPublic(method, path)) {
|
||||
return true;
|
||||
}
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
return reject(response, "未登录");
|
||||
}
|
||||
String token = header.substring(7).trim();
|
||||
Optional<CurrentUser> opt = sessionTokenService.verify(token);
|
||||
if (opt.isEmpty()) {
|
||||
return reject(response, "登录已失效,请重新登录");
|
||||
}
|
||||
CurrentUser current = opt.get();
|
||||
User activeUser = userMapper.findByIdAndDeletedFalse(current.userId()).orElse(null);
|
||||
if (activeUser == null
|
||||
|| !java.util.Objects.equals(activeUser.getStoreId(), current.storeId())
|
||||
|| !java.util.Objects.equals(activeUser.getRole(), current.role())) {
|
||||
return reject(response, "账号权限已变更,请重新登录");
|
||||
}
|
||||
if (current.isStoreUser()
|
||||
&& (current.storeId() == null || storeMapper.findByIdAndDeletedFalse(current.storeId()).isEmpty())) {
|
||||
return reject(response, "门店已停用,请联系管理员");
|
||||
}
|
||||
CurrentUserContext.set(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
CurrentUserContext.clear();
|
||||
}
|
||||
|
||||
private boolean isPublic(String method, String path) {
|
||||
for (String pattern : PUBLIC_PATTERNS) {
|
||||
int sp = pattern.indexOf(' ');
|
||||
if (sp <= 0) continue;
|
||||
String pm = pattern.substring(0, sp).toUpperCase();
|
||||
String pp = pattern.substring(sp + 1);
|
||||
if (pm.equals(method) && pathMatcher.match(pp, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean reject(HttpServletResponse response, String message) throws Exception {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"" + escape(message) + "\",\"bizCode\":\"UNAUTHENTICATED\"}");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String escape(String s) {
|
||||
return s == null ? "" : s.replace("\"", "\\\"").replace("\\", "\\\\");
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
package com.petstore.auth;
|
||||
|
||||
/**
|
||||
* 当前登录用户上下文数据结构(由 AuthInterceptor 从 session token 解析后写入 ThreadLocal)。
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param storeId 所属门店 ID(customer 可能无门店,为 null)
|
||||
* @param role boss / staff / customer
|
||||
*/
|
||||
public record CurrentUser(Long userId, Long storeId, String role) {
|
||||
|
||||
public boolean isBoss() {
|
||||
return "boss".equals(role);
|
||||
}
|
||||
|
||||
public boolean isStaff() {
|
||||
return "staff".equals(role);
|
||||
}
|
||||
|
||||
public boolean isCustomer() {
|
||||
return "customer".equals(role);
|
||||
}
|
||||
|
||||
public boolean isStoreUser() {
|
||||
return isBoss() || isStaff();
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package com.petstore.auth;
|
||||
|
||||
/**
|
||||
* 当前登录用户的 ThreadLocal 读写入口。
|
||||
* AuthInterceptor 在 preHandle 解析成功后写入,afterCompletion 清理。
|
||||
* 业务代码通过 {@link #get()} / {@link #require()} 读取,不再信任请求体里的身份字段。
|
||||
*/
|
||||
public final class CurrentUserContext {
|
||||
|
||||
private static final ThreadLocal<CurrentUser> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private CurrentUserContext() {
|
||||
}
|
||||
|
||||
public static void set(CurrentUser user) {
|
||||
HOLDER.set(user);
|
||||
}
|
||||
|
||||
public static CurrentUser get() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
/** 要求当前已登录,否则抛 IllegalStateException(由全局异常处理转 401)。 */
|
||||
public static CurrentUser require() {
|
||||
CurrentUser u = HOLDER.get();
|
||||
if (u == null) {
|
||||
throw new NotAuthenticatedException("未登录");
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
|
||||
/** 未登录语义异常,便于 Controller / 全局处理区分 401 vs 500。 */
|
||||
public static final class NotAuthenticatedException extends RuntimeException {
|
||||
public NotAuthenticatedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
package com.petstore.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HMAC session token 签发与校验。
|
||||
*
|
||||
* <p>token 格式:{@code base64url(payloadJson).base64url(signature)}。
|
||||
*
|
||||
* <ul>
|
||||
* <li>payload 字段:{@code userId}、{@code storeId}(可空)、{@code role}、{@code exp}(Unix 秒)。</li>
|
||||
* <li>签名算法:HMAC-SHA256,secret 来自 {@code auth.session-secret}。</li>
|
||||
* <li>默认有效期 7 天({@code auth.session-ttl-seconds})。</li>
|
||||
* <li>校验失败(签名不符、过期、格式错误)返回 empty。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>不引入完整 Spring Security;不上 JWT 库,避免依赖膨胀。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class SessionTokenService {
|
||||
|
||||
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final byte[] secret;
|
||||
private final long ttlSeconds;
|
||||
|
||||
public SessionTokenService(
|
||||
@Value("${auth.session-secret:dev-change-me}") String secret,
|
||||
@Value("${auth.session-ttl-seconds:604800}") long ttlSeconds
|
||||
) {
|
||||
if (ttlSeconds <= 0) {
|
||||
throw new IllegalArgumentException("auth.session-ttl-seconds must be positive");
|
||||
}
|
||||
this.secret = secret.getBytes(StandardCharsets.UTF_8);
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
/** 为登录用户签发 session token。 */
|
||||
public String issue(Long userId, Long storeId, String role) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
long exp = Instant.now().getEpochSecond() + ttlSeconds;
|
||||
try {
|
||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||
payload.put("userId", userId);
|
||||
payload.put("storeId", storeId);
|
||||
payload.put("role", role == null ? "" : role);
|
||||
payload.put("exp", exp);
|
||||
byte[] payloadBytes = MAPPER.writeValueAsBytes(payload);
|
||||
String payloadB64 = URL_ENCODER.encodeToString(payloadBytes);
|
||||
String sig = hmacSha256(payloadB64);
|
||||
return payloadB64 + "." + sig;
|
||||
} catch (Exception e) {
|
||||
log.warn("issue session token failed: exceptionType={}", e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 token 并返回 CurrentUser;失败返回 java.util.Optional.empty()。 */
|
||||
public java.util.Optional<CurrentUser> verify(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
String[] parts = token.split("\\.", 2);
|
||||
if (parts.length != 2 || parts[0].isEmpty() || parts[1].isEmpty()) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
String payloadB64 = parts[0];
|
||||
String sig = parts[1];
|
||||
String expected;
|
||||
try {
|
||||
expected = hmacSha256(payloadB64);
|
||||
} catch (Exception e) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
if (!constantTimeEquals(sig, expected)) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
try {
|
||||
byte[] payloadBytes = URL_DECODER.decode(payloadB64);
|
||||
Map<?, ?> payload = MAPPER.readValue(payloadBytes, Map.class);
|
||||
Long userId = toLong(payload.get("userId"));
|
||||
Long storeId = toLong(payload.get("storeId"));
|
||||
String role = payload.get("role") == null ? "" : payload.get("role").toString();
|
||||
Long exp = toLong(payload.get("exp"));
|
||||
if (exp == null || exp <= Instant.now().getEpochSecond()) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
if (userId == null) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
return java.util.Optional.of(new CurrentUser(userId, storeId, role));
|
||||
} catch (Exception e) {
|
||||
log.debug("verify session token parse failed: exceptionType={}", e.getClass().getSimpleName());
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String hmacSha256(String input) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||
byte[] sig = mac.doFinal(input.getBytes(StandardCharsets.UTF_8));
|
||||
return URL_ENCODER.encodeToString(sig);
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null || a.length() != b.length()) {
|
||||
return false;
|
||||
}
|
||||
int r = 0;
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
r |= a.charAt(i) ^ b.charAt(i);
|
||||
}
|
||||
return r == 0;
|
||||
}
|
||||
|
||||
private static Long toLong(Object v) {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Number) return ((Number) v).longValue();
|
||||
try {
|
||||
return Long.valueOf(v.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,27 +2,17 @@ package com.petstore.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
public CorsConfig(@Value("${app.cors.allowed-origins:http://localhost:3000,http://127.0.0.1:3000,http://localhost:5174,http://127.0.0.1:5174}") String origins) {
|
||||
this.allowedOrigins = parseOrigins(origins);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowCredentials(true);
|
||||
config.setAllowedOrigins(allowedOrigins);
|
||||
config.addAllowedOriginPattern("*");
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
|
||||
@ -30,16 +20,4 @@ public class CorsConfig {
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return new CorsFilter(source);
|
||||
}
|
||||
|
||||
static List<String> parseOrigins(String origins) {
|
||||
if (origins == null || origins.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return Arrays.stream(origins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(value -> !value.isBlank())
|
||||
.map(value -> value.endsWith("/") ? value.substring(0, value.length() - 1) : value)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
public class HighlightExecutorConfig {
|
||||
|
||||
@Bean(name = "highlightTaskExecutor")
|
||||
public Executor highlightTaskExecutor() {
|
||||
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||
ex.setCorePoolSize(1);
|
||||
ex.setMaxPoolSize(2);
|
||||
ex.setQueueCapacity(30);
|
||||
ex.setThreadNamePrefix("highlight-video-");
|
||||
ex.initialize();
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** 生产 readiness 的文件系统与 FFmpeg 依赖检查;响应细节不会对公网展示。 */
|
||||
@Component("petstoreRuntime")
|
||||
@Profile("production")
|
||||
public class PetstoreRuntimeHealthIndicator implements HealthIndicator {
|
||||
private static final long CACHE_MILLIS = 30_000L;
|
||||
private final Path uploadPath;
|
||||
private final String ffmpegBinary;
|
||||
private final String ffprobeBinary;
|
||||
private volatile Health cachedHealth;
|
||||
private volatile long cachedUntil;
|
||||
|
||||
public PetstoreRuntimeHealthIndicator(
|
||||
@Value("${upload.path}") String uploadPath,
|
||||
@Value("${app.highlight-video.ffmpeg-binary:ffmpeg}") String ffmpegBinary,
|
||||
@Value("${app.highlight-video.ffprobe-binary:ffprobe}") String ffprobeBinary) {
|
||||
this.uploadPath = Path.of(uploadPath);
|
||||
this.ffmpegBinary = ffmpegBinary;
|
||||
this.ffprobeBinary = ffprobeBinary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
long now = System.currentTimeMillis();
|
||||
Health cached = cachedHealth;
|
||||
if (cached != null && now < cachedUntil) {
|
||||
return cached;
|
||||
}
|
||||
synchronized (this) {
|
||||
now = System.currentTimeMillis();
|
||||
if (cachedHealth != null && now < cachedUntil) {
|
||||
return cachedHealth;
|
||||
}
|
||||
cachedHealth = inspectRuntime();
|
||||
cachedUntil = now + CACHE_MILLIS;
|
||||
return cachedHealth;
|
||||
}
|
||||
}
|
||||
|
||||
private Health inspectRuntime() {
|
||||
boolean uploadReady = Files.isDirectory(uploadPath)
|
||||
&& Files.isReadable(uploadPath)
|
||||
&& Files.isWritable(uploadPath);
|
||||
boolean ffmpegReady = commandReady(ffmpegBinary);
|
||||
boolean ffprobeReady = commandReady(ffprobeBinary);
|
||||
Health.Builder builder = uploadReady && ffmpegReady && ffprobeReady ? Health.up() : Health.down();
|
||||
return builder
|
||||
.withDetail("uploadReady", uploadReady)
|
||||
.withDetail("ffmpegReady", ffmpegReady)
|
||||
.withDetail("ffprobeReady", ffprobeReady)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static boolean commandReady(String command) {
|
||||
if (command == null || command.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
Process process = null;
|
||||
try {
|
||||
process = new ProcessBuilder(command, "-version")
|
||||
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
|
||||
.redirectError(ProcessBuilder.Redirect.DISCARD)
|
||||
.start();
|
||||
if (!process.waitFor(3, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
return false;
|
||||
}
|
||||
return process.exitValue() == 0;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
} finally {
|
||||
if (process != null && process.isAlive()) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** 生产 profile 启动门禁:只校验配置,不打印任何配置值。 */
|
||||
@Component
|
||||
@Profile("production")
|
||||
public class ProductionConfigurationValidator {
|
||||
private final String ddlAuto;
|
||||
private final boolean showSql;
|
||||
private final String mysqlUsername;
|
||||
private final String mysqlPassword;
|
||||
private final String wechatAppId;
|
||||
private final String wechatAppSecret;
|
||||
private final String sessionSecret;
|
||||
private final long sessionTtlSeconds;
|
||||
private final String smsUniversalCode;
|
||||
private final String appBaseUrl;
|
||||
private final String uploadPath;
|
||||
private final String corsAllowedOrigins;
|
||||
|
||||
public ProductionConfigurationValidator(
|
||||
@Value("${spring.jpa.hibernate.ddl-auto:}") String ddlAuto,
|
||||
@Value("${spring.jpa.show-sql:false}") boolean showSql,
|
||||
@Value("${spring.datasource.username:}") String mysqlUsername,
|
||||
@Value("${spring.datasource.password:}") String mysqlPassword,
|
||||
@Value("${wechat.appid:}") String wechatAppId,
|
||||
@Value("${wechat.appsecret:}") String wechatAppSecret,
|
||||
@Value("${auth.session-secret:}") String sessionSecret,
|
||||
@Value("${auth.session-ttl-seconds:604800}") long sessionTtlSeconds,
|
||||
@Value("${app.demo.sms-universal-code:}") String smsUniversalCode,
|
||||
@Value("${app.base-url:}") String appBaseUrl,
|
||||
@Value("${upload.path:}") String uploadPath,
|
||||
@Value("${app.cors.allowed-origins:}") String corsAllowedOrigins) {
|
||||
this.ddlAuto = ddlAuto;
|
||||
this.showSql = showSql;
|
||||
this.mysqlUsername = mysqlUsername;
|
||||
this.mysqlPassword = mysqlPassword;
|
||||
this.wechatAppId = wechatAppId;
|
||||
this.wechatAppSecret = wechatAppSecret;
|
||||
this.sessionSecret = sessionSecret;
|
||||
this.sessionTtlSeconds = sessionTtlSeconds;
|
||||
this.smsUniversalCode = smsUniversalCode;
|
||||
this.appBaseUrl = appBaseUrl;
|
||||
this.uploadPath = uploadPath;
|
||||
this.corsAllowedOrigins = corsAllowedOrigins;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void validate() {
|
||||
List<String> failures = validateValues();
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("生产配置校验失败,需修正: " + String.join(", ", failures));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> validateValues() {
|
||||
List<String> failures = new ArrayList<>();
|
||||
if (!"validate".equalsIgnoreCase(text(ddlAuto))) failures.add("JPA_DDL_AUTO=validate");
|
||||
if (showSql) failures.add("JPA_SHOW_SQL=false");
|
||||
require(mysqlUsername, "MYSQL_USERNAME", failures);
|
||||
require(mysqlPassword, "MYSQL_PASSWORD", failures);
|
||||
require(wechatAppId, "WECHAT_APPID", failures);
|
||||
require(wechatAppSecret, "WECHAT_APPSECRET", failures);
|
||||
if (text(sessionSecret).length() < 32 || "dev-change-me".equals(sessionSecret)) {
|
||||
failures.add("PETSTORE_SESSION_SECRET(至少32字符且非默认值)");
|
||||
}
|
||||
if (sessionTtlSeconds < 300 || sessionTtlSeconds > 2_592_000) {
|
||||
failures.add("PETSTORE_SESSION_TTL_SECONDS必须为300至2592000秒");
|
||||
}
|
||||
if (!text(smsUniversalCode).isEmpty()) failures.add("SMS_UNIVERSAL_CODE必须为空");
|
||||
requireHttpsOrigin(appBaseUrl, "APP_BASE_URL", failures);
|
||||
if (text(uploadPath).isEmpty() || !Path.of(uploadPath).isAbsolute()) {
|
||||
failures.add("UPLOAD_PATH必须为绝对路径");
|
||||
}
|
||||
List<String> origins = CorsConfig.parseOrigins(corsAllowedOrigins);
|
||||
if (origins.isEmpty()) {
|
||||
failures.add("CORS_ALLOWED_ORIGINS");
|
||||
} else {
|
||||
boolean hasWildcard = origins.stream().anyMatch(origin -> origin.contains("*"));
|
||||
if (hasWildcard) {
|
||||
failures.add("CORS_ALLOWED_ORIGINS不得含通配符");
|
||||
} else if (origins.stream().anyMatch(origin -> !isSafeHttpsOrigin(origin))) {
|
||||
failures.add("CORS_ALLOWED_ORIGINS必须全部为显式生产https源");
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static void require(String value, String name, List<String> failures) {
|
||||
if (text(value).isEmpty()) failures.add(name);
|
||||
}
|
||||
|
||||
private static void requireHttpsOrigin(String value, String name, List<String> failures) {
|
||||
if (!isSafeHttpsOrigin(text(value))) {
|
||||
failures.add(name + "必须为生产https源");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSafeHttpsOrigin(String value) {
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
String path = uri.getPath();
|
||||
return isSafeHttpsUri(uri)
|
||||
&& uri.getUserInfo() == null
|
||||
&& uri.getQuery() == null
|
||||
&& uri.getFragment() == null
|
||||
&& (path == null || path.isEmpty() || "/".equals(path));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSafeHttpsUri(URI uri) {
|
||||
String host = text(uri.getHost()).toLowerCase();
|
||||
return "https".equalsIgnoreCase(uri.getScheme())
|
||||
&& !host.isEmpty()
|
||||
&& !"localhost".equals(host)
|
||||
&& !"127.0.0.1".equals(host)
|
||||
&& !"::1".equals(host)
|
||||
&& !isPlaceholderHost(host);
|
||||
}
|
||||
|
||||
private static boolean isPlaceholderHost(String host) {
|
||||
return host.endsWith(".invalid")
|
||||
|| host.endsWith(".test")
|
||||
|| host.endsWith(".example")
|
||||
|| "example.com".equals(host)
|
||||
|| host.endsWith(".example.com")
|
||||
|| "example.net".equals(host)
|
||||
|| host.endsWith(".example.net")
|
||||
|| "example.org".equals(host)
|
||||
|| host.endsWith(".example.org");
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@ -1,264 +0,0 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 显式生产预检模式:JPA validate 通过后,只读核对迁移回填不变量并退出。
|
||||
* 仅在 {@code --app.preflight-only=true} 时启用,不参与正常服务启动。
|
||||
*/
|
||||
@Component
|
||||
@Profile("production")
|
||||
@ConditionalOnProperty(prefix = "app", name = "preflight-only", havingValue = "true")
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ProductionDatabasePreflightRunner implements ApplicationRunner {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ConfigurableApplicationContext applicationContext;
|
||||
|
||||
private static final List<Invariant> INVARIANTS = List.of(
|
||||
new Invariant("unresolved_assisted_appointments", """
|
||||
SELECT COUNT(*) FROM t_appointment
|
||||
WHERE deleted = 0 AND customer_user_id IS NULL
|
||||
"""),
|
||||
new Invariant("reports_without_customer", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE deleted = 0 AND customer_user_id IS NULL
|
||||
"""),
|
||||
new Invariant("reports_without_author", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE deleted = 0 AND author_staff_id IS NULL
|
||||
"""),
|
||||
new Invariant("appointments_without_store_customer", """
|
||||
SELECT COUNT(*) FROM t_appointment a
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = a.store_id
|
||||
AND sc.customer_user_id = a.customer_user_id
|
||||
AND sc.deleted = 0
|
||||
WHERE a.deleted = 0 AND a.customer_user_id IS NOT NULL AND sc.id IS NULL
|
||||
"""),
|
||||
new Invariant("leads_without_store_customer", """
|
||||
SELECT COUNT(*) FROM t_report_lead l
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.store_id = l.store_id AND sc.phone = l.phone AND sc.deleted = 0
|
||||
WHERE l.store_id IS NOT NULL AND l.phone IS NOT NULL AND l.phone <> '' AND sc.id IS NULL
|
||||
"""),
|
||||
new Invariant("store_customers_linked_to_non_customer", """
|
||||
SELECT COUNT(*) FROM t_store_customer sc
|
||||
JOIN t_user u ON u.id = sc.customer_user_id
|
||||
WHERE sc.deleted = 0 AND (u.deleted <> 0 OR u.role <> 'customer')
|
||||
"""),
|
||||
new Invariant("invalid_store_customer_alias_state", """
|
||||
SELECT COUNT(*) FROM t_store_customer sc
|
||||
WHERE (sc.deleted = 0 AND sc.merged_into_store_customer_id IS NOT NULL)
|
||||
OR sc.id = sc.merged_into_store_customer_id
|
||||
"""),
|
||||
new Invariant("invalid_store_customer_alias_target", """
|
||||
SELECT COUNT(*) FROM t_store_customer sc_alias
|
||||
LEFT JOIN t_store_customer canonical
|
||||
ON canonical.id = sc_alias.merged_into_store_customer_id
|
||||
AND canonical.store_id = sc_alias.store_id
|
||||
AND canonical.deleted = 0
|
||||
AND canonical.merged_into_store_customer_id IS NULL
|
||||
WHERE sc_alias.merged_into_store_customer_id IS NOT NULL
|
||||
AND canonical.id IS NULL
|
||||
"""),
|
||||
new Invariant("pending_leads_without_open_follow_up_task", """
|
||||
SELECT COUNT(*) FROM t_report_lead l
|
||||
LEFT JOIN t_follow_up_task t
|
||||
ON t.source_lead_id = l.id
|
||||
AND t.status IN ('pending', 'in_progress')
|
||||
WHERE l.store_id IS NOT NULL
|
||||
AND l.remind_status = 'pending'
|
||||
AND t.id IS NULL
|
||||
"""),
|
||||
new Invariant("invalid_follow_up_customer_scope", """
|
||||
SELECT COUNT(*) FROM t_follow_up_task t
|
||||
LEFT JOIN t_store_customer sc
|
||||
ON sc.id = t.store_customer_id
|
||||
AND sc.store_id = t.store_id
|
||||
LEFT JOIN t_store_customer canonical
|
||||
ON canonical.id = CASE
|
||||
WHEN sc.deleted = 0 THEN sc.id
|
||||
ELSE sc.merged_into_store_customer_id
|
||||
END
|
||||
AND canonical.store_id = t.store_id
|
||||
AND canonical.deleted = 0
|
||||
AND canonical.merged_into_store_customer_id IS NULL
|
||||
WHERE sc.id IS NULL OR canonical.id IS NULL
|
||||
"""),
|
||||
new Invariant("invalid_follow_up_state", """
|
||||
SELECT COUNT(*) FROM t_follow_up_task t
|
||||
WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled')
|
||||
OR (t.status IN ('pending', 'in_progress')
|
||||
AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL
|
||||
OR t.rebooked_appointment_id IS NOT NULL))
|
||||
OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL)
|
||||
OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL)
|
||||
OR (t.status = 'completed'
|
||||
AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact')
|
||||
OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL
|
||||
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL
|
||||
OR t.attempt_count < 1))
|
||||
OR (t.status = 'canceled'
|
||||
AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL
|
||||
OR t.closed_by_user_id IS NOT NULL))
|
||||
OR (t.last_attempt_outcome IS NOT NULL
|
||||
AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later')
|
||||
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL))
|
||||
OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL)
|
||||
OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL)
|
||||
OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM t_appointment a
|
||||
WHERE a.id = t.rebooked_appointment_id
|
||||
AND a.store_id = t.store_id
|
||||
AND a.deleted = 0
|
||||
))
|
||||
OR t.attempt_count < 0
|
||||
"""),
|
||||
new Invariant("duplicate_open_follow_up_task", """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT source_lead_id FROM t_follow_up_task
|
||||
WHERE status IN ('pending', 'in_progress')
|
||||
GROUP BY source_lead_id HAVING COUNT(*) > 1
|
||||
) duplicated
|
||||
"""),
|
||||
new Invariant("follow_up_tasks_without_events", """
|
||||
SELECT COUNT(*) FROM t_follow_up_task t
|
||||
LEFT JOIN t_business_event created
|
||||
ON created.idempotency_key = CONCAT('follow_up_created:', t.id)
|
||||
AND created.event_type = 'follow_up_created'
|
||||
AND created.store_id = t.store_id
|
||||
AND created.aggregate_type = 'follow_up_task'
|
||||
AND created.aggregate_id = t.id
|
||||
LEFT JOIN t_business_event rebooked
|
||||
ON rebooked.idempotency_key = CONCAT('rebook_created:', t.rebooked_appointment_id)
|
||||
AND rebooked.event_type = 'rebook_created'
|
||||
AND rebooked.store_id = t.store_id
|
||||
AND rebooked.aggregate_type = 'appointment'
|
||||
AND rebooked.aggregate_id = t.rebooked_appointment_id
|
||||
WHERE created.id IS NULL
|
||||
OR (t.outcome = 'rebooked' AND rebooked.id IS NULL)
|
||||
"""),
|
||||
new Invariant("appointments_without_created_event", """
|
||||
SELECT COUNT(*) FROM t_appointment a
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('appointment_created:', a.id)
|
||||
WHERE a.deleted = 0 AND a.store_id IS NOT NULL AND e.id IS NULL
|
||||
"""),
|
||||
new Invariant("reports_without_submitted_event", """
|
||||
SELECT COUNT(*) FROM t_report r
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('report_submitted:', r.id)
|
||||
WHERE r.deleted = 0 AND r.store_id IS NOT NULL AND e.id IS NULL
|
||||
"""),
|
||||
new Invariant("leads_without_submitted_event", """
|
||||
SELECT COUNT(*) FROM t_report_lead l
|
||||
LEFT JOIN t_business_event e
|
||||
ON e.idempotency_key = CONCAT('lead_submitted:', l.id)
|
||||
WHERE l.store_id IS NOT NULL AND e.id IS NULL
|
||||
"""),
|
||||
new Invariant("events_without_store", "SELECT COUNT(*) FROM t_business_event WHERE store_id IS NULL"),
|
||||
new Invariant("invalid_service_duration", """
|
||||
SELECT COUNT(*) FROM t_service_type
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||
"""),
|
||||
new Invariant("invalid_appointment_duration", """
|
||||
SELECT COUNT(*) FROM t_appointment
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||
"""),
|
||||
new Invariant("invalid_block_duration", """
|
||||
SELECT COUNT(*) FROM t_schedule_block
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0
|
||||
"""),
|
||||
new Invariant("invalid_store_capacity", """
|
||||
SELECT COUNT(*) FROM t_store
|
||||
WHERE booking_capacity < 1 OR booking_capacity > 10
|
||||
"""),
|
||||
new Invariant("duplicate_report_appointment", """
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT appointment_id FROM t_report
|
||||
WHERE deleted = 0 AND appointment_id IS NOT NULL
|
||||
GROUP BY appointment_id HAVING COUNT(*) > 1
|
||||
) duplicated
|
||||
"""),
|
||||
new Invariant("invalid_report_send_status", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent')
|
||||
"""),
|
||||
new Invariant("sent_report_without_receipt", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status = 'sent'
|
||||
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'))
|
||||
"""),
|
||||
new Invariant("unconfirmed_report_with_receipt", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status IN ('unknown', 'unsent')
|
||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||
"""),
|
||||
new Invariant("invalid_store_onboarding_status", """
|
||||
SELECT COUNT(*) FROM t_store
|
||||
WHERE onboarding_status NOT IN ('unknown', 'in_progress', 'completed')
|
||||
"""),
|
||||
new Invariant("completed_onboarding_without_receipt", """
|
||||
SELECT COUNT(*) FROM t_store
|
||||
WHERE onboarding_status = 'completed'
|
||||
AND (onboarding_completed_at IS NULL OR onboarding_completed_by_user_id IS NULL)
|
||||
"""),
|
||||
new Invariant("incomplete_onboarding_with_receipt", """
|
||||
SELECT COUNT(*) FROM t_store
|
||||
WHERE onboarding_status IN ('unknown', 'in_progress')
|
||||
AND (onboarding_completed_at IS NOT NULL OR onboarding_completed_by_user_id IS NOT NULL)
|
||||
"""),
|
||||
new Invariant("invalid_staff_invitation_status", """
|
||||
SELECT COUNT(*) FROM t_staff_invitation
|
||||
WHERE status NOT IN ('pending', 'accepted', 'revoked', 'expired')
|
||||
"""),
|
||||
new Invariant("inconsistent_staff_invitation_acceptance", """
|
||||
SELECT COUNT(*) FROM t_staff_invitation
|
||||
WHERE (status = 'accepted' AND (accepted_by_user_id IS NULL OR accepted_at IS NULL))
|
||||
OR (status <> 'accepted' AND (accepted_by_user_id IS NOT NULL OR accepted_at IS NOT NULL))
|
||||
"""),
|
||||
new Invariant("inconsistent_staff_invitation_revocation", """
|
||||
SELECT COUNT(*) FROM t_staff_invitation
|
||||
WHERE (status = 'revoked' AND (revoked_by_user_id IS NULL OR revoked_at IS NULL))
|
||||
OR (status <> 'revoked' AND (revoked_by_user_id IS NOT NULL OR revoked_at IS NOT NULL))
|
||||
""")
|
||||
);
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
List<String> failures = INVARIANTS.stream()
|
||||
.filter(invariant -> count(invariant.sql()) != 0L)
|
||||
.map(Invariant::name)
|
||||
.toList();
|
||||
if (!failures.isEmpty()) {
|
||||
throw new IllegalStateException("生产数据库预检失败,非零检查项: " + String.join(", ", failures));
|
||||
}
|
||||
log.info("Production database preflight PASS: {} invariants", INVARIANTS.size());
|
||||
int exitCode = SpringApplication.exit(applicationContext, () -> 0);
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
private long count(String sql) {
|
||||
Long value = jdbcTemplate.queryForObject(sql, Long.class);
|
||||
return value == null ? -1L : value;
|
||||
}
|
||||
|
||||
private record Invariant(String name, String sql) {
|
||||
}
|
||||
}
|
||||
@ -1,35 +1,27 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import com.petstore.auth.AuthInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Value("${upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 受保护接口:所有 /api/** 需 Bearer session token,Public allowlist 在 AuthInterceptor 内放行。
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/**");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String uploadDir = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||
// 硬编码绝对路径,确保能找到文件
|
||||
String uploadDir = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
|
||||
System.out.println(">>> WebConfig uploadDir: " + uploadDir);
|
||||
System.out.println(">>> /2026 exists: " + new File(uploadDir + "2026/04/01/").exists());
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:" + uploadDir);
|
||||
registry.addResourceHandler("/2026/**")
|
||||
.addResourceLocations("file:" + uploadDir);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,20 +3,31 @@ package com.petstore.config;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** 微信小程序服务端凭据;不承载已下线的网页 OAuth redirect 配置。 */
|
||||
@Configuration
|
||||
public class WechatConfig {
|
||||
@Value("${wechat.appid:}")
|
||||
// TODO: 替换为实际的微信开放平台 AppID 和 AppSecret
|
||||
@Value("${wechat.appid:YOUR_APPID}")
|
||||
private String appid;
|
||||
|
||||
@Value("${wechat.appsecret:}")
|
||||
@Value("${wechat.appsecret:YOUR_APPSECRET}")
|
||||
private String appsecret;
|
||||
|
||||
public String getAppid() {
|
||||
return appid;
|
||||
}
|
||||
@Value("${wechat.redirect_uri:http://localhost:8080/api/wechat/callback}")
|
||||
private String redirectUri;
|
||||
|
||||
public String getAppsecret() {
|
||||
return appsecret;
|
||||
public String getAppid() { return appid; }
|
||||
public String getAppsecret() { return appsecret; }
|
||||
public String getRedirectUri() { return redirectUri; }
|
||||
|
||||
/**
|
||||
* 获取微信授权跳转地址
|
||||
*/
|
||||
public String getAuthorizeUrl() {
|
||||
return "https://open.weixin.qq.com/connect/qrconnect" +
|
||||
"?appid=" + appid +
|
||||
"&redirect_uri=" + redirectUri +
|
||||
"&response_type=code" +
|
||||
"&scope=snsapi_login" +
|
||||
"&state=petstore#wechat_redirect";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.AdminBusinessEventService;
|
||||
import com.petstore.service.AdminWorkbenchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** 门店工作台的低敏事件指标。storeId 只从会话派生。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/workbench")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminBusinessEventController {
|
||||
|
||||
private final AdminBusinessEventService adminBusinessEventService;
|
||||
private final AdminWorkbenchService adminWorkbenchService;
|
||||
|
||||
/** 今日行动聚合:只返回本店准确计数和低敏预览。 */
|
||||
@GetMapping("/actions")
|
||||
public Map<String, Object> actions() {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isStoreUser() || user.storeId() == null) {
|
||||
return Map.of(
|
||||
"code", 403,
|
||||
"message", "仅门店员工可查看今日行动",
|
||||
"bizCode", "FORBIDDEN"
|
||||
);
|
||||
}
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", adminWorkbenchService.today(user.storeId())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/report-funnel")
|
||||
public Map<String, Object> reportFunnel(
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date
|
||||
) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isStoreUser() || user.storeId() == null) {
|
||||
return Map.of(
|
||||
"code", 403,
|
||||
"message", "仅门店员工可查看经营指标",
|
||||
"bizCode", "FORBIDDEN"
|
||||
);
|
||||
}
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", adminBusinessEventService.reportFunnel(user.storeId(), date)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.FlowException;
|
||||
import com.petstore.service.FollowUpTaskService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** 门店 Web 后台回访任务池;所有 storeId/actor 均从会话派生。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/follow-up-tasks")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminFollowUpTaskController {
|
||||
|
||||
private final FollowUpTaskService followUpTaskService;
|
||||
|
||||
@GetMapping("")
|
||||
public Map<String, Object> list(
|
||||
@RequestParam(required = false, defaultValue = "open") String status,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDateTo,
|
||||
@RequestParam(required = false, defaultValue = "1") int page,
|
||||
@RequestParam(required = false, defaultValue = "20") int pageSize
|
||||
) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!isStoreUser(user)) return forbidden();
|
||||
try {
|
||||
return ok(followUpTaskService.list(
|
||||
user.storeId(), status, dueDateTo, page, pageSize, user.userId()
|
||||
));
|
||||
} catch (FlowException e) {
|
||||
return error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{taskId}/start")
|
||||
public Map<String, Object> start(@PathVariable String taskId) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!isStoreUser(user)) return forbidden();
|
||||
try {
|
||||
return ok(followUpTaskService.start(
|
||||
user.storeId(), taskId, user.userId(), user.role()
|
||||
));
|
||||
} catch (FlowException e) {
|
||||
return error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{taskId}/reschedule")
|
||||
public Map<String, Object> reschedule(
|
||||
@PathVariable String taskId,
|
||||
@RequestBody Map<String, Object> body
|
||||
) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!isStoreUser(user)) return forbidden();
|
||||
try {
|
||||
return ok(followUpTaskService.reschedule(
|
||||
user.storeId(),
|
||||
taskId,
|
||||
text(body.get("dueDate")),
|
||||
text(body.get("reason")),
|
||||
user.userId(),
|
||||
user.role()
|
||||
));
|
||||
} catch (FlowException e) {
|
||||
return error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{taskId}/close")
|
||||
public Map<String, Object> close(
|
||||
@PathVariable String taskId,
|
||||
@RequestBody Map<String, Object> body
|
||||
) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!isStoreUser(user)) return forbidden();
|
||||
try {
|
||||
return ok(followUpTaskService.close(
|
||||
user.storeId(), taskId, text(body.get("outcome")), user.userId(), user.role()
|
||||
));
|
||||
} catch (FlowException e) {
|
||||
return error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isStoreUser(CurrentUser user) {
|
||||
return user.isStoreUser() && user.storeId() != null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> ok(Object data) {
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
private static Map<String, Object> error(FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
|
||||
private static Map<String, Object> forbidden() {
|
||||
return Map.of("code", 403, "message", "仅门店员工可处理回访任务", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
return value == null ? null : value.toString().trim();
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.FlowException;
|
||||
import com.petstore.service.StoreOnboardingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** 门店开通清单:门店成员可查看,只有老板可显式完成。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/onboarding")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminOnboardingController {
|
||||
|
||||
private final StoreOnboardingService onboardingService;
|
||||
|
||||
@GetMapping("")
|
||||
public Map<String, Object> status() {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isStoreUser() || user.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店成员可查看开通清单", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
try {
|
||||
return Map.of("code", 200, "data", onboardingService.getStatus(user.storeId()));
|
||||
} catch (FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/complete")
|
||||
public Map<String, Object> complete() {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isBoss() || user.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可完成门店开通", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
try {
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"message", "门店已完成开通,可进入真实试点",
|
||||
"data", onboardingService.complete(user.storeId(), user.userId(), user.role())
|
||||
);
|
||||
} catch (FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.AdminServiceCustomerService;
|
||||
import com.petstore.service.FlowException;
|
||||
import com.petstore.service.StoreCustomerTimelineService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 门店 Web 后台:服务客户聚合列表。
|
||||
* storeId 从 CurrentUserContext 派生;仅 boss/staff。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminServiceCustomerController {
|
||||
|
||||
private final AdminServiceCustomerService adminServiceCustomerService;
|
||||
private final StoreCustomerTimelineService storeCustomerTimelineService;
|
||||
|
||||
@GetMapping("/service-customers")
|
||||
public Map<String, Object> listServiceCustomers(
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(required = false) Boolean hasPendingLead,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate lastVisitFrom,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate lastVisitTo,
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false, defaultValue = "1") int page,
|
||||
@RequestParam(required = false, defaultValue = "20") int pageSize
|
||||
) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看服务客户", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Map<String, Object> data = adminServiceCustomerService.list(
|
||||
u.storeId(),
|
||||
source,
|
||||
hasPendingLead,
|
||||
lastVisitFrom,
|
||||
lastVisitTo,
|
||||
q,
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
/** 本店客户的低敏业务事实时间线;storeId 始终从会话派生。 */
|
||||
@GetMapping("/service-customers/timeline")
|
||||
public Map<String, Object> timeline(
|
||||
@RequestParam Long storeCustomerId,
|
||||
@RequestParam(required = false, defaultValue = "1") int page,
|
||||
@RequestParam(required = false, defaultValue = "20") int pageSize
|
||||
) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看客户时间线", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
try {
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", storeCustomerTimelineService.timeline(
|
||||
u.storeId(), storeCustomerId, page, pageSize
|
||||
)
|
||||
);
|
||||
} catch (FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.service.StoreService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 门店 Web 后台:本店资料。legacy inviteCode 不再返回客户端。
|
||||
* 规则:{@code rule:BR-ADMIN-001}、{@code rule:BR-ADMIN-002}、{@code rule:BR-ADMIN-003}
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminStoreController {
|
||||
|
||||
private final StoreService storeService;
|
||||
|
||||
@GetMapping("/store")
|
||||
public Map<String, Object> currentStore() {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看门店资料", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Store store = storeService.findById(u.storeId());
|
||||
if (store == null) {
|
||||
return Map.of("code", 404, "message", "门店不存在");
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("id", store.getId());
|
||||
data.put("name", store.getName());
|
||||
data.put("phone", store.getPhone());
|
||||
data.put("address", store.getAddress());
|
||||
data.put("latitude", store.getLatitude());
|
||||
data.put("longitude", store.getLongitude());
|
||||
data.put("logo", store.getLogo());
|
||||
data.put("intro", store.getIntro());
|
||||
data.put("onboardingStatus", store.getOnboardingStatus());
|
||||
data.put("onboardingCompletedAt", store.getOnboardingCompletedAt());
|
||||
data.put("bookingDayStart", store.getBookingDayStart() == null ? null : store.getBookingDayStart().toString());
|
||||
data.put("bookingLastSlotStart",
|
||||
store.getBookingLastSlotStart() == null ? null : store.getBookingLastSlotStart().toString());
|
||||
data.put("bookingCapacity", StoreService.normalizeCapacity(store.getBookingCapacity()));
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
}
|
||||
@ -1,130 +1,47 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.service.AppointmentService;
|
||||
import com.petstore.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/appointment")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class AppointmentController {
|
||||
private final AppointmentService appointmentService;
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 门店某日可预约开始时段;按服务时长与门店并发接待数计算。
|
||||
* date 格式:yyyy-MM-dd。公开接口,无需登录。
|
||||
*/
|
||||
@GetMapping("/available-slots")
|
||||
public Map<String, Object> availableSlots(
|
||||
@RequestParam Long storeId,
|
||||
@RequestParam String date,
|
||||
@RequestParam(required = false) String serviceType) {
|
||||
LocalDate d;
|
||||
try {
|
||||
d = LocalDate.parse(date);
|
||||
} catch (Exception e) {
|
||||
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
|
||||
}
|
||||
return appointmentService.availableSlots(storeId, d, serviceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约列表:身份边界从 CurrentUserContext 派生,不再信任 query userId/storeId 作为权限依据。
|
||||
* - customer:只看自己的预约(scopedUserId = current.userId)
|
||||
* - boss/staff:只看本店预约(scopedStoreId = current.storeId)
|
||||
*/
|
||||
/** 获取预约列表(员工查自己/老板查全店) */
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) Integer page,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
||||
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
||||
@RequestParam(required = false) Long userId,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) String status) {
|
||||
|
||||
List<Appointment> appointments;
|
||||
boolean usePaging = page != null || pageSize != null;
|
||||
if (usePaging) {
|
||||
int pageNo = page == null ? 1 : page;
|
||||
int size = pageSize == null ? 20 : pageSize;
|
||||
pageNo = Math.max(pageNo, 1);
|
||||
size = Math.min(Math.max(size, 1), 200);
|
||||
Page<Appointment> paged = appointmentService.pageByScope(scopedUserId, scopedStoreId, status, pageNo - 1, size);
|
||||
Object data = u.isStoreUser()
|
||||
? appointmentService.toAdminViews(paged.getContent())
|
||||
: paged.getContent();
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", data,
|
||||
"page", pageNo,
|
||||
"pageSize", size,
|
||||
"total", paged.getTotalElements(),
|
||||
"totalPages", paged.getTotalPages(),
|
||||
"hasNext", paged.hasNext()
|
||||
);
|
||||
}
|
||||
|
||||
if (scopedStoreId != null) {
|
||||
if (storeId != null) {
|
||||
appointments = (status != null && !status.isEmpty())
|
||||
? appointmentService.getByStoreIdAndStatus(scopedStoreId, status)
|
||||
: appointmentService.getByStoreId(scopedStoreId);
|
||||
} else {
|
||||
? appointmentService.getByStoreIdAndStatus(storeId, status)
|
||||
: appointmentService.getByStoreId(storeId);
|
||||
} else if (userId != null) {
|
||||
appointments = (status != null && !status.isEmpty())
|
||||
? appointmentService.getByUserIdAndStatus(scopedUserId, status)
|
||||
: appointmentService.getByUserId(scopedUserId);
|
||||
}
|
||||
|
||||
Object data = u.isStoreUser()
|
||||
? appointmentService.toAdminViews(appointments)
|
||||
: appointments;
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
/** 预约详情:customer 仅可查本人;boss/staff 仅可查本店;跨身份/跨店 403。 */
|
||||
@GetMapping("/detail")
|
||||
public Map<String, Object> detail(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Appointment appointment = appointmentService.getById(id);
|
||||
if (appointment == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
if (u.isCustomer()) {
|
||||
if (!u.userId().equals(appointment.resolvedCustomerUserId())) {
|
||||
return Map.of("code", 403, "message", "无权查看他人预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
} else if (u.isStoreUser()) {
|
||||
if (u.storeId() != null && !u.storeId().equals(appointment.getStoreId())) {
|
||||
return Map.of("code", 403, "message", "无权查看他店预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
? appointmentService.getByUserIdAndStatus(userId, status)
|
||||
: appointmentService.getByUserId(userId);
|
||||
} else {
|
||||
return Map.of("code", 403, "message", "无权查看", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Object data = u.isStoreUser()
|
||||
? appointmentService.toAdminView(appointment)
|
||||
: appointment;
|
||||
return Map.of("code", 200, "data", data);
|
||||
return Map.of("code", 400, "message", "userId或storeId必填");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预约:客户、创建人分别记录;不再把代客操作员工写成客户。
|
||||
* - customer:customerUserId = createdByUserId = current.userId
|
||||
* - boss/staff:createdByUserId = current.userId;customerUserId 由 body.customerUserId/customerPhone 解析
|
||||
* - legacy userId 仅镜像 customerUserId,兼容旧客户端与历史查询
|
||||
*/
|
||||
return Map.of("code", 200, "data", appointments);
|
||||
}
|
||||
|
||||
/** 创建预约 */
|
||||
@PostMapping("/create")
|
||||
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Appointment appointment = new Appointment();
|
||||
appointment.setPetName(params.get("petName").toString());
|
||||
appointment.setPetType(params.get("petType").toString());
|
||||
@ -133,142 +50,37 @@ public class AppointmentController {
|
||||
String timeStr = params.get("appointmentTime").toString();
|
||||
appointment.setAppointmentTime(java.time.LocalDateTime.parse(timeStr));
|
||||
|
||||
Long storeId;
|
||||
Long customerUserId;
|
||||
if (u.isStoreUser()) {
|
||||
storeId = u.storeId();
|
||||
try {
|
||||
Long requestedCustomerId = optionalLong(params.get("customerUserId"));
|
||||
String customerPhone = optionalText(params.get("customerPhone"));
|
||||
String customerName = optionalText(params.get("customerName"));
|
||||
if (requestedCustomerId == null && customerPhone.isBlank()) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "代客预约必须填写宠主手机号",
|
||||
"bizCode", "CUSTOMER_REQUIRED"
|
||||
);
|
||||
}
|
||||
User customer = userService.resolveBookingCustomer(requestedCustomerId, customerPhone, customerName);
|
||||
customerUserId = customer.getId();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "CUSTOMER_INVALID");
|
||||
}
|
||||
} else if (u.isCustomer()) {
|
||||
if (params.get("storeId") == null) {
|
||||
return Map.of("code", 400, "message", "请选择预约门店", "bizCode", "STORE_REQUIRED");
|
||||
}
|
||||
storeId = Long.valueOf(params.get("storeId").toString());
|
||||
customerUserId = u.userId();
|
||||
} else {
|
||||
return Map.of("code", 403, "message", "当前账号不可创建预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
appointment.setStoreId(storeId);
|
||||
appointment.setCustomerUserId(customerUserId);
|
||||
appointment.setCreatedByUserId(u.userId());
|
||||
appointment.setUserId(customerUserId); // legacy compatibility
|
||||
appointment.setStoreId(Long.valueOf(params.get("storeId").toString()));
|
||||
appointment.setUserId(Long.valueOf(params.get("userId").toString()));
|
||||
appointment.setStatus("new");
|
||||
|
||||
if (params.containsKey("remark") && params.get("remark") != null) {
|
||||
appointment.setRemark(params.get("remark").toString());
|
||||
}
|
||||
if (params.containsKey("petId") && params.get("petId") != null && !params.get("petId").toString().isBlank()) {
|
||||
appointment.setPetId(Long.valueOf(params.get("petId").toString()));
|
||||
|
||||
Appointment created = appointmentService.create(appointment);
|
||||
return Map.of("code", 200, "message", "创建成功", "data", created);
|
||||
}
|
||||
|
||||
String followUpTaskId = optionalText(params.get("followUpTaskId"));
|
||||
try {
|
||||
return followUpTaskId.isBlank()
|
||||
? appointmentService.createBooking(appointment)
|
||||
: appointmentService.createBooking(appointment, followUpTaskId);
|
||||
} catch (com.petstore.service.FlowException e) {
|
||||
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
|
||||
}
|
||||
}
|
||||
|
||||
private static Long optionalLong(Object value) {
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("宠主 ID 格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static String optionalText(Object value) {
|
||||
return value == null ? "" : value.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始服务:仅 boss/staff;staffUserId = 当前用户(谁点谁服务);预约必须属于本店。
|
||||
*/
|
||||
/** 开始服务:状态变进行中 + 指定技师 */
|
||||
@PostMapping("/start")
|
||||
public Map<String, Object> start(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可开始服务", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Long appointmentId = Long.valueOf(params.get("appointmentId").toString());
|
||||
// 校验预约属于当前用户所在门店
|
||||
Appointment appt = appointmentService.getById(appointmentId);
|
||||
if (appt == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
|
||||
Long staffUserId = Long.valueOf(params.get("staffUserId").toString());
|
||||
Appointment updated = appointmentService.startService(appointmentId, staffUserId);
|
||||
if (updated != null) {
|
||||
return Map.of("code", 200, "message", "已开始服务", "data", updated);
|
||||
}
|
||||
if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) {
|
||||
return Map.of("code", 403, "message", "无权操作他店预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
return appointmentService.startService(appointmentId, u.userId(), u.role());
|
||||
return Map.of("code", 404, "message", "预约不存在");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预约状态(合法迁移见 AppointmentService.transitionStatus)。
|
||||
* - customer:只能取消自己的预约(status=cancel)
|
||||
* - boss/staff:只能操作本店预约
|
||||
*/
|
||||
/** 更新预约状态 */
|
||||
@PutMapping("/status")
|
||||
public Map<String, Object> updateStatus(@RequestParam Long id, @RequestParam String status) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Appointment appt = appointmentService.getById(id);
|
||||
if (appt == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
|
||||
Appointment updated = appointmentService.updateStatus(id, status);
|
||||
if (updated != null) {
|
||||
return Map.of("code", 200, "message", "更新成功", "data", updated);
|
||||
}
|
||||
if (u.isCustomer()) {
|
||||
// customer 仅可取消自己的预约
|
||||
if (!"cancel".equalsIgnoreCase(status)) {
|
||||
return Map.of("code", 403, "message", "宠主仅可取消自己的预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
if (!u.userId().equals(appt.resolvedCustomerUserId())) {
|
||||
return Map.of("code", 403, "message", "无权操作他人预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
} else if (u.isStoreUser()) {
|
||||
if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) {
|
||||
return Map.of("code", 403, "message", "无权操作他店预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
} else {
|
||||
return Map.of("code", 403, "message", "无权操作", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
return appointmentService.transitionStatus(id, status, u.userId(), u.role());
|
||||
}
|
||||
|
||||
/** 删除预约:仅 boss/staff 且同店。 */
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可删除预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Appointment appt = appointmentService.getById(id);
|
||||
if (appt == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在");
|
||||
}
|
||||
if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) {
|
||||
return Map.of("code", 403, "message", "无权操作他店预约", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
boolean ok = appointmentService.softDelete(id);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "预约不存在");
|
||||
}
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -18,102 +17,31 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/upload")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class FileController {
|
||||
|
||||
private static final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif");
|
||||
private static final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm");
|
||||
private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
|
||||
|
||||
@Value("${upload.path}")
|
||||
@Value("${upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
/**
|
||||
* 上传根目录(归一化绝对路径),用于路径逃逸校验。
|
||||
*/
|
||||
private Path uploadRoot() throws IOException {
|
||||
return Paths.get(uploadPath).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析相对路径并校验不逃逸上传根目录。
|
||||
* - 反斜杠统一为正斜杠
|
||||
* - 去掉前导斜杠
|
||||
* - normalize 后必须仍以 uploadRoot 为前缀
|
||||
* 非法路径抛 SecurityException,由调用方转 400/404。
|
||||
*/
|
||||
private Path resolveSafePath(String rawPath) throws IOException {
|
||||
String p = rawPath == null ? "" : rawPath.replace("\\", "/");
|
||||
while (p.startsWith("/")) {
|
||||
p = p.substring(1);
|
||||
}
|
||||
Path root = uploadRoot();
|
||||
Path target = root.resolve(p).normalize();
|
||||
if (!target.startsWith(root)) {
|
||||
throw new SecurityException("非法路径");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传媒体类型校验:
|
||||
* - 原文件有扩展名:必须在图片/视频白名单内,否则拒绝。
|
||||
* - 原文件无扩展名:MIME 必须是 image/* / video/* / application/octet-stream / 空(小程序常见),否则拒绝。
|
||||
*/
|
||||
static boolean isAllowedMediaType(String contentType, String originalFilename) {
|
||||
if (hasMediaExtension(originalFilename)) {
|
||||
return true;
|
||||
}
|
||||
// 有扩展名但不在白名单 → 拒绝
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
return false;
|
||||
}
|
||||
// 无扩展名:按 MIME 判断
|
||||
if (contentType == null || contentType.isBlank()) {
|
||||
// 小程序 multipart 常无 MIME 无扩展名,保留放行(保存时用默认后缀 + 大小启发式)
|
||||
return true;
|
||||
}
|
||||
String ct = contentType.toLowerCase(Locale.ROOT).trim();
|
||||
return ct.startsWith("image/") || ct.startsWith("video/") || "application/octet-stream".equals(ct);
|
||||
}
|
||||
|
||||
private static boolean hasMediaExtension(String originalFilename) {
|
||||
if (originalFilename == null || !originalFilename.contains(".")) {
|
||||
return false;
|
||||
}
|
||||
String ext = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
|
||||
return IMAGE_EXT.contains(ext) || VIDEO_EXT.contains(ext);
|
||||
}
|
||||
|
||||
@GetMapping("/image/**")
|
||||
public ResponseEntity<Resource> getImage(HttpServletRequest request) throws IOException {
|
||||
String path = request.getRequestURI().replace("/api/upload/image", "");
|
||||
Path target;
|
||||
try {
|
||||
target = resolveSafePath(path);
|
||||
} catch (SecurityException e) {
|
||||
log.warn("upload getImage illegal path: {}", path);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
File file = target.toFile();
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
File file = new File(UPLOAD_BASE + path);
|
||||
if (!file.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String contentType = Files.probeContentType(file.toPath());
|
||||
if (contentType == null) contentType = "image/jpeg";
|
||||
// 去掉 charset 参数(Spring 的 Accept-Charset 会错误地给 binary 类型加上 charset)
|
||||
int semi = contentType.indexOf(';');
|
||||
if (semi >= 0) contentType = contentType.substring(0, semi).trim();
|
||||
MediaType mediaType = MediaType.parseMediaType(contentType);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.contentLength(file.length())
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(new FileSystemResource(file));
|
||||
}
|
||||
|
||||
@ -121,30 +49,18 @@ public class FileController {
|
||||
@GetMapping("/legacy/**")
|
||||
public ResponseEntity<Resource> getLegacyImage(HttpServletRequest request) throws IOException {
|
||||
String path = request.getRequestURI().replace("/api/upload/legacy", "");
|
||||
Path target;
|
||||
try {
|
||||
target = resolveSafePath(path);
|
||||
} catch (SecurityException e) {
|
||||
log.warn("upload getLegacyImage illegal path: {}", path);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
File file = target.toFile();
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
File file = new File(UPLOAD_BASE + path);
|
||||
if (!file.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String contentType = Files.probeContentType(file.toPath());
|
||||
if (contentType == null) contentType = "image/jpeg";
|
||||
int semi = contentType.indexOf(';');
|
||||
if (semi >= 0) contentType = contentType.substring(0, semi).trim();
|
||||
MediaType mediaType = MediaType.parseMediaType(contentType);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.contentLength(file.length())
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(new FileSystemResource(file));
|
||||
}
|
||||
|
||||
/** produces 显式 UTF-8,避免网关/客户端按 ISO-8859-1 解码导致 message 中文乱码 */
|
||||
@PostMapping(value = "/image", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
@PostMapping("/image")
|
||||
public Map<String, Object> uploadImage(@RequestParam("file") MultipartFile file) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
@ -154,63 +70,31 @@ public class FileController {
|
||||
return result;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String contentType = file.getContentType();
|
||||
|
||||
if (!isAllowedMediaType(contentType, originalFilename)) {
|
||||
if (contentType == null || !contentType.startsWith("image/")) {
|
||||
result.put("code", 400);
|
||||
result.put("message", "只能上传图片或视频");
|
||||
result.put("message", "只能上传图片");
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建上传目录(归一化,防逃逸)
|
||||
// 创建上传目录(使用绝对路径)
|
||||
String datePath = LocalDate.now().toString().replace("-", "/");
|
||||
Path rootDir = uploadRoot();
|
||||
Path dirPath = rootDir.resolve(datePath).normalize();
|
||||
if (!dirPath.startsWith(rootDir)) {
|
||||
log.warn("upload image illegal datePath: {}", datePath);
|
||||
result.put("code", 400);
|
||||
result.put("message", "上传路径异常");
|
||||
return result;
|
||||
}
|
||||
File dir = dirPath.toFile();
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
log.warn("upload image mkdirs failed: {}", dirPath);
|
||||
result.put("code", 500);
|
||||
result.put("message", "上传目录创建失败");
|
||||
return result;
|
||||
}
|
||||
String dirPath = UPLOAD_BASE + datePath; // /.../uploads/2026/04/01
|
||||
File dir = new File(dirPath);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
// 生成文件名
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String ext = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
ext = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
|
||||
if (ext.length() > 10 || !hasMediaExtension(originalFilename)) {
|
||||
// 扩展名过长或不在白名单 → 用默认后缀
|
||||
ext = "";
|
||||
ext = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
}
|
||||
if (ext.isEmpty()) {
|
||||
boolean looksVideo = contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("video/");
|
||||
// application/octet-stream 且无扩展名时,大文件更可能是视频
|
||||
if (!looksVideo && "application/octet-stream".equalsIgnoreCase(String.valueOf(contentType).trim())) {
|
||||
looksVideo = file.getSize() > 3 * 1024 * 1024L;
|
||||
}
|
||||
ext = looksVideo ? ".mp4" : ".jpg";
|
||||
}
|
||||
|
||||
String filename = UUID.randomUUID().toString().replace("-", "") + ext;
|
||||
|
||||
// 保存文件(流式写入,避免大视频一次性进内存);最终路径再次校验不逃逸
|
||||
Path filePath = dirPath.resolve(filename).normalize();
|
||||
if (!filePath.startsWith(rootDir)) {
|
||||
log.warn("upload image illegal final path: {}", filename);
|
||||
result.put("code", 400);
|
||||
result.put("message", "上传路径异常");
|
||||
return result;
|
||||
}
|
||||
file.transferTo(filePath.toFile());
|
||||
// 保存文件
|
||||
Path filePath = Paths.get(dirPath, filename);
|
||||
Files.write(filePath, file.getBytes());
|
||||
|
||||
// 返回访问URL(/api/upload/image/ + 日期路径 + 文件名)
|
||||
String url = "/api/upload/image/" + datePath + "/" + filename;
|
||||
@ -221,15 +105,10 @@ public class FileController {
|
||||
return result;
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("upload failed: exceptionType={}", e.getClass().getSimpleName());
|
||||
e.printStackTrace();
|
||||
result.put("code", 500);
|
||||
result.put("message", "上传失败");
|
||||
return result;
|
||||
} catch (SecurityException e) {
|
||||
log.warn("upload security violation: exceptionType={}", e.getClass().getSimpleName());
|
||||
result.put("code", 400);
|
||||
result.put("message", "上传路径异常");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.service.FlowException;
|
||||
import com.petstore.service.MerchantOnboardingService;
|
||||
import com.petstore.service.WechatMiniProgramService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** 新门店通过微信核验手机号后自助开通。 */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/onboarding")
|
||||
@RequiredArgsConstructor
|
||||
public class MerchantOnboardingController {
|
||||
|
||||
private final MerchantOnboardingService onboardingService;
|
||||
private final WechatMiniProgramService wechatMiniProgramService;
|
||||
|
||||
@PostMapping("/register-boss")
|
||||
public Map<String, Object> registerBoss(@RequestBody(required = false) Map<String, String> params) {
|
||||
if (params == null) {
|
||||
return error(400, "INVALID_ONBOARDING_INPUT", "请求体不能为空");
|
||||
}
|
||||
String phoneCode = params.get("phoneCode");
|
||||
if (phoneCode == null || phoneCode.isBlank()) {
|
||||
return error(400, "WECHAT_PHONE_REQUIRED", "请先授权微信手机号");
|
||||
}
|
||||
var phoneResult = wechatMiniProgramService.exchangePhoneCode(phoneCode);
|
||||
if (!phoneResult.isOk()) {
|
||||
return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage());
|
||||
}
|
||||
|
||||
String openid = null;
|
||||
String unionid = null;
|
||||
String loginCode = params.get("loginCode");
|
||||
if (loginCode != null && !loginCode.isBlank()) {
|
||||
var identity = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||
if (identity.isOk()) {
|
||||
openid = identity.openid();
|
||||
unionid = identity.unionid();
|
||||
} else {
|
||||
log.debug("老板注册时 openid 沉淀跳过");
|
||||
}
|
||||
}
|
||||
try {
|
||||
Map<String, Object> data = onboardingService.registerVerifiedBoss(
|
||||
params.get("storeName"), params.get("bossName"), phoneResult.phone(), openid, unionid
|
||||
);
|
||||
return Map.of("code", 200, "message", "门店创建成功,请继续完成开通清单", "data", data);
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> error(int code, String bizCode, String message) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("code", code);
|
||||
result.put("bizCode", bizCode);
|
||||
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.Pet;
|
||||
import com.petstore.service.PetService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/pet")
|
||||
@RequiredArgsConstructor
|
||||
public class PetController {
|
||||
private final PetService petService;
|
||||
|
||||
/**
|
||||
* 列表:身份边界从上下文派生。
|
||||
* - customer:ownerUserId = current.userId(查自己宠物)
|
||||
* - boss/staff:storeId = current.storeId(查本店服务过的宠物)
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list() {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (u.isCustomer()) {
|
||||
return Map.of("code", 200, "data", petService.listByOwner(u.userId()));
|
||||
}
|
||||
if (u.isStoreUser() && u.storeId() != null) {
|
||||
return Map.of("code", 200, "data", petService.listByStoreServed(u.storeId()));
|
||||
}
|
||||
return Map.of("code", 400, "message", "无法识别身份范围");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建宠物档案。
|
||||
* - customer:ownerUserId 强制为 current.userId(覆盖请求体)
|
||||
* - boss/staff:ownerUserId 取 body(代客建档)
|
||||
*/
|
||||
@PostMapping("/create")
|
||||
public Map<String, Object> create(@RequestBody Pet pet) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (u.isCustomer()) {
|
||||
pet.setOwnerUserId(u.userId());
|
||||
}
|
||||
return petService.create(pet);
|
||||
}
|
||||
|
||||
/** 更新:operatorUserId / role 从上下文派生,不信任请求体。 */
|
||||
@PutMapping("/update")
|
||||
public Map<String, Object> update(@RequestBody Map<String, Object> body) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Pet input = new Pet();
|
||||
input.setId(Long.valueOf(body.get("id").toString()));
|
||||
if (body.containsKey("name")) input.setName(body.get("name").toString());
|
||||
if (body.containsKey("petType")) input.setPetType(body.get("petType").toString());
|
||||
if (body.containsKey("breed")) input.setBreed(body.get("breed") != null ? body.get("breed").toString() : null);
|
||||
if (body.containsKey("avatar")) input.setAvatar(body.get("avatar") != null ? body.get("avatar").toString() : null);
|
||||
if (body.containsKey("remark")) input.setRemark(body.get("remark") != null ? body.get("remark").toString() : null);
|
||||
return petService.update(u.userId(), u.role(), input);
|
||||
}
|
||||
|
||||
/** 删除:operatorUserId / role 从上下文派生。 */
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
return petService.delete(id, u.userId(), u.role());
|
||||
}
|
||||
|
||||
/**
|
||||
* 某宠物的预约 + 报告时间线(宠主本人或本店员工/老板)。
|
||||
* operatorUserId / role 从上下文派生。
|
||||
*/
|
||||
@GetMapping("/history")
|
||||
public Map<String, Object> history(@RequestParam Long petId) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
return petService.getServiceHistory(petId, u.userId(), u.role());
|
||||
}
|
||||
}
|
||||
@ -1,196 +1,38 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.HighlightFailReason;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportImage;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.service.AppointmentService;
|
||||
import com.petstore.service.BusinessEventService;
|
||||
import com.petstore.service.ReportHighlightVideoService;
|
||||
import com.petstore.service.ReportService;
|
||||
import com.petstore.service.ReportTestimonialService;
|
||||
import com.petstore.service.StoreService;
|
||||
import com.petstore.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/report")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class ReportController {
|
||||
private static final Logger log = LoggerFactory.getLogger(ReportController.class);
|
||||
private final ReportService reportService;
|
||||
private final AppointmentService appointmentService;
|
||||
private final StoreService storeService;
|
||||
private final ReportHighlightVideoService reportHighlightVideoService;
|
||||
private final ReportTestimonialService reportTestimonialService;
|
||||
private final UserService userService;
|
||||
private final BusinessEventService businessEventService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:8080}")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* 报告 token 的短 hash(SHA-256 前 8 位 hex),用于日志埋点,不输出原始 token 前缀。
|
||||
*/
|
||||
private static String shortTokenHash(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < Math.min(4, digest.length); i++) {
|
||||
sb.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "hash_error";
|
||||
}
|
||||
}
|
||||
private static final String BASE_URL = "http://localhost:8080";
|
||||
|
||||
private String fullUrl(String path) {
|
||||
if (path == null || path.isEmpty()) return path;
|
||||
if (path.startsWith("http")) return path;
|
||||
return baseUrl + path;
|
||||
}
|
||||
|
||||
private String resolveMediaType(ReportImage img) {
|
||||
String mt = img.getMediaType();
|
||||
if (mt != null && !mt.isBlank()) {
|
||||
return mt;
|
||||
}
|
||||
String url = img.getPhotoUrl();
|
||||
if (url == null) {
|
||||
return "photo";
|
||||
}
|
||||
String lower = url.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v")
|
||||
|| lower.endsWith(".webm") || lower.endsWith(".avi")) {
|
||||
return "video";
|
||||
}
|
||||
return "photo";
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告 JSON 中与「服务回顾短片」相关的字段(与小程序 / H5 共用契约)。
|
||||
* <ul>
|
||||
* <li>{@code highlightVideoUrl} — 成片 MP4;库内为相对路径 {@code /api/upload/image/...} 或历史绝对 URL;接口统一为可访问绝对 URL。</li>
|
||||
* <li>{@code highlightShareCoverUrl} — 成片首帧 JPEG,用于分享卡片封面;截帧失败时为 {@code null},前端按 utils 中分享封面降级链处理。</li>
|
||||
* <li>{@code highlightPosterUrl} — 预留(画布海报等);当前恒为 {@code null},前端可用 {@code highlightShareCoverUrl}。</li>
|
||||
* <li>{@code highlightVideoStatus} — {@code processing} | {@code done} | {@code failed} 等。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void putHighlightFields(Map<String, Object> target, Report r) {
|
||||
target.put("highlightVideoUrl", r.getHighlightVideoUrl() != null ? fullUrl(r.getHighlightVideoUrl()) : null);
|
||||
target.put("highlightShareCoverUrl", r.getHighlightShareCoverUrl() != null ? fullUrl(r.getHighlightShareCoverUrl()) : null);
|
||||
target.put("highlightPosterUrl", null);
|
||||
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
|
||||
target.put("highlightVideoError", r.getHighlightVideoError());
|
||||
target.put("highlightDurationSec", r.getHighlightDurationSec());
|
||||
target.put("highlightComposeMode", r.getHighlightComposeMode());
|
||||
String failCode = r.getHighlightFailReason();
|
||||
target.put("highlightFailReason", failCode);
|
||||
target.put("highlightFailReasonLabel", resolveHighlightFailLabel(failCode));
|
||||
}
|
||||
|
||||
private static String resolveHighlightFailLabel(String code) {
|
||||
if (code == null || code.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
for (HighlightFailReason fr : HighlightFailReason.values()) {
|
||||
if (fr.getCode().equalsIgnoreCase(code)) {
|
||||
return fr.getShortLabel();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg)。composeMode:preset | interleave;时长由素材自动计算(有上限)。 */
|
||||
@PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可生成成片", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
if (body.get("reportId") == null) {
|
||||
return Map.of("code", 400, "message", "缺少 reportId");
|
||||
}
|
||||
Long reportId = Long.valueOf(body.get("reportId").toString());
|
||||
String composeMode = body.get("composeMode") == null ? "preset" : body.get("composeMode").toString().trim();
|
||||
// operatorUserId / role 从上下文派生,不再信任请求体
|
||||
return reportHighlightVideoService.requestGenerate(reportId, u.userId(), u.role(), composeMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告打开(首访/再次):写入低敏 BusinessEvent;日志仅保留 token 短 hash。
|
||||
*/
|
||||
@PostMapping("/open-track")
|
||||
public Map<String, Object> trackReportOpen(@RequestBody Map<String, Object> body) {
|
||||
Object rawTok = body == null ? null : body.get("token");
|
||||
String token = rawTok == null ? "" : rawTok.toString().trim();
|
||||
if (token.isEmpty()) {
|
||||
return Map.of("code", 400, "message", "token 必填");
|
||||
}
|
||||
String visitType = body.get("visitType") == null ? "" : body.get("visitType").toString();
|
||||
boolean recorded = businessEventService.recordReportOpenByToken(token, visitType, LocalDateTime.now());
|
||||
log.info("report_open tokenHash={} visitType={} recorded={}", shortTokenHash(token), visitType, recorded);
|
||||
return Map.of("code", 200);
|
||||
return BASE_URL + path;
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
public Map<String, Object> create(@RequestBody Report report) {
|
||||
// 仅 boss/staff 可创建报告;userId 从上下文派生,不信任请求体
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可提交报告", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
// 报告必须绑定预约(P0 不做无预约报告主路径)
|
||||
if (report.getAppointmentId() == null) {
|
||||
return Map.of("code", 400, "message", "报告必须关联预约", "bizCode", "APPOINTMENT_REQUIRED");
|
||||
}
|
||||
Appointment appt = appointmentService.getById(report.getAppointmentId());
|
||||
if (appt == null) {
|
||||
return Map.of("code", 404, "message", "关联预约不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
// 报告所属门店必须等于当前用户所在门店
|
||||
if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) {
|
||||
return Map.of("code", 403, "message", "无权为他店预约创建报告", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
String st = appt.getStatus() == null ? "" : appt.getStatus().trim().toLowerCase();
|
||||
if (!"doing".equals(st)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "请先开始服务后再提交报告",
|
||||
"bizCode", "INVALID_STATUS"
|
||||
);
|
||||
}
|
||||
// 报告作者从上下文派生;legacy userId 仅镜像作者,客户归属由预约派生。
|
||||
report.setAuthorStaffId(u.userId());
|
||||
report.setUserId(u.userId());
|
||||
// 照片必填兜底:服务前/服务后至少各 1 张(非 video)
|
||||
if (!hasPhotoType(report, "before")) {
|
||||
return Map.of("code", 400, "message", "请至少上传1张服务前照片", "bizCode", "BEFORE_PHOTO_REQUIRED");
|
||||
}
|
||||
if (!hasPhotoType(report, "after")) {
|
||||
return Map.of("code", 400, "message", "请至少上传1张服务后照片", "bizCode", "AFTER_PHOTO_REQUIRED");
|
||||
}
|
||||
try {
|
||||
System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", before=" + report.getBeforePhoto());
|
||||
Report created = reportService.create(report);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", "提交成功");
|
||||
@ -199,97 +41,12 @@ public class ReportController {
|
||||
"reportId", created.getId()
|
||||
));
|
||||
return result;
|
||||
} catch (IllegalStateException e) {
|
||||
return Map.of("code", 409, "message", e.getMessage(), "bizCode", "REPORT_ALREADY_EXISTS");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_REPORT");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店员工在真实完成发送后显式确认。复制链接、二维码和预览不会自动调用本接口。
|
||||
*/
|
||||
@PostMapping("/confirm-sent")
|
||||
public Map<String, Object> confirmSent(@RequestBody Map<String, Object> body) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可确认报告发送", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Object rawReportId = body == null ? null : body.get("reportId");
|
||||
if (rawReportId == null) {
|
||||
return Map.of("code", 400, "message", "reportId 必填", "bizCode", "REPORT_ID_REQUIRED");
|
||||
}
|
||||
try {
|
||||
Long reportId = Long.valueOf(rawReportId.toString());
|
||||
String channel = body.get("channel") == null ? "" : body.get("channel").toString();
|
||||
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||
reportId, u.storeId(), u.userId(), u.role(), channel
|
||||
);
|
||||
if (outcome == null) {
|
||||
return Map.of("code", 404, "message", "报告不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
Report report = outcome.report();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reportId", report.getId());
|
||||
data.put("sendStatus", report.getSendStatus());
|
||||
data.put("sentAt", report.getSentAt());
|
||||
data.put("sendChannel", report.getSendChannel());
|
||||
data.put("alreadyConfirmed", outcome.alreadyConfirmed());
|
||||
return Map.of("code", 200, "message", "已确认发送", "data", data);
|
||||
} catch (NumberFormatException e) {
|
||||
return Map.of("code", 400, "message", "reportId 格式错误", "bizCode", "INVALID_REPORT_ID");
|
||||
} catch (SecurityException e) {
|
||||
return Map.of("code", 403, "message", e.getMessage(), "bizCode", "FORBIDDEN");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SEND_CHANNEL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告是否含有指定类型(before / after)的有效照片:url 非空且 mediaType 非 video。
|
||||
*/
|
||||
private boolean hasPhotoType(Report report, String type) {
|
||||
if (report == null || report.getImages() == null) {
|
||||
return false;
|
||||
}
|
||||
return report.getImages().stream().anyMatch(img ->
|
||||
type.equals(img.getPhotoType())
|
||||
&& img.getPhotoUrl() != null
|
||||
&& !img.getPhotoUrl().isBlank()
|
||||
&& !"video".equalsIgnoreCase(img.getMediaType())
|
||||
);
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(@RequestParam(required = false) Integer page,
|
||||
@RequestParam(required = false) Integer pageSize,
|
||||
@RequestParam(required = false) String sendStatus) {
|
||||
// 身份边界从上下文派生:boss/staff 查本店;customer 查自己相关报告
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
||||
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
||||
String scopedSendStatus = null;
|
||||
if (u.isStoreUser() && sendStatus != null && !sendStatus.isBlank()) {
|
||||
scopedSendStatus = sendStatus.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Set.of(Report.SEND_STATUS_UNKNOWN, Report.SEND_STATUS_UNSENT, Report.SEND_STATUS_SENT)
|
||||
.contains(scopedSendStatus)) {
|
||||
return Map.of("code", 400, "message", "发送状态无效", "bizCode", "INVALID_SEND_STATUS");
|
||||
}
|
||||
}
|
||||
|
||||
boolean usePaging = page != null || pageSize != null;
|
||||
List<Report> reports;
|
||||
Page<Report> paged = null;
|
||||
Integer pageNo = null;
|
||||
Integer size = null;
|
||||
if (usePaging) {
|
||||
pageNo = Math.max(page == null ? 1 : page, 1);
|
||||
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
|
||||
paged = reportService.page(scopedStoreId, scopedUserId, scopedSendStatus, pageNo - 1, size);
|
||||
reports = paged.getContent();
|
||||
} else {
|
||||
reports = reportService.list(scopedStoreId, scopedUserId, scopedSendStatus);
|
||||
}
|
||||
public Map<String, Object> list(@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) Long userId) {
|
||||
List<Report> reports = reportService.list(storeId, userId);
|
||||
// 附加技师名称,并补全图片URL
|
||||
List<Map<String, Object>> data = reports.stream().map(r -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
@ -301,50 +58,12 @@ public class ReportController {
|
||||
item.put("staffName", r.getStaffName());
|
||||
item.put("reportToken", r.getReportToken());
|
||||
item.put("createTime", r.getCreateTime());
|
||||
item.put("updateTime", r.getUpdateTime());
|
||||
item.put("beforePhoto", fullUrl(r.getBeforePhoto()));
|
||||
item.put("afterPhoto", fullUrl(r.getAfterPhoto()));
|
||||
item.put("storeId", r.getStoreId());
|
||||
item.put("userId", r.getUserId());
|
||||
item.put("customerUserId", r.getCustomerUserId());
|
||||
item.put("authorStaffId", r.resolvedAuthorStaffId());
|
||||
if (u.isStoreUser()) {
|
||||
putSendFields(item, r, true);
|
||||
}
|
||||
// 图片列表
|
||||
List<ReportImage> imgs = r.getImages();
|
||||
List<String> beforePhotos = new ArrayList<>();
|
||||
List<String> afterPhotos = new ArrayList<>();
|
||||
List<Map<String, Object>> duringMedia = new ArrayList<>();
|
||||
if (imgs != null) {
|
||||
for (ReportImage img : imgs) {
|
||||
if ("before".equals(img.getPhotoType())) {
|
||||
beforePhotos.add(fullUrl(img.getPhotoUrl()));
|
||||
} else if ("after".equals(img.getPhotoType())) {
|
||||
afterPhotos.add(fullUrl(img.getPhotoUrl()));
|
||||
} else if ("during".equals(img.getPhotoType())) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("url", fullUrl(img.getPhotoUrl()));
|
||||
m.put("mediaType", resolveMediaType(img));
|
||||
duringMedia.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
item.put("beforePhotos", beforePhotos);
|
||||
item.put("afterPhotos", afterPhotos);
|
||||
item.put("duringMedia", duringMedia);
|
||||
putHighlightFields(item, r);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
if (usePaging && paged != null) {
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", data,
|
||||
"page", pageNo,
|
||||
"pageSize", size,
|
||||
"total", paged.getTotalElements(),
|
||||
"totalPages", paged.getTotalPages(),
|
||||
"hasNext", paged.hasNext()
|
||||
);
|
||||
}
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
@ -352,8 +71,7 @@ public class ReportController {
|
||||
public Map<String, Object> getByAppointmentId(@RequestParam(required = false) Long appointmentId,
|
||||
@RequestParam(required = false) String token) {
|
||||
Report report = null;
|
||||
boolean publicTokenRequest = token != null && !token.isEmpty();
|
||||
if (publicTokenRequest) {
|
||||
if (token != null && !token.isEmpty()) {
|
||||
report = reportService.getByToken(token);
|
||||
} else if (appointmentId != null) {
|
||||
report = reportService.getByAppointmentId(appointmentId);
|
||||
@ -366,79 +84,28 @@ public class ReportController {
|
||||
if (report.getStoreId() != null) {
|
||||
store = storeService.findById(report.getStoreId());
|
||||
}
|
||||
List<ReportImage> imgs = report.getImages();
|
||||
List<String> beforePhotos = new ArrayList<>();
|
||||
List<String> afterPhotos = new ArrayList<>();
|
||||
List<Map<String, Object>> duringMedia = new ArrayList<>();
|
||||
if (imgs != null) {
|
||||
for (ReportImage img : imgs) {
|
||||
if ("before".equals(img.getPhotoType())) {
|
||||
beforePhotos.add(fullUrl(img.getPhotoUrl()));
|
||||
} else if ("after".equals(img.getPhotoType())) {
|
||||
afterPhotos.add(fullUrl(img.getPhotoUrl()));
|
||||
} else if ("during".equals(img.getPhotoType())) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("url", fullUrl(img.getPhotoUrl()));
|
||||
m.put("mediaType", resolveMediaType(img));
|
||||
duringMedia.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", report.getId());
|
||||
data.put("appointmentId", report.getAppointmentId());
|
||||
data.put("beforePhotos", beforePhotos);
|
||||
data.put("afterPhotos", afterPhotos);
|
||||
data.put("duringMedia", duringMedia);
|
||||
data.put("beforePhoto", report.getBeforePhoto());
|
||||
data.put("afterPhoto", report.getAfterPhoto());
|
||||
data.put("remark", report.getRemark());
|
||||
data.put("userId", report.getUserId());
|
||||
data.put("storeId", report.getStoreId());
|
||||
data.put("reportToken", report.getReportToken());
|
||||
data.put("petName", report.getPetName());
|
||||
data.put("serviceType", report.getServiceType());
|
||||
data.put("appointmentTime", report.getAppointmentTime());
|
||||
data.put("staffName", report.getStaffName());
|
||||
data.put("createTime", report.getCreateTime());
|
||||
Long serviceStaffId = report.resolvedAuthorStaffId();
|
||||
if (report.getAppointmentId() != null) {
|
||||
Appointment appointment = appointmentService.getById(report.getAppointmentId());
|
||||
if (appointment != null && appointment.getAssignedUserId() != null) {
|
||||
serviceStaffId = appointment.getAssignedUserId();
|
||||
}
|
||||
}
|
||||
if (serviceStaffId != null) {
|
||||
User staff = userService.findById(serviceStaffId);
|
||||
if (staff != null && staff.getAvatar() != null && !staff.getAvatar().isBlank()) {
|
||||
data.put("staffAvatar", fullUrl(staff.getAvatar()));
|
||||
} else {
|
||||
data.put("staffAvatar", null);
|
||||
}
|
||||
} else {
|
||||
data.put("staffAvatar", null);
|
||||
}
|
||||
if (store != null) {
|
||||
Map<String, Object> storeInfo = new HashMap<>();
|
||||
// 公开 token 请求:store 对象内返回 id 用于预约跳转深链;
|
||||
// 非 public 请求(门店端 appointmentId 查询)保留全部字段。
|
||||
storeInfo.put("id", store.getId());
|
||||
storeInfo.put("name", store.getName());
|
||||
storeInfo.put("logo", store.getLogo());
|
||||
storeInfo.put("phone", store.getPhone());
|
||||
storeInfo.put("address", store.getAddress());
|
||||
if (!publicTokenRequest) {
|
||||
storeInfo.put("latitude", store.getLatitude());
|
||||
storeInfo.put("longitude", store.getLongitude());
|
||||
}
|
||||
data.put("store", storeInfo);
|
||||
}
|
||||
putHighlightFields(data, report);
|
||||
if (!publicTokenRequest) {
|
||||
// 门店端 appointmentId 查询保留内部字段供门店端使用
|
||||
data.put("userId", report.getUserId());
|
||||
data.put("customerUserId", report.getCustomerUserId());
|
||||
data.put("authorStaffId", report.resolvedAuthorStaffId());
|
||||
data.put("storeId", report.getStoreId());
|
||||
data.put("reportToken", report.getReportToken());
|
||||
putSendFields(data, report, true);
|
||||
}
|
||||
// 公开 token 请求:不返回 top-level userId/storeId/reportToken(已在上方跳过)
|
||||
result.put("code", 200);
|
||||
result.put("data", data);
|
||||
} else {
|
||||
@ -447,58 +114,4 @@ public class ReportController {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void putSendFields(Map<String, Object> target, Report report, boolean includeActor) {
|
||||
target.put("sendStatus", report.getSendStatus());
|
||||
target.put("sentAt", report.getSentAt());
|
||||
target.put("sendChannel", report.getSendChannel());
|
||||
if (includeActor) {
|
||||
target.put("sentByUserId", report.getSentByUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可删除报告", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
// 同店校验:用 reportService.list 拿本店报告,遍历匹配 id
|
||||
List<Report> sameStoreReports = reportService.list(u.storeId(), null);
|
||||
boolean inStore = sameStoreReports.stream().anyMatch(r -> r.getId().equals(id));
|
||||
if (!inStore) {
|
||||
return Map.of("code", 403, "message", "无权删除他店报告", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
boolean ok = reportService.softDelete(id);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "报告不存在");
|
||||
}
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* 宠主寄语(生成分享海报前提交,同一报告覆盖更新;供后续口碑墙)
|
||||
*/
|
||||
@PostMapping("/{token}/testimonial")
|
||||
public Map<String, Object> submitTestimonial(@PathVariable("token") String token,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String content = body.get("content") == null ? "" : body.get("content").toString();
|
||||
boolean isPublic = true;
|
||||
if (body.get("isPublic") != null) {
|
||||
isPublic = Boolean.parseBoolean(body.get("isPublic").toString());
|
||||
}
|
||||
try {
|
||||
var row = reportTestimonialService.submit(token, content, isPublic);
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"data", Map.of(
|
||||
"id", row.getId(),
|
||||
"content", row.getContent(),
|
||||
"isPublic", row.getIsPublic()
|
||||
)
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,137 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.service.ReportLeadService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 报告页留资 & 回访池。
|
||||
* 前 3 个接口面向 H5 宠主端(无需登录,凭 reportToken / unsubscribeToken)。
|
||||
* leads 接口面向老板后台:storeId 从 CurrentUserContext 派生,仅允许 boss/staff 查本店。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/report")
|
||||
@RequiredArgsConstructor
|
||||
public class ReportLeadController {
|
||||
|
||||
private final ReportLeadService reportLeadService;
|
||||
|
||||
/** GET /api/report/{token}/suggestion — 下次建议日期 */
|
||||
@GetMapping("/{token}/suggestion")
|
||||
public Map<String, Object> suggestion(@PathVariable("token") String token) {
|
||||
Map<String, Object> data = reportLeadService.suggestNext(token);
|
||||
if (data == null) {
|
||||
return Map.of("code", 404, "message", "报告不存在");
|
||||
}
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
/** POST /api/report/{token}/reminder — 宠主留资 */
|
||||
@PostMapping("/{token}/reminder")
|
||||
public Map<String, Object> submitReminder(@PathVariable("token") String token,
|
||||
@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest request) {
|
||||
String phone = body.get("phone") == null ? null : body.get("phone").toString().trim();
|
||||
String loginCode = body.get("loginCode") == null ? null : body.get("loginCode").toString().trim();
|
||||
Object consent = body.get("consent");
|
||||
if (consent == null || !Boolean.parseBoolean(consent.toString())) {
|
||||
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
|
||||
}
|
||||
try {
|
||||
ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
|
||||
ReportLead lead = outcome.lead();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("ok", true);
|
||||
data.put("leadId", lead.getId());
|
||||
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
|
||||
data.put("unsubscribeToken", lead.getUnsubscribeToken());
|
||||
data.put("wechatBound", outcome.wechatBound());
|
||||
data.put("repeatSubmit", outcome.repeatSubmit());
|
||||
data.put("alreadySubmitted", outcome.repeatSubmit());
|
||||
String message = outcome.repeatSubmit()
|
||||
? "该手机号已登记过本报告的提醒,信息已更新"
|
||||
: "提交成功";
|
||||
return Map.of("code", 200, "message", message, "data", data);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/report/unsubscribe — 一键退订 */
|
||||
@PostMapping("/unsubscribe")
|
||||
public Map<String, Object> unsubscribe(@RequestBody Map<String, Object> body) {
|
||||
String token = body.get("unsubscribeToken") == null ? null : body.get("unsubscribeToken").toString();
|
||||
boolean ok = reportLeadService.unsubscribe(token);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "退订链接无效或已失效");
|
||||
}
|
||||
return Map.of("code", 200, "data", Map.of("ok", true));
|
||||
}
|
||||
|
||||
/** GET /api/report/leads?status=pending — 老板后台回访池;storeId 从上下文派生,忽略 query storeId。 */
|
||||
@GetMapping("/leads")
|
||||
public Map<String, Object> leads(@RequestParam(required = false, defaultValue = "pending") String status) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看回访池", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Long storeId = u.storeId();
|
||||
List<ReportLead> list;
|
||||
if ("all".equalsIgnoreCase(status)) {
|
||||
list = reportLeadService.listAllForStore(storeId);
|
||||
} else {
|
||||
list = reportLeadService.listPending(storeId);
|
||||
}
|
||||
List<Map<String, Object>> data = list.stream().map(l -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", l.getId());
|
||||
m.put("reportId", l.getReportId());
|
||||
m.put("petName", l.getPetName());
|
||||
m.put("serviceType", l.getServiceType());
|
||||
m.put("phone", l.getPhone());
|
||||
// 微信标识脱敏:不返回完整 openid/unionid,只返回是否绑定 + 脱敏 id
|
||||
m.put("wechatBound", l.getWechatOpenid() != null && !l.getWechatOpenid().isBlank());
|
||||
m.put("wechatOpenidMasked", maskIdentifier(l.getWechatOpenid()));
|
||||
m.put("wechatUnionidMasked", maskIdentifier(l.getWechatUnionid()));
|
||||
m.put("reminderType", l.getReminderType());
|
||||
m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString());
|
||||
m.put("remindStatus", l.getRemindStatus());
|
||||
m.put("createTime", l.getCreateTime());
|
||||
return m;
|
||||
}).toList();
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
/** 微信 openid/unionid 脱敏:保留首 4 + 末 4,中间用 **** 替换;过短或为空返回 null 或 ****。 */
|
||||
private String maskIdentifier(String v) {
|
||||
if (v == null || v.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (v.length() <= 8) {
|
||||
return "****";
|
||||
}
|
||||
return v.substring(0, 4) + "****" + v.substring(v.length() - 4);
|
||||
}
|
||||
|
||||
/** 取客户端 IP(合规留证) */
|
||||
private String extractClientIp(HttpServletRequest request) {
|
||||
if (request == null) return null;
|
||||
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
|
||||
for (String h : headers) {
|
||||
String v = request.getHeader(h);
|
||||
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
|
||||
int comma = v.indexOf(',');
|
||||
return (comma > 0 ? v.substring(0, comma) : v).trim();
|
||||
}
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.ScheduleService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 门店排班/日程:仅 boss/staff 可操作;storeId 从 CurrentUserContext 派生,忽略请求体里的 storeId。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/schedule")
|
||||
@RequiredArgsConstructor
|
||||
public class ScheduleController {
|
||||
|
||||
private final ScheduleService scheduleService;
|
||||
|
||||
/** date 格式:yyyy-MM-dd;storeId 从上下文派生。 */
|
||||
@GetMapping("/day")
|
||||
public Map<String, Object> day(@RequestParam String date) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看排班", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
LocalDate d;
|
||||
try {
|
||||
d = LocalDate.parse(date);
|
||||
} catch (Exception e) {
|
||||
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
|
||||
}
|
||||
return scheduleService.dayAgenda(u.storeId(), d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动占用一个或多个连续半小时档。仅 boss/staff;storeId 与 createdByUserId 从上下文派生。
|
||||
* <br>slotStart:ISO 本地时间,如 2026-04-17T10:00:00
|
||||
* <br>blockType:walk_in(到店) / blocked(暂停线上预约)
|
||||
*/
|
||||
@PostMapping("/block")
|
||||
public Map<String, Object> createBlock(@RequestBody Map<String, Object> body) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可占用排班", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
String slotStr = body.get("slotStart") != null ? body.get("slotStart").toString() : null;
|
||||
String blockType = body.get("blockType") != null ? body.get("blockType").toString() : null;
|
||||
String note = body.get("note") != null ? body.get("note").toString() : null;
|
||||
Integer durationMinutes;
|
||||
try {
|
||||
durationMinutes = body.get("durationMinutes") == null
|
||||
? 30
|
||||
: Integer.valueOf(body.get("durationMinutes").toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return Map.of("code", 400, "message", "durationMinutes 格式无效");
|
||||
}
|
||||
|
||||
LocalDateTime slotStart;
|
||||
try {
|
||||
slotStart = LocalDateTime.parse(slotStr);
|
||||
} catch (Exception e) {
|
||||
return Map.of("code", 400, "message", "slotStart 格式无效");
|
||||
}
|
||||
// storeId / createdByUserId 从上下文派生,不信任请求体
|
||||
return scheduleService.createBlock(u.storeId(), slotStart, durationMinutes, blockType, note, u.userId());
|
||||
}
|
||||
|
||||
/** 取消手动占用:仅 boss/staff 且同店(由 service 校验 storeId 匹配)。 */
|
||||
@DeleteMapping("/block")
|
||||
public Map<String, Object> deleteBlock(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可取消排班占用", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
return scheduleService.deleteBlock(id, u.storeId());
|
||||
}
|
||||
}
|
||||
@ -1,112 +1,52 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.ServiceType;
|
||||
import com.petstore.service.ServiceTypeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 服务类型:list 公开(C 端可选门店);create/update/delete/init 仅 boss 且同店。
|
||||
* 系统默认(storeId 为空)不允许任何门店老板改/删。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/service-type")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class ServiceTypeController {
|
||||
private final ServiceTypeService serviceTypeService;
|
||||
|
||||
/** 公开接口:不传 storeId 时仅返回系统默认;传 storeId 返回系统默认 + 该店自定义。 */
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(@RequestParam(required = false) Long storeId) {
|
||||
List<ServiceType> list =
|
||||
storeId == null ? serviceTypeService.listSystemDefaultsOnly() : serviceTypeService.getByStoreId(storeId);
|
||||
public Map<String, Object> list(@RequestParam Long storeId) {
|
||||
List<ServiceType> list = serviceTypeService.getByStoreId(storeId);
|
||||
return Map.of("code", 200, "data", list);
|
||||
}
|
||||
|
||||
/** 新增服务类型:仅 boss;storeId 从上下文派生。 */
|
||||
@PostMapping("/create")
|
||||
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可新增服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
try {
|
||||
String name = params.get("name") == null ? null : params.get("name").toString();
|
||||
Integer durationMinutes = integerParam(params.get("durationMinutes"));
|
||||
ServiceType created = serviceTypeService.create(u.storeId(), name, durationMinutes);
|
||||
Long storeId = Long.valueOf(params.get("storeId").toString());
|
||||
String name = params.get("name").toString();
|
||||
ServiceType created = serviceTypeService.create(storeId, name);
|
||||
return Map.of("code", 200, "data", created);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑服务类型:仅 boss;必须为本店自定义类型(系统默认 storeId 为空不可改)。 */
|
||||
@PutMapping("/update")
|
||||
public Map<String, Object> update(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可编辑服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Long id = Long.valueOf(params.get("id").toString());
|
||||
ServiceType existing = serviceTypeService.findById(id);
|
||||
if (existing == null) {
|
||||
return Map.of("code", 404, "message", "服务类型不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
if (existing.getStoreId() == null || !existing.getStoreId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权修改系统默认或他店服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
try {
|
||||
String name = params.get("name") == null ? null : params.get("name").toString();
|
||||
Integer durationMinutes = integerParam(params.get("durationMinutes"));
|
||||
ServiceType updated = serviceTypeService.update(id, name, durationMinutes);
|
||||
String name = params.get("name").toString();
|
||||
ServiceType updated = serviceTypeService.update(id, name);
|
||||
return Map.of("code", 200, "data", updated);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除服务类型:仅 boss;必须为本店自定义类型(系统默认不可删)。 */
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可删除服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
ServiceType existing = serviceTypeService.findById(id);
|
||||
if (existing == null) {
|
||||
return Map.of("code", 404, "message", "服务类型不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
if (existing.getStoreId() == null || !existing.getStoreId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权删除系统默认或他店服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
serviceTypeService.delete(id);
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
|
||||
/** 初始化系统默认服务类型:仅 boss/staff(生产可由运维触发;启动期已自动初始化)。 */
|
||||
@PostMapping("/init")
|
||||
public Map<String, Object> init() {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可初始化服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
serviceTypeService.initDefaults();
|
||||
return Map.of("code", 200, "message", "初始化成功");
|
||||
}
|
||||
|
||||
private static Integer integerParam(Object value) {
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("服务时长格式不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,58 +1,45 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* 短信验证码发送:仅 dev 模式可用(演示万能验证码)。
|
||||
* 生产环境短信登录未启用,请使用微信授权登录(/api/user/wx-phone-login)。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/sms")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class SmsController {
|
||||
|
||||
/**
|
||||
* 演示模式万能验证码:dev 默认 123456;production profile 默认空(关闭 SMS 登录)。
|
||||
* 为空时 send 接口直接拒绝,避免生产环境误用演示短信。
|
||||
*/
|
||||
@Value("${app.demo.sms-universal-code:}")
|
||||
private String universalSmsCode;
|
||||
// TODO: 接入真实的短信服务商(阿里云/腾讯云/华为云等)
|
||||
// 这里用演示模式,随机生成6位验证码
|
||||
|
||||
@PostMapping("/send")
|
||||
public Map<String, Object> send(@RequestBody Map<String, String> params) {
|
||||
String phone = params.get("phone");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// 生产登录策略:SMS 登录 dev-only;universalSmsCode 为空 = 已关闭
|
||||
if (universalSmsCode == null || universalSmsCode.isBlank()) {
|
||||
result.put("code", 403);
|
||||
result.put("message", "生产环境短信验证码登录未启用,请使用微信授权登录");
|
||||
result.put("bizCode", "SMS_LOGIN_DISABLED");
|
||||
return result;
|
||||
}
|
||||
|
||||
String phone = params.get("phone");
|
||||
if (phone == null || phone.length() != 11) {
|
||||
result.put("code", 400);
|
||||
result.put("message", "手机号格式不正确");
|
||||
return result;
|
||||
}
|
||||
|
||||
// dev 演示:生成 6 位验证码并记日志(生产不会走到这里)
|
||||
// 演示:生成6位验证码
|
||||
String code = String.format("%06d", new Random().nextInt(999999));
|
||||
log.info("【宠小它】dev 验证码:phone={} code={}(5分钟内有效)", phone, code);
|
||||
|
||||
// TODO: 调用真实短信服务商发送验证码
|
||||
// 阿里云示例: dysmsapi.aliyuncs.com
|
||||
// 腾讯云示例: sms.tencentcloudapi.com
|
||||
|
||||
System.out.println("【宠伴生活馆】验证码:" + code + ",您正在登录,5分钟内有效。");
|
||||
|
||||
result.put("code", 200);
|
||||
result.put("message", "发送成功");
|
||||
// dev 演示用:返回 code 便于本地联调;生产不会走到此分支
|
||||
result.put("data", Map.of("code", code));
|
||||
result.put("data", Map.of("code", code)); // 演示用,实际生产环境不应返回code
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.service.FlowException;
|
||||
import com.petstore.service.StaffInvitationService;
|
||||
import com.petstore.service.WechatMiniProgramService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** 老板签发/撤销邀请;员工在小程序用微信核验手机号后接受。 */
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@RequiredArgsConstructor
|
||||
public class StaffInvitationController {
|
||||
|
||||
private final StaffInvitationService invitationService;
|
||||
private final WechatMiniProgramService wechatMiniProgramService;
|
||||
|
||||
@PostMapping("/admin/staff-invitations")
|
||||
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isBoss() || user.storeId() == null) {
|
||||
return error(403, "FORBIDDEN", "仅老板可邀请员工");
|
||||
}
|
||||
try {
|
||||
Integer validDays = integerOrNull(params.get("validDays"));
|
||||
Map<String, Object> data = invitationService.create(
|
||||
user.storeId(), user.userId(), user.role(), text(params.get("name")),
|
||||
text(params.get("phone")), validDays
|
||||
);
|
||||
return Map.of("code", 200, "message", "邀请已创建,请立即复制邀请链接", "data", data);
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
} catch (NumberFormatException e) {
|
||||
return error(400, "INVALID_EXPIRY", "邀请有效期格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/admin/staff-invitations")
|
||||
public Map<String, Object> list() {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isBoss() || user.storeId() == null) {
|
||||
return error(403, "FORBIDDEN", "仅老板可查看员工邀请");
|
||||
}
|
||||
try {
|
||||
return Map.of("code", 200, "data", invitationService.list(user.storeId()));
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/admin/staff-invitations")
|
||||
public Map<String, Object> revoke(@RequestParam String invitationId) {
|
||||
CurrentUser user = CurrentUserContext.require();
|
||||
if (!user.isBoss() || user.storeId() == null) {
|
||||
return error(403, "FORBIDDEN", "仅老板可撤销员工邀请");
|
||||
}
|
||||
try {
|
||||
return Map.of(
|
||||
"code", 200,
|
||||
"message", "邀请已撤销",
|
||||
"data", invitationService.revoke(user.storeId(), user.userId(), user.role(), invitationId)
|
||||
);
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/staff-invitations/preview")
|
||||
public Map<String, Object> preview(@RequestParam String token) {
|
||||
try {
|
||||
return Map.of("code", 200, "data", invitationService.preview(token));
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/staff-invitations/accept")
|
||||
public Map<String, Object> accept(@RequestBody(required = false) Map<String, String> params) {
|
||||
if (params == null || params.get("token") == null || params.get("phoneCode") == null) {
|
||||
return error(400, "INVITATION_VERIFICATION_REQUIRED", "缺少邀请凭证或微信手机号授权码");
|
||||
}
|
||||
var phoneResult = wechatMiniProgramService.exchangePhoneCode(params.get("phoneCode"));
|
||||
if (!phoneResult.isOk()) {
|
||||
return error(400, "WECHAT_PHONE_FAILED", phoneResult.errorMessage());
|
||||
}
|
||||
String openid = null;
|
||||
String unionid = null;
|
||||
String loginCode = params.get("loginCode");
|
||||
if (loginCode != null && !loginCode.isBlank()) {
|
||||
var identity = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||
if (identity.isOk()) {
|
||||
openid = identity.openid();
|
||||
unionid = identity.unionid();
|
||||
} else {
|
||||
log.debug("员工接受邀请时 openid 沉淀跳过");
|
||||
}
|
||||
}
|
||||
try {
|
||||
Map<String, Object> data = invitationService.acceptVerified(
|
||||
params.get("token"), phoneResult.phone(), openid, unionid
|
||||
);
|
||||
return Map.of("code", 200, "message", "已加入门店", "data", data);
|
||||
} catch (FlowException e) {
|
||||
return error(e.code(), e.bizCode(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private static Integer integerOrNull(Object value) {
|
||||
return value == null || value.toString().isBlank() ? null : Integer.valueOf(value.toString());
|
||||
}
|
||||
|
||||
private static Map<String, Object> error(int code, String bizCode, String message) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("code", code);
|
||||
result.put("bizCode", bizCode);
|
||||
result.put("message", message == null || message.isBlank() ? "操作失败" : message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,58 +1,37 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.service.StoreService;
|
||||
import com.petstore.service.AuditLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/store")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class StoreController {
|
||||
private final StoreService storeService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public Map<String, Object> register(@RequestBody Store store) {
|
||||
return Map.of(
|
||||
"code", 410,
|
||||
"message", "旧门店创建入口已停用,请使用微信核验手机号完成门店开通",
|
||||
"bizCode", "LEGACY_STORE_REGISTRATION_DISABLED"
|
||||
);
|
||||
Store created = storeService.create(store);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", "注册成功");
|
||||
result.put("data", created);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户预约:门店列表(公开字段,不含 ownerId/inviteCode)。公开接口。
|
||||
* 规则:{@code rule:BR-STORE-003}
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list() {
|
||||
List<Store> all = storeService.listAll();
|
||||
List<Map<String, Object>> rows = all.stream()
|
||||
.map(StoreController::toPublicStoreView)
|
||||
.collect(Collectors.toList());
|
||||
return Map.of("code", 200, "data", rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店详情:公开接口,仅返回公开字段(不含 ownerId/inviteCode)。
|
||||
* 规则:{@code rule:BR-STORE-003}
|
||||
*/
|
||||
@GetMapping("/get")
|
||||
public Map<String, Object> get(@RequestParam Long id) {
|
||||
Store store = storeService.findById(id);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (store != null) {
|
||||
result.put("code", 200);
|
||||
result.put("data", toPublicStoreView(store));
|
||||
result.put("data", store);
|
||||
} else {
|
||||
result.put("code", 404);
|
||||
result.put("message", "店铺不存在");
|
||||
@ -60,55 +39,9 @@ public class StoreController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开门店视图:预约/展示所需字段;排除 ownerId、inviteCode、deleted 等内部字段。
|
||||
*/
|
||||
static Map<String, Object> toPublicStoreView(Store s) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", s.getId());
|
||||
m.put("name", s.getName());
|
||||
m.put("address", s.getAddress());
|
||||
m.put("latitude", s.getLatitude());
|
||||
m.put("longitude", s.getLongitude());
|
||||
m.put("phone", s.getPhone());
|
||||
m.put("logo", s.getLogo());
|
||||
m.put("intro", s.getIntro());
|
||||
m.put("bookingDayStart", s.getBookingDayStart());
|
||||
m.put("bookingLastSlotStart", s.getBookingLastSlotStart());
|
||||
m.put("bookingCapacity", StoreService.normalizeCapacity(s.getBookingCapacity()));
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新店铺:仅 boss 且只能改自己所属门店;store.id 强制为 current.storeId。
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
public Map<String, Object> update(@RequestBody Store store) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可更新店铺信息", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
if (store == null || store.getId() == null || !store.getId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权修改他店信息", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Store updated;
|
||||
try {
|
||||
updated = storeService.update(store);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_STORE_CONFIG");
|
||||
}
|
||||
if (updated == null) {
|
||||
return Map.of("code", 404, "message", "店铺不存在");
|
||||
}
|
||||
boolean profileChanged = store.getName() != null || store.getPhone() != null || store.getAddress() != null
|
||||
|| store.getIntro() != null || store.getLogo() != null
|
||||
|| store.getLatitude() != null || store.getLongitude() != null;
|
||||
boolean bookingChanged = store.getBookingDayStart() != null || store.getBookingLastSlotStart() != null
|
||||
|| store.getBookingCapacity() != null;
|
||||
auditLogService.record(
|
||||
u.storeId(), u.userId(), u.role(), "store_settings_updated", "store", u.storeId().toString(),
|
||||
"success", Map.of("profileChanged", profileChanged, "bookingChanged", bookingChanged)
|
||||
);
|
||||
Store updated = storeService.update(store);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", "更新成功");
|
||||
@ -116,38 +49,17 @@ public class StoreController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除店铺:仅 boss 且只能删自己所属门店。
|
||||
*/
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可删除店铺", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
if (!id.equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权删除他店", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
boolean ok = storeService.softDelete(id);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "店铺不存在");
|
||||
}
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* legacy 永久邀请码不再作为员工凭证;保留入口只为明确返回迁移提示。
|
||||
*/
|
||||
@GetMapping("/invite-code")
|
||||
public Map<String, Object> getByInviteCode(@RequestParam String code) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可按邀请码查询", "bizCode", "FORBIDDEN");
|
||||
Store store = storeService.findByInviteCode(code);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (store != null) {
|
||||
result.put("code", 200);
|
||||
result.put("data", store);
|
||||
} else {
|
||||
result.put("code", 404);
|
||||
result.put("message", "邀请码无效");
|
||||
}
|
||||
return Map.of(
|
||||
"code", 410,
|
||||
"message", "永久邀请码已停用,请使用限时员工邀请",
|
||||
"bizCode", "LEGACY_INVITE_DISABLED"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +1,29 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.auth.CurrentUser;
|
||||
import com.petstore.auth.CurrentUserContext;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.service.UserService;
|
||||
import com.petstore.service.WechatMiniProgramService;
|
||||
import com.petstore.service.AuditLogService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final WechatMiniProgramService wechatMiniProgramService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/** 老板注册店铺 */
|
||||
@PostMapping("/register-boss")
|
||||
public Map<String, Object> registerBoss(@RequestBody Map<String, String> params) {
|
||||
return Map.of(
|
||||
"code", 410,
|
||||
"message", "旧注册入口已停用,请使用微信核验手机号完成门店开通",
|
||||
"bizCode", "LEGACY_REGISTRATION_DISABLED"
|
||||
);
|
||||
String storeName = params.get("storeName");
|
||||
String bossName = params.get("bossName");
|
||||
String phone = params.get("phone");
|
||||
String password = params.get("password");
|
||||
return userService.registerBoss(storeName, bossName, phone, password);
|
||||
}
|
||||
|
||||
/** 登录(老板/员工) */
|
||||
@ -41,138 +34,52 @@ public class UserController {
|
||||
return userService.login(phone, code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序:button open-type=getPhoneNumber 拿到的 phoneCode 换手机号并登录。
|
||||
*/
|
||||
@PostMapping("/wx-phone-login")
|
||||
public Map<String, Object> wxPhoneLogin(@RequestBody(required = false) Map<String, String> params) {
|
||||
try {
|
||||
if (params == null) {
|
||||
return Map.of("code", 400, "message", "请求体不能为空");
|
||||
}
|
||||
String phoneCode = params.get("phoneCode");
|
||||
/** 与 getPhoneNumber 不同:来自 uni.login / wx.login 的 js_code,用于 jscode2session 沉淀 openid */
|
||||
String loginCode = params.get("loginCode");
|
||||
if (phoneCode == null || phoneCode.isBlank()) {
|
||||
return Map.of("code", 400, "message", "缺少手机号授权码");
|
||||
}
|
||||
var wx = wechatMiniProgramService.exchangePhoneCode(phoneCode);
|
||||
if (!wx.isOk()) {
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("code", 400);
|
||||
err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败");
|
||||
return err;
|
||||
}
|
||||
Map<String, Object> result = userService.loginByVerifiedPhone(wx.phone());
|
||||
if (!Integer.valueOf(200).equals(result.get("code"))) {
|
||||
return result;
|
||||
}
|
||||
if (loginCode != null && !loginCode.isBlank()) {
|
||||
var js = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||
if (js.isOk()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||
if (data != null) {
|
||||
User u = (User) data.get("user");
|
||||
if (u != null && u.getId() != null) {
|
||||
User bound = userService.bindWechatMiniIdentity(u.getId(), js.openid(), js.unionid());
|
||||
if (bound != null) {
|
||||
bound.setPassword(null);
|
||||
data.put("user", bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("openid 沉淀跳过(jscode2session 失败)");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("wx-phone-login 异常: exceptionType={}", e.getClass().getSimpleName());
|
||||
Map<String, Object> err = new HashMap<>();
|
||||
err.put("code", 500);
|
||||
err.put("message", "登录处理失败,请稍后重试");
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
/** 员工注册(邀请码方式) */
|
||||
@PostMapping("/register-staff")
|
||||
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {
|
||||
return Map.of(
|
||||
"code", 410,
|
||||
"message", "永久邀请码注册已停用,请让门店老板重新发送限时邀请",
|
||||
"bizCode", "LEGACY_INVITE_DISABLED"
|
||||
);
|
||||
String phone = params.get("phone");
|
||||
String password = params.get("password");
|
||||
String name = params.get("name");
|
||||
String inviteCode = params.get("inviteCode");
|
||||
return userService.registerStaff(phone, password, name, inviteCode);
|
||||
}
|
||||
|
||||
/** 老板:创建员工;storeId 从上下文派生,仅 boss。 */
|
||||
/** 老板:创建员工 */
|
||||
@PostMapping("/create-staff")
|
||||
public Map<String, Object> createStaff(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可创建员工", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
return Map.of(
|
||||
"code", 410,
|
||||
"message", "直接创建员工已停用,请创建限时员工邀请",
|
||||
"bizCode", "DIRECT_STAFF_CREATION_DISABLED"
|
||||
);
|
||||
Long storeId = Long.valueOf(params.get("storeId").toString());
|
||||
String name = params.get("name").toString();
|
||||
String phone = params.get("phone").toString();
|
||||
return userService.createStaff(storeId, name, phone);
|
||||
}
|
||||
|
||||
/** 老板/员工:员工列表;storeId 从上下文派生。 */
|
||||
/** 老板:员工列表 */
|
||||
@GetMapping("/staff-list")
|
||||
public Map<String, Object> staffList() {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可查看员工列表", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
List<User> list = userService.getStaffList(u.storeId());
|
||||
public Map<String, Object> staffList(@RequestParam Long storeId) {
|
||||
List<User> list = userService.getStaffList(storeId);
|
||||
// 不返回密码
|
||||
list.forEach(usr -> usr.setPassword(null));
|
||||
list.forEach(u -> u.setPassword(null));
|
||||
return Map.of("code", 200, "data", list);
|
||||
}
|
||||
|
||||
/** 老板:删除员工;仅 boss 且员工属于本店。 */
|
||||
/** 老板:删除员工 */
|
||||
@DeleteMapping("/staff")
|
||||
public Map<String, Object> deleteStaff(@RequestParam Long staffId) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isBoss()) {
|
||||
return Map.of("code", 403, "message", "仅老板可删除员工", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
User target = userService.findById(staffId);
|
||||
if (target == null || target.getStoreId() == null || !target.getStoreId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权删除他店员工", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
if (!"staff".equals(target.getRole())) {
|
||||
return Map.of("code", 403, "message", "只能删除员工账号", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
boolean ok = userService.deleteStaff(staffId);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "员工不存在");
|
||||
}
|
||||
auditLogService.record(
|
||||
u.storeId(), u.userId(), u.role(), "staff_account_removed", "user", staffId.toString(),
|
||||
"success", Map.of("removedRole", "staff")
|
||||
);
|
||||
return Map.of("code", 200, "message", "删除成功,员工现有会话已失效");
|
||||
userService.deleteStaff(staffId);
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息(userId 从上下文派生,不信任 query)。 */
|
||||
/** 获取用户信息 */
|
||||
@GetMapping("/info")
|
||||
public Map<String, Object> info() {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
User user = userService.findById(u.userId());
|
||||
public Map<String, Object> info(@RequestParam Long userId) {
|
||||
User user = userService.findById(userId);
|
||||
if (user != null) user.setPassword(null);
|
||||
return Map.of("code", 200, "data", user);
|
||||
}
|
||||
|
||||
/** 更新当前登录用户信息(头像/姓名/手机号);id 从上下文派生,覆盖请求体。 */
|
||||
/** 更新用户信息(头像/姓名/手机号) */
|
||||
@PutMapping("/update")
|
||||
public Map<String, Object> updateUser(@RequestBody Map<String, Object> params) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Map<String, Object> p = new HashMap<>(params);
|
||||
p.put("id", u.userId());
|
||||
return userService.updateUser(p);
|
||||
return userService.updateUser(params);
|
||||
}
|
||||
}
|
||||
|
||||
49
src/main/java/com/petstore/controller/WechatController.java
Normal file
49
src/main/java/com/petstore/controller/WechatController.java
Normal file
@ -0,0 +1,49 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.config.WechatConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/wechat")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class WechatController {
|
||||
private final WechatConfig wechatConfig;
|
||||
|
||||
/**
|
||||
* 获取微信授权跳转地址
|
||||
*/
|
||||
@GetMapping("/authorize")
|
||||
public Map<String, Object> getAuthorizeUrl() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("data", wechatConfig.getAuthorizeUrl());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权回调
|
||||
* 通过 code 换取 access_token,返回用户信息
|
||||
*/
|
||||
@GetMapping("/callback")
|
||||
public Map<String, Object> callback(@RequestParam String code, @RequestParam String state) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
// TODO: 通过 code 调用微信接口换取 openid 和 access_token
|
||||
// https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
|
||||
|
||||
// 演示用:直接返回成功
|
||||
result.put("code", 200);
|
||||
result.put("message", "微信授权成功");
|
||||
result.put("data", Map.of(
|
||||
"openid", "demo_openid_" + code,
|
||||
"nickname", "微信用户",
|
||||
"avatar", ""
|
||||
));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,12 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_appointment",
|
||||
indexes = {
|
||||
@Index(name = "idx_appt_store_status_time", columnList = "store_id,status,appointment_time"),
|
||||
@Index(name = "idx_appt_user_status_time", columnList = "user_id,status,appointment_time"),
|
||||
@Index(name = "idx_appt_customer_status_time", columnList = "customer_user_id,status,appointment_time")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_appointment")
|
||||
public class Appointment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -26,66 +17,19 @@ public class Appointment {
|
||||
private String serviceType;
|
||||
private LocalDateTime appointmentTime;
|
||||
|
||||
/** 下单时的服务时长快照;后续修改服务项目不会改变已预约占用。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 60;
|
||||
|
||||
/** 预约预计结束时间,由开始时间与时长快照派生。 */
|
||||
@Transient
|
||||
@JsonProperty("endTime")
|
||||
public LocalDateTime getEndTime() {
|
||||
return appointmentTime == null ? null : appointmentTime.plusMinutes(resolvedDurationMinutes());
|
||||
}
|
||||
|
||||
@Transient
|
||||
@JsonIgnore
|
||||
public int resolvedDurationMinutes() {
|
||||
return durationMinutes == null || durationMinutes <= 0 ? 60 : durationMinutes;
|
||||
}
|
||||
|
||||
/** 状态: new/doing/done/cancel */
|
||||
private String status;
|
||||
|
||||
@Column(name = "store_id")
|
||||
private Long storeId;
|
||||
|
||||
/**
|
||||
* 兼容字段:新数据镜像 customerUserId;历史数据曾混用“客户/创建人”。
|
||||
* 新业务逻辑不得再用它判断创建人。
|
||||
*/
|
||||
@Deprecated
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
/** 被服务的客户账号;门店代客预约时不得写入操作员工账号。 */
|
||||
@Column(name = "customer_user_id")
|
||||
private Long customerUserId;
|
||||
|
||||
/** 实际创建预约的登录账号:customer / boss / staff。 */
|
||||
@Column(name = "created_by_user_id")
|
||||
private Long createdByUserId;
|
||||
|
||||
/** 关联宠物档案(可选,用于本店「服务过的宠物」统计) */
|
||||
@Column(name = "pet_id")
|
||||
private Long petId;
|
||||
|
||||
/** 服务技师 ID,开始服务时赋值。数据库列名暂保留 assigned_user_id。 */
|
||||
/** 技师ID,开始服务时赋值 */
|
||||
@Column(name = "assigned_user_id")
|
||||
private Long assignedUserId;
|
||||
|
||||
/** 新 API 语义别名;兼容期仍同时序列化 assignedUserId。 */
|
||||
@JsonProperty("assignedStaffId")
|
||||
public Long getAssignedStaffId() {
|
||||
return assignedUserId;
|
||||
}
|
||||
|
||||
/** 读取兼容:新字段优先,旧数据回退到 legacy user_id。 */
|
||||
@Transient
|
||||
@JsonIgnore
|
||||
public Long resolvedCustomerUserId() {
|
||||
return customerUserId != null ? customerUserId : userId;
|
||||
}
|
||||
|
||||
private String remark;
|
||||
|
||||
@Column(name = "create_time")
|
||||
@ -93,7 +37,4 @@ public class Appointment {
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 不可变操作审计。只记录安全、权限和门店配置类操作,不承载业务对象当前状态。
|
||||
* metadataJson 只能由服务端白名单字段构造,禁止写手机号、凭证、请求体和第三方身份标识。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_audit_log",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_audit_log_audit_id", columnNames = "audit_id"),
|
||||
indexes = {
|
||||
@Index(name = "idx_audit_store_time", columnList = "store_id,occurred_at"),
|
||||
@Index(name = "idx_audit_action_time", columnList = "action,occurred_at"),
|
||||
@Index(name = "idx_audit_target", columnList = "target_type,target_id")
|
||||
}
|
||||
)
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "audit_id", nullable = false, length = 36)
|
||||
private String auditId;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(name = "actor_user_id")
|
||||
private Long actorUserId;
|
||||
|
||||
@Column(name = "actor_role", length = 16)
|
||||
private String actorRole;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String action;
|
||||
|
||||
@Column(name = "target_type", nullable = false, length = 32)
|
||||
private String targetType;
|
||||
|
||||
@Column(name = "target_id", length = 64)
|
||||
private String targetId;
|
||||
|
||||
@Column(nullable = false, length = 16)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "metadata_json", nullable = false, columnDefinition = "TEXT")
|
||||
private String metadataJson;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
@Column(name = "create_time", nullable = false)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 不可变业务事实。用于经营漏斗、客户时间线与后续审计,不承载业务对象当前状态。
|
||||
*
|
||||
* <p>metadataJson 只允许服务端构造的低敏枚举/布尔维度;不得写手机号、报告 token、
|
||||
* 媒体 URL、IP、openid/unionid、备注或任意请求体。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_business_event",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_business_event_event_id", columnNames = "event_id"),
|
||||
@UniqueConstraint(name = "uk_business_event_idempotency", columnNames = "idempotency_key")
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_be_store_type_time", columnList = "store_id,event_type,occurred_at"),
|
||||
@Index(name = "idx_be_aggregate_time", columnList = "aggregate_type,aggregate_id,occurred_at"),
|
||||
@Index(name = "idx_be_store_customer_time", columnList = "store_customer_id,occurred_at")
|
||||
}
|
||||
)
|
||||
public class BusinessEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 对外可安全引用的随机事件 ID,不暴露数据库自增主键。 */
|
||||
@Column(name = "event_id", nullable = false, length = 36)
|
||||
private String eventId;
|
||||
|
||||
@Column(name = "event_type", nullable = false, length = 64)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "event_version", nullable = false)
|
||||
private Integer eventVersion = 1;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
/** 门店客户稳定关系;没有客户语义的事件可空。 */
|
||||
@Column(name = "store_customer_id")
|
||||
private Long storeCustomerId;
|
||||
|
||||
@Column(name = "aggregate_type", nullable = false, length = 32)
|
||||
private String aggregateType;
|
||||
|
||||
@Column(name = "aggregate_id", nullable = false)
|
||||
private Long aggregateId;
|
||||
|
||||
/** 操作人快照;公开访问事件可空。 */
|
||||
@Column(name = "actor_user_id")
|
||||
private Long actorUserId;
|
||||
|
||||
@Column(name = "actor_role", length = 16)
|
||||
private String actorRole;
|
||||
|
||||
/** customer / admin / public_report / system / migration。 */
|
||||
@Column(length = 32, nullable = false)
|
||||
private String source;
|
||||
|
||||
@Column(name = "occurred_at", nullable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
/** 服务端白名单维度 JSON;不存原始请求。 */
|
||||
@Column(name = "metadata_json", nullable = false, columnDefinition = "TEXT")
|
||||
private String metadataJson;
|
||||
|
||||
/** 可空;用于一业务结果只落一条事件。MySQL 允许多个 NULL。 */
|
||||
@Column(name = "idempotency_key", length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@Column(name = "create_time", nullable = false)
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import jakarta.persistence.Version;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 门店回访执行任务。ReportLead 保留宠主同意与来源事实,本实体承载可领取、改期和闭环的操作状态。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_follow_up_task",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_follow_up_task_public_id", columnNames = "task_id"),
|
||||
indexes = {
|
||||
@Index(name = "idx_follow_up_store_status_due", columnList = "store_id,status,due_date"),
|
||||
@Index(name = "idx_follow_up_customer_status", columnList = "store_customer_id,status"),
|
||||
@Index(name = "idx_follow_up_lead_status", columnList = "source_lead_id,status"),
|
||||
@Index(name = "idx_follow_up_rebook", columnList = "rebooked_appointment_id")
|
||||
}
|
||||
)
|
||||
public class FollowUpTask {
|
||||
|
||||
public static final String STATUS_PENDING = "pending";
|
||||
public static final String STATUS_IN_PROGRESS = "in_progress";
|
||||
public static final String STATUS_COMPLETED = "completed";
|
||||
public static final String STATUS_CANCELED = "canceled";
|
||||
|
||||
public static final String ATTEMPT_NO_ANSWER = "no_answer";
|
||||
public static final String ATTEMPT_FOLLOW_LATER = "follow_later";
|
||||
|
||||
public static final String OUTCOME_REBOOKED = "rebooked";
|
||||
public static final String OUTCOME_NOT_INTERESTED = "not_interested";
|
||||
public static final String OUTCOME_INVALID_CONTACT = "invalid_contact";
|
||||
public static final String OUTCOME_UNSUBSCRIBED = "unsubscribed";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 对管理端暴露的随机 ID,不使用自增 ID 承担跨请求标识。 */
|
||||
@Column(name = "task_id", nullable = false, length = 36)
|
||||
private String taskId;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(name = "store_customer_id", nullable = false)
|
||||
private Long storeCustomerId;
|
||||
|
||||
@Column(name = "source_lead_id", nullable = false)
|
||||
private Long sourceLeadId;
|
||||
|
||||
@Column(name = "source_report_id")
|
||||
private Long sourceReportId;
|
||||
|
||||
/** pending / in_progress / completed / canceled */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String status;
|
||||
|
||||
/** 仅终态使用:rebooked / not_interested / invalid_contact / unsubscribed。 */
|
||||
@Column(length = 24)
|
||||
private String outcome;
|
||||
|
||||
@Column(name = "due_date", nullable = false)
|
||||
private LocalDate dueDate;
|
||||
|
||||
@Column(name = "assigned_user_id")
|
||||
private Long assignedUserId;
|
||||
|
||||
@Column(name = "attempt_count", nullable = false)
|
||||
private Integer attemptCount = 0;
|
||||
|
||||
/** 最近一次非终态联系结果:no_answer / follow_later。 */
|
||||
@Column(name = "last_attempt_outcome", length = 24)
|
||||
private String lastAttemptOutcome;
|
||||
|
||||
@Column(name = "last_contacted_at")
|
||||
private LocalDateTime lastContactedAt;
|
||||
|
||||
@Column(name = "last_contacted_by_user_id")
|
||||
private Long lastContactedByUserId;
|
||||
|
||||
@Column(name = "closed_at")
|
||||
private LocalDateTime closedAt;
|
||||
|
||||
@Column(name = "closed_by_user_id")
|
||||
private Long closedByUserId;
|
||||
|
||||
/** 只能由真实 Appointment 创建成功后写入。 */
|
||||
@Column(name = "rebooked_appointment_id")
|
||||
private Long rebookedAppointmentId;
|
||||
|
||||
@Column(name = "create_time", nullable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time", nullable = false)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** 防止两个员工同时处理同一任务时发生静默覆盖。 */
|
||||
@Version
|
||||
@Column(nullable = false)
|
||||
private Long version = 0L;
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
/**
|
||||
* 报告短片生成失败时对宠主展示的原因归类(不含技术细节)。
|
||||
*/
|
||||
public enum HighlightFailReason {
|
||||
/** 素材缺失、无法读取或编码失败(单段) */
|
||||
MATERIAL("material"),
|
||||
/** 合成服务异常(ffmpeg 拼接/转码、磁盘等) */
|
||||
SERVICE("service"),
|
||||
/** 网络类异常(极少见于服务端,预留) */
|
||||
NETWORK("network"),
|
||||
/** 未归类 */
|
||||
UNKNOWN("unknown");
|
||||
|
||||
private final String code;
|
||||
|
||||
HighlightFailReason(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 对宠主展示的说明(中文,无堆栈、无英文技术术语) */
|
||||
public String getUserMessage() {
|
||||
return switch (this) {
|
||||
case MATERIAL -> "部分照片或视频无法用于合成,请调整素材后重新生成。";
|
||||
case SERVICE -> "短片合成服务暂时不可用,请稍后重试。若多次失败请联系门店。";
|
||||
case NETWORK -> "网络异常,请检查网络连接后重试。";
|
||||
case UNKNOWN -> "短片生成失败,请稍后重试或联系门店。";
|
||||
};
|
||||
}
|
||||
|
||||
/** 短标题,供前端与 reason 并列展示 */
|
||||
public String getShortLabel() {
|
||||
return switch (this) {
|
||||
case MATERIAL -> "素材问题";
|
||||
case SERVICE -> "服务不可用";
|
||||
case NETWORK -> "网络异常";
|
||||
case UNKNOWN -> "生成失败";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 宠物档案(归属某客户;商家/员工通过预约关联查看本店服务过的宠物)
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "t_pet")
|
||||
public class Pet {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 昵称 */
|
||||
private String name;
|
||||
|
||||
/** 类型:猫/狗/其他 */
|
||||
@Column(name = "pet_type")
|
||||
private String petType;
|
||||
|
||||
/** 品种(可选) */
|
||||
private String breed;
|
||||
|
||||
/** 头像相对路径 */
|
||||
private String avatar;
|
||||
|
||||
/** 所属用户(客户) */
|
||||
@Column(name = "owner_user_id")
|
||||
private Long ownerUserId;
|
||||
|
||||
private String remark;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
@ -1,34 +1,14 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import jakarta.persistence.*;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_report",
|
||||
indexes = {
|
||||
@Index(name = "idx_report_appointment_id", columnList = "appointment_id"),
|
||||
@Index(name = "idx_report_token", columnList = "report_token"),
|
||||
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time"),
|
||||
@Index(name = "idx_report_customer_time", columnList = "customer_user_id,create_time"),
|
||||
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time"),
|
||||
@Index(name = "idx_report_store_send_time", columnList = "store_id,send_status,create_time")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_report")
|
||||
public class Report {
|
||||
public static final String SEND_STATUS_UNKNOWN = "unknown";
|
||||
public static final String SEND_STATUS_UNSENT = "unsent";
|
||||
public static final String SEND_STATUS_SENT = "sent";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@ -37,24 +17,17 @@ public class Report {
|
||||
@Column(name = "appointment_id")
|
||||
private Long appointmentId;
|
||||
|
||||
@Column(name = "before_photo", columnDefinition = "TEXT")
|
||||
private String beforePhoto;
|
||||
|
||||
@Column(name = "after_photo", columnDefinition = "TEXT")
|
||||
private String afterPhoto;
|
||||
|
||||
private String remark;
|
||||
|
||||
@Transient
|
||||
private List<ReportImage> images = new ArrayList<>();
|
||||
|
||||
/** 兼容字段:新数据镜像 authorStaffId。 */
|
||||
@Deprecated
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
/** 报告归属的客户账号,从关联预约 customer_user_id 派生。 */
|
||||
@Column(name = "customer_user_id")
|
||||
private Long customerUserId;
|
||||
|
||||
/** 实际提交报告的门店账号,可与服务技师不同。 */
|
||||
@Column(name = "author_staff_id")
|
||||
private Long authorStaffId;
|
||||
|
||||
@Column(name = "store_id")
|
||||
private Long storeId;
|
||||
|
||||
@ -66,64 +39,9 @@ public class Report {
|
||||
private LocalDateTime appointmentTime;
|
||||
private String staffName;
|
||||
|
||||
/** 读取兼容:新字段优先,旧报告回退到 legacy user_id。 */
|
||||
@Transient
|
||||
@JsonIgnore
|
||||
public Long resolvedAuthorStaffId() {
|
||||
return authorStaffId != null ? authorStaffId : userId;
|
||||
}
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** unknown=历史不可确认;unsent=新报告待发送;sent=员工已显式确认发送。 */
|
||||
@Column(name = "send_status", nullable = false, length = 16)
|
||||
private String sendStatus = SEND_STATUS_UNSENT;
|
||||
|
||||
/** 首次显式确认发送时间;复制链接、展示二维码或预览均不写入。 */
|
||||
@Column(name = "sent_at")
|
||||
private LocalDateTime sentAt;
|
||||
|
||||
/** 首次确认发送的门店员工。 */
|
||||
@Column(name = "sent_by_user_id")
|
||||
private Long sentByUserId;
|
||||
|
||||
/** 首次确认渠道:wechat | qr | other。 */
|
||||
@Column(name = "send_channel", length = 16)
|
||||
private String sendChannel;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
|
||||
/** 服务回顾短片(由服务前/中/后素材合成)相对 URL,如 /api/upload/image/... */
|
||||
@Column(name = "highlight_video_url", length = 500)
|
||||
private String highlightVideoUrl;
|
||||
|
||||
/** processing | done | failed */
|
||||
@Column(name = "highlight_video_status", length = 32)
|
||||
private String highlightVideoStatus;
|
||||
|
||||
@Column(name = "highlight_video_error", length = 500)
|
||||
private String highlightVideoError;
|
||||
|
||||
@Column(name = "highlight_duration_sec")
|
||||
private Integer highlightDurationSec;
|
||||
|
||||
/** 短片编排:preset=对比封面+分段顺序;interleave=全局照片/视频穿插 */
|
||||
@Column(name = "highlight_compose_mode", length = 16)
|
||||
private String highlightComposeMode;
|
||||
|
||||
/** 失败原因归类:material | service | network | unknown(对宠主展示,无技术细节) */
|
||||
@Column(name = "highlight_fail_reason", length = 16)
|
||||
private String highlightFailReason;
|
||||
|
||||
/**
|
||||
* 成片首帧截图(竖屏内一帧),用于分享卡片封面等;相对路径如 {@code /api/upload/image/年/月/日/xxx_cover.jpg}。
|
||||
* 与成片 MP4 同目录;仅当 highlight 状态为 done 且截帧成功时有值。
|
||||
*/
|
||||
@Column(name = "highlight_share_cover_url", length = 500)
|
||||
private String highlightShareCoverUrl;
|
||||
}
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "t_report_image")
|
||||
public class ReportImage {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "report_id", insertable = false, updatable = false)
|
||||
@JsonIgnore
|
||||
private Report report;
|
||||
|
||||
@Column(name = "report_id")
|
||||
private Long reportId;
|
||||
|
||||
@Column(name = "photo_url", length = 500)
|
||||
private String photoUrl;
|
||||
|
||||
/** 媒体分组:before=服务前,after=服务后,during=服务过程中 */
|
||||
@Column(name = "photo_type", length = 20)
|
||||
private String photoType;
|
||||
|
||||
/** 媒体类型:photo=图片,video=视频 */
|
||||
@Column(name = "media_type", length = 20)
|
||||
private String mediaType;
|
||||
|
||||
/** 排序序号,同类型内按顺序展示 */
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createTime == null) {
|
||||
createTime = LocalDateTime.now();
|
||||
}
|
||||
if (sortOrder == null) {
|
||||
sortOrder = 0;
|
||||
}
|
||||
if (mediaType == null || mediaType.isBlank()) {
|
||||
mediaType = "photo";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 宠主在报告页留下的联系方式 + 下次服务提醒。
|
||||
* 同一报告下同一手机号仅一条(数据库唯一约束);换手机号可再留一条。
|
||||
* remind_status 等用于软退订与发送状态。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_report_lead",
|
||||
uniqueConstraints = @UniqueConstraint(
|
||||
name = "uk_lead_report_phone",
|
||||
columnNames = {"report_id", "phone"}
|
||||
),
|
||||
indexes = {
|
||||
@Index(name = "idx_lead_report_id", columnList = "report_id"),
|
||||
@Index(name = "idx_lead_store_status_remind", columnList = "store_id,remind_status,remind_date"),
|
||||
@Index(name = "idx_lead_phone", columnList = "phone"),
|
||||
@Index(name = "idx_lead_unsubscribe_token", columnList = "unsubscribe_token")
|
||||
}
|
||||
)
|
||||
public class ReportLead {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "report_id")
|
||||
private Long reportId;
|
||||
|
||||
@Column(name = "store_id")
|
||||
private Long storeId;
|
||||
|
||||
/** 冗余:宠物名 / 服务类型,避免关联报告查询,用于文案拼接 */
|
||||
@Column(name = "pet_name", length = 64)
|
||||
private String petName;
|
||||
|
||||
@Column(name = "service_type", length = 64)
|
||||
private String serviceType;
|
||||
|
||||
@Column(length = 20)
|
||||
private String phone;
|
||||
|
||||
/** 微信 openid(小程序留资时 jscode2session 或与用户手机号对齐回填) */
|
||||
@Column(name = "wechat_openid", length = 64)
|
||||
private String wechatOpenid;
|
||||
|
||||
/** 微信 unionid(开放平台账号关联后才有) */
|
||||
@Column(name = "wechat_unionid", length = 64)
|
||||
private String wechatUnionid;
|
||||
|
||||
/** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */
|
||||
@Column(name = "reminder_type", length = 32)
|
||||
private String reminderType;
|
||||
|
||||
/** 计算出的下次建议日期(date 不含时间) */
|
||||
@Column(name = "remind_date")
|
||||
private LocalDate remindDate;
|
||||
|
||||
/** pending / sent / canceled / unsubscribed */
|
||||
@Column(name = "remind_status", length = 16)
|
||||
private String remindStatus;
|
||||
|
||||
/** 用户勾选「同意接收」的时间,合规留证 */
|
||||
@Column(name = "consent_at")
|
||||
private LocalDateTime consentAt;
|
||||
|
||||
/** 用户提交时的 IP(合规留证) */
|
||||
@Column(name = "consent_ip", length = 64)
|
||||
private String consentIp;
|
||||
|
||||
/** 独立的退订令牌,避免暴露 report_token */
|
||||
@Column(name = "unsubscribe_token", length = 64, unique = true)
|
||||
private String unsubscribeToken;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 宠主在生成分享海报时填写的一句话(口碑素材)。
|
||||
* 同一报告保留一条:重复提交则更新内容与时间。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_report_testimonial",
|
||||
indexes = {
|
||||
@Index(name = "idx_rt_store_time", columnList = "store_id,create_time")
|
||||
}
|
||||
)
|
||||
public class ReportTestimonial {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "report_id", nullable = false, unique = true)
|
||||
private Long reportId;
|
||||
|
||||
@Column(name = "store_id")
|
||||
private Long storeId;
|
||||
|
||||
@Column(length = 200, nullable = false)
|
||||
private String content;
|
||||
|
||||
@Column(name = "is_public", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 1")
|
||||
private Boolean isPublic = true;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 门店日程手动占用:以半小时为粒度,可连续占用多个档,用于到店客或暂停接受预约等。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_schedule_block",
|
||||
indexes = {
|
||||
@Index(name = "idx_sched_block_store_slot", columnList = "store_id,slot_start")
|
||||
}
|
||||
)
|
||||
public class ScheduleBlock {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
/** 该半小时档的起始时刻(与预约 appointment_time 对齐规则一致) */
|
||||
@Column(name = "slot_start", nullable = false)
|
||||
private LocalDateTime slotStart;
|
||||
|
||||
/** 连续占用时长,30~480 分钟且必须为 30 的倍数。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 30;
|
||||
|
||||
@Transient
|
||||
public LocalDateTime getEndTime() {
|
||||
int minutes = durationMinutes == null || durationMinutes <= 0 ? 30 : durationMinutes;
|
||||
return slotStart == null ? null : slotStart.plusMinutes(minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* walk_in:到店占用(未走线上预约)<br>
|
||||
* blocked:暂停预约(外出等,线上不可再约该档)
|
||||
*/
|
||||
@Column(name = "block_type", nullable = false, length = 32)
|
||||
private String blockType;
|
||||
|
||||
@Column(length = 500)
|
||||
private String note;
|
||||
|
||||
@Column(name = "created_by_user_id")
|
||||
private Long createdByUserId;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 服务建议周期配置:用于「下次该洗了」的日期预测。
|
||||
* store_id 为 null 表示系统默认,老板可针对自家店覆盖。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_service_interval",
|
||||
indexes = {
|
||||
@Index(name = "idx_si_store_service", columnList = "store_id,service_name")
|
||||
}
|
||||
)
|
||||
public class ServiceInterval {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** null = 系统默认 */
|
||||
@Column(name = "store_id")
|
||||
private Long storeId;
|
||||
|
||||
/** 与 t_service_type.name 对齐,作为匹配键 */
|
||||
@Column(name = "service_name", length = 64)
|
||||
private String serviceName;
|
||||
|
||||
/** 可选:按宠物类型细分(dog/cat/other),null 表示全部 */
|
||||
@Column(name = "pet_type", length = 16)
|
||||
private String petType;
|
||||
|
||||
/** 建议最短间隔(天) */
|
||||
@Column(name = "interval_days_min")
|
||||
private Integer intervalDaysMin;
|
||||
|
||||
/** 建议最长间隔(天) */
|
||||
@Column(name = "interval_days_max")
|
||||
private Integer intervalDaysMax;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@ -6,12 +6,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_service_type",
|
||||
indexes = {
|
||||
@Index(name = "idx_service_type_store_id", columnList = "store_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_service_type")
|
||||
public class ServiceType {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -22,10 +17,6 @@ public class ServiceType {
|
||||
|
||||
private String name;
|
||||
|
||||
/** 预计服务时长,按 30 分钟粒度配置。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 60;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 老板签发的限时、一次性员工邀请。原始 token 不落库,只保存 SHA-256 摘要。 */
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_staff_invitation",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_staff_invitation_public_id", columnNames = "invitation_id"),
|
||||
@UniqueConstraint(name = "uk_staff_invitation_token_hash", columnNames = "token_hash")
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_staff_invite_store_status", columnList = "store_id,status,expires_at"),
|
||||
@Index(name = "idx_staff_invite_phone", columnList = "store_id,invited_phone,status")
|
||||
}
|
||||
)
|
||||
public class StaffInvitation {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "invitation_id", nullable = false, length = 36)
|
||||
private String invitationId;
|
||||
|
||||
@JsonIgnore
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
@Column(name = "invited_name", nullable = false, length = 64)
|
||||
private String invitedName;
|
||||
|
||||
@JsonIgnore
|
||||
@Column(name = "invited_phone", nullable = false, length = 20)
|
||||
private String invitedPhone;
|
||||
|
||||
/** pending / accepted / revoked / expired */
|
||||
@Column(nullable = false, length = 16)
|
||||
private String status;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "created_by_user_id", nullable = false)
|
||||
private Long createdByUserId;
|
||||
|
||||
@Column(name = "accepted_by_user_id")
|
||||
private Long acceptedByUserId;
|
||||
|
||||
@Column(name = "accepted_at")
|
||||
private LocalDateTime acceptedAt;
|
||||
|
||||
@Column(name = "revoked_by_user_id")
|
||||
private Long revokedByUserId;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
@Column(name = "create_time", nullable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time", nullable = false)
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@ -2,19 +2,11 @@ package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_store",
|
||||
indexes = {
|
||||
@Index(name = "idx_store_invite_code", columnList = "invite_code"),
|
||||
@Index(name = "idx_store_owner_id", columnList = "owner_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_store")
|
||||
public class Store {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -36,40 +28,9 @@ public class Store {
|
||||
@Column(name = "invite_code")
|
||||
private String inviteCode;
|
||||
|
||||
/**
|
||||
* 可预约:每日首个半点号源起始时刻(须为整点或半点)。
|
||||
* 为 null 时后端按默认 09:00 处理。
|
||||
*/
|
||||
@Column(name = "booking_day_start")
|
||||
private LocalTime bookingDayStart;
|
||||
|
||||
/**
|
||||
* 可预约:每日最后一个可约时段的起始时刻(含),须为整点或半点,且不早于 {@link #bookingDayStart}。
|
||||
* 为 null 时后端按默认 21:30 处理。
|
||||
*/
|
||||
@Column(name = "booking_last_slot_start")
|
||||
private LocalTime bookingLastSlotStart;
|
||||
|
||||
/** 同一半小时内可并行服务的顾客数,默认 1。 */
|
||||
@Column(name = "booking_capacity", nullable = false)
|
||||
private Integer bookingCapacity;
|
||||
|
||||
/** unknown(历史)/ in_progress / completed。 */
|
||||
@Column(name = "onboarding_status", nullable = false, length = 16)
|
||||
private String onboardingStatus = "in_progress";
|
||||
|
||||
@Column(name = "onboarding_completed_at")
|
||||
private LocalDateTime onboardingCompletedAt;
|
||||
|
||||
@Column(name = "onboarding_completed_by_user_id")
|
||||
private Long onboardingCompletedByUserId;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -1,87 +0,0 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 门店与客户的稳定关系主档。
|
||||
*
|
||||
* <p>它不是会员资产表;余额、积分、次卡不属于本聚合。
|
||||
* customerUserId 允许为空,用于承接尚未登录/建号的报告留资客户。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_store_customer",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(
|
||||
name = "uk_store_customer_user",
|
||||
columnNames = {"store_id", "customer_user_id"}
|
||||
),
|
||||
@UniqueConstraint(
|
||||
name = "uk_store_customer_phone",
|
||||
columnNames = {"store_id", "phone"}
|
||||
)
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_store_customer_active_update", columnList = "store_id,deleted,update_time"),
|
||||
@Index(name = "idx_store_customer_last_contact", columnList = "store_id,last_contact_at"),
|
||||
@Index(name = "idx_store_customer_merge", columnList = "store_id,merged_into_store_customer_id")
|
||||
}
|
||||
)
|
||||
public class StoreCustomer {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "store_id", nullable = false)
|
||||
private Long storeId;
|
||||
|
||||
/** 全局 customer 账号;留资客户可先为空,后续再绑定。 */
|
||||
@Column(name = "customer_user_id")
|
||||
private Long customerUserId;
|
||||
|
||||
/** 本店最后确认的联系手机号;API 默认只返回脱敏值。 */
|
||||
@Column(length = 20)
|
||||
private String phone;
|
||||
|
||||
/** 门店视角的客户称呼。 */
|
||||
@Column(name = "display_name", length = 64)
|
||||
private String displayName;
|
||||
|
||||
/** 首次关系来源:customer_booking / assisted_booking / report_lead。 */
|
||||
@Column(length = 32, nullable = false)
|
||||
private String source;
|
||||
|
||||
@Column(name = "first_contact_at")
|
||||
private LocalDateTime firstContactAt;
|
||||
|
||||
@Column(name = "last_contact_at")
|
||||
private LocalDateTime lastContactAt;
|
||||
|
||||
/**
|
||||
* 软删除投影并入的同店 canonical StoreCustomer。
|
||||
*
|
||||
* <p>BusinessEvent 保持不可变并继续引用旧投影;读取客户时间线时通过此指针合并历史事实。
|
||||
*/
|
||||
@Column(name = "merged_into_store_customer_id")
|
||||
private Long mergedIntoStoreCustomerId;
|
||||
|
||||
@Column(name = "create_time", nullable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time", nullable = false)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
@ -1,28 +1,18 @@
|
||||
package com.petstore.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_user",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_phone", columnList = "phone"),
|
||||
@Index(name = "idx_user_store_id", columnList = "store_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_user")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String username;
|
||||
|
||||
/** 密码仅服务端使用,不随 JSON 返回前端 */
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
private String name;
|
||||
private String phone;
|
||||
@ -34,22 +24,9 @@ public class User {
|
||||
/** 角色:boss / staff / customer */
|
||||
private String role;
|
||||
|
||||
/** 微信小程序 openid(jscode2session 沉淀;仅服务端使用,不随 JSON 返回前端) */
|
||||
@JsonIgnore
|
||||
@Column(name = "wechat_openid", length = 64, unique = true)
|
||||
private String wechatOpenid;
|
||||
|
||||
/** 微信 unionid(需小程序绑定开放平台;未绑定时可能为空) */
|
||||
@JsonIgnore
|
||||
@Column(name = "wechat_unionid", length = 64, unique = true)
|
||||
private String wechatUnionid;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -1,89 +1,13 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
|
||||
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
|
||||
@Deprecated
|
||||
List<Appointment> findByUserIdAndDeletedFalse(Long userId);
|
||||
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
|
||||
@Deprecated
|
||||
List<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status);
|
||||
List<Appointment> findByStoreIdAndDeletedFalse(Long storeId);
|
||||
List<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status);
|
||||
List<Appointment> findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(Long storeId, String status);
|
||||
|
||||
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
|
||||
@Deprecated
|
||||
Page<Appointment> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
|
||||
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
|
||||
@Deprecated
|
||||
Page<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status, Pageable pageable);
|
||||
Page<Appointment> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
|
||||
Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable);
|
||||
Optional<Appointment> findByIdAndDeletedFalse(Long id);
|
||||
|
||||
long countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan(
|
||||
Long storeId, LocalDateTime from, LocalDateTime to);
|
||||
|
||||
long countByStoreIdAndStatusAndDeletedFalseAndUpdateTimeGreaterThanEqualAndUpdateTimeLessThan(
|
||||
Long storeId, String status, LocalDateTime from, LocalDateTime to);
|
||||
|
||||
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)) "
|
||||
+ "ORDER BY a.appointmentTime DESC")
|
||||
List<Appointment> findForCustomer(@Param("customerUserId") Long customerUserId);
|
||||
|
||||
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND a.status = :status AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)) "
|
||||
+ "ORDER BY a.appointmentTime DESC")
|
||||
List<Appointment> findForCustomerByStatus(
|
||||
@Param("customerUserId") Long customerUserId,
|
||||
@Param("status") String status);
|
||||
|
||||
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))")
|
||||
Page<Appointment> pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable);
|
||||
|
||||
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND a.status = :status AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))")
|
||||
Page<Appointment> pageForCustomerByStatus(
|
||||
@Param("customerUserId") Long customerUserId,
|
||||
@Param("status") String status,
|
||||
Pageable pageable);
|
||||
|
||||
/** 某宠物档案关联的全部预约(按预约时间倒序) */
|
||||
List<Appointment> findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(Long petId);
|
||||
|
||||
@Query("SELECT a.appointmentTime FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel') AND a.appointmentTime >= :start AND a.appointmentTime < :end")
|
||||
List<LocalDateTime> findOccupiedAppointmentTimes(
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end
|
||||
);
|
||||
|
||||
@Query("SELECT COUNT(a) > 0 FROM Appointment a WHERE a.storeId = :storeId AND a.appointmentTime = :t AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel')")
|
||||
boolean existsActiveBookingAt(@Param("storeId") Long storeId, @Param("t") LocalDateTime t);
|
||||
|
||||
/** 某日门店内有效预约(不含已取消),用于日程视图 */
|
||||
@Query("SELECT a FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false "
|
||||
+ "AND a.appointmentTime >= :start AND a.appointmentTime < :end "
|
||||
+ "AND (a.status IS NULL OR a.status <> 'cancel') ORDER BY a.appointmentTime ASC")
|
||||
List<Appointment> findActiveByStoreAndDateRange(
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
/** 本店是否存在关联该宠物的预约(与 listByStoreServed 口径一致) */
|
||||
@Query("SELECT COUNT(a) > 0 FROM Appointment a WHERE a.petId = :petId AND a.storeId = :storeId AND a.deleted = false")
|
||||
boolean existsByPetIdAndStoreIdAndDeletedFalse(@Param("petId") Long petId, @Param("storeId") Long storeId);
|
||||
List<Appointment> findByUserId(Long userId);
|
||||
List<Appointment> findByUserIdAndStatus(Long userId, String status);
|
||||
List<Appointment> findByStoreId(Long storeId);
|
||||
List<Appointment> findByStoreIdAndStatus(Long storeId, String status);
|
||||
}
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.AuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuditLogMapper extends JpaRepository<AuditLog, Long> {
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.BusinessEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BusinessEventMapper extends JpaRepository<BusinessEvent, Long> {
|
||||
|
||||
Optional<BusinessEvent> findFirstByIdempotencyKey(String idempotencyKey);
|
||||
|
||||
List<BusinessEvent> findByStoreCustomerIdOrderByOccurredAtDesc(Long storeCustomerId);
|
||||
|
||||
Page<BusinessEvent> findByStoreIdAndStoreCustomerIdIn(
|
||||
Long storeId,
|
||||
Collection<Long> storeCustomerIds,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select count(distinct e.aggregateId)
|
||||
from BusinessEvent e
|
||||
where e.storeId = :storeId
|
||||
and e.eventType = :eventType
|
||||
and e.occurredAt >= :from
|
||||
and e.occurredAt < :to
|
||||
""")
|
||||
long countDistinctAggregates(
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("eventType") String eventType,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to
|
||||
);
|
||||
|
||||
@Query("""
|
||||
select count(distinct e.aggregateId)
|
||||
from BusinessEvent e
|
||||
where e.storeId = :storeId
|
||||
and e.eventType in :eventTypes
|
||||
and e.occurredAt >= :from
|
||||
and e.occurredAt < :to
|
||||
""")
|
||||
long countDistinctAggregatesByTypes(
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("eventTypes") Collection<String> eventTypes,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to
|
||||
);
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface FollowUpTaskMapper
|
||||
extends JpaRepository<FollowUpTask, Long>, JpaSpecificationExecutor<FollowUpTask> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from FollowUpTask t where t.taskId = :taskId and t.storeId = :storeId")
|
||||
Optional<FollowUpTask> findByTaskIdAndStoreIdForUpdate(
|
||||
@Param("taskId") String taskId,
|
||||
@Param("storeId") Long storeId
|
||||
);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from FollowUpTask t where t.sourceLeadId = :sourceLeadId and t.status in :statuses order by t.createTime desc")
|
||||
List<FollowUpTask> findOpenBySourceLeadIdForUpdate(
|
||||
@Param("sourceLeadId") Long sourceLeadId,
|
||||
@Param("statuses") Collection<String> statuses
|
||||
);
|
||||
|
||||
List<FollowUpTask> findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
|
||||
Long storeId,
|
||||
Collection<String> statuses,
|
||||
LocalDate dueDate
|
||||
);
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.Pet;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PetMapper extends JpaRepository<Pet, Long> {
|
||||
|
||||
List<Pet> findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(Long ownerUserId);
|
||||
|
||||
/** 本店预约中关联过的宠物(预约.pet_id 指向该宠物) */
|
||||
@Query("SELECT DISTINCT p FROM Pet p, Appointment a WHERE a.petId = p.id AND a.storeId = :storeId AND p.deleted = false AND a.deleted = false ORDER BY p.updateTime DESC")
|
||||
List<Pet> findDistinctPetsServedAtStore(@Param("storeId") Long storeId);
|
||||
Optional<Pet> findByIdAndDeletedFalse(Long id);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.ReportImage;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ReportImageMapper extends JpaRepository<ReportImage, Long> {
|
||||
List<ReportImage> findByReportIdOrderBySortOrderAscIdAsc(Long reportId);
|
||||
|
||||
List<ReportImage> findByReportIdAndPhotoTypeOrderBySortOrderAscIdAsc(Long reportId, String photoType);
|
||||
|
||||
void deleteByReportId(Long reportId);
|
||||
|
||||
List<ReportImage> findByReportIdInOrderByReportIdAscSortOrderAscIdAsc(List<Long> reportIds);
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.ReportLead;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReportLeadMapper extends JpaRepository<ReportLead, Long> {
|
||||
|
||||
/** 同一报告 + 同一手机号只保留一条,用于幂等 */
|
||||
Optional<ReportLead> findFirstByReportIdAndPhone(Long reportId, String phone);
|
||||
|
||||
Optional<ReportLead> findFirstByUnsubscribeToken(String unsubscribeToken);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select l from ReportLead l where l.id = :id")
|
||||
Optional<ReportLead> findByIdForUpdate(@Param("id") Long id);
|
||||
|
||||
/** 老板回访池:按状态倒查、按提醒日期升序 */
|
||||
List<ReportLead> findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus);
|
||||
|
||||
List<ReportLead> findByStoreIdOrderByCreateTimeDesc(Long storeId);
|
||||
}
|
||||
@ -1,78 +1,7 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.Report;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||
Optional<Report> findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId);
|
||||
|
||||
Optional<Report> findFirstByReportTokenAndDeletedFalse(String reportToken);
|
||||
|
||||
/** 同一预约是否已存在未删除报告(用于「一约一份」不变量兜底)。 */
|
||||
boolean existsByAppointmentIdAndDeletedFalse(Long appointmentId);
|
||||
|
||||
List<Report> findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId);
|
||||
|
||||
List<Report> findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||
Long storeId, List<String> highlightVideoStatuses);
|
||||
|
||||
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
Long storeId, String sendStatus);
|
||||
|
||||
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
Long storeId, String sendStatus);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
List<Report> findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId, Long userId);
|
||||
|
||||
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
|
||||
|
||||
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
|
||||
Page<Report> findByStoreIdAndSendStatusAndDeletedFalse(Long storeId, String sendStatus, Pageable pageable);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
Page<Report> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
Page<Report> findByStoreIdAndUserIdAndDeletedFalse(Long storeId, Long userId, Pageable pageable);
|
||||
|
||||
@Query("SELECT r FROM Report r WHERE r.deleted = false AND "
|
||||
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
|
||||
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))))) "
|
||||
+ "ORDER BY r.createTime DESC")
|
||||
List<Report> findForCustomer(@Param("customerUserId") Long customerUserId);
|
||||
|
||||
@Query(value = "SELECT r FROM Report r WHERE r.deleted = false AND "
|
||||
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
|
||||
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)))))",
|
||||
countQuery = "SELECT COUNT(r) FROM Report r WHERE r.deleted = false AND "
|
||||
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
|
||||
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
|
||||
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)))))")
|
||||
Page<Report> pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable);
|
||||
Page<Report> findByDeletedFalse(Pageable pageable);
|
||||
Optional<Report> findByIdAndDeletedFalse(Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT r FROM Report r WHERE r.id = :id AND r.deleted = false")
|
||||
Optional<Report> findByIdAndDeletedFalseForUpdate(@Param("id") Long id);
|
||||
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.ReportTestimonial;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ReportTestimonialMapper extends JpaRepository<ReportTestimonial, Long> {
|
||||
|
||||
Optional<ReportTestimonial> findByReportId(Long reportId);
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.ScheduleBlock;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ScheduleBlockMapper extends JpaRepository<ScheduleBlock, Long> {
|
||||
|
||||
Optional<ScheduleBlock> findByIdAndDeletedFalse(Long id);
|
||||
|
||||
@Query("SELECT b.slotStart FROM ScheduleBlock b WHERE b.storeId = :storeId AND b.deleted = false "
|
||||
+ "AND b.slotStart >= :start AND b.slotStart < :end")
|
||||
List<LocalDateTime> findOccupiedSlotStarts(
|
||||
@Param("storeId") Long storeId,
|
||||
@Param("start") LocalDateTime start,
|
||||
@Param("end") LocalDateTime end);
|
||||
|
||||
boolean existsByStoreIdAndSlotStartAndDeletedFalse(Long storeId, LocalDateTime slotStart);
|
||||
|
||||
List<ScheduleBlock> findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
Long storeId,
|
||||
LocalDateTime start,
|
||||
LocalDateTime endExclusive);
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.ServiceInterval;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServiceIntervalMapper extends JpaRepository<ServiceInterval, Long> {
|
||||
|
||||
/** 店铺自定义优先(store_id = :storeId),兜底系统默认(store_id IS NULL) */
|
||||
Optional<ServiceInterval> findFirstByStoreIdAndServiceName(Long storeId, String serviceName);
|
||||
|
||||
Optional<ServiceInterval> findFirstByStoreIdIsNullAndServiceName(String serviceName);
|
||||
|
||||
List<ServiceInterval> findByStoreIdIsNull();
|
||||
}
|
||||
@ -8,7 +8,4 @@ import java.util.List;
|
||||
public interface ServiceTypeMapper extends JpaRepository<ServiceType, Long> {
|
||||
List<ServiceType> findByStoreIdOrStoreIdIsNull(Long storeId);
|
||||
List<ServiceType> findByStoreId(Long storeId);
|
||||
|
||||
/** 系统内置(store_id 为空) */
|
||||
List<ServiceType> findByStoreIdIsNull();
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.StaffInvitation;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StaffInvitationMapper extends JpaRepository<StaffInvitation, Long> {
|
||||
|
||||
Optional<StaffInvitation> findFirstByTokenHash(String tokenHash);
|
||||
|
||||
List<StaffInvitation> findByStoreIdOrderByCreateTimeDesc(Long storeId);
|
||||
|
||||
List<StaffInvitation> findByStoreIdAndInvitedPhoneAndStatus(Long storeId, String invitedPhone, String status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT i FROM StaffInvitation i WHERE i.tokenHash = :tokenHash")
|
||||
Optional<StaffInvitation> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT i FROM StaffInvitation i WHERE i.invitationId = :invitationId AND i.storeId = :storeId")
|
||||
Optional<StaffInvitation> findByInvitationIdAndStoreIdForUpdate(
|
||||
@Param("invitationId") String invitationId,
|
||||
@Param("storeId") Long storeId
|
||||
);
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoreCustomerMapper extends JpaRepository<StoreCustomer, Long> {
|
||||
|
||||
List<StoreCustomer> findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(Long storeId);
|
||||
|
||||
Optional<StoreCustomer> findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId);
|
||||
|
||||
Optional<StoreCustomer> findFirstByStoreIdAndPhone(Long storeId, String phone);
|
||||
|
||||
Optional<StoreCustomer> findFirstByIdAndStoreIdAndDeletedFalse(Long id, Long storeId);
|
||||
|
||||
Optional<StoreCustomer> findFirstByIdAndStoreId(Long id, Long storeId);
|
||||
|
||||
List<StoreCustomer> findByStoreIdAndMergedIntoStoreCustomerId(Long storeId, Long mergedIntoStoreCustomerId);
|
||||
}
|
||||
@ -2,22 +2,7 @@ package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.Store;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoreMapper extends JpaRepository<Store, Long> {
|
||||
Store findByInviteCodeAndDeletedFalse(String inviteCode);
|
||||
Optional<Store> findByIdAndDeletedFalse(Long id);
|
||||
List<Store> findAllByDeletedFalse();
|
||||
|
||||
/** 创建预约/占用时串行化同一门店的容量判定,避免并发超卖。 */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT s FROM Store s WHERE s.id = :id AND s.deleted = false")
|
||||
Optional<Store> findByIdAndDeletedFalseForUpdate(@Param("id") Long id);
|
||||
Store findByInviteCode(String inviteCode);
|
||||
}
|
||||
|
||||
@ -4,14 +4,9 @@ import com.petstore.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserMapper extends JpaRepository<User, Long> {
|
||||
User findByUsernameAndDeletedFalse(String username);
|
||||
User findByPhoneAndDeletedFalse(String phone);
|
||||
Optional<User> findByWechatOpenidAndDeletedFalse(String wechatOpenid);
|
||||
|
||||
Optional<User> findByWechatUnionidAndDeletedFalse(String wechatUnionid);
|
||||
List<User> findByStoreIdAndDeletedFalse(Long storeId);
|
||||
Optional<User> findByIdAndDeletedFalse(Long id);
|
||||
User findByUsername(String username);
|
||||
User findByPhone(String phone);
|
||||
List<User> findByStoreId(Long storeId);
|
||||
}
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.mapper.BusinessEventMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 门店后台的业务事件读模型;只返回汇总,不暴露事件明细或内部身份字段。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminBusinessEventService {
|
||||
|
||||
private final BusinessEventMapper businessEventMapper;
|
||||
|
||||
public Map<String, Object> reportFunnel(Long storeId, LocalDate date) {
|
||||
if (storeId == null) {
|
||||
throw new IllegalArgumentException("storeId required");
|
||||
}
|
||||
LocalDate day = date == null ? LocalDate.now() : date;
|
||||
LocalDateTime from = day.atStartOfDay();
|
||||
LocalDateTime to = day.plusDays(1).atStartOfDay();
|
||||
|
||||
long submitted = count(storeId, BusinessEventService.REPORT_SUBMITTED, from, to);
|
||||
long sent = count(storeId, BusinessEventService.REPORT_SENT, from, to);
|
||||
long opened = businessEventMapper.countDistinctAggregatesByTypes(
|
||||
storeId,
|
||||
List.of(BusinessEventService.REPORT_OPENED, BusinessEventService.REPORT_REOPENED),
|
||||
from,
|
||||
to
|
||||
);
|
||||
long leads = count(storeId, BusinessEventService.LEAD_SUBMITTED, from, to);
|
||||
long rebooked = count(storeId, BusinessEventService.REBOOK_CREATED, from, to);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("date", day.toString());
|
||||
data.put("reportSubmittedCount", submitted);
|
||||
data.put("reportSentCount", sent);
|
||||
data.put("reportOpenedCount", opened);
|
||||
data.put("leadSubmittedCount", leads);
|
||||
data.put("rebookCreatedCount", rebooked);
|
||||
data.put("reportSentTrackingReady", true);
|
||||
data.put("rebookTrackingReady", true);
|
||||
return data;
|
||||
}
|
||||
|
||||
private long count(Long storeId, String eventType, LocalDateTime from, LocalDateTime to) {
|
||||
return businessEventMapper.countDistinctAggregates(storeId, eventType, from, to);
|
||||
}
|
||||
}
|
||||
@ -1,369 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.Pet;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.PetMapper;
|
||||
import com.petstore.mapper.ReportLeadMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.StoreCustomerMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 门店后台「服务客户」读模型:StoreCustomer 稳定主档 + 预约/宠物/报告/留资动态事实。
|
||||
* 不做余额/次卡/会员字段。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminServiceCustomerService {
|
||||
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final PetMapper petMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportLeadMapper reportLeadMapper;
|
||||
private final StoreCustomerMapper storeCustomerMapper;
|
||||
|
||||
public Map<String, Object> list(
|
||||
Long storeId,
|
||||
String source,
|
||||
Boolean hasPendingLead,
|
||||
LocalDate lastVisitFrom,
|
||||
LocalDate lastVisitTo,
|
||||
String q,
|
||||
int page,
|
||||
int pageSize
|
||||
) {
|
||||
if (storeId == null) {
|
||||
throw new IllegalArgumentException("storeId required");
|
||||
}
|
||||
int safePage = Math.max(page, 1);
|
||||
int safeSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
|
||||
List<Appointment> appointments = appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||
List<ReportLead> leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
|
||||
List<Report> reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
||||
List<StoreCustomer> masters = storeCustomerMapper
|
||||
.findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(storeId);
|
||||
|
||||
Map<Long, Appointment> appointmentsById = appointments.stream()
|
||||
.filter(a -> a.getId() != null)
|
||||
.collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a));
|
||||
Set<Long> userIds = masters.stream()
|
||||
.map(StoreCustomer::getCustomerUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<Long, User> users = new HashMap<>();
|
||||
if (!userIds.isEmpty()) {
|
||||
for (User u : userMapper.findAllById(userIds)) {
|
||||
if (!Boolean.TRUE.equals(u.getDeleted()) && "customer".equals(u.getRole())) {
|
||||
users.put(u.getId(), u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只聚合已经进入本店客户主档的 customer;迁移脚本负责历史回填。
|
||||
Map<Long, List<Appointment>> apptsByUser = new LinkedHashMap<>();
|
||||
for (Appointment appointment : appointments) {
|
||||
Long customerUserId = appointment.resolvedCustomerUserId();
|
||||
if (customerUserId == null || !userIds.contains(customerUserId)) {
|
||||
continue;
|
||||
}
|
||||
apptsByUser.computeIfAbsent(customerUserId, ignored -> new ArrayList<>()).add(appointment);
|
||||
}
|
||||
|
||||
Map<Long, List<Pet>> petsByOwner = new HashMap<>();
|
||||
for (Long uid : users.keySet()) {
|
||||
petsByOwner.put(uid, petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(uid));
|
||||
}
|
||||
|
||||
Map<Long, Report> latestReportByUser = new HashMap<>();
|
||||
for (Report r : reports) {
|
||||
Long customerUserId = r.getCustomerUserId();
|
||||
if (customerUserId == null && r.getAppointmentId() != null) {
|
||||
Appointment appointment = appointmentsById.get(r.getAppointmentId());
|
||||
if (appointment != null) {
|
||||
customerUserId = appointment.resolvedCustomerUserId();
|
||||
}
|
||||
}
|
||||
if (customerUserId == null || !userIds.contains(customerUserId)) {
|
||||
continue;
|
||||
}
|
||||
latestReportByUser.putIfAbsent(customerUserId, r);
|
||||
}
|
||||
|
||||
Map<String, List<ReportLead>> leadsByPhone = leads.stream()
|
||||
.filter(l -> l.getPhone() != null && !l.getPhone().isBlank())
|
||||
.collect(Collectors.groupingBy(ReportLead::getPhone));
|
||||
|
||||
List<CustomerAgg> aggs = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (StoreCustomer master : masters) {
|
||||
Long userId = master.getCustomerUserId();
|
||||
User user = userId == null ? null : users.get(userId);
|
||||
CustomerAgg agg = CustomerAgg.fromMaster(master, user);
|
||||
if (isBookingSource(master.getSource())) {
|
||||
agg.sources.add("appointment");
|
||||
} else if (StoreCustomerService.SOURCE_REPORT_LEAD.equals(master.getSource())) {
|
||||
agg.sources.add("lead");
|
||||
}
|
||||
|
||||
for (Appointment a : apptsByUser.getOrDefault(userId, List.of())) {
|
||||
agg.sources.add("appointment");
|
||||
if (isActualVisit(a, now)) {
|
||||
agg.touchVisit(a.getAppointmentTime());
|
||||
}
|
||||
if (a.getPetName() != null && !a.getPetName().isBlank()) {
|
||||
agg.petNames.add(a.getPetName().trim());
|
||||
}
|
||||
}
|
||||
for (Pet p : petsByOwner.getOrDefault(userId, List.of())) {
|
||||
if (p.getName() != null && !p.getName().isBlank()) {
|
||||
agg.petNames.add(p.getName().trim());
|
||||
}
|
||||
}
|
||||
Report latest = latestReportByUser.get(userId);
|
||||
if (latest != null) {
|
||||
agg.lastReportId = latest.getId();
|
||||
}
|
||||
if (agg.phone != null) {
|
||||
applyLeads(agg, leadsByPhone.getOrDefault(agg.phone, List.of()));
|
||||
}
|
||||
aggs.add(agg);
|
||||
}
|
||||
|
||||
String sourceNorm = source == null || source.isBlank() || "all".equalsIgnoreCase(source)
|
||||
? "all"
|
||||
: source.trim().toLowerCase(Locale.ROOT);
|
||||
String qNorm = q == null ? "" : q.trim().toLowerCase(Locale.ROOT);
|
||||
|
||||
List<CustomerAgg> filtered = new ArrayList<>();
|
||||
for (CustomerAgg agg : aggs) {
|
||||
if (!matchSource(agg, sourceNorm)) {
|
||||
continue;
|
||||
}
|
||||
if (Boolean.TRUE.equals(hasPendingLead) && !agg.hasPendingLead) {
|
||||
continue;
|
||||
}
|
||||
if (lastVisitFrom != null || lastVisitTo != null) {
|
||||
if (agg.lastVisitAt == null) {
|
||||
continue;
|
||||
}
|
||||
LocalDate visitDay = agg.lastVisitAt.toLocalDate();
|
||||
if (lastVisitFrom != null && visitDay.isBefore(lastVisitFrom)) {
|
||||
continue;
|
||||
}
|
||||
if (lastVisitTo != null && visitDay.isAfter(lastVisitTo)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!qNorm.isEmpty() && !agg.matchesQuery(qNorm)) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(agg);
|
||||
}
|
||||
|
||||
filtered.sort(Comparator
|
||||
.comparing((CustomerAgg c) -> c.lastVisitAt, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(c -> c.displayName == null ? "" : c.displayName));
|
||||
|
||||
LocalDate monthStart = LocalDate.now().withDayOfMonth(1);
|
||||
long monthVisitCount = filtered.stream()
|
||||
.filter(c -> c.lastVisitAt != null && !c.lastVisitAt.toLocalDate().isBefore(monthStart))
|
||||
.count();
|
||||
long pendingLeadCount = filtered.stream().filter(c -> c.hasPendingLead).count();
|
||||
|
||||
int total = filtered.size();
|
||||
int from = Math.min((safePage - 1) * safeSize, total);
|
||||
int to = Math.min(from + safeSize, total);
|
||||
List<Map<String, Object>> list = filtered.subList(from, to).stream()
|
||||
.map(CustomerAgg::toRow)
|
||||
.toList();
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("customerCount", total);
|
||||
summary.put("pendingLeadCount", pendingLeadCount);
|
||||
summary.put("monthVisitCount", monthVisitCount);
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("total", total);
|
||||
data.put("page", safePage);
|
||||
data.put("pageSize", safeSize);
|
||||
data.put("summary", summary);
|
||||
data.put("list", list);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void applyLeads(CustomerAgg agg, List<ReportLead> phoneLeads) {
|
||||
if (phoneLeads == null || phoneLeads.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
agg.sources.add("lead");
|
||||
for (ReportLead lead : phoneLeads) {
|
||||
if (lead.getPetName() != null && !lead.getPetName().isBlank()) {
|
||||
agg.petNames.add(lead.getPetName().trim());
|
||||
}
|
||||
if ("pending".equalsIgnoreCase(lead.getRemindStatus())) {
|
||||
agg.hasPendingLead = true;
|
||||
agg.leadStatus = "pending";
|
||||
} else if (agg.leadStatus == null) {
|
||||
agg.leadStatus = lead.getRemindStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matchSource(CustomerAgg agg, String sourceNorm) {
|
||||
if ("all".equals(sourceNorm)) {
|
||||
return true;
|
||||
}
|
||||
if ("appointment".equals(sourceNorm)) {
|
||||
return agg.sources.contains("appointment");
|
||||
}
|
||||
if ("lead".equals(sourceNorm) || "report_lead".equals(sourceNorm)) {
|
||||
return agg.sources.contains("lead");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isBookingSource(String source) {
|
||||
return StoreCustomerService.SOURCE_CUSTOMER_BOOKING.equals(source)
|
||||
|| StoreCustomerService.SOURCE_ASSISTED_BOOKING.equals(source)
|
||||
|| "appointment".equals(source);
|
||||
}
|
||||
|
||||
/** 「到店」只认已开始/已完成的服务,不把未来预约或过期未履约 new 单计为到店。 */
|
||||
private static boolean isActualVisit(Appointment appointment, LocalDateTime now) {
|
||||
if (appointment.getAppointmentTime() == null || appointment.getAppointmentTime().isAfter(now)) {
|
||||
return false;
|
||||
}
|
||||
String status = appointment.getStatus() == null ? "" : appointment.getStatus().trim().toLowerCase(Locale.ROOT);
|
||||
return "doing".equals(status) || "done".equals(status);
|
||||
}
|
||||
|
||||
static String maskPhone(String phone) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String p = phone.trim();
|
||||
if (p.length() < 7) {
|
||||
return "****";
|
||||
}
|
||||
if (p.length() == 11) {
|
||||
return p.substring(0, 3) + "****" + p.substring(7);
|
||||
}
|
||||
return p.substring(0, 2) + "****" + p.substring(p.length() - 2);
|
||||
}
|
||||
|
||||
private static final class CustomerAgg {
|
||||
Long storeCustomerId;
|
||||
Long userId;
|
||||
String displayName;
|
||||
String phone;
|
||||
String phoneMasked;
|
||||
String originSource;
|
||||
LocalDateTime firstContactAt;
|
||||
LocalDateTime lastContactAt;
|
||||
final Set<String> petNames = new LinkedHashSet<>();
|
||||
final Set<String> sources = new LinkedHashSet<>();
|
||||
LocalDateTime lastVisitAt;
|
||||
Long lastReportId;
|
||||
String leadStatus;
|
||||
boolean hasPendingLead;
|
||||
|
||||
static CustomerAgg fromMaster(StoreCustomer master, User user) {
|
||||
CustomerAgg a = new CustomerAgg();
|
||||
a.storeCustomerId = master.getId();
|
||||
a.userId = master.getCustomerUserId();
|
||||
a.displayName = firstNonBlank(
|
||||
master.getDisplayName(),
|
||||
user == null ? null : user.getName(),
|
||||
user == null ? null : user.getUsername(),
|
||||
"留资客户"
|
||||
);
|
||||
a.phone = firstNonBlank(master.getPhone(), user == null ? null : user.getPhone());
|
||||
a.phoneMasked = maskPhone(a.phone);
|
||||
a.originSource = master.getSource();
|
||||
a.firstContactAt = master.getFirstContactAt();
|
||||
a.lastContactAt = master.getLastContactAt();
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void touchVisit(LocalDateTime t) {
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
if (lastVisitAt == null || t.isAfter(lastVisitAt)) {
|
||||
lastVisitAt = t;
|
||||
}
|
||||
}
|
||||
|
||||
boolean matchesQuery(String qNorm) {
|
||||
if (displayName != null && displayName.toLowerCase(Locale.ROOT).contains(qNorm)) {
|
||||
return true;
|
||||
}
|
||||
if (phone != null && phone.toLowerCase(Locale.ROOT).contains(qNorm)) {
|
||||
return true;
|
||||
}
|
||||
if (phoneMasked != null && phoneMasked.toLowerCase(Locale.ROOT).contains(qNorm)) {
|
||||
return true;
|
||||
}
|
||||
for (String pet : petNames) {
|
||||
if (pet.toLowerCase(Locale.ROOT).contains(qNorm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, Object> toRow() {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("storeCustomerId", storeCustomerId);
|
||||
row.put("userId", userId);
|
||||
row.put("displayName", displayName);
|
||||
row.put("phoneMasked", phoneMasked);
|
||||
row.put("originSource", originSource);
|
||||
row.put("firstContactAt", firstContactAt == null ? null : firstContactAt.toString());
|
||||
row.put("lastContactAt", lastContactAt == null ? null : lastContactAt.toString());
|
||||
row.put("pets", petNames.stream()
|
||||
.map(n -> Map.of("name", n))
|
||||
.toList());
|
||||
row.put("lastVisitAt", lastVisitAt == null ? null : lastVisitAt.toString());
|
||||
row.put("lastReportId", lastReportId);
|
||||
row.put("leadStatus", leadStatus);
|
||||
row.put("hasPendingLead", hasPendingLead);
|
||||
row.put("source", sources.contains("appointment") && sources.contains("lead")
|
||||
? "appointment+lead"
|
||||
: (sources.contains("appointment") ? "appointment" : "lead"));
|
||||
return row;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,273 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.FollowUpTaskMapper;
|
||||
import com.petstore.mapper.ReportLeadMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 单店今日行动读模型:准确计数、低敏预览,不返回手机号、报告 token 或媒体地址。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminWorkbenchService {
|
||||
static final int PREVIEW_LIMIT = 3;
|
||||
static final int HIGHLIGHT_TIMEOUT_MINUTES = 30;
|
||||
|
||||
public static final String OVERDUE_APPOINTMENT = "overdue_appointment";
|
||||
public static final String IN_SERVICE = "in_service";
|
||||
public static final String UPCOMING_TODAY = "upcoming_today";
|
||||
public static final String HIGHLIGHT_ANOMALY = "highlight_anomaly";
|
||||
public static final String UNSENT_REPORT = "unsent_report";
|
||||
public static final String FOLLOW_UP_DUE = "follow_up_due";
|
||||
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final FollowUpTaskMapper followUpTaskMapper;
|
||||
private final ReportLeadMapper reportLeadMapper;
|
||||
private final AdminBusinessEventService adminBusinessEventService;
|
||||
|
||||
public WorkbenchData today(Long storeId) {
|
||||
return build(storeId, LocalDateTime.now());
|
||||
}
|
||||
|
||||
WorkbenchData build(Long storeId, LocalDateTime now) {
|
||||
if (storeId == null) {
|
||||
throw new IllegalArgumentException("storeId required");
|
||||
}
|
||||
LocalDate day = now.toLocalDate();
|
||||
LocalDateTime dayStart = day.atStartOfDay();
|
||||
LocalDateTime dayEnd = day.plusDays(1).atStartOfDay();
|
||||
|
||||
List<Appointment> newAppointments = sortedAppointments(
|
||||
appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "new")
|
||||
);
|
||||
List<Appointment> inService = sortedAppointments(
|
||||
appointmentMapper.findByStoreIdAndStatusAndDeletedFalseOrderByAppointmentTimeAsc(storeId, "doing")
|
||||
);
|
||||
List<Appointment> overdueAppointments = newAppointments.stream()
|
||||
.filter(appointment -> appointment.getAppointmentTime() != null
|
||||
&& appointment.getAppointmentTime().isBefore(now))
|
||||
.toList();
|
||||
List<Appointment> upcomingToday = newAppointments.stream()
|
||||
.filter(appointment -> appointment.getAppointmentTime() != null
|
||||
&& !appointment.getAppointmentTime().isBefore(now)
|
||||
&& appointment.getAppointmentTime().isBefore(dayEnd))
|
||||
.toList();
|
||||
|
||||
LocalDateTime staleBefore = now.minusMinutes(HIGHLIGHT_TIMEOUT_MINUTES);
|
||||
List<Report> highlightAnomalies = safeReports(
|
||||
reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||
storeId, List.of("failed", "processing"))
|
||||
).stream()
|
||||
.filter(report -> isHighlightAnomaly(report, staleBefore))
|
||||
.sorted(Comparator
|
||||
.comparing((Report report) -> !"failed".equals(report.getHighlightVideoStatus()))
|
||||
.thenComparing(AdminWorkbenchService::reportActivityTime,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
|
||||
List<Report> unsentReports = safeReports(
|
||||
reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
storeId, Report.SEND_STATUS_UNSENT
|
||||
)
|
||||
);
|
||||
|
||||
List<FollowUpTask> dueFollowUps = followUpTaskMapper
|
||||
.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
|
||||
storeId,
|
||||
Set.of(FollowUpTask.STATUS_PENDING, FollowUpTask.STATUS_IN_PROGRESS),
|
||||
day
|
||||
);
|
||||
if (dueFollowUps == null) dueFollowUps = List.of();
|
||||
Map<Long, ReportLead> dueLeads = reportLeadMapper.findAllById(
|
||||
dueFollowUps.stream()
|
||||
.map(FollowUpTask::getSourceLeadId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(Collectors.toSet())
|
||||
).stream()
|
||||
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
|
||||
|
||||
long todayAppointments = appointmentMapper
|
||||
.countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan(
|
||||
storeId, dayStart, dayEnd);
|
||||
long todayDone = appointmentMapper
|
||||
.countByStoreIdAndStatusAndDeletedFalseAndUpdateTimeGreaterThanEqualAndUpdateTimeLessThan(
|
||||
storeId, "done", dayStart, dayEnd);
|
||||
|
||||
WorkbenchMetrics metrics = new WorkbenchMetrics(
|
||||
todayAppointments,
|
||||
todayDone,
|
||||
overdueAppointments.size(),
|
||||
inService.size(),
|
||||
upcomingToday.size(),
|
||||
highlightAnomalies.size(),
|
||||
unsentReports.size(),
|
||||
dueFollowUps.size()
|
||||
);
|
||||
|
||||
List<ActionGroup> actions = new ArrayList<>();
|
||||
addAppointmentGroup(actions, OVERDUE_APPOINTMENT, "critical", overdueAppointments, now);
|
||||
addAppointmentGroup(actions, IN_SERVICE, "high", inService, now);
|
||||
addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now);
|
||||
addReportGroup(actions, highlightAnomalies);
|
||||
addUnsentReportGroup(actions, unsentReports);
|
||||
addFollowUpGroup(actions, dueFollowUps, dueLeads);
|
||||
|
||||
Map<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
|
||||
return new WorkbenchData(day.toString(), now, metrics, List.copyOf(actions), reportFunnel);
|
||||
}
|
||||
|
||||
private static List<Appointment> sortedAppointments(List<Appointment> appointments) {
|
||||
return (appointments == null ? List.<Appointment>of() : appointments).stream()
|
||||
.sorted(Comparator.comparing(
|
||||
Appointment::getAppointmentTime,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<Report> safeReports(List<Report> reports) {
|
||||
return reports == null ? List.of() : reports;
|
||||
}
|
||||
|
||||
private static boolean isHighlightAnomaly(Report report, LocalDateTime staleBefore) {
|
||||
if ("failed".equals(report.getHighlightVideoStatus())) {
|
||||
return true;
|
||||
}
|
||||
if (!"processing".equals(report.getHighlightVideoStatus())) {
|
||||
return false;
|
||||
}
|
||||
LocalDateTime activity = reportActivityTime(report);
|
||||
return activity == null || activity.isBefore(staleBefore);
|
||||
}
|
||||
|
||||
private static LocalDateTime reportActivityTime(Report report) {
|
||||
return report.getUpdateTime() != null ? report.getUpdateTime() : report.getCreateTime();
|
||||
}
|
||||
|
||||
private static void addAppointmentGroup(
|
||||
List<ActionGroup> groups,
|
||||
String kind,
|
||||
String urgency,
|
||||
List<Appointment> appointments,
|
||||
LocalDateTime now) {
|
||||
if (appointments.isEmpty()) return;
|
||||
List<ActionItem> items = appointments.stream()
|
||||
.limit(PREVIEW_LIMIT)
|
||||
.map(appointment -> new ActionItem(
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
appointment.getPetName(),
|
||||
appointment.getServiceType(),
|
||||
appointment.getAppointmentTime() == null ? null : appointment.getAppointmentTime().toString(),
|
||||
appointment.getStatus(),
|
||||
appointment.getAppointmentTime() != null && appointment.getAppointmentTime().isBefore(now)
|
||||
))
|
||||
.toList();
|
||||
groups.add(new ActionGroup(kind, urgency, appointments.size(), items));
|
||||
}
|
||||
|
||||
private static void addReportGroup(List<ActionGroup> groups, List<Report> reports) {
|
||||
if (reports.isEmpty()) return;
|
||||
List<ActionItem> items = reports.stream()
|
||||
.limit(PREVIEW_LIMIT)
|
||||
.map(report -> new ActionItem(
|
||||
"report",
|
||||
report.getId(),
|
||||
report.getPetName(),
|
||||
report.getServiceType(),
|
||||
reportActivityTime(report) == null ? null : reportActivityTime(report).toString(),
|
||||
report.getHighlightVideoStatus(),
|
||||
true
|
||||
))
|
||||
.toList();
|
||||
groups.add(new ActionGroup(HIGHLIGHT_ANOMALY, "high", reports.size(), items));
|
||||
}
|
||||
|
||||
private static void addUnsentReportGroup(List<ActionGroup> groups, List<Report> reports) {
|
||||
if (reports.isEmpty()) return;
|
||||
List<ActionItem> items = reports.stream()
|
||||
.limit(PREVIEW_LIMIT)
|
||||
.map(report -> new ActionItem(
|
||||
"report",
|
||||
report.getId(),
|
||||
report.getPetName(),
|
||||
report.getServiceType(),
|
||||
report.getCreateTime() == null ? null : report.getCreateTime().toString(),
|
||||
report.getSendStatus(),
|
||||
true
|
||||
))
|
||||
.toList();
|
||||
groups.add(new ActionGroup(UNSENT_REPORT, "high", reports.size(), items));
|
||||
}
|
||||
|
||||
private static void addFollowUpGroup(
|
||||
List<ActionGroup> groups,
|
||||
List<FollowUpTask> tasks,
|
||||
Map<Long, ReportLead> leads
|
||||
) {
|
||||
if (tasks.isEmpty()) return;
|
||||
List<ActionItem> items = tasks.stream()
|
||||
.limit(PREVIEW_LIMIT)
|
||||
.map(task -> {
|
||||
ReportLead lead = leads.get(task.getSourceLeadId());
|
||||
return new ActionItem(
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
lead == null ? null : lead.getPetName(),
|
||||
lead == null ? null : lead.getServiceType(),
|
||||
task.getDueDate() == null ? null : task.getDueDate().toString(),
|
||||
task.getStatus(),
|
||||
true
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", tasks.size(), items));
|
||||
}
|
||||
|
||||
public record WorkbenchData(
|
||||
String date,
|
||||
LocalDateTime generatedAt,
|
||||
WorkbenchMetrics metrics,
|
||||
List<ActionGroup> actions,
|
||||
Map<String, Object> reportFunnel) {
|
||||
}
|
||||
|
||||
public record WorkbenchMetrics(
|
||||
long todayAppointmentCount,
|
||||
long todayDoneCount,
|
||||
long overdueAppointmentCount,
|
||||
long inServiceCount,
|
||||
long upcomingTodayCount,
|
||||
long highlightAnomalyCount,
|
||||
long unsentReportCount,
|
||||
long dueFollowUpCount) {
|
||||
}
|
||||
|
||||
public record ActionGroup(String kind, String urgency, long count, List<ActionItem> items) {
|
||||
}
|
||||
|
||||
public record ActionItem(
|
||||
String resourceType,
|
||||
Long resourceId,
|
||||
String petName,
|
||||
String serviceType,
|
||||
String scheduledAt,
|
||||
String status,
|
||||
boolean overdue) {
|
||||
}
|
||||
}
|
||||
@ -1,516 +1,63 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.Pet;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.PetMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.ScheduleBlockMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppointmentService {
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final ScheduleBlockMapper scheduleBlockMapper;
|
||||
private final StoreService storeService;
|
||||
private final UserMapper userMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final PetMapper petMapper;
|
||||
private final StoreCustomerService storeCustomerService;
|
||||
private final BusinessEventService businessEventService;
|
||||
private final ServiceTypeService serviceTypeService;
|
||||
private final BookingCapacityService bookingCapacityService;
|
||||
private final FollowUpTaskService followUpTaskService;
|
||||
|
||||
// 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。
|
||||
public List<Appointment> getByUserId(Long customerUserId) {
|
||||
return appointmentMapper.findForCustomer(customerUserId);
|
||||
// 员工查看自己的预约
|
||||
public List<Appointment> getByUserId(Long userId) {
|
||||
return appointmentMapper.findByUserId(userId);
|
||||
}
|
||||
|
||||
// 老板查看本店所有预约
|
||||
public List<Appointment> getByStoreId(Long storeId) {
|
||||
return appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||
return appointmentMapper.findByStoreId(storeId);
|
||||
}
|
||||
|
||||
// 宠主按状态查归属于自己的预约。
|
||||
public List<Appointment> getByUserIdAndStatus(Long customerUserId, String status) {
|
||||
return appointmentMapper.findForCustomerByStatus(customerUserId, status);
|
||||
// 员工按状态查
|
||||
public List<Appointment> getByUserIdAndStatus(Long userId, String status) {
|
||||
return appointmentMapper.findByUserIdAndStatus(userId, status);
|
||||
}
|
||||
|
||||
// 老板按状态查
|
||||
public List<Appointment> getByStoreIdAndStatus(Long storeId, String status) {
|
||||
return appointmentMapper.findByStoreIdAndStatusAndDeletedFalse(storeId, status);
|
||||
return appointmentMapper.findByStoreIdAndStatus(storeId, status);
|
||||
}
|
||||
|
||||
public Page<Appointment> pageByScope(Long userId, Long storeId, String status, int pageNo, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(
|
||||
Math.max(pageNo, 0),
|
||||
Math.max(pageSize, 1),
|
||||
Sort.by(Sort.Direction.DESC, "appointmentTime")
|
||||
);
|
||||
boolean hasStatus = status != null && !status.isBlank();
|
||||
if (storeId != null) {
|
||||
return hasStatus
|
||||
? appointmentMapper.findByStoreIdAndStatusAndDeletedFalse(storeId, status, pageable)
|
||||
: appointmentMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
|
||||
}
|
||||
if (userId != null) {
|
||||
return hasStatus
|
||||
? appointmentMapper.pageForCustomerByStatus(userId, status, pageable)
|
||||
: appointmentMapper.pageForCustomer(userId, pageable);
|
||||
}
|
||||
return Page.empty(pageable);
|
||||
}
|
||||
|
||||
public Appointment getById(Long id) {
|
||||
return appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某门店某日:半小时预约档列表(已占用 / 已过 / 可约)。
|
||||
*/
|
||||
public Map<String, Object> availableSlots(Long storeId, LocalDate date, String serviceType) {
|
||||
if (storeId == null || date == null) {
|
||||
return Map.of("code", 400, "message", "缺少门店或日期");
|
||||
}
|
||||
com.petstore.entity.Store store = storeService.findById(storeId);
|
||||
if (store == null) {
|
||||
return Map.of("code", 404, "message", "门店不存在");
|
||||
}
|
||||
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
|
||||
com.petstore.entity.ServiceType resolvedService = serviceTypeService.resolveForStore(storeId, serviceType);
|
||||
if (serviceType != null && !serviceType.isBlank() && resolvedService == null) {
|
||||
return Map.of("code", 400, "message", "服务项目不存在或不属于该门店", "bizCode", "SERVICE_TYPE_INVALID");
|
||||
}
|
||||
int durationMinutes = resolvedService == null
|
||||
? BookingDurationSupport.DEFAULT_SERVICE_MINUTES
|
||||
: BookingDurationSupport.serviceMinutes(resolvedService.getDurationMinutes());
|
||||
int capacity = StoreService.normalizeCapacity(store.getBookingCapacity());
|
||||
LocalDateTime dayStart = date.atStartOfDay();
|
||||
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
|
||||
List<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, dayStart, dayEnd);
|
||||
List<com.petstore.entity.ScheduleBlock> blocks =
|
||||
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
storeId, dayStart, dayEnd);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
|
||||
String hhmm = formatHhMm(slotStart);
|
||||
boolean past = slotStart.isBefore(now);
|
||||
boolean withinWindow = AppointmentSlotSupport.isWithinBookableWindow(slotStart, durationMinutes, window);
|
||||
BookingCapacityService.CapacityResult capacityResult = bookingCapacityService.evaluateAppointment(
|
||||
slotStart, durationMinutes, capacity, appointments, blocks);
|
||||
boolean available = !past && withinWindow && capacityResult.available();
|
||||
String reason = null;
|
||||
if (past) {
|
||||
reason = "已过时段";
|
||||
} else if (!withinWindow) {
|
||||
reason = "服务将在营业结束后完成";
|
||||
} else if (!capacityResult.available()) {
|
||||
reason = capacityResult.reason();
|
||||
}
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("time", hhmm);
|
||||
one.put("available", available);
|
||||
one.put("endTime", formatHhMm(slotStart.plusMinutes(durationMinutes)));
|
||||
one.put("usedCapacity", capacityResult.peakUsed());
|
||||
one.put("totalCapacity", capacity);
|
||||
if (reason != null) {
|
||||
one.put("reason", reason);
|
||||
}
|
||||
rows.add(one);
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("slots", rows);
|
||||
data.put("dayStart", window.dayStart().toString());
|
||||
data.put("lastSlotStart", window.lastSlotStart().toString());
|
||||
data.put("closingTime", formatHhMm(window.closingTime(date.atStartOfDay())));
|
||||
data.put("durationMinutes", durationMinutes);
|
||||
data.put("bookingCapacity", capacity);
|
||||
data.put("serviceType", resolvedService == null ? serviceType : resolvedService.getName());
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
private static String formatHhMm(LocalDateTime dt) {
|
||||
return String.format("%02d:%02d", dt.getHour(), dt.getMinute());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预约:按服务时长跨半小时容量桶占号,取消({@code cancel})不占号。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> createBooking(Appointment appointment) {
|
||||
return createBooking(appointment, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> createBooking(Appointment appointment, String followUpTaskId) {
|
||||
if (appointment.getAppointmentTime() == null) {
|
||||
return Map.of("code", 400, "message", "请填写预约时间");
|
||||
}
|
||||
LocalDateTime t = AppointmentSlotSupport.alignToHalfHour(appointment.getAppointmentTime());
|
||||
appointment.setAppointmentTime(t);
|
||||
if (appointment.getStoreId() == null) {
|
||||
return Map.of("code", 400, "message", "缺少门店");
|
||||
}
|
||||
if (appointment.getCustomerUserId() == null) {
|
||||
appointment.setCustomerUserId(appointment.getUserId());
|
||||
}
|
||||
if (appointment.getCustomerUserId() == null) {
|
||||
return Map.of("code", 400, "message", "缺少宠主", "bizCode", "CUSTOMER_REQUIRED");
|
||||
}
|
||||
if (appointment.getCreatedByUserId() == null) {
|
||||
appointment.setCreatedByUserId(appointment.getCustomerUserId());
|
||||
}
|
||||
appointment.setUserId(appointment.getCustomerUserId()); // legacy compatibility
|
||||
|
||||
if (appointment.getPetId() != null) {
|
||||
Pet pet = petMapper.findByIdAndDeletedFalse(appointment.getPetId()).orElse(null);
|
||||
if (pet == null) {
|
||||
return Map.of("code", 404, "message", "宠物档案不存在", "bizCode", "PET_NOT_FOUND");
|
||||
}
|
||||
if (pet.getOwnerUserId() == null || !pet.getOwnerUserId().equals(appointment.getCustomerUserId())) {
|
||||
return Map.of("code", 403, "message", "宠物档案不属于该宠主", "bizCode", "PET_CUSTOMER_MISMATCH");
|
||||
}
|
||||
}
|
||||
com.petstore.entity.Store store = storeService.findByIdForUpdate(appointment.getStoreId());
|
||||
if (store == null) {
|
||||
return Map.of("code", 404, "message", "门店不存在");
|
||||
}
|
||||
com.petstore.entity.ServiceType resolvedService =
|
||||
serviceTypeService.resolveForStore(appointment.getStoreId(), appointment.getServiceType());
|
||||
if (resolvedService == null) {
|
||||
return Map.of("code", 400, "message", "请选择本店有效的服务项目", "bizCode", "SERVICE_TYPE_INVALID");
|
||||
}
|
||||
int durationMinutes = BookingDurationSupport.serviceMinutes(resolvedService.getDurationMinutes());
|
||||
appointment.setServiceType(resolvedService.getName());
|
||||
appointment.setDurationMinutes(durationMinutes);
|
||||
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
|
||||
if (!AppointmentSlotSupport.isWithinBookableWindow(t, durationMinutes, window)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message",
|
||||
String.format(
|
||||
"服务须在 %s~%s 的营业容量时段内完成",
|
||||
window.dayStart(),
|
||||
window.closingTime(t).toLocalTime()
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!t.isAfter(LocalDateTime.now())) {
|
||||
return Map.of("code", 400, "message", "不能预约当前及已过去的时段");
|
||||
}
|
||||
LocalDateTime dayStart = t.toLocalDate().atStartOfDay();
|
||||
LocalDateTime dayEnd = t.toLocalDate().plusDays(1).atStartOfDay();
|
||||
List<Appointment> activeAppointments = appointmentMapper.findActiveByStoreAndDateRange(
|
||||
appointment.getStoreId(), dayStart, dayEnd);
|
||||
List<com.petstore.entity.ScheduleBlock> blocks =
|
||||
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
appointment.getStoreId(), dayStart, dayEnd);
|
||||
BookingCapacityService.CapacityResult capacityResult = bookingCapacityService.evaluateAppointment(
|
||||
t,
|
||||
durationMinutes,
|
||||
StoreService.normalizeCapacity(store.getBookingCapacity()),
|
||||
activeAppointments,
|
||||
blocks
|
||||
);
|
||||
if (!capacityResult.available()) {
|
||||
return Map.of("code", 409, "message", capacityResult.reason() + ",请更换其它时段", "bizCode", "CAPACITY_FULL");
|
||||
}
|
||||
public Appointment create(Appointment appointment) {
|
||||
appointment.setCreateTime(LocalDateTime.now());
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
appointment.setDeleted(false);
|
||||
StoreCustomer storeCustomer;
|
||||
try {
|
||||
storeCustomer = storeCustomerService.touchBooking(
|
||||
appointment.getStoreId(),
|
||||
appointment.getCustomerUserId(),
|
||||
appointment.getCreatedByUserId(),
|
||||
appointment.getCreateTime()
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of(
|
||||
"code", 409,
|
||||
"message", e.getMessage(),
|
||||
"bizCode", "CUSTOMER_IDENTITY_CONFLICT"
|
||||
);
|
||||
}
|
||||
com.petstore.entity.FollowUpTask followUpTask = null;
|
||||
if (followUpTaskId != null && !followUpTaskId.isBlank()) {
|
||||
followUpTask = followUpTaskService.prepareRebook(
|
||||
appointment.getStoreId(),
|
||||
followUpTaskId,
|
||||
storeCustomer.getId(),
|
||||
appointment.getCreatedByUserId()
|
||||
);
|
||||
}
|
||||
Appointment saved = appointmentMapper.save(appointment);
|
||||
businessEventService.recordAppointmentCreated(
|
||||
saved,
|
||||
storeCustomer,
|
||||
resolveActorRole(saved.getCreatedByUserId())
|
||||
);
|
||||
if (followUpTask != null) {
|
||||
followUpTaskService.markRebooked(
|
||||
followUpTask,
|
||||
saved,
|
||||
saved.getCreatedByUserId(),
|
||||
resolveActorRole(saved.getCreatedByUserId())
|
||||
);
|
||||
}
|
||||
Map<String, Object> ok = new HashMap<>();
|
||||
ok.put("code", 200);
|
||||
ok.put("message", "创建成功");
|
||||
ok.put("data", saved);
|
||||
return ok;
|
||||
return appointmentMapper.save(appointment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约状态迁移(PUT /appointment/status 等)。
|
||||
* 允许:new→cancel、doing→done;new→doing 请走 {@link #startService}。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> transitionStatus(Long id, String nextStatus) {
|
||||
return transitionStatus(id, nextStatus, null, null);
|
||||
public Appointment updateStatus(Long id, String status) {
|
||||
Appointment appointment = appointmentMapper.findById(id).orElse(null);
|
||||
if (appointment != null) {
|
||||
appointment.setStatus(status);
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
return appointmentMapper.save(appointment);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> transitionStatus(
|
||||
Long id,
|
||||
String nextStatus,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
if (appointment == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
if (nextStatus == null || nextStatus.isBlank()) {
|
||||
return Map.of("code", 400, "message", "状态不能为空", "bizCode", "INVALID_STATUS");
|
||||
}
|
||||
String to = nextStatus.trim().toLowerCase();
|
||||
String from = appointment.getStatus() == null ? "" : appointment.getStatus().trim().toLowerCase();
|
||||
|
||||
if ("cancel".equals(to)) {
|
||||
if (!"new".equals(from)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "仅待开始的预约可取消",
|
||||
"bizCode", "CANCEL_NOT_ALLOWED"
|
||||
);
|
||||
}
|
||||
} else if ("done".equals(to)) {
|
||||
if (!"doing".equals(from)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "仅进行中的预约可标记为已完成",
|
||||
"bizCode", "INVALID_STATUS"
|
||||
);
|
||||
}
|
||||
} else if ("doing".equals(to)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "请使用「开始服务」接口将预约置为进行中",
|
||||
"bizCode", "USE_START_ENDPOINT"
|
||||
);
|
||||
} else {
|
||||
return Map.of("code", 400, "message", "不支持的状态变更", "bizCode", "INVALID_STATUS");
|
||||
}
|
||||
|
||||
LocalDateTime occurredAt = LocalDateTime.now();
|
||||
appointment.setStatus(to);
|
||||
appointment.setUpdateTime(occurredAt);
|
||||
Appointment saved = appointmentMapper.save(appointment);
|
||||
businessEventService.recordAppointmentStatusChanged(
|
||||
saved,
|
||||
from,
|
||||
to,
|
||||
actorUserId,
|
||||
actorRole,
|
||||
occurredAt
|
||||
);
|
||||
return Map.of("code", 200, "message", "更新成功", "data", saved);
|
||||
}
|
||||
|
||||
/** 开始服务:仅 new → doing,并指定技师 */
|
||||
@Transactional
|
||||
public Map<String, Object> startService(Long appointmentId, Long staffUserId) {
|
||||
return startService(appointmentId, staffUserId, resolveActorRole(staffUserId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> startService(Long appointmentId, Long staffUserId, String actorRole) {
|
||||
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null);
|
||||
if (appointment == null) {
|
||||
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
String from = appointment.getStatus() == null ? "" : appointment.getStatus().trim().toLowerCase();
|
||||
if (!"new".equals(from)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message", "仅待开始的预约可开始服务",
|
||||
"bizCode", "INVALID_STATUS"
|
||||
);
|
||||
}
|
||||
LocalDateTime occurredAt = LocalDateTime.now();
|
||||
/** 开始服务:状态变为进行中,同时指定技师为当前用户 */
|
||||
public Appointment startService(Long appointmentId, Long staffUserId) {
|
||||
Appointment appointment = appointmentMapper.findById(appointmentId).orElse(null);
|
||||
if (appointment != null) {
|
||||
appointment.setStatus("doing");
|
||||
appointment.setAssignedUserId(staffUserId);
|
||||
appointment.setUpdateTime(occurredAt);
|
||||
Appointment saved = appointmentMapper.save(appointment);
|
||||
businessEventService.recordAppointmentStatusChanged(
|
||||
saved,
|
||||
from,
|
||||
"doing",
|
||||
staffUserId,
|
||||
actorRole,
|
||||
occurredAt
|
||||
);
|
||||
return Map.of("code", 200, "message", "已开始服务", "data", saved);
|
||||
}
|
||||
|
||||
private String resolveActorRole(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
return userMapper.findByIdAndDeletedFalse(userId)
|
||||
.map(User::getRole)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public boolean softDelete(Long id) {
|
||||
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
if (appointment == null) {
|
||||
return false;
|
||||
}
|
||||
appointment.setDeleted(true);
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
appointmentMapper.save(appointment);
|
||||
return true;
|
||||
return appointmentMapper.save(appointment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店后台列表/详情视图:附加宠主脱敏、技师名、是否已有报告。
|
||||
*/
|
||||
public List<Map<String, Object>> toAdminViews(List<Appointment> appointments) {
|
||||
if (appointments == null || appointments.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
for (Appointment a : appointments) {
|
||||
if (a.resolvedCustomerUserId() != null) {
|
||||
userIds.add(a.resolvedCustomerUserId());
|
||||
}
|
||||
if (a.getAssignedUserId() != null) {
|
||||
userIds.add(a.getAssignedUserId());
|
||||
}
|
||||
}
|
||||
Map<Long, User> users = userIds.isEmpty()
|
||||
? Map.of()
|
||||
: userMapper.findAllById(userIds).stream()
|
||||
.filter(u -> !Boolean.TRUE.equals(u.getDeleted()))
|
||||
.collect(Collectors.toMap(User::getId, u -> u, (a, b) -> a));
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>(appointments.size());
|
||||
for (Appointment a : appointments) {
|
||||
rows.add(toAdminView(a, users));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public Map<String, Object> toAdminView(Appointment a) {
|
||||
Set<Long> userIds = new HashSet<>();
|
||||
if (a.resolvedCustomerUserId() != null) {
|
||||
userIds.add(a.resolvedCustomerUserId());
|
||||
}
|
||||
if (a.getAssignedUserId() != null) {
|
||||
userIds.add(a.getAssignedUserId());
|
||||
}
|
||||
Map<Long, User> users = userIds.isEmpty()
|
||||
? Map.of()
|
||||
: userMapper.findAllById(userIds).stream()
|
||||
.filter(u -> !Boolean.TRUE.equals(u.getDeleted()))
|
||||
.collect(Collectors.toMap(User::getId, u -> u, (x, y) -> x));
|
||||
return toAdminView(a, users);
|
||||
}
|
||||
|
||||
private Map<String, Object> toAdminView(Appointment a, Map<Long, User> users) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", a.getId());
|
||||
row.put("petName", a.getPetName());
|
||||
row.put("petType", a.getPetType());
|
||||
row.put("serviceType", a.getServiceType());
|
||||
row.put("appointmentTime", a.getAppointmentTime());
|
||||
row.put("endTime", a.getEndTime());
|
||||
row.put("durationMinutes", a.resolvedDurationMinutes());
|
||||
row.put("status", a.getStatus());
|
||||
row.put("storeId", a.getStoreId());
|
||||
row.put("userId", a.getUserId());
|
||||
row.put("customerUserId", a.resolvedCustomerUserId());
|
||||
row.put("createdByUserId", a.getCreatedByUserId());
|
||||
row.put("petId", a.getPetId());
|
||||
row.put("assignedUserId", a.getAssignedUserId());
|
||||
row.put("assignedStaffId", a.getAssignedUserId());
|
||||
row.put("remark", a.getRemark());
|
||||
row.put("createTime", a.getCreateTime());
|
||||
row.put("updateTime", a.getUpdateTime());
|
||||
|
||||
Long customerUserId = a.resolvedCustomerUserId();
|
||||
User customer = customerUserId == null ? null : users.get(customerUserId);
|
||||
if (customer != null && !"customer".equals(customer.getRole())) {
|
||||
customer = null;
|
||||
}
|
||||
if (customer != null) {
|
||||
row.put("customerName", customer.getName() != null ? customer.getName() : customer.getUsername());
|
||||
row.put("customerPhoneMasked", maskPhone(customer.getPhone()));
|
||||
} else {
|
||||
row.put("customerName", null);
|
||||
row.put("customerPhoneMasked", null);
|
||||
}
|
||||
|
||||
User staff = a.getAssignedUserId() == null ? null : users.get(a.getAssignedUserId());
|
||||
row.put("staffName", staff == null ? null : (staff.getName() != null ? staff.getName() : staff.getUsername()));
|
||||
|
||||
Optional<Report> report = reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(a.getId());
|
||||
row.put("hasReport", report.isPresent());
|
||||
row.put("reportId", report.map(Report::getId).orElse(null));
|
||||
return row;
|
||||
}
|
||||
|
||||
static String maskPhone(String phone) {
|
||||
if (phone == null || phone.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String p = phone.trim();
|
||||
if (p.length() == 11) {
|
||||
return p.substring(0, 3) + "****" + p.substring(7);
|
||||
}
|
||||
if (p.length() < 7) {
|
||||
return "****";
|
||||
}
|
||||
return p.substring(0, 2) + "****" + p.substring(p.length() - 2);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 预约时段:半小时一档(与前端 halfHourTime 对齐)。
|
||||
*/
|
||||
public final class AppointmentSlotSupport {
|
||||
|
||||
private AppointmentSlotSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间对齐到半点档(00 或 30 分,秒纳秒归零)。
|
||||
*/
|
||||
public static LocalDateTime alignToHalfHour(LocalDateTime dt) {
|
||||
if (dt == null) {
|
||||
return null;
|
||||
}
|
||||
int m = dt.getMinute();
|
||||
int nm = m < 30 ? 0 : 30;
|
||||
return dt.withMinute(nm).withSecond(0).withNano(0);
|
||||
}
|
||||
|
||||
public static boolean isWithinBookableWindow(LocalDateTime slotStart, StoreBookingWindow window) {
|
||||
if (slotStart == null || window == null) {
|
||||
return false;
|
||||
}
|
||||
LocalTime t = slotStart.toLocalTime();
|
||||
return !t.isBefore(window.dayStart()) && !t.isAfter(window.lastSlotStart());
|
||||
}
|
||||
|
||||
public static boolean isWithinBookableWindow(
|
||||
LocalDateTime slotStart,
|
||||
int durationMinutes,
|
||||
StoreBookingWindow window) {
|
||||
if (!isWithinBookableWindow(slotStart, window)
|
||||
|| !BookingDurationSupport.isValid(durationMinutes)) {
|
||||
return false;
|
||||
}
|
||||
return !slotStart.plusMinutes(durationMinutes).isAfter(window.closingTime(slotStart));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定可预约时间窗口内,生成某日所有半点起始时刻(含首尾)。
|
||||
*/
|
||||
public static List<LocalDateTime> allSlotStartsOnDay(LocalDate date, StoreBookingWindow window) {
|
||||
List<LocalDateTime> list = new ArrayList<>();
|
||||
LocalDateTime cur = LocalDateTime.of(date, window.dayStart());
|
||||
LocalDateTime end = LocalDateTime.of(date, window.lastSlotStart());
|
||||
while (!cur.isAfter(end)) {
|
||||
list.add(cur);
|
||||
cur = cur.plusMinutes(30);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.petstore.entity.AuditLog;
|
||||
import com.petstore.mapper.AuditLogMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 统一写入低敏、不可变的操作审计。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogService {
|
||||
|
||||
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
||||
"phone", "token", "url", "openid", "unionid", "password", "secret",
|
||||
"remark", "content", "request", "payload", "address", "latitude", "longitude"
|
||||
);
|
||||
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AuditLog record(
|
||||
Long storeId,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
String action,
|
||||
String targetType,
|
||||
String targetId,
|
||||
String outcome,
|
||||
Map<String, Object> metadata
|
||||
) {
|
||||
if (storeId == null) {
|
||||
throw new IllegalArgumentException("审计记录必须归属门店");
|
||||
}
|
||||
AuditLog log = new AuditLog();
|
||||
log.setAuditId(UUID.randomUUID().toString());
|
||||
log.setStoreId(storeId);
|
||||
log.setActorUserId(actorUserId);
|
||||
log.setActorRole(normalizeOptional(actorRole, 16));
|
||||
log.setAction(normalizeRequired(action, 64));
|
||||
log.setTargetType(normalizeRequired(targetType, 32));
|
||||
log.setTargetId(normalizeOptional(targetId, 64));
|
||||
log.setOutcome(normalizeRequired(outcome == null ? "success" : outcome, 16));
|
||||
log.setMetadataJson(serializeMetadata(metadata));
|
||||
log.setOccurredAt(LocalDateTime.now());
|
||||
log.setCreateTime(LocalDateTime.now());
|
||||
return auditLogMapper.save(log);
|
||||
}
|
||||
|
||||
private String serializeMetadata(Map<String, Object> metadata) {
|
||||
Map<String, Object> safe = new LinkedHashMap<>();
|
||||
if (metadata != null) {
|
||||
for (Map.Entry<String, Object> entry : metadata.entrySet()) {
|
||||
String key = entry.getKey() == null ? "" : entry.getKey().trim();
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT).replace("_", "");
|
||||
boolean forbidden = FORBIDDEN_METADATA_FRAGMENTS.stream().anyMatch(normalizedKey::contains)
|
||||
|| "ip".equals(normalizedKey);
|
||||
if (key.isBlank() || forbidden) {
|
||||
throw new IllegalArgumentException("审计 metadata 包含禁止字段: " + key);
|
||||
}
|
||||
Object value = entry.getValue();
|
||||
if (value == null || value instanceof Boolean || value instanceof Number) {
|
||||
safe.put(key, value);
|
||||
} else if (value instanceof String text) {
|
||||
safe.put(key, text.length() <= 100 ? text : text.substring(0, 100));
|
||||
} else {
|
||||
throw new IllegalArgumentException("审计 metadata 仅允许标量值");
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(safe);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("审计 metadata 无法序列化", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeRequired(String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.isBlank()) {
|
||||
throw new IllegalArgumentException("审计必填字段为空");
|
||||
}
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private static String normalizeOptional(String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.ScheduleBlock;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门店级预约容量计算。线上预约尚不预选技师,因此容量口径是门店承诺的并发接待数;
|
||||
* walk_in 消耗一个并发名额,blocked 关闭覆盖范围内的全部名额。
|
||||
*/
|
||||
@Service
|
||||
public class BookingCapacityService {
|
||||
|
||||
public record CapacityResult(boolean available, String reason, int peakUsed) {
|
||||
}
|
||||
|
||||
public CapacityResult evaluateAppointment(
|
||||
LocalDateTime start,
|
||||
int durationMinutes,
|
||||
int capacity,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
int peak = 0;
|
||||
LocalDateTime end = start.plusMinutes(durationMinutes);
|
||||
for (LocalDateTime bucket = start; bucket.isBefore(end); bucket = bucket.plusMinutes(30)) {
|
||||
LocalDateTime bucketStart = bucket;
|
||||
LocalDateTime bucketEnd = bucketStart.plusMinutes(30);
|
||||
if (blocks.stream().anyMatch(b -> isBlocked(b) && overlaps(
|
||||
b.getSlotStart(), blockEnd(b), bucketStart, bucketEnd))) {
|
||||
return new CapacityResult(false, "门店暂停预约", peak);
|
||||
}
|
||||
int used = (int) appointments.stream()
|
||||
.filter(a -> overlaps(a.getAppointmentTime(), appointmentEnd(a), bucketStart, bucketEnd))
|
||||
.count();
|
||||
used += (int) blocks.stream()
|
||||
.filter(this::isWalkIn)
|
||||
.filter(b -> overlaps(b.getSlotStart(), blockEnd(b), bucketStart, bucketEnd))
|
||||
.count();
|
||||
peak = Math.max(peak, used);
|
||||
if (used >= capacity) {
|
||||
return new CapacityResult(false, "接待容量已满", peak);
|
||||
}
|
||||
}
|
||||
return new CapacityResult(true, null, peak);
|
||||
}
|
||||
|
||||
public boolean hasAnyOccupancy(
|
||||
LocalDateTime start,
|
||||
int durationMinutes,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
LocalDateTime end = start.plusMinutes(durationMinutes);
|
||||
return appointments.stream().anyMatch(a -> overlaps(
|
||||
a.getAppointmentTime(), appointmentEnd(a), start, end))
|
||||
|| blocks.stream().anyMatch(b -> overlaps(
|
||||
b.getSlotStart(), blockEnd(b), start, end));
|
||||
}
|
||||
|
||||
public int usedAt(
|
||||
LocalDateTime bucket,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
LocalDateTime bucketEnd = bucket.plusMinutes(30);
|
||||
int used = (int) appointments.stream()
|
||||
.filter(a -> overlaps(a.getAppointmentTime(), appointmentEnd(a), bucket, bucketEnd))
|
||||
.count();
|
||||
used += (int) blocks.stream()
|
||||
.filter(this::isWalkIn)
|
||||
.filter(b -> overlaps(b.getSlotStart(), blockEnd(b), bucket, bucketEnd))
|
||||
.count();
|
||||
return used;
|
||||
}
|
||||
|
||||
public boolean hasBlackoutAt(LocalDateTime bucket, List<ScheduleBlock> blocks) {
|
||||
LocalDateTime bucketEnd = bucket.plusMinutes(30);
|
||||
return blocks.stream().anyMatch(b -> isBlocked(b) && overlaps(
|
||||
b.getSlotStart(), blockEnd(b), bucket, bucketEnd));
|
||||
}
|
||||
|
||||
public boolean overlaps(LocalDateTime aStart, LocalDateTime aEnd, LocalDateTime bStart, LocalDateTime bEnd) {
|
||||
return aStart != null && aEnd != null && bStart != null && bEnd != null
|
||||
&& aStart.isBefore(bEnd) && bStart.isBefore(aEnd);
|
||||
}
|
||||
|
||||
private LocalDateTime appointmentEnd(Appointment appointment) {
|
||||
return appointment.getAppointmentTime() == null
|
||||
? null
|
||||
: appointment.getAppointmentTime().plusMinutes(appointment.resolvedDurationMinutes());
|
||||
}
|
||||
|
||||
private LocalDateTime blockEnd(ScheduleBlock block) {
|
||||
if (block.getSlotStart() == null) {
|
||||
return null;
|
||||
}
|
||||
return block.getSlotStart().plusMinutes(BookingDurationSupport.blockMinutes(block.getDurationMinutes()));
|
||||
}
|
||||
|
||||
private boolean isWalkIn(ScheduleBlock block) {
|
||||
return "walk_in".equals(block.getBlockType());
|
||||
}
|
||||
|
||||
private boolean isBlocked(ScheduleBlock block) {
|
||||
return "blocked".equals(block.getBlockType());
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
/** 预约容量模型共用的 30 分钟粒度与时长边界。 */
|
||||
public final class BookingDurationSupport {
|
||||
public static final int SLOT_MINUTES = 30;
|
||||
public static final int DEFAULT_SERVICE_MINUTES = 60;
|
||||
public static final int DEFAULT_BLOCK_MINUTES = 30;
|
||||
public static final int MIN_MINUTES = 30;
|
||||
public static final int MAX_MINUTES = 480;
|
||||
|
||||
private BookingDurationSupport() {
|
||||
}
|
||||
|
||||
public static boolean isValid(Integer minutes) {
|
||||
return minutes != null
|
||||
&& minutes >= MIN_MINUTES
|
||||
&& minutes <= MAX_MINUTES
|
||||
&& minutes % SLOT_MINUTES == 0;
|
||||
}
|
||||
|
||||
public static int serviceMinutes(Integer minutes) {
|
||||
return isValid(minutes) ? minutes : DEFAULT_SERVICE_MINUTES;
|
||||
}
|
||||
|
||||
public static int blockMinutes(Integer minutes) {
|
||||
return isValid(minutes) ? minutes : DEFAULT_BLOCK_MINUTES;
|
||||
}
|
||||
}
|
||||
@ -1,493 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.BusinessEvent;
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.mapper.BusinessEventMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.StoreCustomerMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 统一记录低敏、不可变、可统计的业务事实。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BusinessEventService {
|
||||
|
||||
public static final String APPOINTMENT_CREATED = "appointment_created";
|
||||
public static final String APPOINTMENT_STATUS_CHANGED = "appointment_status_changed";
|
||||
public static final String SERVICE_STARTED = "service_started";
|
||||
public static final String SERVICE_COMPLETED = "service_completed";
|
||||
public static final String REPORT_SUBMITTED = "report_submitted";
|
||||
public static final String REPORT_SENT = "report_sent";
|
||||
public static final String REPORT_OPENED = "report_opened";
|
||||
public static final String REPORT_REOPENED = "report_reopened";
|
||||
public static final String LEAD_SUBMITTED = "lead_submitted";
|
||||
public static final String FOLLOW_UP_CREATED = "follow_up_created";
|
||||
public static final String FOLLOW_UP_STARTED = "follow_up_started";
|
||||
public static final String FOLLOW_UP_RESCHEDULED = "follow_up_rescheduled";
|
||||
public static final String FOLLOW_UP_COMPLETED = "follow_up_completed";
|
||||
public static final String FOLLOW_UP_CANCELED = "follow_up_canceled";
|
||||
public static final String REBOOK_CREATED = "rebook_created";
|
||||
public static final String STORE_REGISTERED = "store_registered";
|
||||
|
||||
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
|
||||
"phone", "token", "url", "openid", "unionid",
|
||||
"password", "secret", "remark", "content", "request", "payload"
|
||||
);
|
||||
|
||||
private final BusinessEventMapper businessEventMapper;
|
||||
private final StoreCustomerMapper storeCustomerMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordStoreRegistered(Store store, Long actorUserId) {
|
||||
return record(new EventCommand(
|
||||
STORE_REGISTERED,
|
||||
store.getId(),
|
||||
null,
|
||||
"store",
|
||||
store.getId(),
|
||||
actorUserId,
|
||||
"boss",
|
||||
"admin",
|
||||
store.getCreateTime(),
|
||||
"store_registered:" + store.getId(),
|
||||
Map.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordAppointmentCreated(
|
||||
Appointment appointment,
|
||||
StoreCustomer storeCustomer,
|
||||
String actorRole
|
||||
) {
|
||||
String source = appointment.getCreatedByUserId() != null
|
||||
&& appointment.getCreatedByUserId().equals(appointment.resolvedCustomerUserId())
|
||||
? "customer"
|
||||
: "admin";
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("bookingOrigin", source);
|
||||
metadata.put("durationMinutes", appointment.resolvedDurationMinutes());
|
||||
return record(new EventCommand(
|
||||
APPOINTMENT_CREATED,
|
||||
appointment.getStoreId(),
|
||||
storeCustomer == null ? null : storeCustomer.getId(),
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
appointment.getCreatedByUserId(),
|
||||
actorRole,
|
||||
source,
|
||||
appointment.getCreateTime(),
|
||||
"appointment_created:" + appointment.getId(),
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recordAppointmentStatusChanged(
|
||||
Appointment appointment,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
LocalDateTime occurredAt
|
||||
) {
|
||||
Long storeCustomerId = resolveStoreCustomerId(
|
||||
appointment.getStoreId(),
|
||||
appointment.resolvedCustomerUserId()
|
||||
);
|
||||
String to = normalizeStatus(toStatus);
|
||||
Map<String, Object> statusMetadata = new LinkedHashMap<>();
|
||||
statusMetadata.put("fromStatus", normalizeStatus(fromStatus));
|
||||
statusMetadata.put("toStatus", to);
|
||||
record(new EventCommand(
|
||||
APPOINTMENT_STATUS_CHANGED,
|
||||
appointment.getStoreId(),
|
||||
storeCustomerId,
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
actorRole == null ? "system" : ("customer".equals(actorRole) ? "customer" : "admin"),
|
||||
occurredAt,
|
||||
"appointment_status:" + appointment.getId() + ":" + to,
|
||||
statusMetadata
|
||||
));
|
||||
|
||||
if ("doing".equals(to)) {
|
||||
record(new EventCommand(
|
||||
SERVICE_STARTED,
|
||||
appointment.getStoreId(),
|
||||
storeCustomerId,
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
"admin",
|
||||
occurredAt,
|
||||
"service_started:" + appointment.getId(),
|
||||
Map.of()
|
||||
));
|
||||
} else if ("done".equals(to)) {
|
||||
record(new EventCommand(
|
||||
SERVICE_COMPLETED,
|
||||
appointment.getStoreId(),
|
||||
storeCustomerId,
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
actorRole == null ? "system" : "admin",
|
||||
occurredAt,
|
||||
"service_completed:" + appointment.getId(),
|
||||
Map.of()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordReportSubmitted(Report report, String actorRole) {
|
||||
return record(new EventCommand(
|
||||
REPORT_SUBMITTED,
|
||||
report.getStoreId(),
|
||||
resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()),
|
||||
"report",
|
||||
report.getId(),
|
||||
report.resolvedAuthorStaffId(),
|
||||
actorRole,
|
||||
"admin",
|
||||
report.getCreateTime(),
|
||||
"report_submitted:" + report.getId(),
|
||||
Map.of()
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordReportSent(Report report, String actorRole) {
|
||||
return record(new EventCommand(
|
||||
REPORT_SENT,
|
||||
report.getStoreId(),
|
||||
resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()),
|
||||
"report",
|
||||
report.getId(),
|
||||
report.getSentByUserId(),
|
||||
actorRole,
|
||||
"admin",
|
||||
report.getSentAt(),
|
||||
"report_sent:" + report.getId(),
|
||||
Map.of("channel", report.getSendChannel())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordLeadSubmitted(
|
||||
ReportLead lead,
|
||||
StoreCustomer storeCustomer,
|
||||
boolean repeatSubmit
|
||||
) {
|
||||
return record(new EventCommand(
|
||||
LEAD_SUBMITTED,
|
||||
lead.getStoreId(),
|
||||
storeCustomer == null ? null : storeCustomer.getId(),
|
||||
"report_lead",
|
||||
lead.getId(),
|
||||
null,
|
||||
null,
|
||||
"public_report",
|
||||
lead.getConsentAt() == null ? lead.getUpdateTime() : lead.getConsentAt(),
|
||||
null,
|
||||
Map.of("repeatSubmit", repeatSubmit)
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordFollowUpCreated(FollowUpTask task) {
|
||||
return record(new EventCommand(
|
||||
FOLLOW_UP_CREATED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
null,
|
||||
null,
|
||||
"system",
|
||||
task.getCreateTime(),
|
||||
"follow_up_created:" + task.getId(),
|
||||
Map.of("dueDate", task.getDueDate().toString())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordFollowUpStarted(
|
||||
FollowUpTask task,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
return record(new EventCommand(
|
||||
FOLLOW_UP_STARTED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
"admin",
|
||||
task.getUpdateTime(),
|
||||
"follow_up_started:" + task.getId() + ":" + safeAttemptCount(task),
|
||||
Map.of("attemptCount", safeAttemptCount(task))
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordFollowUpRescheduled(
|
||||
FollowUpTask task,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
return record(new EventCommand(
|
||||
FOLLOW_UP_RESCHEDULED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
"admin",
|
||||
task.getLastContactedAt(),
|
||||
"follow_up_rescheduled:" + task.getId() + ":" + safeAttemptCount(task),
|
||||
Map.of(
|
||||
"attemptOutcome", task.getLastAttemptOutcome(),
|
||||
"dueDate", task.getDueDate().toString(),
|
||||
"attemptCount", safeAttemptCount(task)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordFollowUpCompleted(
|
||||
FollowUpTask task,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
return record(new EventCommand(
|
||||
FOLLOW_UP_COMPLETED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
"admin",
|
||||
task.getClosedAt(),
|
||||
"follow_up_completed:" + task.getId(),
|
||||
Map.of(
|
||||
"outcome", task.getOutcome(),
|
||||
"attemptCount", safeAttemptCount(task)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordFollowUpCanceled(FollowUpTask task) {
|
||||
return record(new EventCommand(
|
||||
FOLLOW_UP_CANCELED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"follow_up_task",
|
||||
task.getId(),
|
||||
null,
|
||||
null,
|
||||
"public_report",
|
||||
task.getClosedAt(),
|
||||
"follow_up_canceled:" + task.getId(),
|
||||
Map.of("outcome", task.getOutcome())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordRebookCreated(
|
||||
FollowUpTask task,
|
||||
Appointment appointment,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
return record(new EventCommand(
|
||||
REBOOK_CREATED,
|
||||
task.getStoreId(),
|
||||
task.getStoreCustomerId(),
|
||||
"appointment",
|
||||
appointment.getId(),
|
||||
actorUserId,
|
||||
actorRole,
|
||||
"admin",
|
||||
appointment.getCreateTime(),
|
||||
"rebook_created:" + appointment.getId(),
|
||||
Map.of("bookingOrigin", "follow_up")
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开报告打开事件。无效 token 静默忽略,避免借埋点接口枚举报告;绝不持久化 token/hash。
|
||||
*/
|
||||
@Transactional
|
||||
public boolean recordReportOpenByToken(String token, String visitType, LocalDateTime occurredAt) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(token.trim()).orElse(null);
|
||||
if (report == null || report.getId() == null || report.getStoreId() == null) {
|
||||
return false;
|
||||
}
|
||||
String visit = "repeat".equalsIgnoreCase(visitType) ? "repeat" : "first";
|
||||
String eventType = "repeat".equals(visit) ? REPORT_REOPENED : REPORT_OPENED;
|
||||
record(new EventCommand(
|
||||
eventType,
|
||||
report.getStoreId(),
|
||||
resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()),
|
||||
"report",
|
||||
report.getId(),
|
||||
null,
|
||||
null,
|
||||
"public_report",
|
||||
occurredAt,
|
||||
null,
|
||||
Map.of("visitType", visit)
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
private BusinessEvent record(EventCommand command) {
|
||||
validate(command);
|
||||
if (command.idempotencyKey() != null) {
|
||||
BusinessEvent existing = businessEventMapper
|
||||
.findFirstByIdempotencyKey(command.idempotencyKey())
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
BusinessEvent event = new BusinessEvent();
|
||||
event.setEventId(UUID.randomUUID().toString());
|
||||
event.setEventType(command.eventType());
|
||||
event.setEventVersion(1);
|
||||
event.setStoreId(command.storeId());
|
||||
event.setStoreCustomerId(command.storeCustomerId());
|
||||
event.setAggregateType(command.aggregateType());
|
||||
event.setAggregateId(command.aggregateId());
|
||||
event.setActorUserId(command.actorUserId());
|
||||
event.setActorRole(normalizeOptional(command.actorRole(), 16));
|
||||
event.setSource(normalizeRequired(command.source(), 32, "system"));
|
||||
event.setOccurredAt(command.occurredAt() == null ? LocalDateTime.now() : command.occurredAt());
|
||||
event.setMetadataJson(serializeMetadata(command.metadata()));
|
||||
event.setIdempotencyKey(normalizeOptional(command.idempotencyKey(), 128));
|
||||
event.setCreateTime(LocalDateTime.now());
|
||||
return businessEventMapper.save(event);
|
||||
}
|
||||
|
||||
private Long resolveStoreCustomerId(Long storeId, Long customerUserId) {
|
||||
if (storeId == null || customerUserId == null) {
|
||||
return null;
|
||||
}
|
||||
return storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(storeId, customerUserId)
|
||||
.map(StoreCustomer::getId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static int safeAttemptCount(FollowUpTask task) {
|
||||
return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0);
|
||||
}
|
||||
|
||||
private String serializeMetadata(Map<String, Object> metadata) {
|
||||
Map<String, Object> safe = new LinkedHashMap<>();
|
||||
if (metadata != null) {
|
||||
for (Map.Entry<String, Object> entry : metadata.entrySet()) {
|
||||
String key = entry.getKey() == null ? "" : entry.getKey().trim();
|
||||
String keyNorm = key.toLowerCase(Locale.ROOT).replace("_", "");
|
||||
boolean forbidden = FORBIDDEN_METADATA_FRAGMENTS.stream().anyMatch(keyNorm::contains)
|
||||
|| "ip".equals(keyNorm);
|
||||
if (key.isBlank() || forbidden) {
|
||||
throw new IllegalArgumentException("业务事件 metadata 包含禁止字段: " + key);
|
||||
}
|
||||
Object value = entry.getValue();
|
||||
if (value == null || value instanceof Boolean || value instanceof Number) {
|
||||
safe.put(key, value);
|
||||
} else if (value instanceof String text) {
|
||||
safe.put(key, text.length() <= 100 ? text : text.substring(0, 100));
|
||||
} else {
|
||||
throw new IllegalArgumentException("业务事件 metadata 仅允许标量值");
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(safe);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalArgumentException("业务事件 metadata 无法序列化", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validate(EventCommand command) {
|
||||
if (command == null || command.storeId() == null) {
|
||||
throw new IllegalArgumentException("业务事件必须归属门店");
|
||||
}
|
||||
if (command.aggregateId() == null) {
|
||||
throw new IllegalArgumentException("业务事件必须关联业务对象");
|
||||
}
|
||||
normalizeRequired(command.eventType(), 64, null);
|
||||
normalizeRequired(command.aggregateType(), 32, null);
|
||||
}
|
||||
|
||||
private static String normalizeStatus(String status) {
|
||||
return status == null ? "unknown" : status.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeRequired(String value, int maxLength, String fallback) {
|
||||
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.isBlank()) {
|
||||
if (fallback == null) {
|
||||
throw new IllegalArgumentException("业务事件必填字段为空");
|
||||
}
|
||||
normalized = fallback;
|
||||
}
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private static String normalizeOptional(String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private record EventCommand(
|
||||
String eventType,
|
||||
Long storeId,
|
||||
Long storeCustomerId,
|
||||
String aggregateType,
|
||||
Long aggregateId,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
String source,
|
||||
LocalDateTime occurredAt,
|
||||
String idempotencyKey,
|
||||
Map<String, Object> metadata
|
||||
) {}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
/** 面向 API 的可预期流程错误;不用于系统异常。 */
|
||||
public class FlowException extends RuntimeException {
|
||||
private final int code;
|
||||
private final String bizCode;
|
||||
|
||||
public FlowException(int code, String bizCode, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.bizCode = bizCode;
|
||||
}
|
||||
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String bizCode() {
|
||||
return bizCode;
|
||||
}
|
||||
}
|
||||
@ -1,535 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.FollowUpTask;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.FollowUpTaskMapper;
|
||||
import com.petstore.mapper.ReportLeadMapper;
|
||||
import com.petstore.mapper.StoreCustomerMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** FollowUpTask 状态机、同店权限、操作审计与真实再次预约归因。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FollowUpTaskService {
|
||||
|
||||
private static final Set<String> OPEN_STATUSES = Set.of(
|
||||
FollowUpTask.STATUS_PENDING,
|
||||
FollowUpTask.STATUS_IN_PROGRESS
|
||||
);
|
||||
private static final Set<String> RESCHEDULE_REASONS = Set.of(
|
||||
FollowUpTask.ATTEMPT_NO_ANSWER,
|
||||
FollowUpTask.ATTEMPT_FOLLOW_LATER
|
||||
);
|
||||
private static final Set<String> CLOSE_OUTCOMES = Set.of(
|
||||
FollowUpTask.OUTCOME_NOT_INTERESTED,
|
||||
FollowUpTask.OUTCOME_INVALID_CONTACT
|
||||
);
|
||||
|
||||
private final FollowUpTaskMapper followUpTaskMapper;
|
||||
private final ReportLeadMapper reportLeadMapper;
|
||||
private final StoreCustomerMapper storeCustomerMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final StoreCustomerService storeCustomerService;
|
||||
private final BusinessEventService businessEventService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
/** 留资成功后确保存在且只存在一个开放任务;终态后的再次提交会形成新一轮任务。 */
|
||||
@Transactional
|
||||
public FollowUpTask ensureForLead(ReportLead lead, StoreCustomer storeCustomer) {
|
||||
if (lead == null || lead.getId() == null || lead.getStoreId() == null
|
||||
|| storeCustomer == null || storeCustomer.getId() == null
|
||||
|| !Objects.equals(lead.getStoreId(), storeCustomer.getStoreId())
|
||||
|| Boolean.TRUE.equals(storeCustomer.getDeleted())) {
|
||||
throw new IllegalArgumentException("创建回访任务缺少稳定归属");
|
||||
}
|
||||
ReportLead lockedLead = reportLeadMapper.findByIdForUpdate(lead.getId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("留资记录不存在"));
|
||||
if ("unsubscribed".equals(lockedLead.getRemindStatus())) {
|
||||
throw new IllegalArgumentException("该提醒已退订");
|
||||
}
|
||||
List<FollowUpTask> open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate(
|
||||
lockedLead.getId(), OPEN_STATUSES
|
||||
);
|
||||
if (!open.isEmpty()) {
|
||||
FollowUpTask existing = open.get(0);
|
||||
boolean changed = false;
|
||||
if (FollowUpTask.STATUS_PENDING.equals(existing.getStatus())
|
||||
&& lockedLead.getRemindDate() != null
|
||||
&& !lockedLead.getRemindDate().equals(existing.getDueDate())) {
|
||||
existing.setDueDate(lockedLead.getRemindDate());
|
||||
changed = true;
|
||||
}
|
||||
if (!Objects.equals(existing.getStoreCustomerId(), storeCustomer.getId())) {
|
||||
existing.setStoreCustomerId(storeCustomer.getId());
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
existing.setUpdateTime(LocalDateTime.now());
|
||||
return followUpTaskMapper.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FollowUpTask task = new FollowUpTask();
|
||||
task.setTaskId(UUID.randomUUID().toString());
|
||||
task.setStoreId(lockedLead.getStoreId());
|
||||
task.setStoreCustomerId(storeCustomer.getId());
|
||||
task.setSourceLeadId(lockedLead.getId());
|
||||
task.setSourceReportId(lockedLead.getReportId());
|
||||
task.setStatus(FollowUpTask.STATUS_PENDING);
|
||||
task.setDueDate(lockedLead.getRemindDate() == null ? LocalDate.now() : lockedLead.getRemindDate());
|
||||
task.setAttemptCount(0);
|
||||
task.setCreateTime(now);
|
||||
task.setUpdateTime(now);
|
||||
task.setVersion(0L);
|
||||
task = followUpTaskMapper.save(task);
|
||||
businessEventService.recordFollowUpCreated(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskPage list(
|
||||
Long storeId,
|
||||
String status,
|
||||
LocalDate dueDateTo,
|
||||
int page,
|
||||
int pageSize,
|
||||
Long currentUserId
|
||||
) {
|
||||
requireStore(storeId);
|
||||
String normalizedStatus = normalizeFilterStatus(status);
|
||||
int safePage = Math.max(page, 1);
|
||||
int safeSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
|
||||
Specification<FollowUpTask> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("storeId"), storeId));
|
||||
if ("open".equals(normalizedStatus)) {
|
||||
predicates.add(root.get("status").in(OPEN_STATUSES));
|
||||
} else if (normalizedStatus != null) {
|
||||
predicates.add(cb.equal(root.get("status"), normalizedStatus));
|
||||
}
|
||||
if (dueDateTo != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("dueDate"), dueDateTo));
|
||||
}
|
||||
return cb.and(predicates.toArray(Predicate[]::new));
|
||||
};
|
||||
PageRequest pageable = PageRequest.of(
|
||||
safePage - 1,
|
||||
safeSize,
|
||||
Sort.by(Sort.Order.asc("dueDate"), Sort.Order.asc("id"))
|
||||
);
|
||||
Page<FollowUpTask> result = followUpTaskMapper.findAll(spec, pageable);
|
||||
List<TaskView> views = views(result.getContent(), currentUserId);
|
||||
return new TaskPage(
|
||||
result.getTotalElements(),
|
||||
safePage,
|
||||
safeSize,
|
||||
safePage < result.getTotalPages(),
|
||||
views
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView start(Long storeId, String taskId, Long actorUserId, String actorRole) {
|
||||
FollowUpTask task = taskForUpdate(storeId, taskId);
|
||||
if (FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) {
|
||||
if (Objects.equals(task.getAssignedUserId(), actorUserId)) {
|
||||
return view(task, actorUserId);
|
||||
}
|
||||
throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取");
|
||||
}
|
||||
if (!FollowUpTask.STATUS_PENDING.equals(task.getStatus())) {
|
||||
throw stateConflict();
|
||||
}
|
||||
task.setStatus(FollowUpTask.STATUS_IN_PROGRESS);
|
||||
task.setAssignedUserId(actorUserId);
|
||||
task.setUpdateTime(LocalDateTime.now());
|
||||
task = followUpTaskMapper.save(task);
|
||||
businessEventService.recordFollowUpStarted(task, actorUserId, actorRole);
|
||||
audit(task, actorUserId, actorRole, "follow_up_started", Map.of());
|
||||
return view(task, actorUserId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView reschedule(
|
||||
Long storeId,
|
||||
String taskId,
|
||||
String dueDateText,
|
||||
String reason,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
FollowUpTask task = taskForUpdate(storeId, taskId);
|
||||
requireInProgressOwner(task, actorUserId);
|
||||
String normalizedReason = normalizeRequired(reason);
|
||||
if (!RESCHEDULE_REASONS.contains(normalizedReason)) {
|
||||
throw new FlowException(400, "FOLLOW_UP_REASON_INVALID", "改期原因仅支持未接通或稍后联系");
|
||||
}
|
||||
LocalDate dueDate = parseDueDate(dueDateText);
|
||||
if (dueDate.isBefore(LocalDate.now())) {
|
||||
throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "下次回访日期不能早于今天");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus(FollowUpTask.STATUS_PENDING);
|
||||
task.setOutcome(null);
|
||||
task.setDueDate(dueDate);
|
||||
task.setAttemptCount(safeAttempts(task) + 1);
|
||||
task.setLastAttemptOutcome(normalizedReason);
|
||||
task.setLastContactedAt(now);
|
||||
task.setLastContactedByUserId(actorUserId);
|
||||
task.setAssignedUserId(null);
|
||||
task.setUpdateTime(now);
|
||||
task = followUpTaskMapper.save(task);
|
||||
markLeadStatus(task.getSourceLeadId(), "pending");
|
||||
businessEventService.recordFollowUpRescheduled(task, actorUserId, actorRole);
|
||||
audit(task, actorUserId, actorRole, "follow_up_rescheduled", Map.of(
|
||||
"reason", normalizedReason,
|
||||
"dueDate", dueDate.toString(),
|
||||
"attemptCount", task.getAttemptCount()
|
||||
));
|
||||
return view(task, actorUserId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TaskView close(
|
||||
Long storeId,
|
||||
String taskId,
|
||||
String outcome,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
FollowUpTask task = taskForUpdate(storeId, taskId);
|
||||
requireInProgressOwner(task, actorUserId);
|
||||
String normalizedOutcome = normalizeRequired(outcome);
|
||||
if (!CLOSE_OUTCOMES.contains(normalizedOutcome)) {
|
||||
throw new FlowException(400, "FOLLOW_UP_OUTCOME_INVALID", "关闭结果不受支持");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus(FollowUpTask.STATUS_COMPLETED);
|
||||
task.setOutcome(normalizedOutcome);
|
||||
task.setAttemptCount(safeAttempts(task) + 1);
|
||||
task.setLastAttemptOutcome(null);
|
||||
task.setLastContactedAt(now);
|
||||
task.setLastContactedByUserId(actorUserId);
|
||||
task.setClosedAt(now);
|
||||
task.setClosedByUserId(actorUserId);
|
||||
task.setUpdateTime(now);
|
||||
task = followUpTaskMapper.save(task);
|
||||
markLeadStatus(task.getSourceLeadId(), "canceled");
|
||||
businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole);
|
||||
audit(task, actorUserId, actorRole, "follow_up_closed", Map.of(
|
||||
"outcome", normalizedOutcome,
|
||||
"attemptCount", task.getAttemptCount()
|
||||
));
|
||||
return view(task, actorUserId);
|
||||
}
|
||||
|
||||
/** Appointment 保存前调用:校验任务开放、领取人和 canonical 客户归属。 */
|
||||
@Transactional
|
||||
public FollowUpTask prepareRebook(
|
||||
Long storeId,
|
||||
String taskId,
|
||||
Long canonicalStoreCustomerId,
|
||||
Long actorUserId
|
||||
) {
|
||||
FollowUpTask task = taskForUpdate(storeId, taskId);
|
||||
requireInProgressOwner(task, actorUserId);
|
||||
StoreCustomer taskCustomer = storeCustomerService.resolveCanonical(storeId, task.getStoreCustomerId());
|
||||
if (taskCustomer == null || !Objects.equals(taskCustomer.getId(), canonicalStoreCustomerId)) {
|
||||
throw new FlowException(409, "FOLLOW_UP_CUSTOMER_MISMATCH", "回访任务与本次预约宠主不一致");
|
||||
}
|
||||
task.setStoreCustomerId(taskCustomer.getId());
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Appointment 已真实保存后调用;同一事务内形成终态和 rebook_created 事实。 */
|
||||
@Transactional
|
||||
public void markRebooked(
|
||||
FollowUpTask task,
|
||||
Appointment appointment,
|
||||
Long actorUserId,
|
||||
String actorRole
|
||||
) {
|
||||
if (task == null || appointment == null || appointment.getId() == null) {
|
||||
throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约归因缺少真实预约");
|
||||
}
|
||||
requireInProgressOwner(task, actorUserId);
|
||||
if (!Objects.equals(task.getStoreId(), appointment.getStoreId())
|
||||
|| Boolean.TRUE.equals(appointment.getDeleted())) {
|
||||
throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约与回访任务归属不一致");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
task.setStatus(FollowUpTask.STATUS_COMPLETED);
|
||||
task.setOutcome(FollowUpTask.OUTCOME_REBOOKED);
|
||||
task.setAttemptCount(safeAttempts(task) + 1);
|
||||
task.setLastAttemptOutcome(null);
|
||||
task.setLastContactedAt(now);
|
||||
task.setLastContactedByUserId(actorUserId);
|
||||
task.setClosedAt(now);
|
||||
task.setClosedByUserId(actorUserId);
|
||||
task.setRebookedAppointmentId(appointment.getId());
|
||||
task.setUpdateTime(now);
|
||||
followUpTaskMapper.save(task);
|
||||
markLeadStatus(task.getSourceLeadId(), "sent");
|
||||
businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole);
|
||||
businessEventService.recordRebookCreated(task, appointment, actorUserId, actorRole);
|
||||
audit(task, actorUserId, actorRole, "follow_up_rebooked", Map.of(
|
||||
"appointmentId", appointment.getId(),
|
||||
"attemptCount", task.getAttemptCount()
|
||||
));
|
||||
}
|
||||
|
||||
/** 宠主退订时取消该留资下全部开放任务;不增加员工联系次数。 */
|
||||
@Transactional
|
||||
public void cancelForUnsubscribe(ReportLead lead) {
|
||||
if (lead == null || lead.getId() == null) return;
|
||||
List<FollowUpTask> open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate(lead.getId(), OPEN_STATUSES);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (FollowUpTask task : open) {
|
||||
task.setStatus(FollowUpTask.STATUS_CANCELED);
|
||||
task.setOutcome(FollowUpTask.OUTCOME_UNSUBSCRIBED);
|
||||
task.setAssignedUserId(null);
|
||||
task.setLastAttemptOutcome(null);
|
||||
task.setClosedAt(now);
|
||||
task.setClosedByUserId(null);
|
||||
task.setUpdateTime(now);
|
||||
followUpTaskMapper.save(task);
|
||||
businessEventService.recordFollowUpCanceled(task);
|
||||
}
|
||||
}
|
||||
|
||||
private List<TaskView> views(List<FollowUpTask> tasks, Long currentUserId) {
|
||||
if (tasks.isEmpty()) return List.of();
|
||||
Map<Long, ReportLead> leads = reportLeadMapper.findAllById(ids(tasks, FollowUpTask::getSourceLeadId)).stream()
|
||||
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
|
||||
Map<Long, StoreCustomer> customers = loadCustomers(tasks);
|
||||
Map<Long, User> users = userMapper.findAllById(ids(tasks, FollowUpTask::getAssignedUserId)).stream()
|
||||
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
|
||||
return tasks.stream()
|
||||
.map(task -> toView(
|
||||
task,
|
||||
leads.get(task.getSourceLeadId()),
|
||||
canonicalFromMap(task, customers),
|
||||
users.get(task.getAssignedUserId()),
|
||||
currentUserId
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private TaskView view(FollowUpTask task, Long currentUserId) {
|
||||
return views(List.of(task), currentUserId).get(0);
|
||||
}
|
||||
|
||||
private Map<Long, StoreCustomer> loadCustomers(List<FollowUpTask> tasks) {
|
||||
Map<Long, StoreCustomer> customers = storeCustomerMapper.findAllById(
|
||||
ids(tasks, FollowUpTask::getStoreCustomerId)
|
||||
).stream()
|
||||
.collect(Collectors.toMap(StoreCustomer::getId, Function.identity(), (a, b) -> a));
|
||||
Set<Long> canonicalIds = customers.values().stream()
|
||||
.map(StoreCustomer::getMergedIntoStoreCustomerId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
if (!canonicalIds.isEmpty()) {
|
||||
for (StoreCustomer canonical : storeCustomerMapper.findAllById(canonicalIds)) {
|
||||
customers.put(canonical.getId(), canonical);
|
||||
}
|
||||
}
|
||||
return customers;
|
||||
}
|
||||
|
||||
private static StoreCustomer canonicalFromMap(FollowUpTask task, Map<Long, StoreCustomer> customers) {
|
||||
StoreCustomer customer = customers.get(task.getStoreCustomerId());
|
||||
if (customer != null && Boolean.TRUE.equals(customer.getDeleted())
|
||||
&& customer.getMergedIntoStoreCustomerId() != null) {
|
||||
return customers.get(customer.getMergedIntoStoreCustomerId());
|
||||
}
|
||||
return customer;
|
||||
}
|
||||
|
||||
private static TaskView toView(
|
||||
FollowUpTask task,
|
||||
ReportLead lead,
|
||||
StoreCustomer customer,
|
||||
User assigned,
|
||||
Long currentUserId
|
||||
) {
|
||||
String phone = firstNonBlank(
|
||||
customer == null ? null : customer.getPhone(),
|
||||
lead == null ? null : lead.getPhone()
|
||||
);
|
||||
String displayName = firstNonBlank(customer == null ? null : customer.getDisplayName(), "客户");
|
||||
boolean open = OPEN_STATUSES.contains(task.getStatus());
|
||||
return new TaskView(
|
||||
task.getTaskId(),
|
||||
customer == null ? task.getStoreCustomerId() : customer.getId(),
|
||||
task.getSourceLeadId(),
|
||||
task.getSourceReportId(),
|
||||
displayName,
|
||||
open ? phone : null,
|
||||
AdminServiceCustomerService.maskPhone(phone),
|
||||
lead == null ? null : lead.getPetName(),
|
||||
lead == null ? null : lead.getServiceType(),
|
||||
task.getStatus(),
|
||||
task.getOutcome(),
|
||||
task.getDueDate(),
|
||||
safeAttempts(task),
|
||||
task.getLastAttemptOutcome(),
|
||||
task.getAssignedUserId(),
|
||||
assigned == null ? null : assigned.getName(),
|
||||
task.getAssignedUserId() != null && Objects.equals(task.getAssignedUserId(), currentUserId),
|
||||
task.getLastContactedAt(),
|
||||
task.getClosedAt(),
|
||||
task.getRebookedAppointmentId(),
|
||||
open && task.getDueDate() != null && task.getDueDate().isBefore(LocalDate.now()),
|
||||
task.getCreateTime(),
|
||||
task.getUpdateTime()
|
||||
);
|
||||
}
|
||||
|
||||
private FollowUpTask taskForUpdate(Long storeId, String taskId) {
|
||||
requireStore(storeId);
|
||||
String normalized = normalizeRequired(taskId);
|
||||
return followUpTaskMapper.findByTaskIdAndStoreIdForUpdate(normalized, storeId)
|
||||
.orElseThrow(() -> new FlowException(404, "FOLLOW_UP_TASK_NOT_FOUND", "回访任务不存在"));
|
||||
}
|
||||
|
||||
private static void requireInProgressOwner(FollowUpTask task, Long actorUserId) {
|
||||
if (!FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) {
|
||||
throw stateConflict();
|
||||
}
|
||||
if (task.getAssignedUserId() == null || !Objects.equals(task.getAssignedUserId(), actorUserId)) {
|
||||
throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取");
|
||||
}
|
||||
}
|
||||
|
||||
private void markLeadStatus(Long leadId, String status) {
|
||||
if (leadId == null) return;
|
||||
ReportLead lead = reportLeadMapper.findByIdForUpdate(leadId).orElse(null);
|
||||
if (lead == null || "unsubscribed".equals(lead.getRemindStatus())) return;
|
||||
lead.setRemindStatus(status);
|
||||
lead.setUpdateTime(LocalDateTime.now());
|
||||
reportLeadMapper.save(lead);
|
||||
}
|
||||
|
||||
private void audit(
|
||||
FollowUpTask task,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
String action,
|
||||
Map<String, Object> metadata
|
||||
) {
|
||||
auditLogService.record(
|
||||
task.getStoreId(), actorUserId, actorRole, action,
|
||||
"follow_up_task", task.getTaskId(), "success", metadata
|
||||
);
|
||||
}
|
||||
|
||||
private static String normalizeFilterStatus(String status) {
|
||||
if (status == null || status.isBlank() || "open".equalsIgnoreCase(status)) return "open";
|
||||
String value = status.trim().toLowerCase();
|
||||
if ("all".equals(value)) return null;
|
||||
if (!Set.of(
|
||||
FollowUpTask.STATUS_PENDING,
|
||||
FollowUpTask.STATUS_IN_PROGRESS,
|
||||
FollowUpTask.STATUS_COMPLETED,
|
||||
FollowUpTask.STATUS_CANCELED
|
||||
).contains(value)) {
|
||||
throw new FlowException(400, "FOLLOW_UP_STATUS_INVALID", "回访任务状态不受支持");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static LocalDate parseDueDate(String text) {
|
||||
try {
|
||||
return LocalDate.parse(normalizeRequired(text));
|
||||
} catch (Exception e) {
|
||||
throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "回访日期格式应为 yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeRequired(String value) {
|
||||
String normalized = value == null ? "" : value.trim().toLowerCase();
|
||||
if (normalized.isBlank()) {
|
||||
throw new FlowException(400, "FOLLOW_UP_PARAMETER_REQUIRED", "缺少回访任务参数");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static int safeAttempts(FollowUpTask task) {
|
||||
return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0);
|
||||
}
|
||||
|
||||
private static void requireStore(Long storeId) {
|
||||
if (storeId == null) {
|
||||
throw new FlowException(403, "FORBIDDEN", "仅门店员工可处理回访任务");
|
||||
}
|
||||
}
|
||||
|
||||
private static FlowException stateConflict() {
|
||||
return new FlowException(409, "FOLLOW_UP_STATE_CONFLICT", "回访任务状态已变化,请刷新后重试");
|
||||
}
|
||||
|
||||
private static <T> Set<Long> ids(Collection<T> rows, Function<T, Long> getter) {
|
||||
return rows.stream().map(getter).filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public record TaskPage(long total, int page, int pageSize, boolean hasMore, List<TaskView> list) {}
|
||||
|
||||
public record TaskView(
|
||||
String taskId,
|
||||
Long storeCustomerId,
|
||||
Long sourceLeadId,
|
||||
Long reportId,
|
||||
String displayName,
|
||||
String phone,
|
||||
String phoneMasked,
|
||||
String petName,
|
||||
String serviceType,
|
||||
String status,
|
||||
String outcome,
|
||||
LocalDate dueDate,
|
||||
int attemptCount,
|
||||
String lastAttemptOutcome,
|
||||
Long assignedUserId,
|
||||
String assignedUserName,
|
||||
boolean assignedToCurrentUser,
|
||||
LocalDateTime lastContactedAt,
|
||||
LocalDateTime closedAt,
|
||||
Long rebookedAppointmentId,
|
||||
boolean overdue,
|
||||
LocalDateTime createTime,
|
||||
LocalDateTime updateTime
|
||||
) {}
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.auth.SessionTokenService;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.StoreMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 通过微信核验手机号创建门店与老板账号,替代无验证的 legacy 注册接口。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MerchantOnboardingService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final StoreMapper storeMapper;
|
||||
private final SessionTokenService sessionTokenService;
|
||||
private final BusinessEventService businessEventService;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> registerVerifiedBoss(
|
||||
String storeName,
|
||||
String bossName,
|
||||
String verifiedPhone,
|
||||
String wechatOpenid,
|
||||
String wechatUnionid
|
||||
) {
|
||||
String storeNameNorm = required(storeName, 64, "请填写门店名称");
|
||||
String bossNameNorm = required(bossName, 64, "请填写老板姓名");
|
||||
if (verifiedPhone == null || !verifiedPhone.matches("^1[3-9]\\d{9}$")) {
|
||||
throw new FlowException(400, "INVALID_PHONE", "微信手机号格式不正确");
|
||||
}
|
||||
if (userMapper.findByPhoneAndDeletedFalse(verifiedPhone) != null) {
|
||||
throw new FlowException(409, "PHONE_ALREADY_REGISTERED", "该手机号已有账号,请直接登录");
|
||||
}
|
||||
assertWechatIdentityAvailable(wechatOpenid, wechatUnionid);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Store store = new Store();
|
||||
store.setName(storeNameNorm);
|
||||
store.setPhone(verifiedPhone);
|
||||
store.setOwnerId(0L);
|
||||
// legacy 兼容字段,不再作为员工注册凭证,也不返回客户端。
|
||||
store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase());
|
||||
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||
store.setBookingCapacity(1);
|
||||
store.setOnboardingStatus("in_progress");
|
||||
store.setCreateTime(now);
|
||||
store.setUpdateTime(now);
|
||||
store.setDeleted(false);
|
||||
store = storeMapper.save(store);
|
||||
|
||||
User boss = new User();
|
||||
boss.setUsername(verifiedPhone);
|
||||
boss.setName(bossNameNorm);
|
||||
boss.setPhone(verifiedPhone);
|
||||
boss.setPassword("wx_no_password");
|
||||
boss.setStoreId(store.getId());
|
||||
boss.setRole("boss");
|
||||
boss.setWechatOpenid(blankToNull(wechatOpenid));
|
||||
boss.setWechatUnionid(blankToNull(wechatUnionid));
|
||||
boss.setCreateTime(now);
|
||||
boss.setUpdateTime(now);
|
||||
boss.setDeleted(false);
|
||||
boss = userMapper.save(boss);
|
||||
|
||||
store.setOwnerId(boss.getId());
|
||||
store = storeMapper.save(store);
|
||||
|
||||
businessEventService.recordStoreRegistered(store, boss.getId());
|
||||
auditLogService.record(
|
||||
store.getId(), boss.getId(), "boss", "store_registered", "store", store.getId().toString(),
|
||||
"success", Map.of("verificationMethod", "wechat_phone", "onboardingStatus", "in_progress")
|
||||
);
|
||||
|
||||
Map<String, Object> userView = new LinkedHashMap<>();
|
||||
userView.put("id", boss.getId());
|
||||
userView.put("name", boss.getName());
|
||||
userView.put("phone", boss.getPhone());
|
||||
userView.put("storeId", boss.getStoreId());
|
||||
userView.put("role", boss.getRole());
|
||||
|
||||
Map<String, Object> storeView = new LinkedHashMap<>();
|
||||
storeView.put("id", store.getId());
|
||||
storeView.put("name", store.getName());
|
||||
storeView.put("phone", store.getPhone());
|
||||
storeView.put("onboardingStatus", store.getOnboardingStatus());
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("user", userView);
|
||||
data.put("store", storeView);
|
||||
data.put("sessionToken", sessionTokenService.issue(boss.getId(), store.getId(), "boss"));
|
||||
return data;
|
||||
}
|
||||
|
||||
private void assertWechatIdentityAvailable(String openid, String unionid) {
|
||||
if (openid != null && !openid.isBlank() && userMapper.findByWechatOpenidAndDeletedFalse(openid).isPresent()) {
|
||||
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信");
|
||||
}
|
||||
if (unionid != null && !unionid.isBlank() && userMapper.findByWechatUnionidAndDeletedFalse(unionid).isPresent()) {
|
||||
throw new FlowException(409, "WECHAT_ACCOUNT_CONFLICT", "该微信已绑定其他账号,请直接登录或更换微信");
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, int max, String message) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.isBlank()) {
|
||||
throw new FlowException(400, "INVALID_ONBOARDING_INPUT", message);
|
||||
}
|
||||
return normalized.length() <= max ? normalized : normalized.substring(0, max);
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.Pet;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.PetMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PetService {
|
||||
private final PetMapper petMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final StoreService storeService;
|
||||
|
||||
/** 客户:仅自己的宠物 */
|
||||
public List<Pet> listByOwner(Long ownerUserId) {
|
||||
if (ownerUserId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(ownerUserId);
|
||||
}
|
||||
|
||||
/** 商家/员工:本店预约中关联过的宠物(预约带 pet_id) */
|
||||
public List<Pet> listByStoreServed(Long storeId) {
|
||||
if (storeId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return petMapper.findDistinctPetsServedAtStore(storeId);
|
||||
}
|
||||
|
||||
public Map<String, Object> create(Pet pet) {
|
||||
if (pet.getName() == null || pet.getName().isBlank()) {
|
||||
return Map.of("code", 400, "message", "请填写宠物名字");
|
||||
}
|
||||
if (pet.getPetType() == null || pet.getPetType().isBlank()) {
|
||||
return Map.of("code", 400, "message", "请选择宠物类型");
|
||||
}
|
||||
if (pet.getOwnerUserId() == null) {
|
||||
return Map.of("code", 400, "message", "缺少所属用户");
|
||||
}
|
||||
pet.setCreateTime(LocalDateTime.now());
|
||||
pet.setUpdateTime(LocalDateTime.now());
|
||||
pet.setDeleted(false);
|
||||
Pet saved = petMapper.save(pet);
|
||||
return Map.of("code", 200, "message", "保存成功", "data", saved);
|
||||
}
|
||||
|
||||
public Map<String, Object> update(Long operatorUserId, String role, Pet input) {
|
||||
if (input.getId() == null) {
|
||||
return Map.of("code", 400, "message", "缺少宠物ID");
|
||||
}
|
||||
Optional<Pet> opt = petMapper.findByIdAndDeletedFalse(input.getId());
|
||||
if (opt.isEmpty()) {
|
||||
return Map.of("code", 404, "message", "宠物不存在");
|
||||
}
|
||||
Pet pet = opt.get();
|
||||
if ("customer".equals(role)) {
|
||||
if (!pet.getOwnerUserId().equals(operatorUserId)) {
|
||||
return Map.of("code", 403, "message", "无权修改该宠物");
|
||||
}
|
||||
} else if ("boss".equals(role) || "staff".equals(role)) {
|
||||
var uOpt = userMapper.findByIdAndDeletedFalse(operatorUserId);
|
||||
if (uOpt.isEmpty()) {
|
||||
return Map.of("code", 403, "message", "无权修改该宠物");
|
||||
}
|
||||
Long storeId = uOpt.get().getStoreId();
|
||||
if (storeId == null) {
|
||||
return Map.of("code", 403, "message", "无权修改该宠物");
|
||||
}
|
||||
if (!appointmentMapper.existsByPetIdAndStoreIdAndDeletedFalse(pet.getId(), storeId)) {
|
||||
return Map.of("code", 403, "message", "仅可修改本店预约关联过的宠物");
|
||||
}
|
||||
} else {
|
||||
return Map.of("code", 403, "message", "无权修改该宠物");
|
||||
}
|
||||
if (input.getName() != null && !input.getName().isBlank()) {
|
||||
pet.setName(input.getName());
|
||||
}
|
||||
if (input.getPetType() != null && !input.getPetType().isBlank()) {
|
||||
pet.setPetType(input.getPetType());
|
||||
}
|
||||
if (input.getBreed() != null) {
|
||||
pet.setBreed(input.getBreed());
|
||||
}
|
||||
if (input.getAvatar() != null) {
|
||||
pet.setAvatar(input.getAvatar());
|
||||
}
|
||||
if (input.getRemark() != null) {
|
||||
pet.setRemark(input.getRemark());
|
||||
}
|
||||
pet.setUpdateTime(LocalDateTime.now());
|
||||
petMapper.save(pet);
|
||||
return Map.of("code", 200, "message", "更新成功", "data", pet);
|
||||
}
|
||||
|
||||
public Map<String, Object> delete(Long petId, Long operatorUserId, String role) {
|
||||
Optional<Pet> opt = petMapper.findByIdAndDeletedFalse(petId);
|
||||
if (opt.isEmpty()) {
|
||||
return Map.of("code", 404, "message", "宠物不存在");
|
||||
}
|
||||
Pet pet = opt.get();
|
||||
if (!"customer".equals(role) || !pet.getOwnerUserId().equals(operatorUserId)) {
|
||||
return Map.of("code", 403, "message", "无权删除");
|
||||
}
|
||||
pet.setDeleted(true);
|
||||
pet.setUpdateTime(LocalDateTime.now());
|
||||
petMapper.save(pet);
|
||||
return Map.of("code", 200, "message", "已删除");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按宠物维度:预约与服务报告时间线(复购与情感连接)。
|
||||
* 仅宠主本人或本店曾服务过该宠物的老板/员工可查看。
|
||||
*/
|
||||
public Map<String, Object> getServiceHistory(Long petId, Long operatorUserId, String role) {
|
||||
if (petId == null || operatorUserId == null || role == null || role.isBlank()) {
|
||||
return Map.of("code", 400, "message", "参数不完整");
|
||||
}
|
||||
Optional<Pet> pOpt = petMapper.findByIdAndDeletedFalse(petId);
|
||||
if (pOpt.isEmpty()) {
|
||||
return Map.of("code", 404, "message", "宠物不存在");
|
||||
}
|
||||
Pet pet = pOpt.get();
|
||||
if (!canViewPetHistory(pet, operatorUserId, role)) {
|
||||
return Map.of("code", 403, "message", "无权查看该宠物的服务记录");
|
||||
}
|
||||
|
||||
List<Appointment> appts = appointmentMapper.findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(petId);
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
int doneCount = 0;
|
||||
int cancelCount = 0;
|
||||
for (Appointment a : appts) {
|
||||
if ("done".equals(a.getStatus())) {
|
||||
doneCount++;
|
||||
} else if ("cancel".equals(a.getStatus())) {
|
||||
cancelCount++;
|
||||
}
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("appointmentId", a.getId());
|
||||
row.put("appointmentTime", a.getAppointmentTime());
|
||||
row.put("serviceType", a.getServiceType());
|
||||
row.put("status", a.getStatus());
|
||||
row.put("storeId", a.getStoreId());
|
||||
if (a.getStoreId() != null) {
|
||||
Store st = storeService.findById(a.getStoreId());
|
||||
row.put("storeName", st != null ? st.getName() : null);
|
||||
} else {
|
||||
row.put("storeName", null);
|
||||
}
|
||||
row.put("remark", a.getRemark());
|
||||
Optional<Report> rOpt = reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(a.getId());
|
||||
if (rOpt.isPresent()) {
|
||||
Report r = rOpt.get();
|
||||
row.put("reportId", r.getId());
|
||||
row.put("reportToken", r.getReportToken());
|
||||
} else {
|
||||
row.put("reportId", null);
|
||||
row.put("reportToken", null);
|
||||
}
|
||||
items.add(row);
|
||||
}
|
||||
|
||||
Map<String, Object> summary = new HashMap<>();
|
||||
summary.put("totalAppointments", appts.size());
|
||||
summary.put("completedCount", doneCount);
|
||||
summary.put("cancelledCount", cancelCount);
|
||||
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("pet", pet);
|
||||
data.put("summary", summary);
|
||||
data.put("items", items);
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
private boolean canViewPetHistory(Pet pet, Long operatorUserId, String role) {
|
||||
if ("customer".equals(role)) {
|
||||
return pet.getOwnerUserId() != null && pet.getOwnerUserId().equals(operatorUserId);
|
||||
}
|
||||
if ("boss".equals(role) || "staff".equals(role)) {
|
||||
var uOpt = userMapper.findByIdAndDeletedFalse(operatorUserId);
|
||||
if (uOpt.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Long storeId = uOpt.get().getStoreId();
|
||||
if (storeId == null) {
|
||||
return false;
|
||||
}
|
||||
return appointmentMapper.existsByPetIdAndStoreIdAndDeletedFalse(pet.getId(), storeId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,953 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.HighlightFailReason;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportImage;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.ReportImageMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 将服务报告素材拼为竖屏短片,时长按素材量自动计算(受 {@code max-duration-sec} 上限约束)。
|
||||
* <p><b>preset</b> 预设顺序:</p>
|
||||
* <ol>
|
||||
* <li>封面:服务前第一张 + 服务后第一张 横向对比图(均为照片且文件存在时);</li>
|
||||
* <li>服务前其余照片(上传顺序);</li>
|
||||
* <li>过程中照片与视频(上传顺序);</li>
|
||||
* <li>服务后除第一张外的其余照片;</li>
|
||||
* <li>收尾:服务后第一张再次作为最后一镜。</li>
|
||||
* </ol>
|
||||
* <p><b>interleave</b>:在「服务前→过程→服务后」排序后,将全部照片与全部小视频均匀穿插(组间插入)。</p>
|
||||
* <p>图片段 Ken Burns;视频段保留原音(无音轨补静音)。可选 BGM。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ReportHighlightVideoService {
|
||||
|
||||
private static final int OUTPUT_FPS = 30;
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportImageMapper reportImageMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final Executor highlightExecutor;
|
||||
|
||||
@Value("${upload.path:uploads/}")
|
||||
private String uploadPath;
|
||||
|
||||
@Value("${app.base-url:http://localhost:8080}")
|
||||
private String baseUrl;
|
||||
|
||||
@Value("${app.highlight-video.ffmpeg-binary:ffmpeg}")
|
||||
private String ffmpegBinary;
|
||||
|
||||
@Value("${app.highlight-video.ffprobe-binary:ffprobe}")
|
||||
private String ffprobeBinary;
|
||||
|
||||
/** 在总时长内,单张图片的「权重」秒数(会与视频一起按比例缩放) */
|
||||
@Value("${app.highlight-video.image-segment-sec:2.0}")
|
||||
private double imageSegmentSec;
|
||||
|
||||
/** 单段小视频最多截取的时长(秒) */
|
||||
@Value("${app.highlight-video.video-cap-sec:8.0}")
|
||||
private double videoCapSec;
|
||||
|
||||
/** 成片最大总时长(秒);素材自然时长超过时整体等比压缩 */
|
||||
@Value("${app.highlight-video.max-duration-sec:300}")
|
||||
private double maxDurationSec;
|
||||
|
||||
/** 可选:本地绝对路径,商用授权 BGM(mp3/aac/m4a 等 ffmpeg 可读格式) */
|
||||
@Value("${app.highlight-video.bgm-path:}")
|
||||
private String bgmPath;
|
||||
|
||||
@Value("${app.highlight-video.ken-burns-enabled:true}")
|
||||
private boolean kenBurnsEnabled;
|
||||
|
||||
/** 中文字体文件路径(drawtext 字幕用;留空自动探测系统字体,找不到则跳过字幕) */
|
||||
@Value("${app.highlight-video.font-path:}")
|
||||
private String fontPath;
|
||||
|
||||
/** 懒加载的字体路径,null 表示无可用字体(跳过 drawtext) */
|
||||
private volatile String resolvedFontFile;
|
||||
|
||||
private final ConcurrentHashMap<Long, Object> reportLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public ReportHighlightVideoService(
|
||||
ReportMapper reportMapper,
|
||||
ReportImageMapper reportImageMapper,
|
||||
UserMapper userMapper,
|
||||
AppointmentMapper appointmentMapper,
|
||||
@Qualifier("highlightTaskExecutor") Executor highlightExecutor) {
|
||||
this.reportMapper = reportMapper;
|
||||
this.reportImageMapper = reportImageMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.appointmentMapper = appointmentMapper;
|
||||
this.highlightExecutor = highlightExecutor;
|
||||
}
|
||||
|
||||
private Object lockFor(Long reportId) {
|
||||
return reportLocks.computeIfAbsent(reportId, k -> new Object());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param composeModeRaw {@code preset} 预设顺序;{@code interleave} 穿插(亦接受 ordered / spread)
|
||||
*/
|
||||
public Map<String, Object> requestGenerate(Long reportId, Long operatorUserId, String role, String composeModeRaw) {
|
||||
if (reportId == null || operatorUserId == null || role == null || role.isBlank()) {
|
||||
return Map.of("code", 400, "message", "参数不完整");
|
||||
}
|
||||
String mode = normalizeComposeMode(composeModeRaw);
|
||||
if (mode == null) {
|
||||
return Map.of("code", 400, "message", "composeMode 仅支持 preset(预设顺序)或 interleave(穿插)");
|
||||
}
|
||||
|
||||
synchronized (lockFor(reportId)) {
|
||||
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
|
||||
if (opt.isEmpty()) {
|
||||
return Map.of("code", 404, "message", "报告不存在");
|
||||
}
|
||||
Report report = opt.get();
|
||||
if (!canOperate(operatorUserId, role, report)) {
|
||||
return Map.of("code", 403, "message", "无权生成该报告的短片");
|
||||
}
|
||||
List<ReportImage> imgs = reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId);
|
||||
if (!hasRenderableMedia(imgs)) {
|
||||
return Map.of("code", 400, "message", "请先上传服务前、过程中或服务后的照片或视频");
|
||||
}
|
||||
if ("processing".equals(report.getHighlightVideoStatus())) {
|
||||
return Map.of("code", 409, "message", "短片正在生成中,请稍候刷新");
|
||||
}
|
||||
if (!ffmpegAvailable()) {
|
||||
return Map.of("code", 503, "message", "服务器未安装或未配置 ffmpeg,无法生成短片");
|
||||
}
|
||||
|
||||
report.setHighlightVideoStatus("processing");
|
||||
report.setHighlightVideoError(null);
|
||||
report.setHighlightFailReason(null);
|
||||
report.setHighlightShareCoverUrl(null);
|
||||
report.setHighlightComposeMode(mode);
|
||||
report.setUpdateTime(LocalDateTime.now());
|
||||
reportMapper.save(report);
|
||||
|
||||
highlightExecutor.execute(() -> composeSafely(reportId, mode));
|
||||
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing", "composeMode", mode));
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeComposeMode(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return "preset";
|
||||
}
|
||||
String s = raw.trim().toLowerCase(Locale.ROOT);
|
||||
if ("preset".equals(s) || "ordered".equals(s)) {
|
||||
return "preset";
|
||||
}
|
||||
if ("interleave".equals(s) || "spread".equals(s)) {
|
||||
return "interleave";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void composeSafely(Long reportId, String composeMode) {
|
||||
try {
|
||||
composeInternal(reportId, composeMode);
|
||||
} catch (Exception e) {
|
||||
markFailed(reportId, classifyFailure(e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将异常映射为宠主可见的失败类型(不向用户暴露英文、退出码与堆栈)。
|
||||
*/
|
||||
private static HighlightFailReason classifyFailure(Throwable e) {
|
||||
Throwable cur = e;
|
||||
int depth = 0;
|
||||
while (cur != null && depth++ < 8) {
|
||||
if (cur instanceof java.net.SocketTimeoutException
|
||||
|| cur instanceof java.net.UnknownHostException
|
||||
|| cur instanceof java.net.ConnectException) {
|
||||
return HighlightFailReason.NETWORK;
|
||||
}
|
||||
cur = cur.getCause();
|
||||
}
|
||||
String m = e.getMessage() != null ? e.getMessage() : "";
|
||||
if (m.contains("素材") || m.contains("不存在") || m.contains("无法读取")) {
|
||||
return HighlightFailReason.MATERIAL;
|
||||
}
|
||||
if (m.contains("图片转视频") || m.contains("视频片段重编码")) {
|
||||
return HighlightFailReason.MATERIAL;
|
||||
}
|
||||
if (m.contains("ffmpeg") || m.contains("拼接失败") || m.contains("退出码")) {
|
||||
return HighlightFailReason.SERVICE;
|
||||
}
|
||||
if (m.contains("分段时长")) {
|
||||
return HighlightFailReason.SERVICE;
|
||||
}
|
||||
if (e instanceof java.io.IOException) {
|
||||
return HighlightFailReason.SERVICE;
|
||||
}
|
||||
return HighlightFailReason.UNKNOWN;
|
||||
}
|
||||
|
||||
private void markFailed(Long reportId, HighlightFailReason reason) {
|
||||
synchronized (lockFor(reportId)) {
|
||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||
r.setHighlightVideoStatus("failed");
|
||||
r.setHighlightFailReason(reason.getCode());
|
||||
String msg = reason.getUserMessage();
|
||||
r.setHighlightVideoError(msg.length() > 500 ? msg.substring(0, 500) : msg);
|
||||
r.setUpdateTime(LocalDateTime.now());
|
||||
reportMapper.save(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void composeInternal(Long reportId, String composeMode) throws Exception {
|
||||
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
|
||||
if (opt.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ReportImage> ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
|
||||
List<ReportImage> before = filterByPhotoType(ordered, "before");
|
||||
List<ReportImage> during = filterByPhotoType(ordered, "during");
|
||||
List<ReportImage> after = filterByPhotoType(ordered, "after");
|
||||
|
||||
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
|
||||
try {
|
||||
List<Path> sources = new ArrayList<>();
|
||||
List<Boolean> isVideo = new ArrayList<>();
|
||||
List<String> labels = new ArrayList<>();
|
||||
|
||||
if ("interleave".equals(composeMode)) {
|
||||
List<ReportImage> photos = new ArrayList<>();
|
||||
List<ReportImage> videos = new ArrayList<>();
|
||||
for (ReportImage img : ordered) {
|
||||
if (isVideoMedia(img)) {
|
||||
videos.add(img);
|
||||
} else {
|
||||
photos.add(img);
|
||||
}
|
||||
}
|
||||
for (ReportImage img : interleaveSpread(photos, videos)) {
|
||||
appendResolvedMedia(sources, isVideo, labels, img, null);
|
||||
}
|
||||
} else {
|
||||
// preset 模式:对比段改为全屏硬切(前图 → 后图),比左右拼接更有冲击力
|
||||
if (!before.isEmpty()) {
|
||||
appendResolvedMedia(sources, isVideo, labels, before.get(0), "服务前");
|
||||
}
|
||||
if (!after.isEmpty()) {
|
||||
appendResolvedMedia(sources, isVideo, labels, after.get(0), "服务后");
|
||||
}
|
||||
for (int i = 1; i < before.size(); i++) {
|
||||
appendResolvedMedia(sources, isVideo, labels, before.get(i), null);
|
||||
}
|
||||
for (ReportImage d : during) {
|
||||
appendResolvedMedia(sources, isVideo, labels, d, null);
|
||||
}
|
||||
for (int i = 1; i < after.size(); i++) {
|
||||
appendResolvedMedia(sources, isVideo, labels, after.get(i), null);
|
||||
}
|
||||
// 收尾:after[0] 再次展示(如果 after 只有 1 张则跳过,避免重复)
|
||||
if (!after.isEmpty() && after.size() > 1) {
|
||||
appendResolvedMedia(sources, isVideo, labels, after.get(0), null);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.isEmpty()) {
|
||||
markFailed(reportId, HighlightFailReason.MATERIAL);
|
||||
return;
|
||||
}
|
||||
|
||||
int n = sources.size();
|
||||
double[] segmentSecs = computeSegmentDurationsNatural(sources, isVideo);
|
||||
double totalDurationSec = 0;
|
||||
for (double s : segmentSecs) {
|
||||
totalDurationSec += s;
|
||||
}
|
||||
if (totalDurationSec < 0.5) {
|
||||
markFailed(reportId, HighlightFailReason.SERVICE);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Path> segments = new ArrayList<>();
|
||||
int imageVariant = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
Path seg = workDir.resolve("seg_" + i + ".mp4");
|
||||
String label = labels.isEmpty() ? null : labels.get(i);
|
||||
if (Boolean.TRUE.equals(isVideo.get(i))) {
|
||||
encodeVideoSegment(sources.get(i), seg, segmentSecs[i], label);
|
||||
} else {
|
||||
encodeImageSegment(sources.get(i), seg, segmentSecs[i], imageVariant, label);
|
||||
imageVariant++;
|
||||
}
|
||||
segments.add(seg);
|
||||
}
|
||||
|
||||
Path listFile = workDir.resolve("concat.txt");
|
||||
try (BufferedWriter w = Files.newBufferedWriter(listFile, StandardCharsets.UTF_8)) {
|
||||
for (Path seg : segments) {
|
||||
String abs = seg.toAbsolutePath().toString().replace('\\', '/');
|
||||
abs = abs.replace("'", "'\\''");
|
||||
w.write("file '");
|
||||
w.write(abs);
|
||||
w.write("'\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 始终重编码拼接(段内已含 fadein/fadeout,需统一编码参数输出)
|
||||
Path merged = workDir.resolve("merged.mp4");
|
||||
String tStr = String.format(Locale.ROOT, "%.3f", totalDurationSec);
|
||||
int c = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
|
||||
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-profile:v", "high",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-t", tStr,
|
||||
merged.toString()
|
||||
));
|
||||
if (c != 0) {
|
||||
throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c + ")");
|
||||
}
|
||||
Path sourceToSave = merged;
|
||||
|
||||
Path afterBgm = applyOptionalBgm(sourceToSave, workDir);
|
||||
|
||||
String datePath = LocalDate.now().toString().replace("-", "/");
|
||||
String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||
Path outDir = Paths.get(base + datePath);
|
||||
Files.createDirectories(outDir);
|
||||
String filename = "highlight_" + reportId + "_" + UUID.randomUUID().toString().replace("-", "") + ".mp4";
|
||||
Path finalPath = outDir.resolve(filename);
|
||||
Files.copy(afterBgm, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String videoRel = "/api/upload/image/" + datePath + "/" + filename;
|
||||
String coverFilename = filename.endsWith(".mp4")
|
||||
? filename.substring(0, filename.length() - 4) + "_cover.jpg"
|
||||
: filename + "_cover.jpg";
|
||||
Path coverPath = outDir.resolve(coverFilename);
|
||||
String coverRel = extractVideoCoverFrame(finalPath, coverPath)
|
||||
? "/api/upload/image/" + datePath + "/" + coverFilename
|
||||
: null;
|
||||
|
||||
int durationRoundedSec = (int) Math.ceil(totalDurationSec);
|
||||
synchronized (lockFor(reportId)) {
|
||||
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
|
||||
r.setHighlightVideoUrl(videoRel);
|
||||
r.setHighlightShareCoverUrl(coverRel);
|
||||
r.setHighlightVideoStatus("done");
|
||||
r.setHighlightVideoError(null);
|
||||
r.setHighlightFailReason(null);
|
||||
r.setHighlightDurationSec(durationRoundedSec);
|
||||
r.setUpdateTime(LocalDateTime.now());
|
||||
reportMapper.save(r);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
Files.walk(workDir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
});
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将照片均匀拆成 nv+1 组,组间插入小视频(全局「穿插」模式使用)。
|
||||
*/
|
||||
private static List<ReportImage> interleaveSpread(List<ReportImage> photos, List<ReportImage> videos) {
|
||||
int np = photos.size();
|
||||
int nv = videos.size();
|
||||
if (nv == 0) {
|
||||
return new ArrayList<>(photos);
|
||||
}
|
||||
int groups = nv + 1;
|
||||
int[] sizes = splitEven(np, groups);
|
||||
List<ReportImage> out = new ArrayList<>(np + nv);
|
||||
int pi = 0;
|
||||
for (int g = 0; g < groups; g++) {
|
||||
for (int j = 0; j < sizes[g]; j++) {
|
||||
out.add(photos.get(pi++));
|
||||
}
|
||||
if (g < nv) {
|
||||
out.add(videos.get(g));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int[] splitEven(int total, int parts) {
|
||||
if (parts <= 0) {
|
||||
return new int[0];
|
||||
}
|
||||
int[] a = new int[parts];
|
||||
if (total == 0) {
|
||||
return a;
|
||||
}
|
||||
int base = total / parts;
|
||||
int rem = total % parts;
|
||||
for (int i = 0; i < parts; i++) {
|
||||
a[i] = base + (i < rem ? 1 : 0);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static List<ReportImage> filterByPhotoType(List<ReportImage> imgs, String photoType) {
|
||||
List<ReportImage> out = new ArrayList<>();
|
||||
if (imgs == null) {
|
||||
return out;
|
||||
}
|
||||
for (ReportImage img : imgs) {
|
||||
if (photoType.equals(img.getPhotoType())) {
|
||||
out.add(img);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void appendResolvedMedia(List<Path> sources, List<Boolean> isVideo, List<String> labels, ReportImage img, String label) {
|
||||
Path p = resolveUploadFile(img.getPhotoUrl());
|
||||
if (p == null || !Files.isRegularFile(p)) {
|
||||
return;
|
||||
}
|
||||
sources.add(p);
|
||||
isVideo.add(isVideoMedia(img));
|
||||
labels.add(label != null ? label : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务前第一张 | 服务后第一张 左右拼接为 1080×1920 竖屏对比图(左前右后)。
|
||||
*/
|
||||
/**
|
||||
* 从成片 MP4 截取首帧为 JPEG,供分享卡片等使用。
|
||||
*
|
||||
* @return 是否写出有效文件
|
||||
*/
|
||||
private boolean extractVideoCoverFrame(Path mp4, Path outJpg) {
|
||||
try {
|
||||
int code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-i", mp4.toString(),
|
||||
"-map", "0:v:0",
|
||||
"-frames:v", "1",
|
||||
"-q:v", "2",
|
||||
outJpg.toString()));
|
||||
return code == 0 && Files.isRegularFile(outJpg);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测可用的中文字体路径(drawtext 字幕用)。
|
||||
* 优先用配置的 fontPath;否则探测常见系统路径;找不到返回 null(跳过字幕)。
|
||||
*/
|
||||
private String resolveFontFile() {
|
||||
if (resolvedFontFile != null) {
|
||||
return resolvedFontFile.isEmpty() ? null : resolvedFontFile;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (resolvedFontFile != null) {
|
||||
return resolvedFontFile.isEmpty() ? null : resolvedFontFile;
|
||||
}
|
||||
if (fontPath != null && !fontPath.isBlank()) {
|
||||
Path p = Paths.get(fontPath.trim());
|
||||
if (Files.isRegularFile(p)) {
|
||||
resolvedFontFile = p.toString();
|
||||
return resolvedFontFile;
|
||||
}
|
||||
}
|
||||
String[] candidates = {
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||
"/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc",
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/simhei.ttf"
|
||||
};
|
||||
for (String c : candidates) {
|
||||
if (Files.isRegularFile(Paths.get(c))) {
|
||||
resolvedFontFile = c;
|
||||
return resolvedFontFile;
|
||||
}
|
||||
}
|
||||
resolvedFontFile = "";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 6 种 Ken Burns 动效,按段索引轮换,避免每张图动效一样。
|
||||
* 0=zoom-in center, 1=zoom-out center, 2=pan-left, 3=pan-right, 4=pan-up, 5=pan-down
|
||||
*/
|
||||
private String kenBurnsFilter(int variant, int frames) {
|
||||
int v = ((variant % 6) + 6) % 6;
|
||||
String f = String.valueOf(frames);
|
||||
switch (v) {
|
||||
case 1:
|
||||
return "z='if(eq(on,1),1.22,max(zoom-0.0015,1.0))':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
case 2:
|
||||
return "z='1.15':x='if(eq(on,1),0,(iw-iw/zoom)*on/" + f + ")':y='ih/2-(ih/zoom/2)':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
case 3:
|
||||
return "z='1.15':x='if(eq(on,1),(iw-iw/zoom),(iw-iw/zoom)*(1-on/" + f + "))':y='ih/2-(ih/zoom/2)':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
case 4:
|
||||
return "z='1.15':x='iw/2-(iw/zoom/2)':y='if(eq(on,1),0,(ih-ih/zoom)*on/" + f + ")':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
case 5:
|
||||
return "z='1.15':x='iw/2-(iw/zoom/2)':y='if(eq(on,1),(ih-ih/zoom),(ih-ih/zoom)*(1-on/" + f + "))':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
default:
|
||||
return "z='if(eq(on,1),1,min(zoom+0.0015,1.22))':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d="
|
||||
+ f + ":s=1080x1920:fps=" + OUTPUT_FPS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 drawtext 滤镜(底部居中字幕标签,带半透明底框)。无字体时返回空串。
|
||||
*/
|
||||
private String drawtextFilter(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String font = resolveFontFile();
|
||||
if (font == null) {
|
||||
return "";
|
||||
}
|
||||
String escaped = text
|
||||
.replace("\\", "\\\\")
|
||||
.replace(":", "\\:")
|
||||
.replace("'", "\\'");
|
||||
return ",drawtext=fontfile='" + font + "':text='" + escaped
|
||||
+ "':fontcolor=white:fontsize=56:x=(w-text_w)/2:y=h-text_h-100"
|
||||
+ ":box=1:boxcolor=black@0.55:boxborderw=16";
|
||||
}
|
||||
|
||||
/**
|
||||
* 段内 fadein/fadeout(0.15s),让段间衔接更柔和。
|
||||
*/
|
||||
private static String fadeFilter(double seconds) {
|
||||
double fadeDur = Math.min(0.15, seconds / 3);
|
||||
double outStart = seconds - fadeDur;
|
||||
return String.format(Locale.ROOT,
|
||||
"fade=t=in:st=0:d=%.3f,fade=t=out:st=%.3f:d=%.3f",
|
||||
fadeDur, outStart, fadeDur);
|
||||
}
|
||||
|
||||
private boolean buildComparisonCover(Path beforeFile, Path afterFile, Path outJpg) throws Exception {
|
||||
String fc = "[0:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[va];"
|
||||
+ "[1:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[vb];"
|
||||
+ "[va][vb]hstack=inputs=2,format=yuv420p[v]";
|
||||
int code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-i", beforeFile.toString(),
|
||||
"-i", afterFile.toString(),
|
||||
"-filter_complex", fc,
|
||||
"-map", "[v]", "-frames:v", "1",
|
||||
"-q:v", "2",
|
||||
outJpg.toString()
|
||||
));
|
||||
return code == 0 && Files.isRegularFile(outJpg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按图片固定秒数 + 视频实长(封顶)得到自然总长;超过 {@link #maxDurationSec} 时整体等比压缩。
|
||||
*/
|
||||
private double[] computeSegmentDurationsNatural(List<Path> sources, List<Boolean> isVideo) {
|
||||
int n = sources.size();
|
||||
double[] raw = new double[n];
|
||||
double sum = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (Boolean.TRUE.equals(isVideo.get(i))) {
|
||||
double probed = probeDurationSeconds(sources.get(i));
|
||||
if (probed <= 0) {
|
||||
probed = 10.0;
|
||||
}
|
||||
raw[i] = Math.min(probed, videoCapSec);
|
||||
} else {
|
||||
raw[i] = imageSegmentSec;
|
||||
}
|
||||
sum += raw[i];
|
||||
}
|
||||
if (sum <= 0) {
|
||||
for (int i = 0; i < n; i++) {
|
||||
raw[i] = 1.0;
|
||||
}
|
||||
sum = n;
|
||||
}
|
||||
double targetTotal = Math.min(sum, maxDurationSec);
|
||||
double scale = targetTotal / sum;
|
||||
double[] out = new double[n];
|
||||
double acc = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
out[i] = raw[i] * scale;
|
||||
if (out[i] < 0.35) {
|
||||
out[i] = 0.35;
|
||||
}
|
||||
acc += out[i];
|
||||
}
|
||||
if (n > 0) {
|
||||
out[n - 1] = Math.max(0.35, out[n - 1] + (targetTotal - acc));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Path applyOptionalBgm(Path mergedMp4, Path workDir) throws Exception {
|
||||
if (bgmPath == null || bgmPath.isBlank()) {
|
||||
return mergedMp4;
|
||||
}
|
||||
Path bgm = Paths.get(bgmPath.trim());
|
||||
if (!Files.isRegularFile(bgm)) {
|
||||
return mergedMp4;
|
||||
}
|
||||
Path out = workDir.resolve("with_bgm.mp4");
|
||||
// BGM 循环铺满成片长度,与人声音轨混合(人声音量来自原片)
|
||||
int code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-i", mergedMp4.toString(),
|
||||
"-stream_loop", "-1", "-i", bgm.toString(),
|
||||
"-filter_complex", "[1:a]volume=0.28[bg];[0:a][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]",
|
||||
"-map", "0:v:0", "-map", "[aout]",
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "160k",
|
||||
"-movflags", "+faststart",
|
||||
"-shortest",
|
||||
out.toString()
|
||||
));
|
||||
if (code != 0) {
|
||||
return mergedMp4;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private double probeDurationSeconds(Path videoFile) {
|
||||
if (!ffprobeAvailable()) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
ffprobeBinary, "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", videoFile.toString());
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
String line;
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
line = r.readLine();
|
||||
}
|
||||
p.waitFor();
|
||||
if (line != null && !line.isBlank()) {
|
||||
return Double.parseDouble(line.trim());
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private boolean hasAudioStream(Path videoFile) {
|
||||
if (!ffprobeAvailable()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
ffprobeBinary, "-v", "error", "-select_streams", "a",
|
||||
"-show_entries", "stream=codec_type", "-of", "csv=p=0", videoFile.toString());
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String ln;
|
||||
while ((ln = r.readLine()) != null) {
|
||||
sb.append(ln);
|
||||
}
|
||||
}
|
||||
int code = p.waitFor();
|
||||
return code == 0 && sb.toString().toLowerCase(Locale.ROOT).contains("audio");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean ffprobeAvailable() {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(ffprobeBinary, "-version");
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
drain(p);
|
||||
return p.waitFor() == 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasRenderableMedia(List<ReportImage> imgs) {
|
||||
if (imgs == null || imgs.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (ReportImage img : imgs) {
|
||||
Path p = resolveUploadFile(img.getPhotoUrl());
|
||||
if (p != null && Files.isRegularFile(p)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean ffmpegAvailable() {
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(ffmpegBinary, "-version");
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
drain(p);
|
||||
return p.waitFor() == 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private int runFfmpeg(List<String> cmd) throws Exception {
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
drain(p);
|
||||
return p.waitFor();
|
||||
}
|
||||
|
||||
private void drain(Process p) throws Exception {
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
while (r.readLine() != null) {
|
||||
// discard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void encodeImageSegment(Path image, Path out, double seconds, int variant, String label) throws Exception {
|
||||
String t = String.format(Locale.ROOT, "%.3f", seconds);
|
||||
int frames = Math.max(2, (int) Math.round(seconds * OUTPUT_FPS));
|
||||
String baseScale = "scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
+ "pad=1080:1920:(ow-iw)/2:(oh-ih)/2";
|
||||
String vf;
|
||||
if (kenBurnsEnabled) {
|
||||
vf = baseScale + ",zoompan=" + kenBurnsFilter(variant, frames) + ",format=yuv420p"
|
||||
+ drawtextFilter(label) + "," + fadeFilter(seconds);
|
||||
} else {
|
||||
vf = baseScale + ",format=yuv420p,fps=" + OUTPUT_FPS
|
||||
+ drawtextFilter(label) + "," + fadeFilter(seconds);
|
||||
}
|
||||
int code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-loop", "1", "-framerate", "1", "-i", image.toString(),
|
||||
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-profile:v", "high",
|
||||
"-c:a", "aac", "-b:a", "96k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-t", t,
|
||||
out.toString()
|
||||
));
|
||||
if (code != 0) {
|
||||
if (kenBurnsEnabled) {
|
||||
encodeImageSegmentStatic(image, out, seconds, label);
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("图片转视频失败(退出码 " + code + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void encodeImageSegmentStatic(Path image, Path out, double seconds, String label) throws Exception {
|
||||
String t = String.format(Locale.ROOT, "%.3f", seconds);
|
||||
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
+ "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=" + OUTPUT_FPS
|
||||
+ drawtextFilter(label) + "," + fadeFilter(seconds);
|
||||
int code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-loop", "1", "-framerate", "1", "-i", image.toString(),
|
||||
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-profile:v", "high",
|
||||
"-c:a", "aac", "-b:a", "96k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-t", t,
|
||||
out.toString()
|
||||
));
|
||||
if (code != 0) {
|
||||
throw new IllegalStateException("图片转视频失败(退出码 " + code + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void encodeVideoSegment(Path video, Path out, double seconds, String label) throws Exception {
|
||||
String t = String.format(Locale.ROOT, "%.3f", seconds);
|
||||
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
+ "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=" + OUTPUT_FPS
|
||||
+ drawtextFilter(label) + "," + fadeFilter(seconds);
|
||||
boolean audio = hasAudioStream(video);
|
||||
int code;
|
||||
if (audio) {
|
||||
code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-i", video.toString(),
|
||||
"-vf", vf,
|
||||
"-t", t,
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-profile:v", "high",
|
||||
"-c:a", "aac", "-b:a", "128k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
out.toString()
|
||||
));
|
||||
} else {
|
||||
code = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y",
|
||||
"-i", video.toString(),
|
||||
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
|
||||
"-vf", vf,
|
||||
"-map", "0:v:0", "-map", "1:a:0",
|
||||
"-t", t,
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
|
||||
"-profile:v", "high",
|
||||
"-c:a", "aac", "-b:a", "96k",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
out.toString()
|
||||
));
|
||||
}
|
||||
if (code != 0) {
|
||||
throw new IllegalStateException("视频片段重编码失败(退出码 " + code + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private List<ReportImage> sortedImages(List<ReportImage> imgs) {
|
||||
if (imgs == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<ReportImage> copy = new ArrayList<>(imgs);
|
||||
copy.sort(Comparator
|
||||
.comparingInt((ReportImage i) -> typeOrder(i.getPhotoType()))
|
||||
.thenComparingInt(i -> Optional.ofNullable(i.getSortOrder()).orElse(0))
|
||||
.thenComparingLong(i -> Optional.ofNullable(i.getId()).orElse(0L)));
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static int typeOrder(String photoType) {
|
||||
if ("before".equals(photoType)) {
|
||||
return 0;
|
||||
}
|
||||
if ("during".equals(photoType)) {
|
||||
return 1;
|
||||
}
|
||||
if ("after".equals(photoType)) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private boolean isVideoMedia(ReportImage img) {
|
||||
String mt = img.getMediaType();
|
||||
if (mt != null && mt.toLowerCase(Locale.ROOT).contains("video")) {
|
||||
return true;
|
||||
}
|
||||
String u = img.getPhotoUrl();
|
||||
if (u == null) {
|
||||
return false;
|
||||
}
|
||||
String lower = u.toLowerCase(Locale.ROOT);
|
||||
return lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v")
|
||||
|| lower.endsWith(".webm") || lower.endsWith(".avi") || lower.endsWith(".mkv")
|
||||
|| lower.endsWith(".3gp");
|
||||
}
|
||||
|
||||
private Path resolveUploadFile(String photoUrl) {
|
||||
if (photoUrl == null || photoUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String p = photoUrl.trim();
|
||||
try {
|
||||
if (p.startsWith("http://") || p.startsWith("https://")) {
|
||||
URI uri = URI.create(p);
|
||||
p = uri.getPath();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String rel = null;
|
||||
if (p.startsWith("/api/upload/image/")) {
|
||||
rel = p.substring("/api/upload/image/".length());
|
||||
} else if (p.startsWith("/api/upload/legacy/")) {
|
||||
rel = p.substring("/api/upload/legacy/".length());
|
||||
}
|
||||
if (rel == null || rel.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String rawBase = uploadPath.endsWith("/") ? uploadPath.substring(0, uploadPath.length() - 1) : uploadPath;
|
||||
Path base = Paths.get(rawBase).toAbsolutePath().normalize();
|
||||
Path full = base.resolve(rel).normalize();
|
||||
if (!full.startsWith(base)) {
|
||||
return null;
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
private boolean canOperate(Long operatorUserId, String role, Report report) {
|
||||
String r = role == null ? "" : role.trim();
|
||||
if ("boss".equals(r) || "staff".equals(r)) {
|
||||
Optional<User> u = userMapper.findByIdAndDeletedFalse(operatorUserId);
|
||||
if (u.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Long sid = u.get().getStoreId();
|
||||
return sid != null && sid.equals(report.getStoreId());
|
||||
}
|
||||
if ("customer".equals(r)) {
|
||||
Long apptId = report.getAppointmentId();
|
||||
if (apptId == null) {
|
||||
return false;
|
||||
}
|
||||
Optional<Appointment> ap = appointmentMapper.findByIdAndDeletedFalse(apptId);
|
||||
return ap.isPresent() && operatorUserId.equals(ap.get().resolvedCustomerUserId());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,293 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportLead;
|
||||
import com.petstore.entity.ServiceInterval;
|
||||
import com.petstore.entity.StoreCustomer;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.ReportLeadMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.ServiceIntervalMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportLeadService {
|
||||
|
||||
private final ReportLeadMapper reportLeadMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final ServiceIntervalMapper serviceIntervalMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserService userService;
|
||||
private final WechatMiniProgramService wechatMiniProgramService;
|
||||
private final StoreCustomerService storeCustomerService;
|
||||
private final BusinessEventService businessEventService;
|
||||
private final FollowUpTaskService followUpTaskService;
|
||||
|
||||
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号(S3→S4)+ 是否同报告同手机号再次提交(幂等更新) */
|
||||
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {}
|
||||
|
||||
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
|
||||
private static final int FALLBACK_MIN = 28;
|
||||
private static final int FALLBACK_MAX = 35;
|
||||
|
||||
/** 首次启动时写入系统默认建议周期。ddl-auto=update 会建表,这里只灌数据。 */
|
||||
@PostConstruct
|
||||
public void seedDefaults() {
|
||||
if (!serviceIntervalMapper.findByStoreIdIsNull().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// [service_name, min_days, max_days]
|
||||
Object[][] defaults = new Object[][]{
|
||||
{"洗澡", 21, 28},
|
||||
{"美容", 42, 56},
|
||||
{"洗澡+美容", 28, 35},
|
||||
{"剪指甲", 30, 45},
|
||||
{"驱虫", 30, 30}
|
||||
};
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (Object[] row : defaults) {
|
||||
ServiceInterval si = new ServiceInterval();
|
||||
si.setStoreId(null);
|
||||
si.setServiceName((String) row[0]);
|
||||
si.setPetType(null);
|
||||
si.setIntervalDaysMin((Integer) row[1]);
|
||||
si.setIntervalDaysMax((Integer) row[2]);
|
||||
si.setCreateTime(now);
|
||||
serviceIntervalMapper.save(si);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据报告 token 算「下次建议日期」。
|
||||
* 算法:以 appointmentTime(服务时间)为锚点 + 间隔区间的中点 round。
|
||||
*
|
||||
* @return map 包含 remindDate / intervalText / serviceType / reminderType;report 不存在则返回 null
|
||||
*/
|
||||
public Map<String, Object> suggestNext(String reportToken) {
|
||||
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||
if (report == null) return null;
|
||||
|
||||
Interval itv = resolveInterval(report.getStoreId(), report.getServiceType());
|
||||
int mid = (itv.min + itv.max) / 2;
|
||||
|
||||
LocalDate anchor;
|
||||
if (report.getAppointmentTime() != null) {
|
||||
anchor = report.getAppointmentTime().toLocalDate();
|
||||
} else if (report.getCreateTime() != null) {
|
||||
anchor = report.getCreateTime().toLocalDate();
|
||||
} else {
|
||||
anchor = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||
}
|
||||
LocalDate remind = anchor.plusDays(mid);
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||
if (remind.isBefore(today)) {
|
||||
// 历史报告也能给出一个合理的未来日期(最早一周后)
|
||||
remind = today.plusDays(7);
|
||||
}
|
||||
|
||||
String intervalText = buildIntervalText(itv.min, itv.max);
|
||||
|
||||
return Map.of(
|
||||
"remindDate", remind.toString(),
|
||||
"intervalText", intervalText,
|
||||
"intervalDaysMin", itv.min,
|
||||
"intervalDaysMax", itv.max,
|
||||
"serviceType", report.getServiceType() == null ? "" : report.getServiceType(),
|
||||
"reminderType", resolveReminderType(report.getServiceType()),
|
||||
"petName", report.getPetName() == null ? "" : report.getPetName()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等创建/更新留资。
|
||||
* 已存在 (reportId, phone) 组合时只更新状态和提醒日期,不重复创建。
|
||||
*
|
||||
* @param loginCode 小程序 uni.login 的 code,服务端 jscode2session 取 openid/unionid 写入留资;H5 可空。
|
||||
*/
|
||||
@Transactional
|
||||
public LeadSubmitOutcome submit(String reportToken, String phone, String consentIp, String loginCode) {
|
||||
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("报告不存在或已失效");
|
||||
}
|
||||
if (phone == null || !phone.matches("^1[3-9]\\d{9}$")) {
|
||||
throw new IllegalArgumentException("手机号格式不正确");
|
||||
}
|
||||
|
||||
Map<String, Object> sug = suggestNext(reportToken);
|
||||
LocalDate remindDate = LocalDate.parse((String) sug.get("remindDate"));
|
||||
String reminderType = (String) sug.get("reminderType");
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReportLead lead = reportLeadMapper.findFirstByReportIdAndPhone(report.getId(), phone).orElse(null);
|
||||
boolean repeatSubmit = lead != null;
|
||||
final boolean insertingNew = lead == null;
|
||||
if (lead == null) {
|
||||
lead = new ReportLead();
|
||||
lead.setReportId(report.getId());
|
||||
lead.setStoreId(report.getStoreId());
|
||||
lead.setPetName(report.getPetName());
|
||||
lead.setServiceType(report.getServiceType());
|
||||
lead.setPhone(phone);
|
||||
lead.setUnsubscribeToken(UUID.randomUUID().toString().replace("-", ""));
|
||||
lead.setCreateTime(now);
|
||||
}
|
||||
lead.setReminderType(reminderType);
|
||||
lead.setRemindDate(remindDate);
|
||||
lead.setRemindStatus("pending");
|
||||
lead.setConsentAt(now);
|
||||
lead.setConsentIp(consentIp);
|
||||
lead.setUpdateTime(now);
|
||||
|
||||
String oid = null;
|
||||
String uid = null;
|
||||
if (loginCode != null && !loginCode.isBlank()) {
|
||||
WechatMiniProgramService.JsCode2SessionResult js = wechatMiniProgramService.exchangeJsCode(loginCode);
|
||||
if (js.isOk()) {
|
||||
oid = js.openid();
|
||||
uid = js.unionid();
|
||||
}
|
||||
}
|
||||
User byPhone = userMapper.findByPhoneAndDeletedFalse(phone);
|
||||
if (byPhone != null) {
|
||||
if (oid == null || oid.isBlank()) {
|
||||
oid = byPhone.getWechatOpenid();
|
||||
}
|
||||
if (uid == null || uid.isBlank()) {
|
||||
uid = byPhone.getWechatUnionid();
|
||||
}
|
||||
}
|
||||
if (oid != null && !oid.isBlank()) {
|
||||
lead.setWechatOpenid(oid);
|
||||
}
|
||||
if (uid != null && !uid.isBlank()) {
|
||||
lead.setWechatUnionid(uid);
|
||||
}
|
||||
|
||||
try {
|
||||
lead = reportLeadMapper.save(lead);
|
||||
} catch (DataIntegrityViolationException ex) {
|
||||
if (!insertingNew) {
|
||||
throw ex;
|
||||
}
|
||||
ReportLead existing = reportLeadMapper.findFirstByReportIdAndPhone(report.getId(), phone).orElse(null);
|
||||
if (existing == null) {
|
||||
throw ex;
|
||||
}
|
||||
lead = existing;
|
||||
repeatSubmit = true;
|
||||
lead.setReminderType(reminderType);
|
||||
lead.setRemindDate(remindDate);
|
||||
lead.setRemindStatus("pending");
|
||||
lead.setConsentAt(now);
|
||||
lead.setConsentIp(consentIp);
|
||||
lead.setUpdateTime(now);
|
||||
if (oid != null && !oid.isBlank()) {
|
||||
lead.setWechatOpenid(oid);
|
||||
}
|
||||
if (uid != null && !uid.isBlank()) {
|
||||
lead.setWechatUnionid(uid);
|
||||
}
|
||||
lead = reportLeadMapper.save(lead);
|
||||
}
|
||||
|
||||
boolean wechatBound = false;
|
||||
if (oid != null && !oid.isBlank()) {
|
||||
User u = userService.ensureCustomerByPhone(phone);
|
||||
if (u != null && "customer".equals(u.getRole())) {
|
||||
User after = userService.bindWechatMiniIdentity(u.getId(), oid, uid);
|
||||
wechatBound = after != null && after.getWechatOpenid() != null && !after.getWechatOpenid().isBlank();
|
||||
}
|
||||
}
|
||||
|
||||
StoreCustomer storeCustomer = storeCustomerService.touchLead(report.getStoreId(), phone, null, now);
|
||||
businessEventService.recordLeadSubmitted(lead, storeCustomer, repeatSubmit);
|
||||
followUpTaskService.ensureForLead(lead, storeCustomer);
|
||||
|
||||
return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit);
|
||||
}
|
||||
|
||||
/** 退订:按 unsubscribeToken 找记录并置状态 */
|
||||
@Transactional
|
||||
public boolean unsubscribe(String unsubscribeToken) {
|
||||
if (unsubscribeToken == null || unsubscribeToken.isBlank()) return false;
|
||||
Optional<ReportLead> opt = reportLeadMapper.findFirstByUnsubscribeToken(unsubscribeToken);
|
||||
if (opt.isEmpty()) return false;
|
||||
ReportLead lead = opt.get();
|
||||
if ("unsubscribed".equals(lead.getRemindStatus())) {
|
||||
followUpTaskService.cancelForUnsubscribe(lead);
|
||||
return true;
|
||||
}
|
||||
lead.setRemindStatus("unsubscribed");
|
||||
lead.setUpdateTime(LocalDateTime.now());
|
||||
reportLeadMapper.save(lead);
|
||||
followUpTaskService.cancelForUnsubscribe(lead);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 老板回访池:pending 状态、按日期升序 */
|
||||
public List<ReportLead> listPending(Long storeId) {
|
||||
return reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending");
|
||||
}
|
||||
|
||||
public List<ReportLead> listAllForStore(Long storeId) {
|
||||
return reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
|
||||
}
|
||||
|
||||
// ---------------- 私有工具 ----------------
|
||||
|
||||
private Interval resolveInterval(Long storeId, String serviceName) {
|
||||
if (serviceName != null && storeId != null) {
|
||||
Optional<ServiceInterval> perStore = serviceIntervalMapper
|
||||
.findFirstByStoreIdAndServiceName(storeId, serviceName);
|
||||
if (perStore.isPresent()) return Interval.of(perStore.get());
|
||||
}
|
||||
if (serviceName != null) {
|
||||
Optional<ServiceInterval> sys = serviceIntervalMapper
|
||||
.findFirstByStoreIdIsNullAndServiceName(serviceName);
|
||||
if (sys.isPresent()) return Interval.of(sys.get());
|
||||
}
|
||||
return new Interval(FALLBACK_MIN, FALLBACK_MAX);
|
||||
}
|
||||
|
||||
private String buildIntervalText(int min, int max) {
|
||||
int midWeeks = Math.max(1, ((min + max) / 2 + 3) / 7);
|
||||
if (min == max) {
|
||||
return String.format("约 %d 天后", min);
|
||||
}
|
||||
return String.format("约 %d 周后", midWeeks);
|
||||
}
|
||||
|
||||
private String resolveReminderType(String serviceName) {
|
||||
if (serviceName == null) return "next_service";
|
||||
if (serviceName.contains("驱虫")) return "next_deworm";
|
||||
if (serviceName.contains("指甲")) return "next_nail";
|
||||
if (serviceName.contains("美容") && serviceName.contains("洗")) return "next_bath_groom";
|
||||
if (serviceName.contains("美容")) return "next_grooming";
|
||||
if (serviceName.contains("洗")) return "next_wash";
|
||||
return "next_service";
|
||||
}
|
||||
|
||||
private record Interval(int min, int max) {
|
||||
static Interval of(ServiceInterval si) {
|
||||
int mi = si.getIntervalDaysMin() == null ? FALLBACK_MIN : si.getIntervalDaysMin();
|
||||
int ma = si.getIntervalDaysMax() == null ? FALLBACK_MAX : si.getIntervalDaysMax();
|
||||
if (ma < mi) ma = mi;
|
||||
return new Interval(mi, ma);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,92 +1,55 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.*;
|
||||
import com.petstore.mapper.*;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
private static final Set<String> SEND_CHANNELS = Set.of("wechat", "qr", "other");
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportImageMapper reportImageMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final BusinessEventService businessEventService;
|
||||
|
||||
@Transactional
|
||||
public Report create(Report report) {
|
||||
// 一约一份不变量:报告必须绑定预约;同一预约重复提交拒绝。
|
||||
if (report.getAppointmentId() == null) {
|
||||
throw new IllegalArgumentException("报告必须关联预约");
|
||||
}
|
||||
if (reportMapper.existsByAppointmentIdAndDeletedFalse(report.getAppointmentId())) {
|
||||
throw new IllegalStateException("该预约已生成报告");
|
||||
}
|
||||
// 生成唯一令牌
|
||||
report.setReportToken(UUID.randomUUID().toString().replace("-", ""));
|
||||
|
||||
Long requestedAuthorStaffId = report.getAuthorStaffId() != null
|
||||
? report.getAuthorStaffId()
|
||||
: report.getUserId();
|
||||
Appointment boundAppointment = null;
|
||||
boolean completedByReport = false;
|
||||
LocalDateTime completedAt = null;
|
||||
|
||||
// 填充客户/技师快照字段,并自动完成预约
|
||||
// 填充冗余字段,并自动完成预约
|
||||
if (report.getAppointmentId() != null) {
|
||||
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
|
||||
Appointment appt = appointmentMapper.findById(report.getAppointmentId()).orElse(null);
|
||||
if (appt != null) {
|
||||
boundAppointment = appt;
|
||||
report.setPetName(appt.getPetName());
|
||||
report.setServiceType(appt.getServiceType());
|
||||
report.setAppointmentTime(appt.getAppointmentTime());
|
||||
report.setStoreId(appt.getStoreId());
|
||||
report.setCustomerUserId(appt.resolvedCustomerUserId());
|
||||
// 展示技师取预约分配的服务技师(可能与报告提交人不同)
|
||||
// 技师取预约分配的技师(开始服务时指定的)
|
||||
if (appt.getAssignedUserId() != null) {
|
||||
User staff = userMapper.findByIdAndDeletedFalse(appt.getAssignedUserId()).orElse(null);
|
||||
User staff = userMapper.findById(appt.getAssignedUserId()).orElse(null);
|
||||
if (staff != null) {
|
||||
report.setUserId(staff.getId());
|
||||
report.setStaffName(staff.getName());
|
||||
}
|
||||
}
|
||||
// 填写完报告:仅进行中 → 已完成(与预约状态机一致)
|
||||
if ("doing".equalsIgnoreCase(appt.getStatus())) {
|
||||
completedAt = LocalDateTime.now();
|
||||
// 填写完报告,自动标记预约为已完成
|
||||
appt.setStatus("done");
|
||||
appt.setUpdateTime(completedAt);
|
||||
appt.setUpdateTime(LocalDateTime.now());
|
||||
appointmentMapper.save(appt);
|
||||
completedByReport = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧服务调用:未显式传 author 时,以预约服务技师兜底。
|
||||
if (requestedAuthorStaffId == null && report.getAppointmentId() != null) {
|
||||
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
|
||||
if (appt != null) {
|
||||
requestedAuthorStaffId = appt.getAssignedUserId();
|
||||
}
|
||||
}
|
||||
report.setAuthorStaffId(requestedAuthorStaffId);
|
||||
report.setUserId(requestedAuthorStaffId); // legacy compatibility
|
||||
|
||||
// 预约未分配技师时,展示提交报告人。
|
||||
if (requestedAuthorStaffId != null && report.getStaffName() == null) {
|
||||
User staff = userMapper.findByIdAndDeletedFalse(requestedAuthorStaffId).orElse(null);
|
||||
// 如果预约没分配技师,则用当前操作人
|
||||
if (report.getUserId() != null && report.getStaffName() == null) {
|
||||
User staff = userMapper.findById(report.getUserId()).orElse(null);
|
||||
if (staff != null) {
|
||||
report.setStaffName(staff.getName());
|
||||
if (report.getStoreId() == null) {
|
||||
@ -95,195 +58,29 @@ public class ReportService {
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
report.setCreateTime(now);
|
||||
report.setUpdateTime(now);
|
||||
report.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||
report.setSentAt(null);
|
||||
report.setSentByUserId(null);
|
||||
report.setSendChannel(null);
|
||||
report.setDeleted(false);
|
||||
|
||||
// 先保存 Report 以获取生成的 ID
|
||||
Report saved = reportMapper.save(report);
|
||||
|
||||
// 处理图片列表:设置 reportId 后通过 reportImageMapper 保存
|
||||
if (saved.getImages() != null && !saved.getImages().isEmpty()) {
|
||||
for (ReportImage img : saved.getImages()) {
|
||||
img.setReportId(saved.getId());
|
||||
reportImageMapper.save(img);
|
||||
}
|
||||
}
|
||||
|
||||
String actorRole = resolveActorRole(requestedAuthorStaffId);
|
||||
if (completedByReport && boundAppointment != null) {
|
||||
businessEventService.recordAppointmentStatusChanged(
|
||||
boundAppointment,
|
||||
"doing",
|
||||
"done",
|
||||
requestedAuthorStaffId,
|
||||
actorRole,
|
||||
completedAt
|
||||
);
|
||||
}
|
||||
businessEventService.recordReportSubmitted(saved, actorRole);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录门店员工已经完成的发送事实。首次确认不可覆盖;重复确认幂等成功。
|
||||
* 复制链接、展示二维码和预览报告都不得调用本方法。
|
||||
*/
|
||||
@Transactional
|
||||
public ConfirmSentOutcome confirmSent(
|
||||
Long reportId,
|
||||
Long storeId,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
String channel
|
||||
) {
|
||||
if (reportId == null) {
|
||||
throw new IllegalArgumentException("reportId 必填");
|
||||
}
|
||||
if (storeId == null || actorUserId == null
|
||||
|| !("boss".equalsIgnoreCase(actorRole) || "staff".equalsIgnoreCase(actorRole))) {
|
||||
throw new SecurityException("仅门店员工可确认报告发送");
|
||||
}
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toLowerCase(Locale.ROOT);
|
||||
if (!SEND_CHANNELS.contains(normalizedChannel)) {
|
||||
throw new IllegalArgumentException("发送渠道仅支持 wechat、qr 或 other");
|
||||
}
|
||||
|
||||
Report report = reportMapper.findByIdAndDeletedFalseForUpdate(reportId).orElse(null);
|
||||
if (report == null) {
|
||||
return null;
|
||||
}
|
||||
if (!storeId.equals(report.getStoreId())) {
|
||||
throw new SecurityException("无权确认他店报告发送");
|
||||
}
|
||||
if (Report.SEND_STATUS_SENT.equals(report.getSendStatus())) {
|
||||
return new ConfirmSentOutcome(report, true);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||
report.setSentAt(now);
|
||||
report.setSentByUserId(actorUserId);
|
||||
report.setSendChannel(normalizedChannel);
|
||||
Report saved = reportMapper.save(report);
|
||||
businessEventService.recordReportSent(saved, actorRole);
|
||||
return new ConfirmSentOutcome(saved, false);
|
||||
}
|
||||
|
||||
public record ConfirmSentOutcome(Report report, boolean alreadyConfirmed) {
|
||||
}
|
||||
|
||||
private String resolveActorRole(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
return userMapper.findByIdAndDeletedFalse(userId)
|
||||
.map(User::getRole)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** 查询单条报告并填充图片 */
|
||||
private Report enrichImages(Report r) {
|
||||
if (r == null) return null;
|
||||
List<ReportImage> imgs = reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(r.getId());
|
||||
r.setImages(imgs);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 批量填充图片(列表用) */
|
||||
private List<Report> enrichImages(List<Report> reports) {
|
||||
if (reports == null || reports.isEmpty()) return reports;
|
||||
List<Long> ids = reports.stream().map(Report::getId).toList();
|
||||
List<ReportImage> allImages = reportImageMapper.findByReportIdInOrderByReportIdAscSortOrderAscIdAsc(ids);
|
||||
java.util.Map<Long, List<ReportImage>> map = allImages.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(ReportImage::getReportId));
|
||||
for (Report r : reports) {
|
||||
r.setImages(map.getOrDefault(r.getId(), java.util.List.of()));
|
||||
}
|
||||
return reports;
|
||||
report.setCreateTime(LocalDateTime.now());
|
||||
report.setUpdateTime(LocalDateTime.now());
|
||||
return reportMapper.save(report);
|
||||
}
|
||||
|
||||
public Report getByAppointmentId(Long appointmentId) {
|
||||
if (appointmentId == null) {
|
||||
return null;
|
||||
}
|
||||
return enrichImages(reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(appointmentId).orElse(null));
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> r.getAppointmentId().equals(appointmentId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Report getByToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return enrichImages(reportMapper.findFirstByReportTokenAndDeletedFalse(token).orElse(null));
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> token.equals(r.getReportToken()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<Report> list(Long storeId, Long customerUserId) {
|
||||
return list(storeId, customerUserId, null);
|
||||
}
|
||||
|
||||
public List<Report> list(Long storeId, Long customerUserId, String sendStatus) {
|
||||
List<Report> reports;
|
||||
if (storeId != null && customerUserId != null) {
|
||||
reports = reportMapper.findForCustomer(customerUserId).stream()
|
||||
.filter(r -> storeId.equals(r.getStoreId()))
|
||||
.toList();
|
||||
} else if (storeId != null && sendStatus != null) {
|
||||
reports = reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
storeId, sendStatus
|
||||
);
|
||||
} else if (storeId != null) {
|
||||
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
||||
} else if (customerUserId != null) {
|
||||
reports = reportMapper.findForCustomer(customerUserId);
|
||||
} else {
|
||||
reports = reportMapper.findAllByDeletedFalseOrderByCreateTimeDesc();
|
||||
}
|
||||
return enrichImages(reports);
|
||||
}
|
||||
|
||||
public Page<Report> page(Long storeId, Long customerUserId, int pageNo, int pageSize) {
|
||||
return page(storeId, customerUserId, null, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public Page<Report> page(
|
||||
Long storeId, Long customerUserId, String sendStatus, int pageNo, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(
|
||||
Math.max(pageNo, 0),
|
||||
Math.max(pageSize, 1),
|
||||
Sort.by(Sort.Direction.DESC, "createTime")
|
||||
);
|
||||
Page<Report> paged;
|
||||
if (storeId != null && customerUserId != null) {
|
||||
// 当前角色模型不会同时出现 store/customer scope;保守按客户归属查询。
|
||||
paged = reportMapper.pageForCustomer(customerUserId, pageable);
|
||||
} else if (storeId != null && sendStatus != null) {
|
||||
paged = reportMapper.findByStoreIdAndSendStatusAndDeletedFalse(storeId, sendStatus, pageable);
|
||||
} else if (storeId != null) {
|
||||
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
|
||||
} else if (customerUserId != null) {
|
||||
paged = reportMapper.pageForCustomer(customerUserId, pageable);
|
||||
} else {
|
||||
paged = reportMapper.findByDeletedFalse(pageable);
|
||||
}
|
||||
// 填充图片
|
||||
enrichImages(paged.getContent());
|
||||
return paged;
|
||||
}
|
||||
|
||||
public boolean softDelete(Long id) {
|
||||
Report report = reportMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
if (report == null) {
|
||||
return false;
|
||||
}
|
||||
report.setDeleted(true);
|
||||
report.setUpdateTime(LocalDateTime.now());
|
||||
reportMapper.save(report);
|
||||
return true;
|
||||
public List<Report> list(Long storeId, Long userId) {
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> storeId == null || storeId.equals(r.getStoreId()))
|
||||
.filter(r -> userId == null || userId.equals(r.getUserId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportTestimonial;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.ReportTestimonialMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportTestimonialService {
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportTestimonialMapper testimonialMapper;
|
||||
|
||||
/**
|
||||
* 保存或更新该报告下的宠主寄语(幂等:按 report_id 唯一)。
|
||||
*/
|
||||
@Transactional
|
||||
public ReportTestimonial submit(String reportToken, String content, boolean isPublic) {
|
||||
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("报告不存在或已失效");
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalArgumentException("寄语不能为空");
|
||||
}
|
||||
String trimmed = content.trim();
|
||||
if (trimmed.length() > 200) {
|
||||
throw new IllegalArgumentException("寄语最多 200 字");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ReportTestimonial row = testimonialMapper.findByReportId(report.getId()).orElse(null);
|
||||
if (row == null) {
|
||||
row = new ReportTestimonial();
|
||||
row.setReportId(report.getId());
|
||||
row.setStoreId(report.getStoreId());
|
||||
row.setCreateTime(now);
|
||||
}
|
||||
row.setContent(trimmed);
|
||||
row.setIsPublic(isPublic);
|
||||
row.setUpdateTime(now);
|
||||
return testimonialMapper.save(row);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user