Files
Home-Care/hss-home-service/delivery-miniapp/IMPLEMENTATION.md
comclib 01e1034cc1 feat: 全系统优化 — 并发控制 + 冗余清理 + 数据流修复 + 全面测试
核心修复:
- 状态机加 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>
2026-05-22 11:48:07 +08:00

516 lines
17 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# Delivery 端完整实现方案
> 基于 5 轮需求采集 + 代码审计
> 后端 API已有 86 个接口,`WorkOrderService` 的 checkIn/finish/reportException 已就绪
> 当前状态12 个页面路由中仅实现 2 个login + index10 个缺失
---
## 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` | checkInHaversine+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.jsGPS/拍照/上传/离线队列/水印/轨迹)。
技术栈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 的风格。
```