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

17 KiB
Raw Blame History

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

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 — 服务执行

当前逻辑:不存在。 拟议改动:逐项执行记录 + 证据上传 + 时间记录。

核心逻辑:

  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(新增)

@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. 自测与回归

手动验证步骤

  1. 登录 → 工作台 → 点击待接单 → 工单列表
  2. 点击工单 → 工单详情(患者信息、服务项目、操作入口)
  3. 点击接单 → 确认接单
  4. 点击签到 → GPS定位 → 拍照 → 对象确认 → 提交签到
  5. 点击开始服务
  6. 逐项执行:每项记录时间+拍照/录像证据
  7. (可选)上报异常
  8. 点击完成 → 填写总结 → 签退定位 → 提交
  9. 离线模式:断网操作 → 联网 → 离线补传

关键回归场景

  • GPS 定位失败时显示友好提示
  • 200 米外签到应被后端拒绝
  • 必做项目未完成时 finish 应被拒绝
  • 离线队列在网络恢复后能补传
  • 微信小程序端 #ifdef MP-WEIXIN 条件编译生效

8. 短 Prompt

在 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 的风格。