# Rules（规则 / 不变量）

> 业务约束、决策条件、权限边界。每条规则标注适用动作、落地代码、测试。

## 鉴权

### rule:BR-AUTH-001

**HMAC session token + 活跃账号范围鉴权**：protected `/api/**` 要求 `Authorization: Bearer <token>`；token 为 HMAC-SHA256、默认 7 天有效期，`exp` 必须是未来 Unix 秒时间戳，缺失、非数字、非正或到期边界均 fail-closed。TTL 非正时服务拒绝构造，production 只允许 300～2592000 秒。签名通过后仍须读取活跃 User，确认账号未删除且 `role/storeId` 与 token 快照一致；boss/staff 还须确认所属 Store 仍为有效门店。删员工、停用门店或权限范围变化会立即令旧 token 失效。public allowlist 见 `AuthInterceptor.PUBLIC_PATTERNS`。

- **适用动作**：所有 protected 接口
- **代码**：`backend/src/main/java/com/petstore/auth/AuthInterceptor.java`、`SessionTokenService.java`、`CurrentUserContext.java`
- **测试**：`SessionTokenServiceTest`、`AuthInterceptorActiveUserTest`
- **文档**：`docs/架构决策-SessionToken严格过期边界-2026-08-02.md`
- **证据**：`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`

### rule:BR-AUTH-003

**微信出站敏感日志边界**：`jscode2session`、`getuserphonenumber` 和 `access_token` 的异常日志只记录 HTTP 状态码、微信错误码和异常类型。不得记录完整响应体、手机号、access_token、AppSecret、`phoneCode/jsCode`、含 query 的请求 URL 或异常消息；返回客户端的连接异常使用固定低敏文案。

- **适用动作**：`action:wx_phone_login`、`action:register_boss`、`action:accept_staff_invitation`、`action:submit_lead`
- **代码**：`backend/src/main/java/com/petstore/service/WechatMiniProgramService.java`
- **测试**：`SensitiveLoggingContractTest`
- **证据**：`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

**legacy 永久邀请码禁用**：`invite_code` 仅保留数据库兼容，不返回客户端、不再作为员工注册凭证；`GET /api/store/invite-code` 与 `POST /api/user/register-staff` 固定返回停用业务码，不查询门店或创建账号。

- **适用动作**：`action:get_store_by_invite_code`、`action:register_staff`
- **代码**：`StoreController#getByInviteCode`、`UserController#registerStaff`
- **测试**：`StoreControllerTest`
- **证据**：`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`；非 public（appointmentId 查询）保留全部字段供门店端使用。

- **适用动作**：`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`

### rule:BR-RPT-008

**报告发送必须显式确认且首次回执不可覆盖**：复制链接、二维码、预览和打开报告均不自动改变发送状态。迁移前历史报告为 `unknown`，迁移后新报告为 `unsent`；仅本店 boss/staff 可选择 `wechat|qr|other` 确认为 `sent`。报告行以悲观锁串行确认；首次写入 `sent_at/sent_by_user_id/send_channel` 和幂等 `report_sent` 事件，重复确认返回 `alreadyConfirmed=true` 并保留首次回执。

- **适用动作**：`action:confirm_report_sent`
- **代码**：`ReportController#confirmSent`、`ReportService#confirmSent`、`ReportMapper#findByIdAndDeletedFalseForUpdate`
- **测试**：`ReportControllerTest`、`ReportServiceTest`、`ReportSendMigrationTest`
- **文档**：`docs/架构决策-报告确认发送状态-2026-08-02.md`
- **证据**：`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`

### rule:BR-FU-001

**回访任务状态机与同店领取权**：每个 `sourceLeadId` 至多一个 `pending/in_progress` 任务；`pending` 无领取人，`in_progress` 必须有领取人，只有当前领取人能改期、关闭或再次预约。`no_answer/follow_later` 增加联系次数并回到待领取；`completed/canceled` 不回退；退订取消开放任务但不增加员工联系次数。所有范围从 session 派生，跨店按 404。

- **适用动作**：`action:submit_lead`、`action:unsubscribe`、`action:admin_list_leads`、`action:start_follow_up_task`、`action:reschedule_follow_up_task`、`action:close_follow_up_task`
- **代码**：`backend/src/main/java/com/petstore/service/FollowUpTaskService.java`、`AdminFollowUpTaskController.java`
- **测试**：`FollowUpTaskServiceTest`、`AdminFollowUpTaskControllerTest`、`FollowUpTaskMigrationTest`
- **证据**：`anchored`

### rule:BR-FU-002

**真实再次预约归因**：`rebooked` 不可手工关闭。只有已领取任务与本次预约属于同店同一 canonical StoreCustomer，且预约通过容量校验并真实保存后，才在同一事务写 `rebooked_appointment_id`、终态与 `rebook_created`；任一失败保持任务未闭环。

- **适用动作**：`action:create_appointment`、`action:close_follow_up_task`
- **代码**：`AppointmentService#createBooking`、`FollowUpTaskService#prepareRebook/#markRebooked`
- **测试**：`AppointmentServiceTest`、`FollowUpTaskServiceTest`
- **证据**：`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` 为 30～480 的 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` 不可改删，他店不可改删）；预计时长须为 30～480 的 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 + 同店**：门店成员可查看本店成员列表；只有 boss 可创建/查看/撤销本店邀请并删除本店 `staff`。不能删除 boss，不能直接创建员工账号；员工须本人接受邀请。

- **适用动作**：`action:create_staff`、`action:list_staff`、`action:delete_staff`、`action:create_staff_invitation`、`action:list_staff_invitations`、`action:revoke_staff_invitation`
- **代码**：`UserController.java`、`StaffInvitationController.java`
- **测试**：`StaffInvitationServiceTest`
- **证据**：`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:accept_staff_invitation`、`action:list_staff`
- **代码**：`backend/src/main/java/com/petstore/entity/User.java`
- **测试**：`UserJsonIgnoreTest`
- **证据**：`anchored`

### rule:BR-ONBOARD-001

**微信核验入驻与显式开通**：新老板只能用微信 `phoneCode` 核验手机号后入驻，不接收手填手机号或明文密码。历史门店开通状态迁移为 `unknown`，不得推测。完成开通必须满足：门店名称/电话/地址完整、预约首末时段与并发容量合法、至少一个服务项目；邀请员工为选填，支持单人门店。

- **适用动作**：`action:register_boss`、`action:get_store_onboarding`、`action:complete_store_onboarding`
- **代码**：`MerchantOnboardingService.java`、`StoreOnboardingService.java`
- **测试**：`StoreOnboardingServiceTest`、`StoreOnboardingMigrationTest`
- **证据**：`anchored`

### rule:BR-INVITE-001

**一次性员工邀请安全边界**：邀请 token 为 256-bit 随机值，原文只在创建响应出现一次，数据库只存 SHA-256；邀请绑定手机号、1～30 天有效、可撤销且仅首次接受有效。接受时必须由微信换得的手机号精确匹配。已有 User/openid/unionid 冲突均拒绝，本期不把 customer 静默升级为 staff。

- **适用动作**：创建、列表、撤销、预览、接受员工邀请
- **代码**：`StaffInvitationService.java`、`StaffInvitationController.java`
- **测试**：`StaffInvitationServiceTest`、`StoreOnboardingMigrationTest`
- **证据**：`anchored`

### rule:BR-AUDIT-001

**不可变低敏操作审计**：AuditLog 只追加，强制 `storeId`、受控 action/target/outcome。metadata 只允许服务端白名单标量，禁止手机号、token/URL、openid/unionid、密码/密钥、IP、地址/坐标、备注/内容和原始请求体。

- **适用动作**：门店入驻/完成开通/更新设置、员工邀请创建/撤销/接受、删除员工
- **代码**：`AuditLogService.java`
- **测试**：`AuditLogServiceTest`、`StoreOnboardingMigrationTest`
- **证据**：`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`），并跳过开发期默认服务初始化。最终配置不满足强密钥、300～2592000 秒 session TTL、显式生产 HTTPS 域名、绝对上传目录和显式 CORS 源时启动失败。

**CORS 单一策略**：全局 `CorsFilter` 是唯一跨域策略，白名单只来自 `CORS_ALLOWED_ORIGINS`；Controller 禁止局部 `@CrossOrigin`，防止配置合并或 Filter 变更后意外 fail-open。生产 smoke 必须同时验证一个白名单 Origin 精确回显、一个非白名单 Origin 返回 403。

- **适用动作**：—（全局约束）
- **代码**：`backend/src/main/resources/application.yml`（multi-document production profile）、`ProductionConfigurationValidator.java`、`CorsConfig.java`、`PetstoreApplication.java`
- **测试**：`ProductionConfigurationValidatorTest`、`CorsBoundaryContractTest`
- **文档**：`backend/README.md production profile`、`docs/架构决策-SessionToken严格过期边界-2026-08-02.md`
- **证据**：`anchored`（真实生产值与启动证据仍属发布门禁）

### rule:BR-CONFIG-003

**生产发布只读门禁**：正式切流前必须按固定顺序完成备份和八个版本化迁移，再以最终生产配置运行 JPA schema `validate` 与 31 项只读数据不变量检查。运行期 readiness 必须同时覆盖数据库、磁盘、上传目录和 FFmpeg/ffprobe；上线自动 smoke 只允许 GET，不创建或修改业务数据，并验证 CORS allow/deny。

- **适用动作**：—（发布全局约束）
- **代码**：`backend/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java`、`PetstoreRuntimeHealthIndicator.java`、`backend/deploy/release-preflight.sh`、`production-smoke.sh`
- **文档**：`docs/生产发布与回滚Runbook-2026-08-01.md`、`docs/生产监控与告警基线-2026-08-01.md`
- **证据**：`anchored`（生产备份、迁移和 live smoke 证据待发布时补齐）

## 寄语 / 退订 / 建议周期

### 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-10（staff 可编辑，非只读）
- **文档**：`docs/门店管理后台-PhaseA-页面清单PRD.md`
- **证据**：`documented`

### rule:BR-ADMIN-002

**后台数据仅本店**：所有后台查询/写强制 `current.storeId`；忽略跨店参数；连锁规则同步不在 Phase A。

- **适用动作**：同上
- **证据**：`documented`

### rule:BR-ADMIN-003

**后台内部字段按用途最小返回**：经鉴权后可返回报告内部状态等必要字段；legacy `inviteCode` 不再返回。邀请列表仅返回脱敏手机号且不返回 token/hash，原始 token 只在创建响应返回一次。

- **适用动作**：`action:admin_list_reports`、`action:admin_update_settings`、`action:admin_list_service_customers`、`action:list_staff_invitations`
- **证据**：`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 后绑定或合并。被吸收投影软删除并写入同店 canonical 指针，既有 BusinessEvent 不改写，时间线必须同时读取 canonical 与别名事件。不得将 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-SC-002

**客户时间线同店低敏事实投影**：仅 boss/staff 可按 `current.storeId + storeCustomerId` 查看；跨店和不存在统一 404。软删除合并别名解析为同店有效 canonical，并共同读取其不可变 `BusinessEvent`；按 `occurredAt DESC, id DESC` 稳定分页，只返回服务端白名单化的预约/报告/回访摘要。不得返回手机号原文、备注、媒体 URL、报告/邀请 token、微信标识、IP 或原始 `metadata_json`。

- **适用动作**：`action:view_store_customer_timeline`
- **代码**：`backend/src/main/java/com/petstore/service/StoreCustomerTimelineService.java`、`AdminServiceCustomerController#timeline`
- **测试**：`StoreCustomerTimelineServiceTest`、`AdminServiceCustomerControllerTest`
- **证据**：`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`

## 智能预约助手 M0（Batch 0 · documented）

### rule:BR-BA-001

**customer 与门店数据边界**：所有 endpoint 只允许已登录 customer；session 每次以 `sessionId + current.userId` 查询；他人与不存在 session 同样返回 `SESSION_NOT_FOUND`。`storeId` 只在创建时验证，后续不可覆盖。

- **适用动作**：M0 全部五个动作
- **证据**：`documented`

### rule:BR-BA-002

**模型非权威与结构强校验**：模型只返回通过 `booking-intent-v1` 的文本约束，未知字段拒绝。宠物、服务、日期时间和号源由后端确定性重新解析；模型不得产生 ID、价格、号源或预约状态事实。

- **适用动作**：`action:submit_booking_agent_message`
- **证据**：`documented`

### rule:BR-BA-003

**状态、版本、TTL 与无写路径**：M0 只允许六个会话状态，草稿更新必须校验 `draftVersion`；TTL 固定 30 分钟且不滑动续期，过期 24 小时后物理清理。M0 不注册 `/confirm`、不调用预约写服务、不修改 `Appointment`。

- **适用动作**：创建、提交消息、草稿交接、结束会话
- **证据**：`documented`

### rule:BR-BA-004

**语音、原文与日志最小化**：原始语音、当轮用户原文、完整对话、提示词和模型原始请求/响应不落库、不进事件、不进日志。音频最多 60 秒/3 MB，转写完立即释放，且必须经用户编辑/确认后才发消息。

- **适用动作**：提交消息、语音转写、草稿交接
- **证据**：`documented`

### rule:BR-BA-005

**默认关闭、限流与可退化**：`PETSTORE_BOOKING_AGENT_ENABLED` 默认 `false`。每 customer 10 分钟内最多创建 5 会话、30 条消息、10 段语音；模型/ASR/外部查询失败时保留已验证草稿并退回普通表单，不影响普通预约。单实例进程内限流只是 M0 临时边界，多实例前必须改为共享状态。

- **适用动作**：创建会话、提交消息、语音转写、草稿交接
- **证据**：`documented`
