# Rules(规则 / 不变量) > 业务约束、决策条件、权限边界。每条规则标注适用动作、落地代码、测试。 ## 鉴权 ### rule:BR-AUTH-001 **HMAC session token 鉴权**:protected `/api/**` 要求 `Authorization: Bearer `;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`;非 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` ## 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` 为 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 + 同店**:`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-10(staff 可编辑,非只读) - **文档**:`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`