1229 lines
39 KiB
Markdown
1229 lines
39 KiB
Markdown
# Cursor P0 Handoff Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 给 Cursor 一个可直接执行、可分批 review 的 P0 稳定化开发交接包。
|
||
|
||
**Architecture:** 本计划不重做产品方向,只补齐 P0 进入验收前必须收口的边界、隐私、报告不变量、预约登录深链、配置发布和测试基线。前端按现有 uni-app/Vue 结构小步修正;后端在不引入完整 Spring Security 的前提下,用服务端签发的 HMAC session token 建立最小鉴权上下文,再逐步替换客户端传入的 `userId/storeId/role` 权限依据。
|
||
|
||
**Tech Stack:** Java 17, Spring Boot 3.2.3, Spring Data JPA, MySQL, uni-app, Vue 3, Vite, WeChat Mini Program, Maven, npm.
|
||
|
||
---
|
||
|
||
## 0. Cursor 执行规则
|
||
|
||
把下面这段作为每次开新 Cursor 会话的总提示词:
|
||
|
||
```text
|
||
你现在在 /Users/apple/_src/petstore 工作区开发宠小它 P0 稳定化任务。根目录不是单一 git 仓,docs、backend、frontend 分别是仓库或独立状态区。开始前先运行:
|
||
- git -C docs status --short
|
||
- git -C backend status --short
|
||
- git -C frontend status --short
|
||
|
||
不要回退用户已有改动,不要删除 .agents/、AGENTS.md、.DS_Store 等既存变更。每批任务只改指定文件。不要把真实 DB 密码、微信 appsecret、生产密钥写进代码、注释、提交信息或日志。发现敏感配置时只替换为环境变量占位,并在交付说明里写“已移除明文敏感项,需运维轮换”。
|
||
|
||
本期冻结口径:
|
||
1. P0 只做宠主预约、公开报告、留资、基础埋点、报告分享和门店服务闭环。
|
||
2. 会员、储值、套餐、复杂 CRM、历史报告汇总全部放到 P1/P2,不在本批实现。
|
||
3. 不写“到店不排队”这类无容量模型承诺,统一写“在线选时段,到店更省心”。
|
||
4. P0 报告必须绑定预约 appointmentId;无预约报告不作为本期主路径。
|
||
5. 报告提交要求服务前和服务后至少各 1 张照片,服务过程中素材可选。
|
||
6. 宠主预约采用“提交时登录 + guest 草稿恢复”;未登录可以填写,提交时登录,登录后恢复表单。
|
||
7. 公开报告页可以返回用于预约跳转的公开门店 id,但不能返回 top-level userId、内部 staff userId、reportToken 回包。
|
||
|
||
每批完成后必须写清:
|
||
- 修改文件
|
||
- 行为变化
|
||
- 验收命令与结果
|
||
- 未覆盖风险
|
||
```
|
||
|
||
## 1. 文件结构与责任
|
||
|
||
### 文档
|
||
|
||
- Modify: `docs/产品设计文档.md`
|
||
负责冻结本期产品范围、状态矩阵、报告必填项、预约时间规则、数据模型口径。
|
||
- Modify: `docs/P0-研发落地清单.md`
|
||
负责标记 `已实现 / 部分实现 / 未实现 / 待测试`,并绑定 owner 与验收命令。
|
||
- Modify: `docs/宠主端预约体验优化-产品说明.md`
|
||
负责预约深链、登录草稿策略和无容量承诺文案。
|
||
- Modify: `docs/产品全方位优化说明-v1.md`
|
||
负责把会员储值从 P0 移到 P1/P2。
|
||
- Modify: `docs/本期主线-下一步产品优化点.md`
|
||
负责补埋点事件 schema。
|
||
- Modify: `docs/公众号协同与全链路触达-产品说明.md`
|
||
负责拆分代码验收与公众号后台运营配置验收。
|
||
- Modify: `backend/README.md`, `frontend/README.md`
|
||
负责本地开发、RC、生产上线依赖说明。
|
||
|
||
### 前端
|
||
|
||
- Modify: `frontend/src/utils/session.js`
|
||
负责 session token 存取、角色判断 helper、guest draft 可用的 session 工具。
|
||
- Modify: `frontend/src/api/index.js`
|
||
负责自动附加 `Authorization`,并逐步停止把身份参数作为权限依据传给后端。
|
||
- Modify: `frontend/src/pages/login/Login.vue`
|
||
负责保存后端返回的 `sessionToken`,登录后恢复 redirect。
|
||
- Modify: `frontend/src/pages/report-view/reportView.vue`
|
||
负责公开报告页角色判断、留资/预约 CTA、公开 token 使用方式。
|
||
- Modify: `frontend/src/pages/report/Report.vue`
|
||
负责报告前后照片必填、重复/非法状态错误提示。
|
||
- Modify: `frontend/src/pages/appointment/CustAppointmentCreate.vue`
|
||
负责 `storeId` query、`scene` 解码、guest 草稿、提交时登录恢复。
|
||
- Modify: `frontend/src/pages/mine/Leads.vue`
|
||
负责 openid/unionid 不默认明文展示。
|
||
- Optional Modify: `frontend/src/components/AppPageState.vue`
|
||
负责共享空态默认视觉。
|
||
|
||
### 后端
|
||
|
||
- Create: `backend/src/main/java/com/petstore/auth/CurrentUser.java`
|
||
当前登录用户上下文数据结构。
|
||
- Create: `backend/src/main/java/com/petstore/auth/CurrentUserContext.java`
|
||
ThreadLocal 读写当前用户。
|
||
- Create: `backend/src/main/java/com/petstore/auth/SessionTokenService.java`
|
||
HMAC session token 签发与校验。
|
||
- Create: `backend/src/main/java/com/petstore/auth/AuthInterceptor.java`
|
||
解析 `Authorization: Bearer ...`,并拦截受保护接口。
|
||
- Modify: `backend/src/main/java/com/petstore/config/WebConfig.java`
|
||
注册鉴权拦截器,保留 upload resource handler。
|
||
- Modify: `backend/src/main/java/com/petstore/service/UserService.java`
|
||
登录成功响应中增加 `sessionToken`,清理返回密码。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/UserController.java`
|
||
登录、微信登录、员工/用户接口使用上下文校验。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/AppointmentController.java`
|
||
列表、详情、创建、开始、状态变更从上下文派生身份边界。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/ReportController.java`
|
||
公开报告字段裁剪、token hash 埋点、报告创建不变量、列表边界。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/ReportLeadController.java`
|
||
公开留资保留匿名,回访池加鉴权和脱敏。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/PetController.java`
|
||
宠物列表/创建/更新/删除/历史从上下文派生边界。
|
||
- Modify: `backend/src/main/java/com/petstore/controller/FileController.java`
|
||
上传和访问路径归一化、扩展名/MIME 防护。
|
||
- Modify: `backend/src/main/java/com/petstore/entity/Report.java`
|
||
增加 `appointment_id` 唯一约束。
|
||
- Modify: `backend/src/main/java/com/petstore/mapper/ReportMapper.java`
|
||
增加 `existsByAppointmentIdAndDeletedFalse`。
|
||
- Modify: `backend/src/main/java/com/petstore/service/ReportService.java`
|
||
拒绝重复报告和无预约报告。
|
||
- Modify: `backend/src/main/java/com/petstore/PetstoreApplication.java`
|
||
移除启动期 DDL/历史数据 UPDATE。
|
||
- Modify: `backend/src/main/resources/application.yml`, `backend/src/main/resources/application-example.yml`
|
||
改为环境变量和 profile 口径,不保留真实敏感值。
|
||
- Modify: `backend/pom.xml`
|
||
增加 test scope 依赖。
|
||
- Create: `backend/src/test/java/com/petstore/...`
|
||
增加最小服务层/控制器测试。
|
||
|
||
## 2. 推荐批次
|
||
|
||
1. Batch A: 文档口径 + 前端低风险修正。
|
||
2. Batch B: 后端公开报告、报告不变量、线索脱敏。
|
||
3. Batch C: 最小鉴权上下文 + 跨店/跨用户边界。
|
||
4. Batch D: 配置、上传、迁移和测试/RC 门禁。
|
||
|
||
每个 batch 都要独立可构建、可 review。不要让 Cursor 一次性执行所有 batch。
|
||
|
||
## 3. Batch A - 文档口径与前端低风险修正
|
||
|
||
### Task A1: 冻结产品文档口径
|
||
|
||
**Files:**
|
||
- Modify: `docs/产品设计文档.md`
|
||
- Modify: `docs/P0-研发落地清单.md`
|
||
- Modify: `docs/宠主端预约体验优化-产品说明.md`
|
||
- Modify: `docs/产品全方位优化说明-v1.md`
|
||
- Modify: `docs/本期主线-下一步产品优化点.md`
|
||
- Modify: `docs/公众号协同与全链路触达-产品说明.md`
|
||
|
||
- [ ] **Step 1: 更新本期范围**
|
||
|
||
把本期范围统一为:
|
||
|
||
```markdown
|
||
本期 P0 范围:
|
||
- 门店端:预约列表、开始服务、填写报告、发送报告、回访池。
|
||
- 宠主端:预约填单、公开报告页、下次服务提醒留资、报告打开基础埋点。
|
||
- 报告能力:服务前/服务后照片、服务过程素材、短片生成与下载、分享链接/二维码/话术。
|
||
|
||
不进入 P0:
|
||
- 会员、储值、套餐、复杂 CRM。
|
||
- 宠主账号内历史报告汇总。
|
||
- 完整经营分析看板。
|
||
```
|
||
|
||
- [ ] **Step 2: 修正预约与文案口径**
|
||
|
||
把“到店不排队”改成:
|
||
|
||
```markdown
|
||
在线选时段,到店更省心。
|
||
```
|
||
|
||
把“预约时间无限制,随便选”改成:
|
||
|
||
```markdown
|
||
预约时间以门店营业时间、半小时档、已占用档和已过时间为准;同一门店同一时间档最多一单。
|
||
```
|
||
|
||
- [ ] **Step 3: 更新报告必填项**
|
||
|
||
写入:
|
||
|
||
```markdown
|
||
报告提交必填:
|
||
- 宠物名称
|
||
- 服务类型
|
||
- 服务时间
|
||
- 服务前照片至少 1 张
|
||
- 服务后照片至少 1 张
|
||
|
||
服务过程中照片/视频、备注为选填。
|
||
```
|
||
|
||
- [ ] **Step 4: 跑文档冲突扫描**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
rg "到店不排队|会员储值 \\| 路线图 P0|宠主通过微信查看服务报告(v2)|无限制,随便选" docs
|
||
git -C docs diff --check
|
||
```
|
||
|
||
Expected:
|
||
- 第一条不再命中冲突口径。
|
||
- `git diff --check` 无输出。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git -C docs add 产品设计文档.md P0-研发落地清单.md 宠主端预约体验优化-产品说明.md 产品全方位优化说明-v1.md 本期主线-下一步产品优化点.md 公众号协同与全链路触达-产品说明.md
|
||
git -C docs commit -m "docs: freeze petstore p0 product scope"
|
||
```
|
||
|
||
如果当前不允许提交,只输出上述 staged 文件和 diff 摘要。
|
||
|
||
### Task A2: 修正公开报告页角色判断与预约 CTA
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/report-view/reportView.vue`
|
||
- Modify: `frontend/src/utils/session.js`
|
||
|
||
- [ ] **Step 1: 增加角色 helper**
|
||
|
||
在 `frontend/src/utils/session.js` 增加:
|
||
|
||
```js
|
||
export const isStaffRole = (role) => role === 'boss' || role === 'staff'
|
||
|
||
export const getUserRole = () => {
|
||
const u = getUserSession()
|
||
return u?.role || ''
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 替换报告页员工态判断**
|
||
|
||
在 `reportView.vue` 把:
|
||
|
||
```js
|
||
import { isLoggedIn } from '../../utils/session.js'
|
||
const isStaff = computed(() => isLoggedIn())
|
||
```
|
||
|
||
改为:
|
||
|
||
```js
|
||
import { getUserSession, isStaffRole } from '../../utils/session.js'
|
||
|
||
const currentUser = computed(() => getUserSession() || {})
|
||
const isStaff = computed(() => isStaffRole(currentUser.value?.role))
|
||
```
|
||
|
||
- [ ] **Step 3: 公开报告 token 不依赖回包 token**
|
||
|
||
把:
|
||
|
||
```js
|
||
const reminderToken = computed(() => reportData.value?.reportToken || getRouteToken())
|
||
```
|
||
|
||
改为:
|
||
|
||
```js
|
||
const reminderToken = computed(() => getRouteToken())
|
||
```
|
||
|
||
- [ ] **Step 4: “我也要预约”带门店参数**
|
||
|
||
把 `goHome` 改成优先进入预约页。公开报告后端在 Batch B 会返回 `store.id` 或 `bookingStoreId`:
|
||
|
||
```js
|
||
const bookingStoreId = computed(() => reportData.value?.store?.id || reportData.value?.bookingStoreId || '')
|
||
|
||
const goHome = () => {
|
||
const sid = bookingStoreId.value
|
||
if (sid) {
|
||
uni.navigateTo({ url: `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(sid)}` })
|
||
return
|
||
}
|
||
uni.reLaunch({ url: '/pages/home/Home' })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 验收**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm --prefix frontend install
|
||
npm --prefix frontend run build:h5
|
||
```
|
||
|
||
Expected:
|
||
- build 通过。
|
||
- customer 登录态打开报告页仍显示 `ReminderCard` 和“我也要预约”。
|
||
- boss/staff 登录态打开报告页显示“转发给宠主 / 保存海报到相册”。
|
||
|
||
### Task A3: 报告前后照片必填
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/report/Report.vue`
|
||
- Later backend mirror: `backend/src/main/java/com/petstore/controller/ReportController.java`
|
||
|
||
- [ ] **Step 1: 前端提交前增加素材数量校验**
|
||
|
||
在 `submitReport` 的基础字段校验后、`submitting.value = true` 前增加:
|
||
|
||
```js
|
||
const validBefore = report.value.beforeMedia.filter((item) => !item.uploading && !item.failed)
|
||
const validAfter = report.value.afterMedia.filter((item) => !item.uploading && !item.failed)
|
||
if (validBefore.length < 1) return uni.showToast({ title: '请至少上传1张服务前照片', icon: 'none' })
|
||
if (validAfter.length < 1) return uni.showToast({ title: '请至少上传1张服务后照片', icon: 'none' })
|
||
```
|
||
|
||
- [ ] **Step 2: 构建 images 时复用有效列表**
|
||
|
||
把 before/after 构建改为使用 `validBefore`、`validAfter`,避免重复 filter:
|
||
|
||
```js
|
||
validBefore.forEach((item, i) => {
|
||
images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||
})
|
||
validAfter.forEach((item, i) => {
|
||
images.push({ photoUrl: item.url, photoType: 'after', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 验收**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm --prefix frontend run build:h5
|
||
npm --prefix frontend run build:mp-weixin
|
||
```
|
||
|
||
Expected:
|
||
- 无服务前照片时提示“请至少上传1张服务前照片”。
|
||
- 无服务后照片时提示“请至少上传1张服务后照片”。
|
||
- 有前后照片时仍可提交。
|
||
|
||
### Task A4: 预约 deep link 与提交时登录草稿
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/appointment/CustAppointmentCreate.vue`
|
||
- Modify: `frontend/src/pages/login/Login.vue`
|
||
- Modify: `frontend/src/utils/session.js`
|
||
|
||
- [ ] **Step 1: 增加 scene 解码**
|
||
|
||
在 `CustAppointmentCreate.vue` 的 `onLoad` 处理里支持:
|
||
|
||
```js
|
||
function parseSceneStoreId(scene) {
|
||
if (!scene) return ''
|
||
let decoded = String(scene)
|
||
try { decoded = decodeURIComponent(decoded) } catch (_) {}
|
||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||
return params.get('storeId') || params.get('sid') || ''
|
||
}
|
||
```
|
||
|
||
读取顺序:
|
||
|
||
```js
|
||
const incomingStoreId = options?.storeId || parseSceneStoreId(options?.scene)
|
||
```
|
||
|
||
- [ ] **Step 2: guest 草稿 key 不依赖 userId**
|
||
|
||
把草稿 key 拆成两类:
|
||
|
||
```js
|
||
const APPT_DRAFT_GUEST_KEY = 'petstore_appt_draft_guest'
|
||
const apptDraftUserKey = (userId) => `petstore_appt_draft_${userId}`
|
||
```
|
||
|
||
未登录时写 `APPT_DRAFT_GUEST_KEY`;登录后先读 guest draft,恢复成功后删除 guest draft。
|
||
|
||
- [ ] **Step 3: 未登录提交跳登录并带 redirect**
|
||
|
||
提交前如果没有 `userInfo.id`:
|
||
|
||
```js
|
||
saveGuestApptDraft()
|
||
const redirect = encodeURIComponent('/pages/appointment/CustAppointmentCreate')
|
||
uni.navigateTo({ url: `/pages/login/Login?redirect=${redirect}` })
|
||
return
|
||
```
|
||
|
||
如果当前预约页带 `storeId`,redirect 要带回 query:
|
||
|
||
```js
|
||
const target = selectedStoreId.value
|
||
? `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(selectedStoreId.value)}`
|
||
: '/pages/appointment/CustAppointmentCreate'
|
||
```
|
||
|
||
- [ ] **Step 4: 登录页保存 session 后按 redirect 回跳**
|
||
|
||
`Login.vue` 已有 `redirectAfterLogin`,保持安全校验,只要确保登录成功后恢复目标页。
|
||
|
||
- [ ] **Step 5: 验收**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
npm --prefix frontend run build:h5
|
||
npm --prefix frontend run build:mp-weixin
|
||
```
|
||
|
||
Expected:
|
||
- `/pages/appointment/CustAppointmentCreate?storeId=1` 预填门店。
|
||
- `/pages/appointment/CustAppointmentCreate?scene=storeId%3D1` 预填门店。
|
||
- 未登录填写后提交跳登录;登录返回后门店、宠物、服务、时段、备注恢复。
|
||
|
||
## 4. Batch B - 公开报告、报告不变量、线索脱敏
|
||
|
||
### Task B1: 公开报告字段裁剪与 token hash 日志
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/java/com/petstore/controller/ReportController.java`
|
||
|
||
- [ ] **Step 1: 增加 SHA-256 token hash helper**
|
||
|
||
在 `ReportController` 增加:
|
||
|
||
```java
|
||
private static String shortTokenHash(String token) {
|
||
if (token == null || token.isBlank()) {
|
||
return "";
|
||
}
|
||
try {
|
||
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
|
||
byte[] digest = md.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < Math.min(8, digest.length); i++) {
|
||
sb.append(String.format("%02x", digest[i]));
|
||
}
|
||
return sb.toString();
|
||
} catch (Exception e) {
|
||
return "hash_error";
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: open-track 日志不打印 token 前缀**
|
||
|
||
把:
|
||
|
||
```java
|
||
String prefix = token.length() > 16 ? token.substring(0, 16) + "…" : token;
|
||
log.info("report_open tokenPrefix={} visitType={}", prefix, visitType);
|
||
```
|
||
|
||
改为:
|
||
|
||
```java
|
||
log.info("report_open tokenHash={} visitType={}", shortTokenHash(token), visitType);
|
||
```
|
||
|
||
- [ ] **Step 3: 公开 token 查询不返回内部字段**
|
||
|
||
在 `getByAppointmentId` 中区分:
|
||
|
||
```java
|
||
boolean publicTokenRequest = token != null && !token.isEmpty();
|
||
```
|
||
|
||
如果 `publicTokenRequest` 为 true:
|
||
- 不返回 top-level `userId`
|
||
- 不返回 top-level `storeId`
|
||
- 不返回 `reportToken`
|
||
- 可在 `store` 对象内返回 `id` 作为预约跳转公开字段
|
||
|
||
建议公开字段:
|
||
|
||
```java
|
||
data.put("id", report.getId());
|
||
data.put("appointmentId", report.getAppointmentId());
|
||
data.put("beforePhotos", beforePhotos);
|
||
data.put("afterPhotos", afterPhotos);
|
||
data.put("duringMedia", duringMedia);
|
||
data.put("remark", report.getRemark());
|
||
data.put("petName", report.getPetName());
|
||
data.put("serviceType", report.getServiceType());
|
||
data.put("appointmentTime", report.getAppointmentTime());
|
||
data.put("staffName", report.getStaffName());
|
||
data.put("createTime", report.getCreateTime());
|
||
```
|
||
|
||
`store` 对象建议:
|
||
|
||
```java
|
||
storeInfo.put("id", store.getId());
|
||
storeInfo.put("name", store.getName());
|
||
storeInfo.put("logo", store.getLogo());
|
||
storeInfo.put("phone", store.getPhone());
|
||
storeInfo.put("address", store.getAddress());
|
||
```
|
||
|
||
- [ ] **Step 4: 预约 ID 查询保留门店端字段**
|
||
|
||
如果是受保护的 appointmentId 查询,后续 Batch C 会要求登录;这一路可以保留内部字段供门店端使用。
|
||
|
||
- [ ] **Step 5: 验收**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
mvn -f backend/pom.xml test
|
||
```
|
||
|
||
Expected:
|
||
- `GET /api/report/get?token=...` 不返回 top-level `userId/storeId/reportToken`。
|
||
- 应用日志只出现 `tokenHash`,不出现 token prefix。
|
||
|
||
### Task B2: 报告“一约一份”和照片必填后端兜底
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/java/com/petstore/entity/Report.java`
|
||
- Modify: `backend/src/main/java/com/petstore/mapper/ReportMapper.java`
|
||
- Modify: `backend/src/main/java/com/petstore/controller/ReportController.java`
|
||
- Modify: `backend/src/main/java/com/petstore/service/ReportService.java`
|
||
|
||
- [ ] **Step 1: 实体唯一约束**
|
||
|
||
把 `Report` 的 `@Table` 改成包含唯一约束:
|
||
|
||
```java
|
||
@Table(
|
||
name = "t_report",
|
||
indexes = {
|
||
@Index(name = "idx_report_appointment_id", columnList = "appointment_id"),
|
||
@Index(name = "idx_report_token", columnList = "report_token"),
|
||
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time")
|
||
},
|
||
uniqueConstraints = {
|
||
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
|
||
}
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Mapper 增加存在性检查**
|
||
|
||
```java
|
||
boolean existsByAppointmentIdAndDeletedFalse(Long appointmentId);
|
||
```
|
||
|
||
- [ ] **Step 3: Controller 先做业务校验**
|
||
|
||
在 `ReportController.create` 中:
|
||
|
||
```java
|
||
if (report.getAppointmentId() == null) {
|
||
return Map.of("code", 400, "message", "报告必须关联预约", "bizCode", "APPOINTMENT_REQUIRED");
|
||
}
|
||
```
|
||
|
||
增加照片校验 helper:
|
||
|
||
```java
|
||
private boolean hasPhotoType(Report report, String type) {
|
||
if (report == null || report.getImages() == null) {
|
||
return false;
|
||
}
|
||
return report.getImages().stream().anyMatch(img ->
|
||
type.equals(img.getPhotoType())
|
||
&& img.getPhotoUrl() != null
|
||
&& !img.getPhotoUrl().isBlank()
|
||
&& !"video".equalsIgnoreCase(img.getMediaType())
|
||
);
|
||
}
|
||
```
|
||
|
||
调用:
|
||
|
||
```java
|
||
if (!hasPhotoType(report, "before")) {
|
||
return Map.of("code", 400, "message", "请至少上传1张服务前照片", "bizCode", "BEFORE_PHOTO_REQUIRED");
|
||
}
|
||
if (!hasPhotoType(report, "after")) {
|
||
return Map.of("code", 400, "message", "请至少上传1张服务后照片", "bizCode", "AFTER_PHOTO_REQUIRED");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Service 拒绝重复报告**
|
||
|
||
在 `ReportService.create` 最前面:
|
||
|
||
```java
|
||
if (report.getAppointmentId() == null) {
|
||
throw new IllegalArgumentException("报告必须关联预约");
|
||
}
|
||
if (reportMapper.existsByAppointmentIdAndDeletedFalse(report.getAppointmentId())) {
|
||
throw new IllegalStateException("该预约已生成报告");
|
||
}
|
||
```
|
||
|
||
Controller 捕获并转业务码:
|
||
|
||
```java
|
||
try {
|
||
Report created = reportService.create(report);
|
||
...
|
||
} catch (IllegalStateException e) {
|
||
return Map.of("code", 409, "message", e.getMessage(), "bizCode", "REPORT_ALREADY_EXISTS");
|
||
} catch (IllegalArgumentException e) {
|
||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_REPORT");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 验收**
|
||
|
||
Expected:
|
||
- appointmentId 为空返回 `APPOINTMENT_REQUIRED`。
|
||
- 非 `doing` 预约仍返回 `INVALID_STATUS`。
|
||
- 同一 appointmentId 第二次提交返回 409 / `REPORT_ALREADY_EXISTS`。
|
||
- 前图或后图缺失返回对应业务码。
|
||
|
||
### Task B3: 回访池脱敏
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/java/com/petstore/controller/ReportLeadController.java`
|
||
- Modify: `frontend/src/pages/mine/Leads.vue`
|
||
|
||
- [ ] **Step 1: 后端默认不返回完整 openid/unionid**
|
||
|
||
在 `ReportLeadController` 增加:
|
||
|
||
```java
|
||
private String maskIdentifier(String v) {
|
||
if (v == null || v.isBlank()) {
|
||
return null;
|
||
}
|
||
if (v.length() <= 8) {
|
||
return "****";
|
||
}
|
||
return v.substring(0, 4) + "****" + v.substring(v.length() - 4);
|
||
}
|
||
```
|
||
|
||
把:
|
||
|
||
```java
|
||
m.put("wechatOpenid", l.getWechatOpenid());
|
||
m.put("wechatUnionid", l.getWechatUnionid());
|
||
```
|
||
|
||
改为:
|
||
|
||
```java
|
||
m.put("wechatBound", l.getWechatOpenid() != null && !l.getWechatOpenid().isBlank());
|
||
m.put("wechatOpenidMasked", maskIdentifier(l.getWechatOpenid()));
|
||
m.put("wechatUnionidMasked", maskIdentifier(l.getWechatUnionid()));
|
||
```
|
||
|
||
- [ ] **Step 2: 前端展示脱敏字段**
|
||
|
||
`Leads.vue` 不再渲染完整 `wechatOpenid/wechatUnionid`,改成:
|
||
|
||
```vue
|
||
<text v-if="lead.wechatBound">微信已绑定 {{ lead.wechatOpenidMasked || '' }}</text>
|
||
```
|
||
|
||
- [ ] **Step 3: 验收**
|
||
|
||
Expected:
|
||
- API 回包没有 `wechatOpenid`、`wechatUnionid` 完整字段。
|
||
- 页面只显示“微信已绑定”或脱敏 id。
|
||
|
||
## 5. Batch C - 最小鉴权上下文与跨店边界
|
||
|
||
### Task C1: session token 服务
|
||
|
||
**Files:**
|
||
- Create: `backend/src/main/java/com/petstore/auth/CurrentUser.java`
|
||
- Create: `backend/src/main/java/com/petstore/auth/CurrentUserContext.java`
|
||
- Create: `backend/src/main/java/com/petstore/auth/SessionTokenService.java`
|
||
- Create: `backend/src/main/java/com/petstore/auth/AuthInterceptor.java`
|
||
- Modify: `backend/src/main/java/com/petstore/config/WebConfig.java`
|
||
- Modify: `backend/src/main/resources/application-example.yml`
|
||
|
||
- [ ] **Step 1: CurrentUser**
|
||
|
||
```java
|
||
package com.petstore.auth;
|
||
|
||
public record CurrentUser(Long userId, Long storeId, String role) {
|
||
public boolean isBoss() {
|
||
return "boss".equals(role);
|
||
}
|
||
|
||
public boolean isStaff() {
|
||
return "staff".equals(role);
|
||
}
|
||
|
||
public boolean isCustomer() {
|
||
return "customer".equals(role);
|
||
}
|
||
|
||
public boolean isStoreUser() {
|
||
return isBoss() || isStaff();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: CurrentUserContext**
|
||
|
||
```java
|
||
package com.petstore.auth;
|
||
|
||
public final class CurrentUserContext {
|
||
private static final ThreadLocal<CurrentUser> HOLDER = new ThreadLocal<>();
|
||
|
||
private CurrentUserContext() {}
|
||
|
||
public static void set(CurrentUser user) {
|
||
HOLDER.set(user);
|
||
}
|
||
|
||
public static CurrentUser get() {
|
||
return HOLDER.get();
|
||
}
|
||
|
||
public static CurrentUser require() {
|
||
CurrentUser u = HOLDER.get();
|
||
if (u == null) {
|
||
throw new IllegalStateException("未登录");
|
||
}
|
||
return u;
|
||
}
|
||
|
||
public static void clear() {
|
||
HOLDER.remove();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: SessionTokenService**
|
||
|
||
实现要求:
|
||
- 用 `auth.session-secret` 作为 HMAC secret。
|
||
- token 格式:`base64url(payloadJson).base64url(signature)`。
|
||
- payload 字段:`userId`、`storeId`、`role`、`exp`。
|
||
- 默认有效期 7 天。
|
||
- 校验失败返回 empty。
|
||
|
||
配置示例写入 `application-example.yml`:
|
||
|
||
```yaml
|
||
auth:
|
||
session-secret: ${PETSTORE_SESSION_SECRET:dev-change-me}
|
||
session-ttl-seconds: ${PETSTORE_SESSION_TTL_SECONDS:604800}
|
||
```
|
||
|
||
- [ ] **Step 4: AuthInterceptor**
|
||
|
||
行为:
|
||
- 对 public allowlist 放行。
|
||
- 对 protected `/api/**` 要求 Bearer token。
|
||
- 校验成功后 `CurrentUserContext.set(currentUser)`。
|
||
- `afterCompletion` 里 `CurrentUserContext.clear()`。
|
||
|
||
Public allowlist:
|
||
|
||
```text
|
||
POST /api/user/login
|
||
POST /api/user/wx-phone-login
|
||
POST /api/user/register-boss
|
||
POST /api/user/register-staff
|
||
POST /api/sms/send
|
||
GET /api/store/list
|
||
GET /api/store/get
|
||
GET /api/service-type/list
|
||
GET /api/appointment/available-slots
|
||
GET /api/report/get
|
||
GET /api/report/{token}/suggestion
|
||
POST /api/report/{token}/reminder
|
||
POST /api/report/unsubscribe
|
||
POST /api/report/open-track
|
||
GET /api/upload/image/**
|
||
GET /api/upload/legacy/**
|
||
```
|
||
|
||
- [ ] **Step 5: 注册 interceptor**
|
||
|
||
`WebConfig` 实现:
|
||
|
||
```java
|
||
private final AuthInterceptor authInterceptor;
|
||
|
||
@Override
|
||
public void addInterceptors(InterceptorRegistry registry) {
|
||
registry.addInterceptor(authInterceptor)
|
||
.addPathPatterns("/api/**");
|
||
}
|
||
```
|
||
|
||
保留 `addResourceHandlers`,移除 `System.out.println`。
|
||
|
||
### Task C2: 登录响应与前端 Authorization
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/java/com/petstore/service/UserService.java`
|
||
- Modify: `backend/src/main/java/com/petstore/controller/UserController.java`
|
||
- Modify: `frontend/src/utils/session.js`
|
||
- Modify: `frontend/src/api/index.js`
|
||
- Modify: `frontend/src/pages/login/Login.vue`
|
||
|
||
- [ ] **Step 1: 后端登录成功返回 sessionToken**
|
||
|
||
`UserService` 注入 `SessionTokenService`。所有登录成功的 `data` 增加:
|
||
|
||
```java
|
||
data.put("sessionToken", sessionTokenService.issue(user));
|
||
```
|
||
|
||
返回前清理:
|
||
|
||
```java
|
||
user.setPassword(null);
|
||
```
|
||
|
||
- [ ] **Step 2: 前端 session 保存 token**
|
||
|
||
`session.js` 增加:
|
||
|
||
```js
|
||
export const getSessionToken = () => uni.getStorageSync('petstore_session_token') || ''
|
||
|
||
export const setSessionToken = (token) => {
|
||
if (token) uni.setStorageSync('petstore_session_token', token)
|
||
else uni.removeStorageSync('petstore_session_token')
|
||
}
|
||
```
|
||
|
||
更新 `clearSession`:
|
||
|
||
```js
|
||
uni.removeStorageSync('petstore_session_token')
|
||
```
|
||
|
||
- [ ] **Step 3: request 自动带 Authorization**
|
||
|
||
`api/index.js` 引入:
|
||
|
||
```js
|
||
import { getSessionToken } from '../utils/session.js'
|
||
```
|
||
|
||
在 request header 合并:
|
||
|
||
```js
|
||
const token = getSessionToken()
|
||
const headers = { ...(options.header || {}) }
|
||
if (token) headers.Authorization = `Bearer ${token}`
|
||
```
|
||
|
||
再传给 `uni.request`。
|
||
|
||
- [ ] **Step 4: Login.vue 保存 token**
|
||
|
||
登录成功处:
|
||
|
||
```js
|
||
setUserSession(res.data.user)
|
||
setStoreSession(res.data.store)
|
||
setSessionToken(res.data.sessionToken)
|
||
```
|
||
|
||
微信登录同样处理。需要从 `session.js` import `setSessionToken`。
|
||
|
||
### Task C3: 控制器逐步移除身份传参依赖
|
||
|
||
**Files:**
|
||
- Modify: `AppointmentController`
|
||
- Modify: `ReportController`
|
||
- Modify: `ReportLeadController`
|
||
- Modify: `PetController`
|
||
- Modify: `UserController`
|
||
- Modify: `frontend/src/api/index.js`
|
||
- Modify: relevant frontend pages that still pass identity fields
|
||
|
||
- [ ] **Step 1: AppointmentController**
|
||
|
||
规则:
|
||
- customer list 只能看自己的 `userId`。
|
||
- boss/staff list 只能看自己的 `storeId`。
|
||
- create 的 `userId` 从当前 customer 派生。
|
||
- start/status/delete 只能 boss/staff 且同店。
|
||
|
||
Controller 写法示例:
|
||
|
||
```java
|
||
CurrentUser u = CurrentUserContext.require();
|
||
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
||
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
||
```
|
||
|
||
- [ ] **Step 2: ReportController**
|
||
|
||
规则:
|
||
- `/report/create` 只允许 boss/staff。
|
||
- `userId` 从当前用户派生,不信任请求体。
|
||
- report store 必须等于当前用户 store。
|
||
- `/report/list` 只允许 boss/staff 查本店,customer 只能查自己相关报告。
|
||
- `/report/highlight/start` 不再信任 `operatorUserId/role` 请求体,改用 `CurrentUserContext`。
|
||
|
||
- [ ] **Step 3: ReportLeadController**
|
||
|
||
规则:
|
||
- 留资、suggestion、unsubscribe 仍匿名。
|
||
- `/report/leads` 只允许 boss/staff 查本店,忽略 query storeId 或要求与上下文一致。
|
||
|
||
- [ ] **Step 4: PetController**
|
||
|
||
规则:
|
||
- customer list/create/update/delete/history 只能操作自己宠物。
|
||
- boss/staff list/history 只能查本店服务过的宠物。
|
||
- 不再从前端传 `operatorUserId/role` 作为权限依据。
|
||
|
||
- [ ] **Step 5: 前端 API 减少身份参数**
|
||
|
||
优先新增新签名,保留页面逐步迁移:
|
||
|
||
```js
|
||
export const getAppointmentList = (options = {}) => get('/appointment/list', options)
|
||
export const startAppointment = (appointmentId) => post('/appointment/start', { appointmentId })
|
||
export const getReportLeads = (status = 'pending') => get('/report/leads', { status })
|
||
```
|
||
|
||
页面里不再传 `userInfo.id` / `storeInfo.id` 作为权限依据。
|
||
|
||
- [ ] **Step 6: 验收**
|
||
|
||
Expected:
|
||
- 未登录访问受保护接口返回 401。
|
||
- customer 用 query 伪造 `storeId` 查门店预约失败。
|
||
- A 店 boss/staff 不能查 B 店 leads/list/report。
|
||
- 公开报告 token 仍可匿名打开。
|
||
|
||
## 6. Batch D - 配置、上传、迁移、测试与 RC
|
||
|
||
### Task D1: 配置安全与启动迁移收口
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/resources/application.yml`
|
||
- Modify: `backend/src/main/resources/application-example.yml`
|
||
- Modify: `backend/src/main/java/com/petstore/PetstoreApplication.java`
|
||
- Modify: `backend/README.md`
|
||
|
||
- [ ] **Step 1: application.yml 不保留真实敏感值**
|
||
|
||
要求:
|
||
- datasource url/username/password 全部使用环境变量。
|
||
- wechat appid/appsecret 全部使用环境变量且默认空。
|
||
- production profile 禁用 `ddl-auto:update` 和 `show-sql`。
|
||
- 不在文档里写真实值。
|
||
|
||
示例:
|
||
|
||
```yaml
|
||
spring:
|
||
datasource:
|
||
url: ${MYSQL_URL:jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai}
|
||
username: ${MYSQL_USERNAME:root}
|
||
password: ${MYSQL_PASSWORD:}
|
||
jpa:
|
||
hibernate:
|
||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||
show-sql: ${JPA_SHOW_SQL:false}
|
||
```
|
||
|
||
- [ ] **Step 2: 移除启动期 DDL/历史 UPDATE**
|
||
|
||
`PetstoreApplication.initRunner` 只保留:
|
||
|
||
```java
|
||
serviceTypeService.initDefaults();
|
||
```
|
||
|
||
不再执行 `ALTER TABLE` 和历史 URL `UPDATE`。
|
||
|
||
- [ ] **Step 3: README 写迁移要求**
|
||
|
||
写明:
|
||
- 生产库变更必须走 SQL/Flyway/Liquibase 或人工审核 SQL。
|
||
- `uk_report_appointment` 上线前需清理重复 appointment report。
|
||
- 明文敏感项已移除,线上凭据需轮换。
|
||
|
||
### Task D2: 上传路径和类型保护
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/main/java/com/petstore/controller/FileController.java`
|
||
|
||
- [ ] **Step 1: 使用 Path normalize**
|
||
|
||
新增:
|
||
|
||
```java
|
||
private Path uploadRoot() throws IOException {
|
||
return Paths.get(uploadPath).toAbsolutePath().normalize();
|
||
}
|
||
|
||
private Path resolveSafePath(String rawPath) throws IOException {
|
||
String p = rawPath == null ? "" : rawPath.replace("\\", "/");
|
||
while (p.startsWith("/")) {
|
||
p = p.substring(1);
|
||
}
|
||
Path root = uploadRoot();
|
||
Path target = root.resolve(p).normalize();
|
||
if (!target.startsWith(root)) {
|
||
throw new SecurityException("非法路径");
|
||
}
|
||
return target;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: getImage/getLegacyImage 使用 safe path**
|
||
|
||
路径非法返回 400 或 404,不读取根目录外文件。
|
||
|
||
- [ ] **Step 3: 上传扩展名强校验**
|
||
|
||
如果原文件有扩展名但不在图片/视频集合内,拒绝;如果无扩展名但 MIME 非 image/video/octet-stream,拒绝。
|
||
|
||
- [ ] **Step 4: 不使用 `e.printStackTrace()`**
|
||
|
||
换成 logger:
|
||
|
||
```java
|
||
log.warn("upload failed", e);
|
||
```
|
||
|
||
### Task D3: 最小测试基线
|
||
|
||
**Files:**
|
||
- Modify: `backend/pom.xml`
|
||
- Create: `backend/src/test/java/com/petstore/auth/SessionTokenServiceTest.java`
|
||
- Create: `backend/src/test/java/com/petstore/service/ReportServiceTest.java`
|
||
- Create: `backend/src/test/java/com/petstore/controller/ReportControllerTest.java`
|
||
- Optional Create: `backend/src/test/java/com/petstore/service/AppointmentServiceTest.java`
|
||
|
||
- [ ] **Step 1: pom 增加测试依赖**
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>org.springframework.boot</groupId>
|
||
<artifactId>spring-boot-starter-test</artifactId>
|
||
<scope>test</scope>
|
||
</dependency>
|
||
```
|
||
|
||
- [ ] **Step 2: SessionTokenServiceTest 覆盖**
|
||
|
||
测试:
|
||
- issue 后 verify 成功。
|
||
- 篡改 payload 后 verify 失败。
|
||
- 过期 token verify 失败。
|
||
|
||
- [ ] **Step 3: ReportServiceTest 覆盖**
|
||
|
||
测试:
|
||
- appointmentId 为空抛 `IllegalArgumentException`。
|
||
- appointment 已有报告抛 `IllegalStateException`。
|
||
- doing 预约创建报告后预约变 done。
|
||
|
||
- [ ] **Step 4: ReportControllerTest 覆盖**
|
||
|
||
测试:
|
||
- token 查询不返回 top-level `userId/storeId/reportToken`。
|
||
- 缺 before 返回 `BEFORE_PHOTO_REQUIRED`。
|
||
- 缺 after 返回 `AFTER_PHOTO_REQUIRED`。
|
||
- 重复报告返回 `REPORT_ALREADY_EXISTS`。
|
||
|
||
- [ ] **Step 5: 运行测试**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
mvn -f backend/pom.xml test
|
||
```
|
||
|
||
Expected:
|
||
- 不再出现 `No tests to run`。
|
||
- 新增测试全部通过。
|
||
|
||
### Task D4: 发布 RC 清单
|
||
|
||
**Files:**
|
||
- Modify: `backend/README.md`
|
||
- Modify: `frontend/README.md`
|
||
- Modify: `docs/P0-研发落地清单.md`
|
||
|
||
- [ ] **Step 1: backend README 增加 RC**
|
||
|
||
必须包含:
|
||
|
||
```bash
|
||
ffmpeg -version
|
||
ffprobe -version
|
||
curl -I "$APP_BASE_URL/api/upload/image/<known-file>"
|
||
mvn test
|
||
```
|
||
|
||
必须说明:
|
||
- `APP_BASE_URL`
|
||
- `HIGHLIGHT_FFMPEG`
|
||
- `HIGHLIGHT_FFPROBE`
|
||
- `HIGHLIGHT_MAX_DURATION_SEC`
|
||
- `HIGHLIGHT_BGM_PATH`
|
||
- `PETSTORE_SESSION_SECRET`
|
||
- 上传目录权限
|
||
- Nginx `client_max_body_size`
|
||
|
||
- [ ] **Step 2: frontend README 增加 RC**
|
||
|
||
必须包含:
|
||
|
||
```bash
|
||
npm --prefix frontend install
|
||
npm --prefix frontend run build:h5
|
||
npm --prefix frontend run build:mp-weixin
|
||
```
|
||
|
||
必须说明:
|
||
- `VITE_API_ORIGIN`
|
||
- `VITE_REPORT_PUBLIC_ORIGIN`
|
||
- 微信 request 合法域名
|
||
- 微信 downloadFile 合法域名
|
||
- 报告二维码链接检查
|
||
|
||
- [ ] **Step 3: P0 清单增加最终门禁**
|
||
|
||
加入:
|
||
|
||
```markdown
|
||
P0 通过条件:
|
||
- 后端 `mvn test` 有实际测试用例并通过。
|
||
- 前端 H5 与小程序构建通过。
|
||
- 公开报告页 token 匿名可打开,且不返回内部用户字段。
|
||
- boss/staff 登录后可生成报告、发送报告、查看回访池。
|
||
- customer 登录态打开报告仍显示留资和预约 CTA。
|
||
- 生产配置扫描不含真实敏感项。
|
||
```
|
||
|
||
## 7. Cursor 分批提示词
|
||
|
||
### Prompt A
|
||
|
||
```text
|
||
请只执行 docs/superpowers/plans/2026-07-05-cursor-p0-handoff.md 的 Batch A。先读 0、1、2、3 节。只允许修改 docs 中列出的产品文档,以及 frontend/src/pages/report-view/reportView.vue、frontend/src/pages/report/Report.vue、frontend/src/pages/appointment/CustAppointmentCreate.vue、frontend/src/pages/login/Login.vue、frontend/src/utils/session.js。不要修改后端。完成后运行 git -C docs diff --check、npm --prefix frontend install、npm --prefix frontend run build:h5、npm --prefix frontend run build:mp-weixin,并汇报结果。
|
||
```
|
||
|
||
### Prompt B
|
||
|
||
```text
|
||
请只执行 docs/superpowers/plans/2026-07-05-cursor-p0-handoff.md 的 Batch B。先读 Batch A 的最终 diff,保持兼容。只允许修改 ReportController、ReportService、ReportMapper、Report 实体、ReportLeadController、frontend/src/pages/mine/Leads.vue,以及必要的测试文件。目标是公开报告字段裁剪、token hash 日志、报告一约一份、照片必填后端兜底、回访池脱敏。完成后运行 mvn -f backend/pom.xml test,并汇报公开报告回包字段变化。
|
||
```
|
||
|
||
### Prompt C
|
||
|
||
```text
|
||
请只执行 docs/superpowers/plans/2026-07-05-cursor-p0-handoff.md 的 Batch C。目标是最小鉴权上下文:后端登录签发 HMAC session token,前端自动带 Authorization,后端 protected API 从 CurrentUserContext 派生 userId/storeId/role,不再信任请求里的身份字段。不要引入完整 Spring Security。先改登录和拦截器,再改 Appointment/Report/ReportLead/Pet/User 这些控制器。完成后运行 mvn -f backend/pom.xml test、npm --prefix frontend run build:h5,并列出所有仍保留身份参数的接口。
|
||
```
|
||
|
||
### Prompt D
|
||
|
||
```text
|
||
请只执行 docs/superpowers/plans/2026-07-05-cursor-p0-handoff.md 的 Batch D。目标是配置安全、上传路径保护、启动迁移收口、最小测试基线和 RC 文档。不要输出任何真实密钥值。完成后运行 mvn -f backend/pom.xml test、npm --prefix frontend run build:h5、npm --prefix frontend run build:mp-weixin、git -C docs diff --check,并汇报是否仍有 No tests to run。
|
||
```
|
||
|
||
## 8. 最终验收命令
|
||
|
||
Cursor 完成所有 batch 后,人工 review 前执行:
|
||
|
||
```bash
|
||
git -C docs status --short
|
||
git -C backend status --short
|
||
git -C frontend status --short
|
||
|
||
git -C docs diff --check
|
||
mvn -f backend/pom.xml test
|
||
npm --prefix frontend install
|
||
npm --prefix frontend run build:h5
|
||
npm --prefix frontend run build:mp-weixin
|
||
|
||
rg "到店不排队|会员储值 \\| 路线图 P0|宠主通过微信查看服务报告(v2)|无限制,随便选" docs
|
||
rg "System\\.out\\.println|printStackTrace|tokenPrefix|wechatOpenid\"|wechatUnionid\"" backend/src/main/java frontend/src
|
||
rg "userId, storeId|operatorUserId|role\\)" frontend/src/api/index.js
|
||
```
|
||
|
||
Expected:
|
||
- docs 冲突口径扫描无命中。
|
||
- 后端测试存在并通过。
|
||
- 前端两个构建通过。
|
||
- 后端源码不再打印 token 前缀,不再 `printStackTrace`。
|
||
- 公开接口不返回完整 openid/unionid。
|
||
- 前端 API 不再把身份参数作为权限依据。
|
||
|
||
## 9. Review 重点
|
||
|
||
人工 review 时重点看:
|
||
|
||
1. `application.yml` 是否仍含真实敏感值;如果曾提交过,需要线下轮换。
|
||
2. `AuthInterceptor` public allowlist 是否过宽。
|
||
3. 公开报告 token 查询是否仍可匿名打开。
|
||
4. boss/staff protected API 是否都带 Authorization。
|
||
5. customer 登录态报告页是否误判为员工态。
|
||
6. report create 是否强制 appointmentId、doing 状态、前后照片、唯一报告。
|
||
7. leads API 是否仍返回完整 openid/unionid。
|
||
8. 上传路径是否能逃逸 `upload.path`。
|
||
9. `mvn test` 是否真的跑了测试,而不是 `No tests to run`。
|
||
10. 前端构建是否需要补充锁文件变更。
|
||
|
||
## 10. 不做事项
|
||
|
||
这些事项不要让 Cursor 在本批顺手做:
|
||
|
||
- 不重构整体 UI。
|
||
- 不引入会员、储值、套餐、订单支付。
|
||
- 不做完整 CRM。
|
||
- 不做历史报告汇总。
|
||
- 不把 Spring Security 作为必要前提。
|
||
- 不改品牌视觉方向。
|
||
- 不删除既存未跟踪 `.agents/`、`AGENTS.md` 或用户工作区变更。
|
||
- 不提交真实密钥、密码、appsecret、PEM 内容。
|
||
|
||
## 11. 当前已知前置状态
|
||
|
||
- `backend` 当前 `mvn test` 可跑通,但输出 `No tests to run`。
|
||
- `frontend` 当前缺 `node_modules` 时 `npm --prefix frontend run build:h5` 会失败为 `uni: command not found`;先运行 `npm --prefix frontend install`。
|
||
- `frontend/package-lock.json` 已存在,Cursor 应使用 npm,不要换包管理器。
|
||
- `docs` 当前已有迁移过来的 agent 角色与协作约定改动,不要回退。
|
||
- 根目录不是单一 git 仓,提交和状态检查要分别对 `docs`、`backend`、`frontend` 执行。
|