核心修复: - 状态机加 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>
17 KiB
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/拍照/上传能力。
拟议改动:
- 新增
getLocation()— 封装uni.getLocation({ type: 'gcj02' }) - 新增
chooseImage(count)— 封装uni.chooseImage({ count, sizeType, sourceType }) - 新增
uploadFile(filePath, presignResp)— 封装uni.uploadFile上传到对象存储 - 新增
uploadEvidence(filePath, entityType, entityId)— 完整 presign→upload→commit 流程 - 新增
addWatermark(tempFilePath, text)— Canvas 绘制水印(时间+地点+工单号) - 新增
gpsTrajectory— 每 30 秒上报一次位置到后端
建议代码(新增内容追加到现有 api.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 — 离线缓存队列
当前逻辑:不存在。 拟议改动:新建离线队列,失败请求缓存到本地,恢复网络后批量补传。
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签到
当前逻辑:不存在。 拟议改动:三步签到流程。
<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 — 服务执行
当前逻辑:不存在。 拟议改动:逐项执行记录 + 证据上传 + 时间记录。
核心逻辑:
onLoad→ 调用/delivery/work-orders/{id}/detail获取工单项目列表- 每个项目显示:名称、标准时长、完成状态(PENDING / COMPLETED / SKIPPED)、证据要求
- 点击项目 → 弹出执行记录面板:实际开始/结束时间、备注、拍照/录像证据
- 实时显示已用时长计时器
- 所有项目完成后可进入签退
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 — 签退完成
当前逻辑:不存在。 拟议改动:服务总结 + 签退定位 + 提交完成。
核心逻辑:
- 展示所有执行记录摘要
- 输入服务总结
- 获取签退位置
- 调用
POST /work-orders/{id}/finish - 检查必做项目是否都已非 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(新增)
@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 数据库(新增表)
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. 自测与回归
手动验证步骤
- 登录 → 工作台 → 点击待接单 → 工单列表
- 点击工单 → 工单详情(患者信息、服务项目、操作入口)
- 点击接单 → 确认接单
- 点击签到 → GPS定位 → 拍照 → 对象确认 → 提交签到
- 点击开始服务
- 逐项执行:每项记录时间+拍照/录像证据
- (可选)上报异常
- 点击完成 → 填写总结 → 签退定位 → 提交
- 离线模式:断网操作 → 联网 → 离线补传
关键回归场景
- GPS 定位失败时显示友好提示
- 200 米外签到应被后端拒绝
- 必做项目未完成时 finish 应被拒绝
- 离线队列在网络恢复后能补传
- 微信小程序端
#ifdef MP-WEIXIN条件编译生效
8. 短 Prompt
在 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 的风格。