petstore-docs/superpowers/plans/2026-07-05-cursor-p0-handoff.md

1229 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 执行。