Compare commits

..

No commits in common. "main" and "be63a3a3771d83d2961cfdc788e353147a407aee" have entirely different histories.

162 changed files with 488 additions and 18817 deletions

5
.gitignore vendored
View File

@ -1,7 +1,2 @@
target/
uploads/
.DS_Store
.idea/
*.iml
# 本地密钥,勿提交;用 application-example.yml 复制
src/main/resources/application.yml

View File

@ -1,7 +1,7 @@
# petstore-backend
#### Description
小它
伴生活馆
#### Software Architecture
Software architecture description

250
README.md
View File

@ -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 有效期;生产限制为 3002592000 秒 |
| `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 天。
1. Fork 本仓库
2. 新建 Feat_xxx 分支
3. 提交代码
4. 新建 Pull Request
> ⚠️ **密钥轮换**`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 显式填写 35 个 `@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邀请绑定微信核验手机号、限时、一次使用、可撤销。
- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLogmetadata 禁止 PII、凭证和原始请求体。
## 参与贡献
1. Fork 本仓库
2. 新建 `feat_xxx` 分支
3. 提交代码(使用 conventional commits`feat` / `fix` / `chore` / `docs` / `refactor` / `test`
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/)

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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');

View File

@ -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;

View File

@ -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;

View File

@ -1,26 +0,0 @@
-- Petstore Phase 0StoreCustomer 合并别名与事实时间线连续性
-- 前置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;

View File

@ -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));

View File

@ -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 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -1,374 +0,0 @@
-- Petstore Pilot Metrics v2MySQL 5.7+/8.x只读
-- 默认时间窗为最近 30 天,但默认门店集合为 0不返回业务数据
-- 执行人必须把 @pilot_store_ids 显式改为 35 个试点门店内部 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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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=

View File

@ -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

View File

@ -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'

View File

@ -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
View File

@ -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>

View File

@ -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/%'");
};
}
}

View File

@ -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 Securityfail-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("\\", "\\\\");
}
}

View File

@ -1,27 +0,0 @@
package com.petstore.auth;
/**
* 当前登录用户上下文数据结构 AuthInterceptor session token 解析后写入 ThreadLocal
*
* @param userId 用户 ID
* @param storeId 所属门店 IDcustomer 可能无门店 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();
}
}

View File

@ -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);
}
}
}

View File

@ -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-SHA256secret 来自 {@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;
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}
}
}

View File

@ -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();
}
}

View File

@ -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) {
}
}

View File

@ -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 tokenPublic 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);
}
}

View File

@ -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;
}
public String getAppsecret() {
return appsecret;
@Value("${wechat.redirect_uri:http://localhost:8080/api/wechat/callback}")
private String redirectUri;
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";
}
}

View File

@ -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)
);
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}
}

View File

@ -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());
}
}
}

View File

@ -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);
}
}

View File

@ -1,274 +1,86 @@
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);
? appointmentService.getByStoreIdAndStatus(storeId, status)
: appointmentService.getByStoreId(storeId);
} else if (userId != null) {
appointments = (status != null && !status.isEmpty())
? appointmentService.getByUserIdAndStatus(userId, status)
: appointmentService.getByUserId(userId);
} else {
appointments = (status != null && !status.isEmpty())
? appointmentService.getByUserIdAndStatus(scopedUserId, status)
: appointmentService.getByUserId(scopedUserId);
return Map.of("code", 400, "message", "userId或storeId必填");
}
Object data = u.isStoreUser()
? appointmentService.toAdminViews(appointments)
: appointments;
return Map.of("code", 200, "data", data);
return Map.of("code", 200, "data", appointments);
}
/** 预约详情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");
}
} else {
return Map.of("code", 403, "message", "无权查看", "bizCode", "FORBIDDEN");
}
Object data = u.isStoreUser()
? appointmentService.toAdminView(appointment)
: appointment;
return Map.of("code", 200, "data", data);
}
/**
* 创建预约客户创建人分别记录不再把代客操作员工写成客户
* - customercustomerUserId = createdByUserId = current.userId
* - boss/staffcreatedByUserId = current.userIdcustomerUserId body.customerUserId/customerPhone 解析
* - legacy userId 仅镜像 customerUserId兼容旧客户端与历史查询
*/
/** 创建预约 */
@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());
appointment.setServiceType(params.get("serviceType").toString());
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()));
}
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());
}
Appointment created = appointmentService.create(appointment);
return Map.of("code", 200, "message", "创建成功", "data", created);
}
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/staffstaffUserId = 当前用户谁点谁服务预约必须属于本店
*/
/** 开始服务:状态变进行中 + 指定技师 */
@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", "删除成功");
return Map.of("code", 404, "message", "预约不存在");
}
}

View File

@ -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,218 +17,98 @@ 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");
@Value("${upload.path}")
private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
@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())
.body(new FileSystemResource(file));
.contentType(MediaType.parseMediaType(contentType))
.body(new FileSystemResource(file));
}
// 兼容旧路径/2026/04/01/xxx.jpg
@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())
.body(new FileSystemResource(file));
.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<>();
if (file.isEmpty()) {
result.put("code", 400);
result.put("message", "文件为空");
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;
result.put("code", 200);
result.put("message", "上传成功");
result.put("data", Map.of("url", url));
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;
}
}
}

View File

@ -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;
}
}

View File

@ -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;
/**
* 列表身份边界从上下文派生
* - customerownerUserId = current.userId查自己宠物
* - boss/staffstoreId = 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", "无法识别身份范围");
}
/**
* 创建宠物档案
* - customerownerUserId 强制为 current.userId覆盖请求体
* - boss/staffownerUserId 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());
}
}

View File

@ -1,295 +1,52 @@
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 的短 hashSHA-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。composeModepreset | 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 {
Report created = reportService.create(report);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "提交成功");
result.put("data", Map.of(
"reportToken", created.getReportToken(),
"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())
);
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", "提交成功");
result.put("data", Map.of(
"reportToken", created.getReportToken(),
"reportId", created.getId()
));
return result;
}
@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,13 +71,12 @@ 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);
}
Map<String, Object> result = new HashMap<>();
if (report != null) {
// 附加店铺信息
@ -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());
}
}
}

View File

@ -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();
}
}

View File

@ -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-ddstoreId 从上下文派生。 */
@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/staffstoreId createdByUserId 从上下文派生
* <br>slotStartISO 本地时间 2026-04-17T10:00:00
* <br>blockTypewalk_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());
}
}

View File

@ -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);
}
/** 新增服务类型:仅 bossstoreId 从上下文派生。 */
@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);
return Map.of("code", 200, "data", created);
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
}
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);
}
/** 编辑服务类型:仅 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);
return Map.of("code", 200, "data", updated);
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
}
String name = params.get("name").toString();
ServiceType updated = serviceTypeService.update(id, name);
return Map.of("code", 200, "data", updated);
}
/** 删除服务类型:仅 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("服务时长格式不正确");
}
}
}

View File

@ -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 默认 123456production 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) {
Map<String, Object> result = new HashMap<>();
// 生产登录策略SMS 登录 dev-onlyuniversalSmsCode 为空 = 已关闭
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");
Map<String, Object> result = new HashMap<>();
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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
/**
* 公开门店视图预约/展示所需字段排除 ownerIdinviteCodedeleted 等内部字段
*/
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;
}
}

View File

@ -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);
}
}

View 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;
}
}

View File

@ -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)
@ -25,23 +16,6 @@ public class Appointment {
private String petType;
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;
@ -49,42 +23,12 @@ public class Appointment {
@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;
@ -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;
}

View File

@ -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;
}

View File

@ -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
* 媒体 URLIPopenid/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;
}

View File

@ -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;
}

View File

@ -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 -> "生成失败";
};
}
}

View File

@ -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;
}

View File

@ -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,23 +17,16 @@ 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;
@ -65,65 +38,10 @@ public class Report {
private String serviceType;
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;
}

View File

@ -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";
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
/** 连续占用时长30480 分钟且必须为 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;
}

View File

@ -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/othernull 表示全部 */
@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;
}

View File

@ -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)
@ -21,10 +16,6 @@ public class ServiceType {
private Long storeId;
private String name;
/** 预计服务时长,按 30 分钟粒度配置。 */
@Column(name = "duration_minutes", nullable = false)
private Integer durationMinutes = 60;
@Column(name = "create_time")
private LocalDateTime createTime;

View File

@ -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;
}

View File

@ -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)
@ -35,41 +27,10 @@ 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;
}

View File

@ -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;
}

View File

@ -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;
@ -33,23 +23,10 @@ public class User {
/** 角色boss / staff / customer */
private String role;
/** 微信小程序 openidjscode2session 沉淀;仅服务端使用,不随 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;
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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
);
}

View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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) {
}
}

View File

@ -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
* 允许newcanceldoingdonenewdoing 请走 {@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");
/** 开始服务:状态变为进行中,同时指定技师为当前用户 */
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(LocalDateTime.now());
return appointmentMapper.save(appointment);
}
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();
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;
}
/**
* 门店后台列表/详情视图附加宠主脱敏技师名是否已有报告
*/
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);
return null;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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
) {}
}

View File

@ -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;
}
}

View File

@ -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
) {}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
/** 可选:本地绝对路径,商用授权 BGMmp3/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/fadeout0.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;
}
}

View File

@ -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 / reminderTypereport 不存在则返回 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);
}
}
}

View File

@ -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);
appointmentMapper.save(appt);
completedByReport = true;
}
// 填写完报告自动标记预约为已完成
appt.setStatus("done");
appt.setUpdateTime(LocalDateTime.now());
appointmentMapper.save(appt);
}
}
// 兼容旧服务调用未显式传 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());
}
}

View File

@ -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