# Rules(规则 / 不变量) > 业务约束、决策条件、权限边界。每条规则标注适用动作、落地代码、测试。 ## 鉴权 ### rule:BR-AUTH-001 **HMAC session token + 活跃账号范围鉴权**:protected `/api/**` 要求 `Authorization: Bearer `;token 为 HMAC-SHA256、默认 7 天有效期。签名通过后仍须读取活跃 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` - **证据**:`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 **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`),并跳过开发期默认服务初始化。最终配置不满足强密钥、显式生产 HTTPS 域名、绝对上传目录和显式 CORS 源时启动失败。 - **适用动作**:—(全局约束) - **代码**:`backend/src/main/resources/application.yml`(multi-document production profile)、`ProductionConfigurationValidator.java`、`PetstoreApplication.java` - **测试**:`ProductionConfigurationValidatorTest` - **文档**:`backend/README.md production profile` - **证据**:`anchored`(真实生产值与启动证据仍属发布门禁) ### rule:BR-CONFIG-003 **生产发布只读门禁**:正式切流前必须按固定顺序完成备份和八个版本化迁移,再以最终生产配置运行 JPA schema `validate` 与 31 项只读数据不变量检查。运行期 readiness 必须同时覆盖数据库、磁盘、上传目录和 FFmpeg/ffprobe;上线自动 smoke 只允许 GET,不创建或修改业务数据。 - **适用动作**:—(发布全局约束) - **代码**:`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`