核心修复: - 状态机加 SELECT FOR UPDATE 行锁,消除并发竞态 - hss_md_staff 加 role 列,登录从数据库读取真实角色 - 申请重复校验排除自身,全流程 20 步闭环通过 - 派单 SQL 修复 + 支付状态机过渡 + 完成服务 plan_item_id 修复 并发控制新增: - RedisLockService (SET NX PX + Lua 安全解锁) - RateLimiterService (Redis 滑动窗口 + API 拦截器) - TransactionIsolationConfig (SERIALIZABLE for 支付回调) - MqttPublisher (异步队列 + JDK TCP 探测) - ObjectStorageService (AWS SigV4 预签名, 纯 JDK) 冗余清理: - 删除 6 个死代码文件 (~620 行) - hutool-all → JDK MessageDigest, 去 MapStruct, 去 jsr310 - haversine 提取到 GeoUtil, count/round 提取到 JdbcUtil - 创建 platform layout 组件 前端修复: - 登录页移除角色选择器, 由后端 JWT 返回 - 移除 ClientOnly 包裹, 页面正常渲染 - SPA fallback Nginx 配置修复 Docker: 运行时镜像 eclipse-temurin:17-jre-jammy (缩小 ~300MB) 文档: 新增系统实现与修复报告.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
516 lines
17 KiB
Markdown
516 lines
17 KiB
Markdown
# Delivery 端完整实现方案
|
||
|
||
> 基于 5 轮需求采集 + 代码审计
|
||
> 后端 API:已有 86 个接口,`WorkOrderService` 的 checkIn/finish/reportException 已就绪
|
||
> 当前状态:12 个页面路由中仅实现 2 个(login + index),10 个缺失
|
||
|
||
---
|
||
|
||
## 1. 需求与约束摘要
|
||
|
||
| 项 | 内容 |
|
||
|---|---|
|
||
| **任务类型** | 功能扩展 |
|
||
| **目标** | 实现 delivery-miniapp 全功能:GPS签到 + 轨迹 + 拍照水印 + 执行记录 + 异常上报 + 签退 + 离线缓存 + 断点续传 |
|
||
| **硬边界** | uni-app + Vue、可改后端 API、离线缓存、对象存储(MinIO/OSS)、双端条件编译、Toast 提示 |
|
||
|
||
---
|
||
|
||
## 2. 代码依据
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `delivery-miniapp/common/api.js` | 现有 API 封装:BASE_URL、getHeaders、apiGet、apiPost、idempotency key |
|
||
| `delivery-miniapp/pages/delivery/login/login.vue` | 登录页实现参考(uni.request、token存储、switchTab) |
|
||
| `delivery-miniapp/pages/delivery/index/index.vue` | 工作台实现参考(apiGet、navTo、状态映射) |
|
||
| `delivery-miniapp/pages.json` | 12 个路由已注册 |
|
||
| `delivery-miniapp/manifest.json` | GPS/Camera 权限已声明 |
|
||
| `module/workorder/controller/WorkOrderController.java` | 7 个端点(assign/accept/reject/check-in/start/finish/exception) |
|
||
| `module/workorder/controller/DeliveryController.java` | 5 个端点(workbench/today/detail/messages/read) |
|
||
| `module/execution/controller/ExecutionController.java` | 证据上传(presignUpload/commit/signedUrl/list) |
|
||
| `module/workorder/service/WorkOrderService.java` | checkIn(Haversine+200m)、finish(执行记录+签退+必做检查)、reportException |
|
||
|
||
---
|
||
|
||
## 3. 改动范围清单
|
||
|
||
### 前端(delivery-miniapp)
|
||
|
||
| 路径 | 增/删/改 | 摘要 |
|
||
|------|----------|------|
|
||
| `common/api.js` | 改 | 新增 getLocation/chooseImage/uploadFile/offlineQueue/watermark |
|
||
| `common/offline.js` | 增 | 离线缓存队列 + 断点续传 |
|
||
| `pages/delivery/checkin/checkin.vue` | 增 | GPS签到 + 拍照水印 + 对象确认 |
|
||
| `pages/delivery/order-detail/order-detail.vue` | 增 | 工单详情(患者/项目/操作入口) |
|
||
| `pages/delivery/execute/execute.vue` | 增 | 逐项执行记录 + 证据上传 |
|
||
| `pages/delivery/exception/exception.vue` | 增 | 一键异常上报 |
|
||
| `pages/delivery/finish/finish.vue` | 增 | 服务总结 + 签退定位 |
|
||
| `pages/delivery/accept/accept.vue` | 增 | 接单/拒单确认 |
|
||
| `pages/delivery/orders/orders.vue` | 增 | 工单列表(按状态筛选) |
|
||
| `pages/delivery/offline-sync/offline-sync.vue` | 增 | 离线数据补传 |
|
||
| `pages/delivery/messages/messages.vue` | 增 | 消息通知列表 |
|
||
| `pages/delivery/profile/profile.vue` | 增 | 我的资质 + 统计 |
|
||
|
||
### 后端
|
||
|
||
| 路径 | 增/删/改 | 摘要 |
|
||
|------|----------|------|
|
||
| `module/workorder/controller/WorkOrderController.java` | 改 | checkIn 增加 `photoFileId` → 实际文件上传 URL |
|
||
| `module/execution/service/EvidenceService.java` | 改 | commit 支持多种 fileType、增加真实文件上传 |
|
||
| `module/workorder/controller/GpsController.java` | 增 | GPS 轨迹上报端点 |
|
||
|
||
---
|
||
|
||
## 4. 方案概述
|
||
|
||
### 数据流
|
||
|
||
```
|
||
Service Person opens app → login → workbench
|
||
→ orders list (filtered by status)
|
||
→ order detail (patient, items, actions)
|
||
→ accept order
|
||
→ GPS check-in (location + photo watermark + patient confirm)
|
||
→ start service
|
||
→ execute items (per-item recording + evidence upload)
|
||
→ [exception report if needed]
|
||
→ finish service (summary + sign-off location)
|
||
→ wait for acceptance & settlement
|
||
|
||
Offline mode:
|
||
Any POST → if network fails → save to offline queue
|
||
→ offline-sync page → manual retry with progress
|
||
```
|
||
|
||
### 组件关系
|
||
|
||
```
|
||
common/
|
||
api.js → getLocation(), chooseImage(), uploadFile(), apiGet(), apiPost()
|
||
offline.js → OfflineQueue class (add, retry, clear)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 代码级修改说明
|
||
|
||
### 5.1 common/api.js — 扩展工具函数
|
||
|
||
**当前逻辑**:仅 `apiGet` / `apiPost`,无 GPS/拍照/上传能力。
|
||
|
||
**拟议改动**:
|
||
|
||
1. 新增 `getLocation()` — 封装 `uni.getLocation({ type: 'gcj02' })`
|
||
2. 新增 `chooseImage(count)` — 封装 `uni.chooseImage({ count, sizeType, sourceType })`
|
||
3. 新增 `uploadFile(filePath, presignResp)` — 封装 `uni.uploadFile` 上传到对象存储
|
||
4. 新增 `uploadEvidence(filePath, entityType, entityId)` — 完整 presign→upload→commit 流程
|
||
5. 新增 `addWatermark(tempFilePath, text)` — Canvas 绘制水印(时间+地点+工单号)
|
||
6. 新增 `gpsTrajectory` — 每 30 秒上报一次位置到后端
|
||
|
||
**建议代码**(新增内容追加到现有 api.js):
|
||
|
||
```js
|
||
const BASE_URL = 'http://localhost:18080/api/hss';
|
||
|
||
// ... 保留现有的 getHeaders, generateIdempotencyKey, apiGet, apiPost ...
|
||
|
||
// ==================== GPS ====================
|
||
|
||
/** 获取当前位置(GCJ-02坐标系,微信/高德兼容) */
|
||
function getLocation() {
|
||
return new Promise((resolve, reject) => {
|
||
// #ifdef MP-WEIXIN
|
||
uni.getLocation({ type: 'gcj02', success: resolve, fail: reject });
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
uni.getLocation({ type: 'gcj02', geocode: true, success: resolve, fail: reject });
|
||
// #endif
|
||
});
|
||
}
|
||
|
||
/** GPS 轨迹上报(每30秒) */
|
||
let trajectoryTimer = null;
|
||
function startTrajectory(workOrderId) {
|
||
stopTrajectory();
|
||
trajectoryTimer = setInterval(async () => {
|
||
try {
|
||
const loc = await getLocation();
|
||
await apiPost('/gps/trajectory', {
|
||
workOrderId,
|
||
latitude: loc.latitude,
|
||
longitude: loc.longitude,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
} catch (e) { /* 静默失败,不影响主流程 */ }
|
||
}, 30000);
|
||
}
|
||
function stopTrajectory() {
|
||
if (trajectoryTimer) { clearInterval(trajectoryTimer); trajectoryTimer = null; }
|
||
}
|
||
|
||
// ==================== 拍照与水印 ====================
|
||
|
||
/** 拍照(支持相册选择) */
|
||
function chooseImage(count = 1) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.chooseImage({
|
||
count, sizeType: ['compressed'],
|
||
// #ifdef MP-WEIXIN
|
||
sourceType: ['camera'],
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
sourceType: ['camera', 'album'],
|
||
// #endif
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Canvas 水印:时间 + 地点 + 工单号 */
|
||
function addWatermark(tempFilePath, { workOrderId, address }) {
|
||
return new Promise((resolve, reject) => {
|
||
// #ifdef MP-WEIXIN
|
||
const ctx = uni.createCanvasContext('watermark-canvas');
|
||
// ... draw text on canvas, then save
|
||
// #endif
|
||
// #ifdef APP-PLUS
|
||
// 使用 plus.nativeObj.Bitmap 或直接跳过(APP 端拍照自带 EXIF)
|
||
resolve(tempFilePath);
|
||
// #endif
|
||
});
|
||
}
|
||
|
||
// ==================== 文件上传 ====================
|
||
|
||
/** 完整证据上传流程:presign → upload → commit */
|
||
async function uploadEvidence(filePath, entityType, entityId) {
|
||
const fileName = filePath.split('/').pop() || 'photo.jpg';
|
||
const fileType = fileName.endsWith('.mp4') ? 'VIDEO' : fileName.endsWith('.mp3') ? 'AUDIO' : 'PHOTO';
|
||
|
||
// 1. 获取预签名上传 URL
|
||
const presignRes = await apiPost('/evidence/presign-upload', {
|
||
fileName, fileType, entityType, entityId
|
||
});
|
||
if (presignRes.data.code !== 200) throw new Error('PreSign failed');
|
||
|
||
const { fileKey, uploadUrl } = presignRes.data.data;
|
||
|
||
// 2. 上传文件
|
||
const uploadRes = await new Promise((resolve, reject) => {
|
||
uni.uploadFile({
|
||
url: BASE_URL + '/evidence/commit',
|
||
filePath,
|
||
name: 'file',
|
||
formData: {
|
||
fileKey,
|
||
fileName,
|
||
fileType,
|
||
entityType: String(entityType),
|
||
entityId: String(entityId),
|
||
},
|
||
header: getHeaders(),
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
|
||
return { fileKey, uploadRes };
|
||
}
|
||
|
||
// ==================== 导出 ====================
|
||
|
||
module.exports = {
|
||
BASE_URL, apiGet, apiPost, generateIdempotencyKey,
|
||
getLocation, startTrajectory, stopTrajectory,
|
||
chooseImage, addWatermark, uploadEvidence,
|
||
};
|
||
```
|
||
|
||
### 5.2 common/offline.js — 离线缓存队列
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:新建离线队列,失败请求缓存到本地,恢复网络后批量补传。
|
||
|
||
```js
|
||
const OFFLINE_QUEUE_KEY = 'hss_offline_queue';
|
||
|
||
class OfflineQueue {
|
||
constructor() {
|
||
this.queue = uni.getStorageSync(OFFLINE_QUEUE_KEY) || [];
|
||
}
|
||
|
||
add(item) {
|
||
this.queue.push({ ...item, createdAt: Date.now() });
|
||
this.save();
|
||
}
|
||
|
||
save() {
|
||
uni.setStorageSync(OFFLINE_QUEUE_KEY, this.queue);
|
||
}
|
||
|
||
getPending() { return [...this.queue]; }
|
||
getCount() { return this.queue.length; }
|
||
|
||
async retry(apiPost) {
|
||
const failed = [];
|
||
for (const item of this.queue) {
|
||
try {
|
||
const res = await apiPost(item.path, item.data);
|
||
if (res.data.code === 200) continue;
|
||
failed.push(item);
|
||
} catch (e) { failed.push(item); }
|
||
}
|
||
this.queue = failed;
|
||
this.save();
|
||
return { success: this.getCount() === 0, remaining: this.queue.length };
|
||
}
|
||
|
||
clear() { this.queue = []; this.save(); }
|
||
}
|
||
|
||
module.exports = { OfflineQueue };
|
||
```
|
||
|
||
### 5.3 pages/delivery/checkin/checkin.vue — GPS签到
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:三步签到流程。
|
||
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- Step 1: GPS定位 -->
|
||
<view class="step">
|
||
<text class="step-title">📍 位置确认</text>
|
||
<text v-if="location" class="coords">经度 {{location.longitude}} 纬度 {{location.latitude}}</text>
|
||
<button v-else @click="getGPS" :loading="locating">获取当前位置</button>
|
||
</view>
|
||
|
||
<!-- Step 2: 现场拍照(水印) -->
|
||
<view class="step">
|
||
<text class="step-title">📷 现场拍照</text>
|
||
<image v-if="photoPath" :src="photoPath" class="photo-preview" />
|
||
<button v-else @click="takePhoto">拍摄现场照片</button>
|
||
</view>
|
||
|
||
<!-- Step 3: 对象确认 -->
|
||
<view class="step">
|
||
<text class="step-title">✍️ 服务对象确认</text>
|
||
<switch :checked="confirmed" @change="confirmed = $event.detail.value" />
|
||
<text>{{ confirmed ? '已确认' : '请服务对象确认' }}</text>
|
||
</view>
|
||
|
||
<!-- Submit -->
|
||
<button @click="submitCheckIn" :disabled="!canSubmit" :loading="submitting"
|
||
class="submit-btn">确认签到</button>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { getLocation, chooseImage, uploadEvidence } from '@/common/api.js';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
workOrderId: 0,
|
||
location: null, locating: false,
|
||
photoPath: '', photoFileId: '',
|
||
confirmed: false, submitting: false
|
||
};
|
||
},
|
||
computed: {
|
||
canSubmit() { return this.location && this.photoPath && this.confirmed; }
|
||
},
|
||
async onLoad(options) { this.workOrderId = options.id; },
|
||
methods: {
|
||
async getGPS() {
|
||
this.locating = true;
|
||
try { this.location = await getLocation(); }
|
||
catch(e) { uni.showToast({ title: '获取位置失败', icon: 'none' }); }
|
||
finally { this.locating = false; }
|
||
},
|
||
async takePhoto() {
|
||
try {
|
||
const res = await chooseImage(1);
|
||
this.photoPath = res.tempFilePaths[0];
|
||
// Upload evidence
|
||
const ev = await uploadEvidence(this.photoPath, 'checkin', this.workOrderId);
|
||
this.photoFileId = ev.fileKey;
|
||
} catch(e) { uni.showToast({ title: '拍照失败', icon: 'none' }); }
|
||
},
|
||
async submitCheckIn() {
|
||
this.submitting = true;
|
||
try {
|
||
const { apiPost } = require('@/common/api.js');
|
||
const res = await apiPost(`/work-orders/${this.workOrderId}/check-in`, {
|
||
latitude: this.location.latitude,
|
||
longitude: this.location.longitude,
|
||
photoFileId: this.photoFileId,
|
||
patientConfirmed: this.confirmed
|
||
});
|
||
if (res.data.code === 200) {
|
||
uni.showToast({ title: '签到成功' });
|
||
uni.navigateBack();
|
||
} else {
|
||
uni.showToast({ title: res.data.message, icon: 'none' });
|
||
}
|
||
} catch(e) { uni.showToast({ title: '网络错误', icon: 'none' }); }
|
||
finally { this.submitting = false; }
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
```
|
||
|
||
### 5.4 pages/delivery/execute/execute.vue — 服务执行
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:逐项执行记录 + 证据上传 + 时间记录。
|
||
|
||
核心逻辑:
|
||
1. `onLoad` → 调用 `/delivery/work-orders/{id}/detail` 获取工单项目列表
|
||
2. 每个项目显示:名称、标准时长、完成状态(PENDING / COMPLETED / SKIPPED)、证据要求
|
||
3. 点击项目 → 弹出执行记录面板:实际开始/结束时间、备注、拍照/录像证据
|
||
4. 实时显示已用时长计时器
|
||
5. 所有项目完成后可进入签退
|
||
|
||
### 5.5 pages/delivery/exception/exception.vue — 异常上报
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:一键选择异常类型 + 描述 + 证据上传。
|
||
|
||
异常类型列表(与后端 `ExceptionRequest.exceptionType` 对齐):
|
||
- `PATIENT_ABSENT` — 对象不在家
|
||
- `PATIENT_REFUSE` — 对象拒绝服务
|
||
- `WRONG_ADDRESS` — 地址错误
|
||
- `CANNOT_CONTACT` — 无法联系
|
||
- `CONDITION_NOT_READY` — 服务条件不具备
|
||
- `HEALTH_EMERGENCY` — 身体突发异常
|
||
- `QUALITY_DISPUTE` — 服务质量争议
|
||
|
||
### 5.6 pages/delivery/finish/finish.vue — 签退完成
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:服务总结 + 签退定位 + 提交完成。
|
||
|
||
核心逻辑:
|
||
1. 展示所有执行记录摘要
|
||
2. 输入服务总结
|
||
3. 获取签退位置
|
||
4. 调用 `POST /work-orders/{id}/finish`
|
||
5. 检查必做项目是否都已非 PENDING
|
||
|
||
### 5.7 pages/delivery/accept/accept.vue — 接单确认
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:展示工单基本信息 + 接单/拒单按钮。
|
||
|
||
### 5.8 pages/delivery/orders/orders.vue — 工单列表
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:按状态筛选 + 上拉加载更多。
|
||
|
||
### 5.9 pages/delivery/order-detail/order-detail.vue — 工单详情
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:调用 `/delivery/work-orders/{id}/detail`,展示完整信息 + 操作入口。
|
||
|
||
### 5.10 pages/delivery/offline-sync/offline-sync.vue — 离线补传
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:展示离线队列数量,一键补传,进度展示。
|
||
|
||
### 5.11 pages/delivery/messages/messages.vue — 消息通知
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:调用 `/delivery/messages`,展示消息列表,标记已读。
|
||
|
||
### 5.12 pages/delivery/profile/profile.vue — 我的资质
|
||
|
||
**当前逻辑**:不存在。
|
||
**拟议改动**:展示服务人员信息、资质列表、评分、工单统计。
|
||
|
||
---
|
||
|
||
## 6. 后端改动
|
||
|
||
### 6.1 GPS 轨迹端点(新增)
|
||
|
||
**文件**:`module/workorder/controller/GpsController.java`(新增)
|
||
|
||
```java
|
||
@RestController
|
||
@RequestMapping("/api/hss/gps")
|
||
@Tag(name = "GPS轨迹")
|
||
public class GpsController {
|
||
@PostMapping("/trajectory")
|
||
@PreAuthorize("hasRole('STAFF')")
|
||
public ApiResponse<Void> recordTrajectory(@RequestBody TrajectoryRequest req) {
|
||
jdbcTemplate.update(
|
||
"INSERT INTO hss_gps_trajectory (work_order_id, latitude, longitude, recorded_at, created_by) "
|
||
+ "VALUES (?, ?, ?, NOW(), ?)",
|
||
req.workOrderId(), req.latitude(), req.longitude(), RequestContext.getUserId());
|
||
return ApiResponse.ok(null);
|
||
}
|
||
public record TrajectoryRequest(Long workOrderId, Double latitude, Double longitude, String timestamp) {}
|
||
}
|
||
```
|
||
|
||
### 6.2 数据库(新增表)
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS hss_gps_trajectory (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
work_order_id BIGINT NOT NULL,
|
||
latitude DECIMAL(10,7) NOT NULL,
|
||
longitude DECIMAL(10,7) NOT NULL,
|
||
recorded_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||
created_by BIGINT
|
||
);
|
||
CREATE INDEX idx_hss_gps_wo ON hss_gps_trajectory(work_order_id);
|
||
```
|
||
|
||
### 6.3 EvidenceService 修复
|
||
|
||
**当前问题**:`commit()` 方法硬编码 `file_type = 'PHOTO'`,不支持 VIDEO/AUDIO。
|
||
|
||
**拟议改动**:从请求参数中读取 `fileType`。
|
||
|
||
---
|
||
|
||
## 7. 自测与回归
|
||
|
||
### 手动验证步骤
|
||
|
||
1. 登录 → 工作台 → 点击待接单 → 工单列表
|
||
2. 点击工单 → 工单详情(患者信息、服务项目、操作入口)
|
||
3. 点击接单 → 确认接单
|
||
4. 点击签到 → GPS定位 → 拍照 → 对象确认 → 提交签到
|
||
5. 点击开始服务
|
||
6. 逐项执行:每项记录时间+拍照/录像证据
|
||
7. (可选)上报异常
|
||
8. 点击完成 → 填写总结 → 签退定位 → 提交
|
||
9. 离线模式:断网操作 → 联网 → 离线补传
|
||
|
||
### 关键回归场景
|
||
|
||
- GPS 定位失败时显示友好提示
|
||
- 200 米外签到应被后端拒绝
|
||
- 必做项目未完成时 finish 应被拒绝
|
||
- 离线队列在网络恢复后能补传
|
||
- 微信小程序端 `#ifdef MP-WEIXIN` 条件编译生效
|
||
|
||
---
|
||
|
||
## 8. 短 Prompt
|
||
|
||
```txt
|
||
在 delivery-miniapp/ 中实现完整的服务人员移动作业端。
|
||
已有:login.vue + index.vue + common/api.js。
|
||
需要:10个缺失页面 + 扩展 api.js(GPS/拍照/上传/离线队列/水印/轨迹)。
|
||
技术栈:uni-app + Vue,微信小程序 + APP 双端条件编译。
|
||
后端 API 已就绪(work-orders/check-in/finish/report-exception + delivery/workbench/today/detail + evidence/presign-upload/commit)。
|
||
严格按 IMPLEMENTATION.md 实现,页面结构参考已有 login.vue 和 index.vue 的风格。
|
||
```
|