diff --git a/P0-研发落地清单.md b/P0-研发落地清单.md index 534b7c4..da163d8 100644 --- a/P0-研发落地清单.md +++ b/P0-研发落地清单.md @@ -56,7 +56,8 @@ | **A9** | **线索脱敏**:`/report/leads` 默认不返回完整 `wechatOpenid/wechatUnionid` | 回包只有 `wechatBound`、`wechatOpenidMasked`、`wechatUnionidMasked` | Report Media Backend | ✅ 已实现(Batch B) | 手工 curl + `mvn test` | 对应 P0-2 | | **A10** | **配置安全与启动迁移收口**:`application.yml` 不保留真实敏感值;生产 profile 禁用 `ddl-auto:update`/`show-sql`;移除启动期 DDL/历史 UPDATE | 仓库不含真实 DB/微信密钥;生产启动只读环境变量 | Backend Ops / System Architect | ✅ 已实现(Batch D) | `rg` 扫描 + 启动验证 | 对应 P0-8;已暴露凭据需轮换 | | **A11** | **上传路径与类型保护**:`FileController` 归一化路径 + 扩展名/MIME 校验 | `/api/upload/image/../...`、伪装扩展名、超大文件均被拒绝 | Backend Core / System Architect | ✅ 已实现(Batch D) | 手工 curl + `mvn test` | 对应 P0-9 | -| **A12** | **最小测试基线**:补 `SessionTokenServiceTest`、`ReportServiceTest`、`ReportControllerTest`、`AppointmentServiceTest` | `mvn test` 不再输出 `No tests to run`,新增用例全部通过 | Core Flow QA / System Architect | ✅ 已实现(Batch D) | `mvn -f backend/pom.xml test` | 对应 P0-10;26 个用例全部通过 | +| **A12** | **最小测试基线**:补 `SessionTokenServiceTest`、`ReportServiceTest`、`ReportControllerTest`、`AppointmentServiceTest` | `mvn test` 不再输出 `No tests to run`,新增用例全部通过 | Core Flow QA / System Architect | ✅ 已实现(Batch D) | `mvn -f backend/pom.xml test` | 对应 P0-10;持续回归数量以 Surefire 当次统计为准 | +| **A13** | **预约/报告身份语义拆分**:客户、创建人、服务技师、报告作者不得复用同一语义字段 | customer 自约身份从 session 派生;门店代约必须提供真实宠主;宠主报告按预约客户归属查询;legacy 字段只做兼容 | System Architect / Backend Core / Report Media Backend | ✅ 已实现(Phase 0 RC1 后) | `mvn -f backend/pom.xml test` + H5/小程序构建 | 迁移见 `backend/db/migrations/20260801_split_service_identity.sql` | --- diff --git a/graph/ontology.jsonl b/graph/ontology.jsonl index a5db235..79a082f 100644 --- a/graph/ontology.jsonl +++ b/graph/ontology.jsonl @@ -1,7 +1,7 @@ {"id":"entity:store","type":"Entity","name":"Store","nameZh":"门店","domain":"store","fields":["id","name","logo","phone","address","latitude","longitude","intro","owner_id","invite_code","booking_day_start","booking_last_slot_start","create_time","update_time","deleted"],"systems":["Backend Core"],"code":"backend/src/main/java/com/petstore/entity/Store.java","doc":"docs/产品设计文档.md §三 t_store","test":"StoreControllerTest","evidence":"anchored"} {"id":"entity:user","type":"Entity","name":"User","nameZh":"用户","domain":"user","fields":["id","username","password","name","phone","avatar","store_id","role","wechat_openid","wechat_unionid","create_time","update_time","deleted"],"systems":["Backend Core"],"code":"backend/src/main/java/com/petstore/entity/User.java","doc":"docs/产品设计文档.md §三 t_user; docs/.agents/common-context.md 术语规则","test":"UserServiceLoginStrategyTest; UserJsonIgnoreTest","evidence":"anchored"} -{"id":"entity:appointment","type":"Entity","name":"Appointment","nameZh":"预约","domain":"appointment","fields":["id","pet_name","pet_type","service_type","appointment_time","status","store_id","user_id","assigned_user_id","pet_id","remark","create_time","update_time","deleted"],"systems":["Backend Core"],"code":"backend/src/main/java/com/petstore/entity/Appointment.java; backend/src/main/java/com/petstore/service/AppointmentService.java","doc":"docs/产品设计文档.md §三 t_appointment §5.2","test":"AppointmentServiceTest; AppointmentControllerDetailTest","evidence":"anchored"} -{"id":"entity:report","type":"Entity","name":"Report","nameZh":"服务报告","domain":"report","fields":["id","appointment_id","remark","user_id","store_id","report_token","pet_name","service_type","appointment_time","staff_name","highlight_video_url","highlight_video_status","highlight_video_error","highlight_duration_sec","highlight_compose_mode","highlight_fail_reason","highlight_share_cover_url","create_time","update_time","deleted"],"systems":["Report Media Backend"],"code":"backend/src/main/java/com/petstore/entity/Report.java; backend/src/main/java/com/petstore/service/ReportService.java","doc":"docs/产品设计文档.md §三 t_report §5.3; docs/API-报告成片字段契约.md","test":"ReportServiceTest; ReportControllerTest","evidence":"anchored"} +{"id":"entity:appointment","type":"Entity","name":"Appointment","nameZh":"预约","domain":"appointment","fields":["id","pet_name","pet_type","service_type","appointment_time","status","store_id","user_id","customer_user_id","created_by_user_id","assigned_user_id","pet_id","remark","create_time","update_time","deleted"],"systems":["Backend Core"],"code":"backend/src/main/java/com/petstore/entity/Appointment.java; backend/src/main/java/com/petstore/service/AppointmentService.java","doc":"docs/产品设计文档.md §三 t_appointment §5.2","test":"AppointmentServiceTest; AppointmentControllerDetailTest; AppointmentControllerIdentityTest","evidence":"anchored"} +{"id":"entity:report","type":"Entity","name":"Report","nameZh":"服务报告","domain":"report","fields":["id","appointment_id","remark","user_id","customer_user_id","author_staff_id","store_id","report_token","pet_name","service_type","appointment_time","staff_name","highlight_video_url","highlight_video_status","highlight_video_error","highlight_duration_sec","highlight_compose_mode","highlight_fail_reason","highlight_share_cover_url","create_time","update_time","deleted"],"systems":["Report Media Backend"],"code":"backend/src/main/java/com/petstore/entity/Report.java; backend/src/main/java/com/petstore/service/ReportService.java","doc":"docs/产品设计文档.md §三 t_report §5.3; docs/API-报告成片字段契约.md","test":"ReportServiceTest; ReportControllerTest","evidence":"anchored"} {"id":"entity:report_lead","type":"Entity","name":"ReportLead","nameZh":"留资线索","domain":"lead","fields":["id","report_id","store_id","phone","pet_name","service_type","wechat_openid","wechat_unionid","reminder_type","remind_date","remind_status","unsubscribe_token","client_ip","create_time","update_time","deleted"],"systems":["Report Media Backend"],"code":"backend/src/main/java/com/petstore/controller/ReportLeadController.java; backend/src/main/java/com/petstore/entity/ReportLead.java","doc":"docs/本期主线-下一步产品优化点.md §C 留资","test":"ReportLeadControllerTest","evidence":"anchored"} {"id":"action:register_boss","type":"Action","name":"registerBoss","nameZh":"老板入驻","inputs":["storeName","bossName","phone","password"],"outputs":["entity:store","entity:user","session_token"],"entrypoints":["POST /api/user/register-boss","UserService#registerBoss"],"appliesRules":["rule:BR-AUTH-001"],"code":"backend/src/main/java/com/petstore/service/UserService.java#registerBoss","evidence":"anchored","domain":"user"} {"id":"action:update_store","type":"Action","name":"updateStore","nameZh":"更新店铺","inputs":["entity:store"],"outputs":["entity:store"],"entrypoints":["PUT /api/store/update","StoreController#update","StoreService#update"],"appliesRules":["rule:BR-STORE-001"],"code":"backend/src/main/java/com/petstore/controller/StoreController.java#update","test":"StoreControllerTest","evidence":"anchored","domain":"store"} @@ -10,12 +10,12 @@ {"id":"action:login","type":"Action","name":"login","nameZh":"手机号验证码登录","inputs":["phone","code"],"outputs":["entity:user","entity:store","session_token"],"entrypoints":["POST /api/user/login","UserService#login"],"appliesRules":["rule:BR-AUTH-001","rule:BR-AUTH-002"],"code":"backend/src/main/java/com/petstore/service/UserService.java#login","test":"UserServiceLoginStrategyTest","evidence":"anchored","domain":"auth"} {"id":"action:wx_phone_login","type":"Action","name":"wxPhoneLogin","nameZh":"微信手机号授权登录","inputs":["phoneCode","loginCode"],"outputs":["entity:user","entity:store","session_token"],"entrypoints":["POST /api/user/wx-phone-login","UserController#wxPhoneLogin","UserService#loginByVerifiedPhone"],"appliesRules":["rule:BR-AUTH-001"],"code":"backend/src/main/java/com/petstore/controller/UserController.java#wxPhoneLogin","evidence":"anchored","domain":"auth"} {"id":"action:bind_wechat","type":"Action","name":"bindWechat","nameZh":"绑定微信标识","inputs":["userId","openid","unionid"],"outputs":["entity:user"],"entrypoints":["UserService#bindWechatMiniIdentity"],"appliesRules":[],"code":"backend/src/main/java/com/petstore/service/UserService.java#bindWechatMiniIdentity","evidence":"anchored","domain":"auth"} -{"id":"action:create_appointment","type":"Action","name":"createAppointment","nameZh":"创建预约","inputs":["petName","petType","serviceType","appointmentTime","storeId","remark","petId"],"outputs":["entity:appointment"],"entrypoints":["POST /api/appointment/create","AppointmentController#create","AppointmentService#createBooking"],"appliesRules":["rule:BR-APPT-001","rule:BR-APPT-004"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#create","evidence":"anchored","domain":"appointment"} +{"id":"action:create_appointment","type":"Action","name":"createAppointment","nameZh":"创建预约","inputs":["petName","petType","serviceType","appointmentTime","storeId","remark","petId","customerUserId","customerPhone","customerName"],"outputs":["entity:appointment"],"entrypoints":["POST /api/appointment/create","AppointmentController#create","AppointmentService#createBooking"],"appliesRules":["rule:BR-APPT-001","rule:BR-APPT-003","rule:BR-APPT-004"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#create","test":"AppointmentControllerIdentityTest; AppointmentServiceTest","evidence":"anchored","domain":"appointment"} {"id":"action:start_service","type":"Action","name":"startService","nameZh":"开始服务","inputs":["appointmentId"],"outputs":["entity:appointment"],"entrypoints":["POST /api/appointment/start","AppointmentController#start","AppointmentService#startService"],"appliesRules":["rule:BR-APPT-001","rule:BR-APPT-002"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#start","test":"AppointmentServiceTest","evidence":"anchored","domain":"appointment"} {"id":"action:transition_appointment_status","type":"Action","name":"transitionAppointmentStatus","nameZh":"更新预约状态","inputs":["id","status"],"outputs":["entity:appointment"],"entrypoints":["PUT /api/appointment/status","AppointmentController#updateStatus","AppointmentService#transitionStatus"],"appliesRules":["rule:BR-APPT-001","rule:BR-APPT-002","rule:BR-APPT-003"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#updateStatus","test":"AppointmentServiceTest","evidence":"anchored","domain":"appointment"} {"id":"action:get_appointment_detail","type":"Action","name":"getAppointmentDetail","nameZh":"预约详情","inputs":["id"],"outputs":["entity:appointment"],"entrypoints":["GET /api/appointment/detail","AppointmentController#detail"],"appliesRules":["rule:BR-APPT-002","rule:BR-APPT-003"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#detail","test":"AppointmentControllerDetailTest","evidence":"anchored","domain":"appointment"} {"id":"action:list_appointments","type":"Action","name":"listAppointments","nameZh":"预约列表","inputs":["status","page","pageSize"],"outputs":["entity:appointment"],"entrypoints":["GET /api/appointment/list","AppointmentController#list"],"appliesRules":["rule:BR-APPT-002","rule:BR-APPT-003"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#list","evidence":"anchored","domain":"appointment"} -{"id":"action:submit_report","type":"Action","name":"submitReport","nameZh":"提交报告","inputs":["entity:report"],"outputs":["reportToken","reportId"],"entrypoints":["POST /api/report/create","ReportController#create","ReportService#create"],"appliesRules":["rule:BR-RPT-001","rule:BR-RPT-002","rule:BR-RPT-003","rule:BR-RPT-004","rule:BR-RPT-005"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#create","test":"ReportControllerTest; ReportServiceTest","evidence":"anchored","domain":"report"} +{"id":"action:submit_report","type":"Action","name":"submitReport","nameZh":"提交报告","inputs":["appointmentId","remark","images"],"outputs":["reportToken","reportId"],"entrypoints":["POST /api/report/create","ReportController#create","ReportService#create"],"appliesRules":["rule:BR-RPT-001","rule:BR-RPT-002","rule:BR-RPT-003","rule:BR-RPT-004","rule:BR-RPT-005"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#create","test":"ReportControllerTest; ReportServiceTest","evidence":"anchored","domain":"report","note":"authorStaffId 从登录上下文派生,customerUserId 从预约派生"} {"id":"action:get_report_by_token","type":"Action","name":"getReportByToken","nameZh":"公开报告查询","inputs":["token"],"outputs":["entity:report"],"entrypoints":["GET /api/report/get","ReportController#getByAppointmentId"],"appliesRules":["rule:BR-RPT-006"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#getByAppointmentId","test":"ReportControllerTest#publicTokenQueryHidesInternalFields","evidence":"anchored","domain":"report"} {"id":"action:track_report_open","type":"Action","name":"trackReportOpen","nameZh":"报告打开埋点","inputs":["token","visitType"],"outputs":[],"entrypoints":["POST /api/report/open-track","ReportController#trackReportOpen"],"appliesRules":["rule:BR-RPT-007"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#trackReportOpen","evidence":"anchored","domain":"report"} {"id":"action:delete_report","type":"Action","name":"deleteReport","nameZh":"删除报告","inputs":["id"],"outputs":[],"entrypoints":["DELETE /api/report/delete","ReportController#delete"],"appliesRules":["rule:BR-RPT-005"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#delete","evidence":"anchored","domain":"report"} @@ -25,7 +25,7 @@ {"id":"event:store_registered","type":"Event","name":"StoreRegistered","nameZh":"门店入驻","domain":"store","payload":["storeId","bossUserId","inviteCode"],"emittedBy":["action:register_boss"],"evidence":"anchored"} {"id":"event:session_issued","type":"Event","name":"SessionIssued","nameZh":"签发会话令牌","domain":"auth","payload":["userId","storeId","role","exp"],"emittedBy":["action:register_boss","action:login","action:wx_phone_login"],"code":"backend/src/main/java/com/petstore/auth/SessionTokenService.java#issue","test":"SessionTokenServiceTest","evidence":"anchored"} {"id":"event:wx_bound","type":"Event","name":"WxBound","nameZh":"微信标识绑定","domain":"auth","payload":["userId","openid","unionid"],"emittedBy":["action:bind_wechat"],"code":"backend/src/main/java/com/petstore/service/UserService.java#bindWechatMiniIdentity","evidence":"anchored"} -{"id":"event:appointment_created","type":"Event","name":"AppointmentCreated","nameZh":"预约创建","domain":"appointment","payload":["appointmentId","storeId","userId","appointmentTime"],"emittedBy":["action:create_appointment"],"code":"backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking","evidence":"anchored"} +{"id":"event:appointment_created","type":"Event","name":"AppointmentCreated","nameZh":"预约创建","domain":"appointment","payload":["appointmentId","storeId","customerUserId","createdByUserId","appointmentTime"],"emittedBy":["action:create_appointment"],"code":"backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking","evidence":"anchored"} {"id":"event:appointment_status_changed","type":"Event","name":"AppointmentStatusChanged","nameZh":"预约状态迁移","domain":"appointment","payload":["appointmentId","fromStatus","toStatus","assignedUserId"],"emittedBy":["action:start_service","action:transition_appointment_status","action:submit_report"],"appliesRules":["rule:BR-APPT-001"],"code":"backend/src/main/java/com/petstore/service/AppointmentService.java#startService; #transitionStatus; ReportService#create","test":"AppointmentServiceTest; ReportServiceTest#createDoingAppointmentMarksDone","evidence":"anchored"} {"id":"event:report_submitted","type":"Event","name":"ReportSubmitted","nameZh":"报告提交","domain":"report","payload":["reportId","appointmentId","storeId","reportToken"],"emittedBy":["action:submit_report"],"appliesRules":["rule:BR-RPT-001","rule:BR-RPT-002","rule:BR-RPT-004"],"code":"backend/src/main/java/com/petstore/service/ReportService.java#create","test":"ReportServiceTest","evidence":"anchored"} {"id":"event:report_opened","type":"Event","name":"ReportOpened","nameZh":"报告首次打开","domain":"report","payload":["reportTokenHash","isFirstOpen","source","ts"],"emittedBy":["action:track_report_open"],"appliesRules":["rule:BR-RPT-007"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#trackReportOpen","doc":"docs/本期主线-下一步产品优化点.md 埋点事件 schema","evidence":"anchored"} @@ -37,7 +37,7 @@ {"id":"rule:BR-STORE-002","type":"Rule","name":"邀请码查询仅 boss/staff + 同店","domain":"store","appliesTo":["action:get_store_by_invite_code"],"code":"backend/src/main/java/com/petstore/controller/StoreController.java#getByInviteCode","test":"StoreControllerTest#inviteCodeCrossStoreForbidden","evidence":"anchored"} {"id":"rule:BR-APPT-001","type":"Rule","name":"预约状态机","domain":"appointment","appliesTo":["action:start_service","action:transition_appointment_status","action:submit_report"],"code":"backend/src/main/java/com/petstore/service/AppointmentService.java#startService; #transitionStatus","test":"AppointmentServiceTest","evidence":"anchored"} {"id":"rule:BR-APPT-002","type":"Rule","name":"预约跨店 403","domain":"appointment","appliesTo":["action:list_appointments","action:start_service","action:transition_appointment_status","action:get_appointment_detail"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java","test":"AppointmentControllerDetailTest","evidence":"anchored"} -{"id":"rule:BR-APPT-003","type":"Rule","name":"预约跨用户 403","domain":"appointment","appliesTo":["action:get_appointment_detail","action:transition_appointment_status"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#detail; #updateStatus","test":"AppointmentControllerDetailTest#customerCannotViewOthersAppointment","evidence":"anchored"} +{"id":"rule:BR-APPT-003","type":"Rule","name":"预约客户归属与跨用户 403","domain":"appointment","appliesTo":["action:create_appointment","action:get_appointment_detail","action:transition_appointment_status"],"code":"backend/src/main/java/com/petstore/controller/AppointmentController.java#create; #detail; #updateStatus; backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking","test":"AppointmentControllerIdentityTest; AppointmentControllerDetailTest#customerCannotViewOthersAppointment","evidence":"anchored"} {"id":"rule:BR-APPT-004","type":"Rule","name":"预约时段校验:半小时档 + 营业时间 + 占用 + 已过","domain":"appointment","appliesTo":["action:create_appointment"],"code":"backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking","doc":"docs/产品设计文档.md §5.2","evidence":"anchored"} {"id":"rule:BR-RPT-001","type":"Rule","name":"报告必须绑定预约","domain":"report","appliesTo":["action:submit_report"],"code":"backend/src/main/java/com/petstore/controller/ReportController.java#create; service/ReportService.java#create","test":"ReportControllerTest#createMissingAppointmentIdReturnsBizCode; ReportServiceTest#createWithNullAppointmentIdThrows","evidence":"anchored"} {"id":"rule:BR-RPT-002","type":"Rule","name":"一约一份报告","domain":"report","appliesTo":["action:submit_report"],"code":"backend/src/main/java/com/petstore/entity/Report.java; mapper/ReportMapper.java; service/ReportService.java#create; controller/ReportController.java#create","test":"ReportServiceTest#createDuplicateReportThrows; ReportControllerTest#createDuplicateReportReturnsAlreadyExists","evidence":"anchored"} @@ -52,11 +52,14 @@ {"id":"rel:store_has_users","type":"Relation","subject":"entity:store","predicate":"HAS","object":"entity:user","cardinality":"1—N","evidence":"anchored","doc":"User.store_id → Store.id","domain":"store"} {"id":"rel:user_belongs_to_store","type":"Relation","subject":"entity:user","predicate":"BELONGS_TO","object":"entity:store","cardinality":"N—1","evidence":"anchored","doc":"boss/staff 归属一个门店;customer 可无门店","domain":"user"} {"id":"rel:store_has_appointments","type":"Relation","subject":"entity:store","predicate":"HAS","object":"entity:appointment","cardinality":"1—N","evidence":"anchored","doc":"Appointment.store_id","domain":"store"} -{"id":"rel:user_creates_appointments","type":"Relation","subject":"entity:user","predicate":"CREATES","object":"entity:appointment","cardinality":"1—N","evidence":"anchored","doc":"Appointment.user_id","domain":"user"} +{"id":"rel:user_creates_appointments","type":"Relation","subject":"entity:user","predicate":"CREATES","object":"entity:appointment","cardinality":"1—N","evidence":"anchored","doc":"Appointment.created_by_user_id","domain":"user"} +{"id":"rel:user_owns_appointments","type":"Relation","subject":"entity:user","predicate":"RECEIVES_SERVICE_IN","object":"entity:appointment","cardinality":"1—N","evidence":"anchored","doc":"Appointment.customer_user_id(客户归属)","domain":"user"} {"id":"rel:user_serves_appointments","type":"Relation","subject":"entity:user","predicate":"SERVES","object":"entity:appointment","cardinality":"1—N","evidence":"anchored","doc":"Appointment.assigned_user_id(谁点谁服务)","domain":"user"} {"id":"rel:appointment_has_report","type":"Relation","subject":"entity:appointment","predicate":"HAS","object":"entity:report","cardinality":"1—1","evidence":"anchored","doc":"Report.appointment_id(uk_report_appointment 唯一约束)","domain":"appointment"} {"id":"rel:report_belongs_to_store","type":"Relation","subject":"entity:report","predicate":"BELONGS_TO","object":"entity:store","cardinality":"N—1","evidence":"anchored","doc":"Report.store_id","domain":"report"} -{"id":"rel:report_served_by_user","type":"Relation","subject":"entity:report","predicate":"SERVED_BY","object":"entity:user","cardinality":"N—1","evidence":"anchored","doc":"Report.user_id(技师)","domain":"report"} +{"id":"rel:report_served_by_user","type":"Relation","subject":"entity:report","predicate":"SERVED_BY","object":"entity:user","cardinality":"N—1","evidence":"anchored","doc":"Report.appointment_id -> Appointment.assigned_user_id(服务技师)","domain":"report"} +{"id":"rel:report_authored_by_user","type":"Relation","subject":"entity:report","predicate":"AUTHORED_BY","object":"entity:user","cardinality":"N—1","evidence":"anchored","doc":"Report.author_staff_id(报告作者)","domain":"report"} +{"id":"rel:report_belongs_to_customer","type":"Relation","subject":"entity:report","predicate":"BELONGS_TO_CUSTOMER","object":"entity:user","cardinality":"N—1","evidence":"anchored","doc":"Report.customer_user_id(报告归属客户)","domain":"report"} {"id":"rel:report_has_leads","type":"Relation","subject":"entity:report","predicate":"HAS","object":"entity:report_lead","cardinality":"1—N","evidence":"anchored","doc":"ReportLead.report_id(同手机号去重)","domain":"report"} {"id":"rel:lead_belongs_to_store","type":"Relation","subject":"entity:report_lead","predicate":"BELONGS_TO","object":"entity:store","cardinality":"N—1","evidence":"anchored","doc":"ReportLead.store_id(回访池范围)","domain":"lead"} {"id":"rel:register_boss_emits_store_registered","type":"Relation","subject":"action:register_boss","predicate":"EMITS","object":"event:store_registered","evidence":"anchored","domain":"user"} diff --git a/ontology/actions.md b/ontology/actions.md index 3020ecf..74ff60c 100644 --- a/ontology/actions.md +++ b/ontology/actions.md @@ -84,12 +84,13 @@ ### action:create_appointment -创建预约:半小时占号,取消不占号;`userId` 从当前登录用户派生。 +创建预约:半小时占号,取消不占号;客户与创建人分开记录。 - **入口**:`POST /api/appointment/create` → `AppointmentController#create` → `AppointmentService#createBooking` -- **输入**:`petName`、`petType`、`serviceType`、`appointmentTime`、`storeId`、`remark?`、`petId?` +- **输入**:`petName`、`petType`、`serviceType`、`appointmentTime`、`storeId`、`remark?`、`petId?`;门店代客预约另需 `customerUserId` 或 `customerPhone` - **输出**:`entity:appointment` -- **适用规则**:`rule:BR-APPT-001`(状态机)、`rule:BR-APPT-004`(半小时档 + 营业时间 + 占用校验) +- **身份口径**:customer 自约时客户=创建人=current.userId;boss/staff 代约时创建人=current.userId,客户按请求解析,legacy userId 镜像客户 +- **适用规则**:`rule:BR-APPT-001`(状态机)、`rule:BR-APPT-003`(客户归属)、`rule:BR-APPT-004`(半小时档 + 营业时间 + 占用校验) - **代码**:`backend/src/main/java/com/petstore/controller/AppointmentController.java#create` - **证据**:`anchored` @@ -136,10 +137,10 @@ ### action:submit_report -提交报告:仅 boss/staff;`userId` 从上下文派生;报告所属门店必须等于当前用户门店。 +提交报告:仅 boss/staff;`authorStaffId` 从上下文派生,`customerUserId` 从预约派生;报告所属门店必须等于当前用户门店。 - **入口**:`POST /api/report/create` → `ReportController#create` → `ReportService#create` -- **输入**:`entity:report`(含 `appointmentId`、`images`) +- **输入**:`appointmentId`、`remark?`、`images`;身份字段不从请求体采信 - **输出**:`reportToken`、`reportId` - **适用规则**:`rule:BR-RPT-001`(必须绑定预约)、`rule:BR-RPT-002`(一约一份)、`rule:BR-RPT-003`(前后照片必填)、`rule:BR-RPT-004`(仅 doing 预约)、`rule:BR-RPT-005`(跨店 403) - **代码**:`backend/src/main/java/com/petstore/controller/ReportController.java#create` diff --git a/ontology/events.md b/ontology/events.md index 4a0c791..2384036 100644 --- a/ontology/events.md +++ b/ontology/events.md @@ -40,7 +40,7 @@ 预约创建成功(`status=new`,下单即占号生效)。 - **触发动作**:`action:create_appointment` -- **payload**:`appointmentId`、`storeId`、`userId`、`appointmentTime` +- **payload**:`appointmentId`、`storeId`、`customerUserId`、`createdByUserId`、`appointmentTime` - **代码**:`backend/src/main/java/com/petstore/service/AppointmentService.java#createBooking` - **证据**:`anchored` @@ -49,7 +49,7 @@ 预约状态迁移(`new→doing`、`new→cancel`、`doing→done`、`doing→cancel`)。 - **触发动作**:`action:start_service`(`new→doing`)、`action:transition_appointment_status`、`action:submit_report`(`doing→done`) -- **payload**:`appointmentId`、`fromStatus`、`toStatus`、`assignedUserId?` +- **payload**:`appointmentId`、`fromStatus`、`toStatus`、`assignedStaffId?` - **适用规则**:`rule:BR-APPT-001` - **代码**:`backend/src/main/java/com/petstore/service/AppointmentService.java#startService`/`#transitionStatus`、`backend/src/main/java/com/petstore/service/ReportService.java#create`(置 done) - **测试**:`AppointmentServiceTest`、`ReportServiceTest#createDoingAppointmentMarksDone` diff --git a/ontology/objects.md b/ontology/objects.md index 354bca2..18fcf08 100644 --- a/ontology/objects.md +++ b/ontology/objects.md @@ -67,8 +67,10 @@ | appointment_time | DATETIME | 预约时间(半小时档,按营业时间/占用/已过校验) | | status | VARCHAR | new / doing / done / cancel | | store_id | BIGINT | 所属门店 | -| user_id | BIGINT | 创建人 ID(谁建的) | -| assigned_user_id | BIGINT | 技师 ID(开始服务时赋值,谁点谁服务) | +| customer_user_id | BIGINT | 被服务客户账号;新业务归属主字段 | +| created_by_user_id | BIGINT | 实际创建预约的登录账号(customer/boss/staff) | +| user_id | BIGINT | legacy 兼容;新数据镜像 customer_user_id | +| assigned_user_id | BIGINT | 服务技师 ID(开始服务时赋值,API 别名 assignedStaffId) | | pet_id | BIGINT | 宠物档案 ID | | remark | TEXT | 备注 | | create_time / update_time | DATETIME | 时间戳 | @@ -89,13 +91,15 @@ | id | BIGINT | 主键 | | appointment_id | BIGINT | 关联预约 ID(唯一约束 `uk_report_appointment`) | | remark | TEXT | 备注 | -| user_id | BIGINT | 技师 ID | +| customer_user_id | BIGINT | 报告归属客户,从预约派生 | +| author_staff_id | BIGINT | 实际提交报告的门店账号 | +| user_id | BIGINT | legacy 兼容;新数据镜像 author_staff_id | | store_id | BIGINT | 所属门店 | | report_token | VARCHAR | 公开访问令牌(UUID,不带回包给公开页) | | pet_name | VARCHAR | 宠物名(冗余) | | service_type | VARCHAR | 服务类型(冗余) | | appointment_time | DATETIME | 服务时间(冗余) | -| staff_name | VARCHAR | 技师名(冗余) | +| staff_name | VARCHAR | 服务技师名快照(优先取预约 assigned_user_id) | | highlight_video_url | VARCHAR(500) | 成片 MP4 相对 URL | | highlight_video_status | VARCHAR(32) | processing / done / failed | | highlight_video_error | VARCHAR(500) | 错误信息 | diff --git a/ontology/relations.md b/ontology/relations.md index 9ea1a3f..c48c0b9 100644 --- a/ontology/relations.md +++ b/ontology/relations.md @@ -9,10 +9,10 @@ | `Store` 1—N `User` | 一个门店有多个用户(boss 1 + staff N + customer 不挂门店) | `User.store_id` → `Store.id` | | `User` N—1 `Store` | boss/staff 归属一个门店;customer 可无门店 | — | | `Store` 1—N `Appointment` | 一个门店有多个预约 | `Appointment.store_id` → `Store.id` | -| `User` 1—N `Appointment` | 一个用户创建多个预约(`user_id`);技师服务多个预约(`assigned_user_id`) | `Appointment.user_id` / `assigned_user_id` → `User.id` | +| `User` 1—N `Appointment` | 一个客户拥有多次预约;一个账号可创建多次预约;一个技师服务多次预约 | `Appointment.customer_user_id` / `created_by_user_id` / `assigned_user_id` → `User.id` | | `Appointment` 1—1 `Report` | 一约一份报告(`uk_report_appointment` 唯一约束) | `Report.appointment_id` → `Appointment.id` | | `Report` N—1 `Store` | 报告归属门店 | `Report.store_id` → `Store.id` | -| `Report` N—1 `User` | 报告技师 | `Report.user_id` → `User.id` | +| `Report` N—1 `User` | 报告分别归属客户与提交作者;服务技师由预约承载 | `Report.customer_user_id` / `author_staff_id` → `User.id` | | `Report` 1—N `ReportImage` | 报告含多张照片(before/after/during) | `ReportImage.report_id` → `Report.id` | | `Report` 1—N `ReportLead` | 一份报告可多次留资(同手机号去重) | `ReportLead.report_id` → `Report.id` | | `ReportLead` N—1 `Store` | 留资归属门店(回访池范围) | `ReportLead.store_id` → `Store.id` | diff --git a/ontology/rules.md b/ontology/rules.md index 6876a08..5868ed2 100644 --- a/ontology/rules.md +++ b/ontology/rules.md @@ -73,11 +73,11 @@ ### rule:BR-APPT-003 -**预约跨用户 403**:customer 仅可查看/取消自己的预约(`userId == current.userId`),不可操作他人预约。 +**预约客户归属与跨用户 403**:customer 仅可查看/取消自己的预约(`customerUserId == current.userId`;未回填旧数据可回退 legacy `userId`),不可操作他人预约。customer 自约时客户与创建人均从上下文派生;boss/staff 代客预约必须解析真实 customer,禁止把操作员工写成客户。`petId` 存在时必须属于该客户。 -- **适用动作**:`action:get_appointment_detail`、`action:transition_appointment_status`(仅 cancel) +- **适用动作**:`action:create_appointment`、`action:get_appointment_detail`、`action:transition_appointment_status`(仅 cancel) - **代码**:`backend/src/main/java/com/petstore/controller/AppointmentController.java#detail`/`#updateStatus` -- **测试**:`AppointmentControllerDetailTest#customerCannotViewOthersAppointment` +- **测试**:`AppointmentControllerIdentityTest`、`AppointmentControllerDetailTest#customerCannotViewOthersAppointment` - **证据**:`anchored` ### rule:BR-APPT-004 diff --git a/产品设计文档.md b/产品设计文档.md index ce8a5fc..d2d3359 100644 --- a/产品设计文档.md +++ b/产品设计文档.md @@ -98,8 +98,10 @@ | appointment_time | DATETIME | 预约时间(以门店营业时间、半小时档、已占用档和已过时间为准;同一门店同一时间档最多一单) | | status | VARCHAR | new(待开始)/ doing(服务中)/ done(已完成)/ cancel(已取消) | | store_id | BIGINT | 店铺ID | -| user_id | BIGINT | 创建人ID(谁建的) | -| assigned_user_id | BIGINT | 技师ID(开始服务时赋值) | +| customer_user_id | BIGINT | 被服务客户账号 | +| created_by_user_id | BIGINT | 实际创建预约的账号 | +| user_id | BIGINT | legacy 兼容,新数据镜像 customer_user_id | +| assigned_user_id | BIGINT | 服务技师ID(开始服务时赋值,API 别名 assignedStaffId) | | remark | TEXT | 备注 | | create_time | DATETIME | 创建时间 | | update_time | DATETIME | 更新时间 | @@ -110,13 +112,15 @@ | id | BIGINT | 主键 | | appointment_id | BIGINT | 预约ID | | remark | TEXT | 备注 | -| user_id | BIGINT | 技师ID | +| customer_user_id | BIGINT | 报告归属客户,从预约派生 | +| author_staff_id | BIGINT | 实际提交报告的门店账号 | +| user_id | BIGINT | legacy 兼容,新数据镜像 author_staff_id | | store_id | BIGINT | 店铺ID | | report_token | VARCHAR | 报告访问令牌(UUID,永久有效) | | pet_name | VARCHAR | 宠物名(冗余) | | service_type | VARCHAR | 服务类型(冗余) | | appointment_time | DATETIME | 服务时间(冗余) | -| staff_name | VARCHAR | 技师名(冗余) | +| staff_name | VARCHAR | 服务技师名快照(冗余) | | create_time | DATETIME | 创建时间 | | update_time | DATETIME | 更新时间 | diff --git a/架构决策-身份字段拆分-2026-08-01.md b/架构决策-身份字段拆分-2026-08-01.md new file mode 100644 index 0000000..f055ee8 --- /dev/null +++ b/架构决策-身份字段拆分-2026-08-01.md @@ -0,0 +1,70 @@ +# 架构决策:预约与报告身份字段拆分 + +> 日期:2026-08-01 +> +> 状态:已采纳,兼容迁移期 +> +> 范围:Backend Core、Report Media Backend、Store Miniapp、Store Admin + +## 1. 决策背景 + +RC1 之前,`Appointment.user_id` 同时被解释为客户或创建人;门店代客预约会把操作员工写入该字段。`Report.user_id` 实际保存技师/报告提交人,但宠主报告列表又曾把它当客户归属。两处语义冲突会污染客户档案、报告可见性、回访归因和后续经营指标。 + +## 2. 冻结语义 + +| 聚合 | 字段 | 语义 | +|---|---|---| +| Appointment | `customer_user_id` | 被服务的客户账号 | +| Appointment | `created_by_user_id` | 实际创建预约的登录账号,可为 customer/boss/staff | +| Appointment | `assigned_user_id` | 服务技师;API 新增语义别名 `assignedStaffId` | +| Appointment | `user_id` | legacy 兼容字段;新数据镜像 `customer_user_id` | +| Report | `customer_user_id` | 报告归属客户,从 Appointment 派生 | +| Report | `author_staff_id` | 实际提交报告的门店账号 | +| Report | `user_id` | legacy 兼容字段;新数据镜像 `author_staff_id` | +| Report | `staff_name` | 服务技师姓名快照,优先取 Appointment.assigned_user_id | + +“服务技师”和“报告作者”允许不同:老板可代员工提交报告,但报告仍展示实际服务技师。 + +## 3. 写入规则 + +### 宠主自行预约 + +- `customerUserId = current.userId` +- `createdByUserId = current.userId` +- 忽略请求体中的身份字段 + +### 门店代客预约 + +- `createdByUserId = current.userId` +- 必须提交 `customerUserId` 或 `customerPhone` +- 手机号不存在时创建 customer 账号;若手机号已绑定 boss/staff,则拒绝静默复用 +- `petId` 存在时必须属于解析出的客户 + +### 提交报告 + +- `authorStaffId = current.userId` +- `customerUserId = appointment.customerUserId` +- 服务技师继续来自 `appointment.assignedUserId` + +## 4. 历史回填 + +迁移脚本:`backend/db/migrations/20260801_split_service_identity.sql`。 + +预约客户按以下证据顺序回填: + +1. `pet.owner_user_id` +2. legacy `user_id` 且对应账号角色为 customer +3. 无可靠证据则保持空值,进入人工修正清单 + +报告客户只从关联预约回填;`report.user_id` 仅用于回填 `author_staff_id`,禁止当作客户。 + +## 5. 兼容与退出条件 + +- 兼容期继续读写 legacy `userId` / `assignedUserId`,同时输出 canonical 字段。 +- 客户预约查询优先 `customer_user_id`,字段为空时回退 legacy `user_id`。 +- 客户报告查询通过 `report.customer_user_id` 或关联预约归属,不再按 `report.user_id` 查询。 +- 只有在生产回填完成、旧客户端退出、关键指标稳定后,才可另立迁移删除 legacy 字段。 + +## 6. 非目标 + +本决策不引入多角色账号、跨店客户共享、StoreCustomer 主档、订单/支付或多门店组织;这些保持在后续独立阶段。