petstore-docs/ontology/rules.md
2026-08-01 22:56:31 +08:00

395 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Rules规则 / 不变量)
> 业务约束、决策条件、权限边界。每条规则标注适用动作、落地代码、测试。
## 鉴权
### rule:BR-AUTH-001
**HMAC session token 鉴权**protected `/api/**` 要求 `Authorization: Bearer <token>`token 格式 `base64url(payload).base64url(sig)`HMAC-SHA256默认 7 天有效期public allowlist 见 `AuthInterceptor.PUBLIC_PATTERNS`
- **适用动作**:所有 protected 接口
- **代码**`backend/src/main/java/com/petstore/auth/AuthInterceptor.java`、`SessionTokenService.java`、`CurrentUserContext.java`
- **测试**`SessionTokenServiceTest`
- **证据**`anchored`
### rule:BR-AUTH-002
**生产登录策略**:生产只走微信授权(`/api/user/wx-phone-login`SMS 验证码登录 dev-only`universalSmsCode` 为空 = 生产禁用);`SmsController#send` 生产返回 403 `SMS_LOGIN_DISABLED`
- **适用动作**`action:login`、`action:wx_phone_login`
- **代码**`backend/src/main/java/com/petstore/service/UserService.java#login`、`SmsController.java`
- **测试**`UserServiceLoginStrategyTest`
- **证据**`anchored`
## Store
### rule:BR-STORE-001
**店铺更新/删除仅 boss + 同店**`update`/`delete` 仅 `isBoss() && current.storeId != null``store.id` 必须等于 `current.storeId`
- **适用动作**`action:update_store`、`action:delete_store`
- **代码**`backend/src/main/java/com/petstore/controller/StoreController.java#update`/`#delete`
- **测试**`StoreControllerTest`
- **证据**`anchored`
### rule:BR-STORE-002
**邀请码查询仅 boss/staff + 同店**:按 code 查到的 store.id 必须等于 `current.storeId`;新员工注册预览走公开 `/api/user/register-staff`(请求体含 inviteCode
- **适用动作**`action:get_store_by_invite_code`
- **代码**`backend/src/main/java/com/petstore/controller/StoreController.java#getByInviteCode`
- **测试**`StoreControllerTest#inviteCodeCrossStoreForbidden`
- **证据**`anchored`
### rule:BR-STORE-003
**公开门店字段裁剪**:公开接口 `GET /api/store/get`、`GET /api/store/list` 仅返回预约/展示所需字段id/name/address/坐标/phone/logo/intro/营业时段),**不得**返回 `ownerId`、`inviteCode`、`deleted` 等内部字段(与 `rule:BR-RPT-006` 对称)。
- **适用动作**`action:get_store`、`action:list_stores`
- **代码**`backend/src/main/java/com/petstore/controller/StoreController.java#toPublicStoreView`
- **测试**`StoreControllerTest#publicGetHidesOwnerIdAndInviteCode`、`#publicListHidesOwnerIdAndInviteCode`
- **证据**`anchored`
## Appointment
### rule:BR-APPT-001
**预约状态机**:仅允许 `new→doing``startService`)、`new→cancel`、`doing→done`(报告提交触发)、`doing→cancel`(若产品允许);`done`/`cancel` 为终态不可回退;`→doing` 必须走 `/appointment/start`;非法迁移返回 `INVALID_STATUS` / `CANCEL_NOT_ALLOWED` / `USE_START_ENDPOINT`
- **适用动作**`action:start_service`、`action:transition_appointment_status`、`action:submit_report`
- **代码**`backend/src/main/java/com/petstore/service/AppointmentService.java#startService`/`#transitionStatus`
- **测试**`AppointmentServiceTest`
- **证据**`anchored`
### rule:BR-APPT-002
**预约跨店 403**boss/staff 操作预约list/start/status/detail/delete仅限本店 `storeId == current.storeId`,跨店返回 `FORBIDDEN`
- **适用动作**`action:list_appointments`、`action:start_service`、`action:transition_appointment_status`、`action:get_appointment_detail`、`delete`
- **代码**`backend/src/main/java/com/petstore/controller/AppointmentController.java`
- **测试**`AppointmentControllerDetailTest`
- **证据**`anchored`
### rule:BR-APPT-003
**预约客户归属与跨用户 403**customer 仅可查看/取消自己的预约(`customerUserId == current.userId`;未回填旧数据可回退 legacy `userId`不可操作他人预约。customer 自约时客户与创建人均从上下文派生boss/staff 代客预约必须解析真实 customer禁止把操作员工写成客户。`petId` 存在时必须属于该客户。
- **适用动作**`action:create_appointment`、`action:get_appointment_detail`、`action:transition_appointment_status`(仅 cancel
- **代码**`backend/src/main/java/com/petstore/controller/AppointmentController.java#detail`/`#updateStatus`
- **测试**`AppointmentControllerIdentityTest`、`AppointmentControllerDetailTest#customerCannotViewOthersAppointment`
- **证据**`anchored`
### rule:BR-APPT-004
**真实预约容量校验**:开始时间按半小时对齐;服务必须在 `booking_day_start` 到末容量桶结束时刻内完成。预约保存服务时长快照,并检查覆盖的每个半小时容量桶;有效预约与 `walk_in` 各消耗一个 `booking_capacity``blocked` 关闭覆盖范围内全部容量。创建预约/占用时悲观锁定门店行,避免并发超卖;取消预约不占容量。
- **适用动作**`action:create_appointment`、`action:get_available_slots`、`action:create_schedule_block`
- **代码**`backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking`、`BookingCapacityService`、`ScheduleService#createBlock`
- **测试**`BookingCapacityServiceTest`、`AppointmentServiceTest`、`BookingCapacityMigrationTest`
- **文档**`docs/产品设计文档.md §5.2`
- **证据**`anchored`
## Report
### rule:BR-RPT-001
**报告必须绑定预约**`appointmentId` 为空返回 `APPOINTMENT_REQUIRED`;无预约报告不作为 P0 主路径。
- **适用动作**`action:submit_report`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#create`、`ReportService.java#create`
- **测试**`ReportControllerTest#createMissingAppointmentIdReturnsBizCode`、`ReportServiceTest#createWithNullAppointmentIdThrows`
- **证据**`anchored`
### rule:BR-RPT-002
**一约一份报告**`Report.appointment_id` 唯一约束 `uk_report_appointment`;服务层 `existsByAppointmentIdAndDeletedFalse` 重复提交抛 `IllegalStateException`Controller 转 409 `REPORT_ALREADY_EXISTS`
- **适用动作**`action:submit_report`
- **代码**`backend/src/main/java/com/petstore/entity/Report.java`、`mapper/ReportMapper.java`、`service/ReportService.java#create`、`controller/ReportController.java#create`
- **测试**`ReportServiceTest#createDuplicateReportThrows`、`ReportControllerTest#createDuplicateReportReturnsAlreadyExists`
- **证据**`anchored`(上线前需清理历史重复数据,见 `backend/README.md` 数据库迁移)
### rule:BR-RPT-003
**报告前后照片必填**:服务前/服务后至少各 1 张有效照片(`mediaType` 非 video缺 before 返回 `BEFORE_PHOTO_REQUIRED`,缺 after 返回 `AFTER_PHOTO_REQUIRED`;服务过程中素材可选。
- **适用动作**`action:submit_report`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#hasPhotoType`、`frontend/src/pages/report/Report.vue#submitReport`
- **测试**`ReportControllerTest#createMissingBeforePhotoReturnsBizCode`/`createMissingAfterPhotoReturnsBizCode`
- **文档**`docs/产品设计文档.md §5.3 报告提交必填项`
- **证据**`anchored`(前后端双校验)
### rule:BR-RPT-004
**报告仅 doing 预约可提交**:关联预约状态非 `doing` 返回 `INVALID_STATUS`「请先开始服务后再提交报告」;提交后 `doing→done`
- **适用动作**`action:submit_report`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#create`、`service/ReportService.java#create`
- **测试**`ReportServiceTest#createDoingAppointmentMarksDone`
- **证据**`anchored`
### rule:BR-RPT-005
**报告跨店 403**:仅 boss/staff 可创建/删除报告;报告所属门店必须等于 `current.storeId`,跨店返回 `FORBIDDEN`
- **适用动作**`action:submit_report`、`action:delete_report`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#create`/`#delete`
- **测试**`ReportControllerTest#createCrossStoreReturnsForbidden`、`createNonStaffReturnsForbidden`
- **证据**`anchored`
### rule:BR-RPT-006
**公开报告字段裁剪**`GET /api/report/get?token=`publicTokenRequest不返回 top-level `userId`/`storeId`/`reportToken``store` 对象内可返回 `id`(用于预约跳转)+ `name`/`logo`/`phone`/`address`,不返回 `latitude`/`longitude`;非 publicappointmentId 查询)保留全部字段供门店端使用。
- **适用动作**`action:get_report_by_token`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#getByAppointmentId`
- **测试**`ReportControllerTest#publicTokenQueryHidesInternalFields`
- **前端**`frontend/src/pages/report-view/reportView.vue``reminderToken` 只读路由 token`bookingStoreId` 只读 `store.id`
- **证据**`anchored`
### rule:BR-RPT-007
**token hash 日志**:报告打开埋点日志只记 `tokenHash`SHA-256 前 8 位 hex不输出 token 前缀。
- **适用动作**`action:track_report_open`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#shortTokenHash`/`#trackReportOpen`
- **证据**`anchored`
## ReportLead
### rule:BR-LEAD-001
**留资去重**:同一 `report_token` + 同一手机号二次提交幂等更新,不重复插库,返回 `repeatSubmit=true`
- **适用动作**`action:submit_lead`
- **代码**`backend/src/main/java/com/petstore/service/ReportLeadService.java#submit`
- **证据**`documented`(逻辑已实现,未补测试用例)
### rule:BR-LEAD-002
**留资标识脱敏**`/api/report/leads` 不返回完整 `wechatOpenid`/`wechatUnionid`,只返回 `wechatBound`boolean+ `wechatOpenidMasked` + `wechatUnionidMasked`首4+****+末4过短返回 `****`)。
- **适用动作**`action:list_leads`
- **代码**`backend/src/main/java/com/petstore/controller/ReportLeadController.java#maskIdentifier`/`#leads`
- **前端**`frontend/src/pages/mine/Leads.vue`(只渲染 `wechatBound`/`wechatOpenidMasked`
- **证据**`anchored`
### rule:BR-LEAD-003
**回访池跨店 403**`/api/report/leads` 仅 boss/staff`storeId` 从 `CurrentUserContext` 派生,忽略 query `storeId`
- **适用动作**`action:list_leads`
- **代码**`backend/src/main/java/com/petstore/controller/ReportLeadController.java#leads`
- **证据**`anchored`
---
## Batch 2 录入规则
## Upload
### rule:BR-UPLOAD-001
**上传路径归一化**`FileController` 用 `Paths.get(uploadPath).toAbsolutePath().normalize()``resolveSafePath` 反斜杠统一、去前导斜杠、`normalize` 后校验 `target.startsWith(root)`,逃逸返回 400。
- **适用动作**`action:upload_image`、`action:get_upload_image`、`getLegacyImage`
- **代码**`backend/src/main/java/com/petstore/controller/FileController.java#resolveSafePath`/`#uploadRoot`
- **证据**`anchored`
### rule:BR-UPLOAD-002
**上传扩展名/MIME 校验**:原文件有扩展名必须在图片/视频白名单内,否则拒绝;无扩展名时 MIME 必须是 `image/*`/`video/*`/`application/octet-stream`/空(小程序兼容),否则拒绝。
- **适用动作**`action:upload_image`
- **代码**`backend/src/main/java/com/petstore/controller/FileController.java#isAllowedMediaType`/`#hasMediaExtension`
- **证据**`anchored`
## Highlight Video
### rule:BR-HL-001
**成片仅 boss/staff 可触发**`operatorUserId`/`role` 从 `CurrentUserContext` 派生不信任请求体customer 触发返回 403。
- **适用动作**`action:generate_highlight`
- **代码**`backend/src/main/java/com/petstore/controller/ReportController.java#startHighlight`
- **证据**`anchored`
### rule:BR-HL-002
**成片三态 + composeMode**`highlight_video_status` 枚举 `processing`/`done`/`failed``composeMode` 枚举 `preset`/`interleave`;失败原因归类 `material`/`service`/`network`/`unknown`(对宠主展示归类中文,不暴露堆栈);时长随素材变化(配置级上限 `HIGHLIGHT_MAX_DURATION_SEC`)。
- **适用动作**`action:generate_highlight`
- **代码**`backend/src/main/java/com/petstore/entity/Report.java``highlight_*` 字段)、`service/ReportHighlightVideoService.java`
- **文档**`docs/洗美报告短视频成片方案.md`、`docs/API-报告成片字段契约.md`
- **证据**`anchored`字段与状态已实现FFmpeg 集成测试 `gap`
## Schedule
### rule:BR-SCH-001
**排班仅 boss/staff + 跨店 403**`day`/`block`(create/delete) 仅 `isStoreUser()``storeId=current.storeId`(忽略请求体);`deleteBlock` 经 service `operatorStoreId` 校验同店。
- **适用动作**`action:day_agenda`、`action:create_schedule_block`、`action:delete_schedule_block`
- **代码**`backend/src/main/java/com/petstore/controller/ScheduleController.java`、`service/ScheduleService.java#deleteBlock`
- **测试**`ScheduleControllerTest`
- **证据**`anchored`
### rule:BR-SCH-002
**手动占用容量语义**`durationMinutes` 为 30480 的 30 分钟倍数;`walk_in` 在覆盖的每个容量桶消耗一个并发名额,`blocked` 仅允许创建在没有预约或其他占用的范围,并关闭全部号源。
- **适用动作**`action:day_agenda`、`action:create_schedule_block`
- **代码**`backend/src/main/java/com/petstore/service/ScheduleService.java`、`BookingCapacityService.java`
- **测试**`BookingCapacityServiceTest`、`ScheduleControllerTest`
- **证据**`anchored`
## ServiceType
### rule:BR-ST-001
**服务类型仅 boss + 同店 + 系统默认不可改删**`create`/`update`/`delete` 仅 `isBoss()``storeId=current.storeId``update`/`delete` 时 `existing.storeId` 必须非空且等于 `current.storeId`(系统默认 `storeId=null` 不可改删,他店不可改删);预计时长须为 30480 的 30 分钟倍数;`init` 仅 boss/staff。
- **适用动作**`action:create_service_type`、`action:update_service_type`、`action:delete_service_type`、`action:init_service_types`
- **代码**`backend/src/main/java/com/petstore/controller/ServiceTypeController.java`
- **测试**`ServiceTypeControllerTest`
- **证据**`anchored`
## Pet
### rule:BR-PET-001
**宠物跨用户/跨店**customer `list`/`create` 强制 `ownerUserId=current.userId`boss/staff `list` 查本店服务过的宠物(`storeId=current.storeId``update`/`delete`/`history` 的 `operatorUserId`/`role` 从上下文派生。
- **适用动作**`action:list_pets`、`action:create_pet`、`action:update_pet`、`action:delete_pet`、`action:get_pet_history`
- **代码**`backend/src/main/java/com/petstore/controller/PetController.java`
- **测试**`PetControllerTest`
- **证据**`anchored`
## User 管理
### rule:BR-USER-001
**员工管理仅 boss + 同店**`create_staff`/`list_staff`/`delete_staff` 仅 `isBoss()``storeId=current.storeId``delete_staff` 校验目标员工属于本店。
- **适用动作**`action:create_staff`、`action:list_staff`、`action:delete_staff`
- **代码**`backend/src/main/java/com/petstore/controller/UserController.java`
- **证据**`anchored`
### rule:BR-USER-002
**用户更新仅本人**`update_user` 的 `id=current.userId`(覆盖请求体);手机号变更需 SMS 验证码(生产 `rule:BR-AUTH-002` 禁用)。
- **适用动作**`action:update_user`、`action:get_user_info`
- **代码**`backend/src/main/java/com/petstore/controller/UserController.java#updateUser`/`#info`
- **证据**`anchored`
### rule:BR-USER-003
**User 敏感字段不序列化**`password`、`wechatOpenid`、`wechatUnionid` 必须 `@JsonIgnore`,禁止随 API JSON 返回。
- **适用动作**`action:login`、`action:wx_phone_login`、`action:get_user_info`、`action:update_user`、`action:register_boss`、`action:register_staff`、`action:list_staff`
- **代码**`backend/src/main/java/com/petstore/entity/User.java`
- **测试**`UserJsonIgnoreTest`
- **证据**`anchored`
## Config
### rule:BR-CONFIG-001
**生产配置环境变量**`application.yml` 的 `datasource.url/username/password`、`wechat.appid/appsecret`、`auth.session-secret`、`upload.path` 等敏感项全部改为 `${ENV_VAR:默认}`;不在源码写真实密钥。
- **适用动作**:—(全局约束)
- **代码**`backend/src/main/resources/application.yml`、`application-example.yml`
- **文档**`backend/README.md 环境变量表`
- **证据**`anchored`(已暴露凭据需运维轮换)
### rule:BR-CONFIG-002
**production profile**`--spring.profiles.active=production` 时 `ddl-auto=validate`(不改表)、`show-sql=false`、`SMS_UNIVERSAL_CODE` 默认空(关闭万能验证码 `rule:BR-AUTH-002`)。
- **适用动作**:—(全局约束)
- **代码**`backend/src/main/resources/application.yml`multi-document production profile
- **文档**`backend/README.md production profile`
- **证据**`anchored`配置已加production 实测启动 `gap`
## 寄语 / 退订 / 建议周期
### rule:BR-TST-001
**一报告一寄语**:同一 `report_id` 仅保留一条寄语;重复提交更新 `content`/`is_public`/`update_time`,不新增行。
- **适用动作**`action:submit_testimonial`
- **代码**`backend/src/main/java/com/petstore/service/ReportTestimonialService.java`
- **测试**`ReportTestimonialServiceTest`
- **证据**`anchored`
### rule:BR-UNSUB-001
**凭 unsubscribeToken 一键退订**:公开接口,令牌无效返回明确错误;成功后 `remind_status=unsubscribed`
- **适用动作**`action:unsubscribe`
- **代码**`backend/src/main/java/com/petstore/controller/ReportLeadController.java#unsubscribe`
- **测试**`ReportLeadControllerTest`
- **证据**`anchored`
### rule:BR-INT-001
**服务建议周期推算**:公开 `suggestion` 按报告服务类型 + 宠物类型读取 `entity:service_interval`(门店覆盖优先于系统默认)推算下次建议日期。
- **适用动作**`action:get_report_suggestion`
- **代码**`backend/src/main/java/com/petstore/service/ReportLeadService.java#suggestNext`
- **测试**`ReportLeadControllerTest`
- **证据**`anchored`
## 门店 Web 后台Phase A
### rule:BR-ADMIN-001
**门店后台角色门禁**`entity:admin_console` 仅 `boss` / `staff` 可进且 **均可编辑本店数据**`customer` 禁止。删店、创建/删除员工等仍遵循既有仅 boss 规则(`rule:BR-STORE-001`、`rule:BR-USER-001`)。
- **适用动作**`action:view_workbench`、`action:admin_list_*`、`action:admin_update_settings`、`action:admin_manage_schedule`
- **产品决策**2026-07-10staff 可编辑,非只读)
- **文档**`docs/门店管理后台-PhaseA-页面清单PRD.md`
- **证据**`documented`
### rule:BR-ADMIN-002
**后台数据仅本店**:所有后台查询/写强制 `current.storeId`;忽略跨店参数;连锁规则同步不在 Phase A。
- **适用动作**:同上
- **证据**`documented`
### rule:BR-ADMIN-003
**后台可看内部字段**:经鉴权后可返回公开 API 裁剪掉的字段(如 inviteCode、报告内部字段`rule:BR-STORE-003` / `rule:BR-RPT-006` 对称。手机号默认仍脱敏,明文权限预留。
- **适用动作**`action:admin_list_reports`、`action:admin_update_settings`、`action:admin_list_service_customers`
- **证据**`documented`
### rule:BR-ADMIN-004
**Phase A 后台范围闸门**:禁止将 `membership` / 储值 / 次卡套餐 / 收银 / 库存 / 寄养 / 押金 / 点餐 / 支付宝渠道 写入 Phase A 后台范围或设计稿;上述保持 `evidence=gap`
- **适用**`entity:admin_console`、`entity:workbench` 及一切 admin Phase A 动作
- **文档**`docs/门店管理后台升级方案-对标宠老板.md`
- **证据**`documented`
### rule:BR-SC-001
**门店客户主档唯一性与合并**:同一门店内,`customer_user_id` 和手机号分别只能对应一条有效 StoreCustomer。留资可先按手机号建档后续预约/登录识别到 customer 后绑定或合并。不得将 boss/staff 绑定为客户主档。
- **适用动作**`action:create_appointment`、`action:submit_lead`、`action:admin_list_service_customers`
- **代码**`backend/src/main/java/com/petstore/service/StoreCustomerService.java`
- **测试**`StoreCustomerServiceTest`、`ReportLeadServiceStoreCustomerTest`、`IdentityOwnershipQueryTest`
- **证据**`anchored`
### rule:BR-BE-001
**业务事件不可变、幂等与低敏**BusinessEvent 只追加,不更新/软删;稳定业务结果使用 idempotencyKey 去重。metadata 只允许服务端白名单标量,不得保存手机号、报告 token/hash、媒体 URL、IP、openid/unionid、备注、内容全文、密码、密钥或原始请求体。管理端仅返回当前门店聚合不开放通用事件明细。
- **适用动作**`action:create_appointment`、`action:start_service`、`action:transition_appointment_status`、`action:submit_report`、`action:track_report_open`、`action:submit_lead`、`action:view_workbench`
- **代码**`backend/src/main/java/com/petstore/service/BusinessEventService.java`、`AdminBusinessEventService.java`
- **测试**`BusinessEventServiceTest`、`BusinessEventMigrationTest`、`AdminBusinessEventControllerTest`
- **证据**`anchored`