From c4db0a4526f61758272c92396aa788d84f098102 Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期二, 21 四月 2026 17:04:30 +0800
Subject: [PATCH] 风险规避

---
 backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java                      |   27 +
 backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java          |    3 
 backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java          |    3 
 backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java            |   86 +++
 backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java          |  156 +++++
 backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java    |  156 +++++
 backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java                   |   32 +
 backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java                |  140 ++++
 backend/src/main/java/com/sinata/appointment/controller/ExportController.java                     |   39 +
 backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java           |  217 ++-----
 backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java                       |   15 
 backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java        |   81 ++
 backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java       |  261 +++-----
 backend/src/main/resources/db/migration/V28__performance_indexes.sql                              |   20 
 backend/src/main/resources/mapper/ApprovalTaskMapper.xml                                          |   51 +
 backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java |   42 +
 backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java                 |   18 
 backend/src/main/resources/mapper/AppointmentOrderMapper.xml                                      |  192 +++++++
 backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java                          |   15 
 backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java              |   10 
 20 files changed, 1,236 insertions(+), 328 deletions(-)

diff --git a/backend/src/main/java/com/sinata/appointment/controller/ExportController.java b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
index 2cfd592..06793ee 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
@@ -3,13 +3,23 @@
 import cn.dev33.satoken.annotation.SaCheckPermission;
 import com.sinata.appointment.common.Result;
 import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ExportTaskStatusVo;
 import com.sinata.appointment.service.ExportApplicationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
 import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -35,4 +45,33 @@
         res.put("taskId", taskId);
         return ResultUtil.data(res);
     }
+
+    @Operation(summary = "查询导出任务状态")
+    @GetMapping("/{taskId}")
+    @SaCheckPermission("exports:enqueue")
+    public Result<ExportTaskStatusVo> status(@PathVariable String taskId) {
+        ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+        if (status == null) {
+            return ResultUtil.error(404, "导出任务不存在");
+        }
+        return ResultUtil.data(status);
+    }
+
+    @Operation(summary = "下载导出文件")
+    @GetMapping("/{taskId}/download")
+    @SaCheckPermission("exports:enqueue")
+    public ResponseEntity<byte[]> download(@PathVariable String taskId) throws IOException {
+        Path filePath = exportApplicationService.getExportFilePath(taskId);
+        if (filePath == null) {
+            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new byte[0]);
+        }
+        return ResponseEntity.ok()
+                .contentType(
+                        MediaType.parseMediaType(
+                                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+                .header(
+                        HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=\"" + filePath.getFileName() + "\"")
+                .body(Files.readAllBytes(filePath));
+    }
 }
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java b/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java
new file mode 100644
index 0000000..f985245
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ExportTaskStatusVo.java
@@ -0,0 +1,15 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "导出任务状态")
+public class ExportTaskStatusVo {
+
+    private String taskId;
+    private String type;
+    private String status;
+    private String fileName;
+    private String errorMessage;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java
new file mode 100644
index 0000000..a3d8a4f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalHistoryRow.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序审批历史查询行")
+public class MiniApprovalHistoryRow {
+
+    private Long id;
+    private String uuid;
+    private String orderNo;
+    private String visitedUserName;
+    private Long visitedDeptId;
+    private LocalDate visitStartDate;
+    private LocalDate visitEndDate;
+    private String visitReasonCode;
+    private String visitReasonOther;
+    private String visitTimeMode;
+    private String visitTimeSlot;
+    private String passAccessAreaNames;
+    private String status;
+    private LocalDateTime createTime;
+    private String taskStatus;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
index 2f54b50..2ee7acb 100644
--- a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
+++ b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
@@ -1,8 +1,38 @@
 package com.sinata.appointment.mapper;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.sinata.appointment.entity.AppointmentOrder;
+import java.time.LocalDate;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 @Mapper
-public interface AppointmentOrderMapper extends BaseMapper<AppointmentOrder> {}
+public interface AppointmentOrderMapper extends BaseMapper<AppointmentOrder> {
+
+    IPage<AppointmentOrder> pageAdminOrders(
+            Page<AppointmentOrder> page,
+            @Param("visitedUserName") String visitedUserName,
+            @Param("visitedDeptId") Long visitedDeptId,
+            @Param("accessArea") String accessArea,
+            @Param("localUnit") String localUnit,
+            @Param("visitDateStart") LocalDate visitDateStart,
+            @Param("visitDateEnd") LocalDate visitDateEnd,
+            @Param("displayStatus") String displayStatus,
+            @Param("approvalStatus") String approvalStatus,
+            @Param("visitorKeyword") String visitorKeyword,
+            @Param("visitReasonCode") String visitReasonCode);
+
+    IPage<AppointmentOrder> pageMiniVisitRecords(
+            Page<AppointmentOrder> page,
+            @Param("visitedUserId") Long visitedUserId,
+            @Param("visitorNameKeyword") String visitorNameKeyword,
+            @Param("visitDate") LocalDate visitDate);
+
+    IPage<AppointmentOrder> pageMiniMyOrders(
+            Page<AppointmentOrder> page,
+            @Param("submitterId") Long submitterId,
+            @Param("status") String status,
+            @Param("keyword") String keyword);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
index 88ffc8e..7b6eb7b 100644
--- a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
+++ b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
@@ -1,8 +1,21 @@
 package com.sinata.appointment.mapper;
 
+import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.dto.MiniApprovalHistoryRow;
 import com.sinata.appointment.entity.ApprovalTask;
+import java.time.LocalDate;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 @Mapper
-public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {}
+public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
+
+    IPage<MiniApprovalHistoryRow> pageMiniApprovalHistory(
+            Page<MiniApprovalHistoryRow> page,
+            @Param("assigneeUserId") Long assigneeUserId,
+            @Param("status") String status,
+            @Param("visitedUserNameKeyword") String visitedUserNameKeyword,
+            @Param("visitDate") LocalDate visitDate);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
index e4f9120..3314f48 100644
--- a/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
@@ -1,6 +1,8 @@
 package com.sinata.appointment.service;
 
 import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -87,19 +89,21 @@
             String approvalStatus,
             String visitorKeyword,
             String visitReasonCode) {
-        List<AppointmentAdminListVo> all =
-                listAdminOrders(
-                        visitedUserName,
+        Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+        IPage<AppointmentOrder> pagedOrders =
+                appointmentOrderMapper.pageAdminOrders(
+                        orderPage,
+                        trimToNull(visitedUserName),
                         visitedDeptId,
-                        accessArea,
-                        localUnit,
+                        trimToNull(accessArea),
+                        trimToNull(localUnit),
                         visitDateStart,
                         visitDateEnd,
-                        displayStatus,
-                        approvalStatus,
-                        visitorKeyword,
-                        visitReasonCode);
-        return slicePage(page, size, all);
+                        trimToNull(displayStatus),
+                        trimToNull(approvalStatus),
+                        trimToNull(visitorKeyword),
+                        trimToNull(visitReasonCode));
+        return buildAdminPage(pagedOrders);
     }
 
     public byte[] exportExcel(
@@ -113,53 +117,44 @@
             String approvalStatus,
             String visitorKeyword,
             String visitReasonCode) {
-        Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
-        if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
-            return writeExportRows(List.of());
-        }
-
-        List<AppointmentOrder> orders =
-                appointmentOrderMapper.selectList(
-                        buildOrderQuery(
-                                visitedUserName,
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+            ExcelWriter writer = EasyExcel.write(bos, AppointmentAdminExportRow.class).build();
+            WriteSheet sheet = EasyExcel.writerSheet("预约记录").build();
+            long pageNo = 1L;
+            long pageSize = 200L;
+            while (true) {
+                Page<AppointmentOrder> orderPage = new Page<>(pageNo, pageSize);
+                IPage<AppointmentOrder> pagedOrders =
+                        appointmentOrderMapper.pageAdminOrders(
+                                orderPage,
+                                trimToNull(visitedUserName),
                                 visitedDeptId,
-                                accessArea,
-                                localUnit,
+                                trimToNull(accessArea),
+                                trimToNull(localUnit),
                                 visitDateStart,
                                 visitDateEnd,
-                                visitReasonCode,
-                                visitorOrderIds));
-        if (orders.isEmpty()) {
-            return writeExportRows(List.of());
-        }
-
-        List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
-        Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
-        Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
-        Map<Long, String> deptNameMap = loadDeptNameMap(orders);
-        Map<String, String> reasonNameMap = loadReasonNameMap(orders);
-
-        List<AppointmentOrder> filteredOrders = new ArrayList<>();
-        for (AppointmentOrder order : orders) {
-            AppointmentAdminListVo vo =
-                    toAdminListVo(
-                            order,
-                            deptNameMap.get(order.getVisitedDeptId()),
-                            reasonNameMap,
-                            visitorRowsMap.get(order.getId()),
-                            approvalInstanceMap.get(order.getId()));
-            if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
-                filteredOrders.add(order);
+                                trimToNull(displayStatus),
+                                trimToNull(approvalStatus),
+                                trimToNull(visitorKeyword),
+                                trimToNull(visitReasonCode));
+                List<AppointmentOrder> orders = pagedOrders.getRecords();
+                if (orders == null || orders.isEmpty()) {
+                    break;
+                }
+                AdminPageData pageData = loadAdminPageData(orders);
+                List<AppointmentAdminExportRow> rows =
+                        buildExportRows(
+                                orders,
+                                pageData.visitorRowsMap(),
+                                pageData.approvalInstanceMap(),
+                                pageData.reasonNameMap());
+                writer.write(rows, sheet);
+                if (pageNo * pageSize >= pagedOrders.getTotal()) {
+                    break;
+                }
+                pageNo++;
             }
-        }
-
-        List<AppointmentAdminExportRow> rows = buildExportRows(filteredOrders, visitorRowsMap, approvalInstanceMap, reasonNameMap);
-        return writeExportRows(rows);
-    }
-
-    private byte[] writeExportRows(List<AppointmentAdminExportRow> rows) {
-        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
-            EasyExcel.write(bos, AppointmentAdminExportRow.class).sheet("预约记录").doWrite(rows);
+            writer.finish();
             return bos.toByteArray();
         } catch (Exception e) {
             return new byte[0];
@@ -215,99 +210,37 @@
                         .orderByAsc(AppointmentVisitor::getId));
     }
 
-    private List<AppointmentAdminListVo> listAdminOrders(
-            String visitedUserName,
-            Long visitedDeptId,
-            String accessArea,
-            String localUnit,
-            LocalDate visitDateStart,
-            LocalDate visitDateEnd,
-            String displayStatus,
-            String approvalStatus,
-            String visitorKeyword,
-            String visitReasonCode) {
-        Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
-        if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
-            return List.of();
+
+    private IPage<AppointmentAdminListVo> buildAdminPage(IPage<AppointmentOrder> pagedOrders) {
+        Page<AppointmentAdminListVo> page =
+                new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+        List<AppointmentOrder> orders = pagedOrders.getRecords();
+        if (orders == null || orders.isEmpty()) {
+            page.setRecords(List.of());
+            return page;
         }
-
-        List<AppointmentOrder> orders =
-                appointmentOrderMapper.selectList(
-                        buildOrderQuery(
-                                visitedUserName,
-                                visitedDeptId,
-                                accessArea,
-                                localUnit,
-                                visitDateStart,
-                                visitDateEnd,
-                                visitReasonCode,
-                                visitorOrderIds));
-        if (orders.isEmpty()) {
-            return List.of();
-        }
-
-        List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
-        Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
-        Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
-        Map<Long, String> deptNameMap = loadDeptNameMap(orders);
-        Map<String, String> reasonNameMap = loadReasonNameMap(orders);
-
-        List<AppointmentAdminListVo> result = new ArrayList<>();
+        AdminPageData pageData = loadAdminPageData(orders);
+        List<AppointmentAdminListVo> records = new ArrayList<>();
         for (AppointmentOrder order : orders) {
-            AppointmentAdminListVo vo =
+            records.add(
                     toAdminListVo(
                             order,
-                            deptNameMap.get(order.getVisitedDeptId()),
-                            reasonNameMap,
-                            visitorRowsMap.get(order.getId()),
-                            approvalInstanceMap.get(order.getId()));
-            if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
-                result.add(vo);
-            }
+                            pageData.deptNameMap().get(order.getVisitedDeptId()),
+                            pageData.reasonNameMap(),
+                            pageData.visitorRowsMap().get(order.getId()),
+                            pageData.approvalInstanceMap().get(order.getId())));
         }
-        return result;
+        page.setRecords(records);
+        return page;
     }
 
-    private LambdaQueryWrapper<AppointmentOrder> buildOrderQuery(
-            String visitedUserName,
-            Long visitedDeptId,
-            String accessArea,
-            String localUnit,
-            LocalDate visitDateStart,
-            LocalDate visitDateEnd,
-            String visitReasonCode,
-            Set<Long> visitorOrderIds) {
-        LambdaQueryWrapper<AppointmentOrder> q = new LambdaQueryWrapper<>();
-        q.eq(AppointmentOrder::getDelFlag, 0);
-        q.like(StringUtils.hasText(visitedUserName), AppointmentOrder::getVisitedUserName, trim(visitedUserName));
-        q.eq(visitedDeptId != null, AppointmentOrder::getVisitedDeptId, visitedDeptId);
-        q.like(StringUtils.hasText(accessArea), AppointmentOrder::getPassAccessAreaNames, trim(accessArea));
-        q.like(StringUtils.hasText(localUnit), AppointmentOrder::getPassLocalUnitNames, trim(localUnit));
-        q.eq(StringUtils.hasText(visitReasonCode), AppointmentOrder::getVisitReasonCode, trim(visitReasonCode));
-        q.le(visitDateEnd != null, AppointmentOrder::getVisitStartDate, visitDateEnd);
-        q.ge(visitDateStart != null, AppointmentOrder::getVisitEndDate, visitDateStart);
-        if (visitorOrderIds != null) {
-            q.in(AppointmentOrder::getId, visitorOrderIds);
-        }
-        q.orderByDesc(AppointmentOrder::getCreateTime).orderByDesc(AppointmentOrder::getId);
-        return q;
-    }
-
-    private Set<Long> findOrderIdsByVisitorKeyword(String visitorKeyword) {
-        String keyword = trimToNull(visitorKeyword);
-        if (!StringUtils.hasText(keyword)) {
-            return null;
-        }
-        List<AppointmentVisitor> rows =
-                appointmentVisitorMapper.selectList(
-                        new LambdaQueryWrapper<AppointmentVisitor>()
-                                .eq(AppointmentVisitor::getDelFlag, 0)
-                                .like(AppointmentVisitor::getName, keyword)
-                                .select(AppointmentVisitor::getOrderId));
-        return rows.stream()
-                .map(AppointmentVisitor::getOrderId)
-                .filter(Objects::nonNull)
-                .collect(Collectors.toCollection(LinkedHashSet::new));
+    private AdminPageData loadAdminPageData(List<AppointmentOrder> orders) {
+        List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+        return new AdminPageData(
+                loadVisitorRows(orderIds),
+                loadApprovalInstanceMap(orderIds),
+                loadDeptNameMap(orders),
+                loadReasonNameMap(orders));
     }
 
     private Map<Long, MpVisitor> loadVisitorMap(List<AppointmentVisitor> rows) {
@@ -927,4 +860,10 @@
         }
         return null;
     }
+
+    private record AdminPageData(
+            Map<Long, List<AppointmentVisitor>> visitorRowsMap,
+            Map<Long, ApprovalInstance> approvalInstanceMap,
+            Map<Long, String> deptNameMap,
+            Map<String, String> reasonNameMap) {}
 }
diff --git a/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
index c788437..520d2bd 100644
--- a/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
@@ -15,10 +15,13 @@
 import com.sinata.appointment.mapper.AppointmentOrderMapper;
 import com.sinata.appointment.mapper.ApprovalInstanceMapper;
 import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
+import com.sinata.appointment.service.event.AppointmentRejectedEvent;
 import com.sinata.appointment.util.UuidAssert;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.context.ApplicationEventPublisher;
 
 @Service
 @RequiredArgsConstructor
@@ -28,7 +31,7 @@
     private final ApprovalInstanceMapper approvalInstanceMapper;
     private final AppointmentOrderMapper appointmentOrderMapper;
     private final ApprovalEngineService approvalEngineService;
-    private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+    private final ApplicationEventPublisher applicationEventPublisher;
 
     public IPage<ApprovalTask> pageMyTodo(long page, long size) {
         Long uid = StpUtil.getLoginIdAsLong();
@@ -64,7 +67,7 @@
             task.setComment(reason);
             approvalTaskMapper.updateById(task);
             approvalEngineService.rejectInstance(inst, reason);
-            postApprovalOrchestrationService.onOrderRejected(order, reason);
+            applicationEventPublisher.publishEvent(new AppointmentRejectedEvent(order.getId(), reason));
             return;
         }
 
@@ -77,8 +80,7 @@
             approvalEngineService.advanceOrComplete(inst, order);
             ApprovalInstance latest = approvalInstanceMapper.selectById(inst.getId());
             if (ApprovalInstanceStatus.APPROVED.equals(latest.getStatus())) {
-                AppointmentOrder fresh = appointmentOrderMapper.selectById(order.getId());
-                postApprovalOrchestrationService.onOrderFullyApproved(fresh);
+                applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
             }
         }
     }
diff --git a/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
index 2d0f612..7297cfa 100644
--- a/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
@@ -1,8 +1,17 @@
 package com.sinata.appointment.service;
 
 import com.sinata.appointment.constants.IntegrationConstant;
+import com.sinata.appointment.dto.ExportTaskStatusVo;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.amqp.rabbit.core.RabbitTemplate;
@@ -14,22 +23,137 @@
 @RequiredArgsConstructor
 public class ExportApplicationService {
 
+    private static final String STATUS_QUEUED = "QUEUED";
+    private static final String STATUS_RUNNING = "RUNNING";
+    private static final String STATUS_COMPLETED = "COMPLETED";
+    private static final String STATUS_FAILED = "FAILED";
+    private static final String APPOINTMENT_EXPORT_TYPE = "appointment";
+
     private final ObjectProvider<RabbitTemplate> rabbitTemplateProvider;
+    private final AppointmentApplicationService appointmentApplicationService;
+    private final LocalExportAsyncService localExportAsyncService;
+
+    private final ConcurrentHashMap<String, ExportTaskSnapshot> taskStore = new ConcurrentHashMap<>();
 
     public String enqueueExport(String exportType, Map<String, Object> criteria) {
+        String normalizedType = normalizeType(exportType);
         String taskId = UUID.randomUUID().toString().replace("-", "");
+        Map<String, Object> safeCriteria = criteria == null ? Map.of() : new HashMap<>(criteria);
         RabbitTemplate rabbitTemplate = rabbitTemplateProvider.getIfAvailable();
         if (rabbitTemplate == null) {
-            log.warn(
-                    "RabbitMQ not configured, export task accepted locally only taskId={} type={}",
-                    taskId,
-                    exportType);
+            taskStore.put(taskId, ExportTaskSnapshot.queued(taskId, normalizedType));
+            localExportAsyncService.process(taskId, normalizedType, safeCriteria);
             return taskId;
         }
-        log.info("Enqueue export taskId={} type={}", taskId, exportType);
-        rabbitTemplate.convertAndSend(
-                IntegrationConstant.EXPORT_TASK_QUEUE,
-                Map.of("taskId", taskId, "type", exportType, "criteria", criteria));
+        taskStore.put(taskId, ExportTaskSnapshot.queued(taskId, normalizedType));
+        log.info("Enqueue export taskId={} type={}", taskId, normalizedType);
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("taskId", taskId);
+        payload.put("type", normalizedType);
+        payload.put("criteria", safeCriteria);
+        rabbitTemplate.convertAndSend(IntegrationConstant.EXPORT_TASK_QUEUE, payload);
         return taskId;
     }
+
+    public ExportTaskStatusVo getTaskStatus(String taskId) {
+        ExportTaskSnapshot snapshot = taskStore.get(taskId);
+        if (snapshot == null) {
+            return null;
+        }
+        ExportTaskStatusVo vo = new ExportTaskStatusVo();
+        vo.setTaskId(snapshot.taskId());
+        vo.setType(snapshot.type());
+        vo.setStatus(snapshot.status());
+        vo.setFileName(snapshot.fileName());
+        vo.setErrorMessage(snapshot.errorMessage());
+        return vo;
+    }
+
+    public Path getExportFilePath(String taskId) {
+        ExportTaskSnapshot snapshot = taskStore.get(taskId);
+        if (snapshot == null || snapshot.filePath() == null || !Files.exists(snapshot.filePath())) {
+            return null;
+        }
+        return snapshot.filePath();
+    }
+
+    void processLocalExport(String taskId, String exportType, Map<String, Object> criteria) {
+        taskStore.computeIfPresent(taskId, (_k, v) -> v.toRunning());
+        try {
+            if (!APPOINTMENT_EXPORT_TYPE.equals(normalizeType(exportType))) {
+                throw new IllegalArgumentException("unsupported export type: " + exportType);
+            }
+            byte[] bytes =
+                    appointmentApplicationService.exportExcel(
+                            asString(criteria.get("visitedUserName")),
+                            asLong(criteria.get("visitedDeptId")),
+                            asString(criteria.get("accessArea")),
+                            asString(criteria.get("localUnit")),
+                            asLocalDate(criteria.get("visitDateStart")),
+                            asLocalDate(criteria.get("visitDateEnd")),
+                            asString(criteria.get("displayStatus")),
+                            asString(criteria.get("approvalStatus")),
+                            asString(criteria.get("visitorKeyword")),
+                            asString(criteria.get("visitReasonCode")));
+            Path exportDir = Paths.get(System.getProperty("java.io.tmpdir"), "reservation-mini-program-exports");
+            Files.createDirectories(exportDir);
+            Path filePath = exportDir.resolve(taskId + ".xlsx");
+            Files.write(filePath, bytes);
+            taskStore.put(
+                    taskId,
+                    new ExportTaskSnapshot(taskId, normalizeType(exportType), STATUS_COMPLETED, filePath, filePath.getFileName().toString(), null));
+        } catch (Exception ex) {
+            log.error("Local export failed taskId={} type={}", taskId, exportType, ex);
+            taskStore.put(
+                    taskId,
+                    new ExportTaskSnapshot(taskId, normalizeType(exportType), STATUS_FAILED, null, null, ex.getMessage()));
+        }
+    }
+
+    private String normalizeType(String exportType) {
+        return exportType == null || exportType.isBlank() ? APPOINTMENT_EXPORT_TYPE : exportType.trim();
+    }
+
+    private String asString(Object value) {
+        return value == null ? null : value.toString().trim();
+    }
+
+    private Long asLong(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value.toString().trim());
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    private LocalDate asLocalDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        try {
+            return LocalDate.parse(value.toString().trim());
+        } catch (DateTimeParseException ex) {
+            return null;
+        }
+    }
+
+    private record ExportTaskSnapshot(
+            String taskId,
+            String type,
+            String status,
+            Path filePath,
+            String fileName,
+            String errorMessage) {
+
+        private static ExportTaskSnapshot queued(String taskId, String type) {
+            return new ExportTaskSnapshot(taskId, type, STATUS_QUEUED, null, null, null);
+        }
+
+        private ExportTaskSnapshot toRunning() {
+            return new ExportTaskSnapshot(taskId, type, STATUS_RUNNING, filePath, fileName, errorMessage);
+        }
+    }
 }
diff --git a/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java b/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java
new file mode 100644
index 0000000..c3d5f5e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/LocalExportAsyncService.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.service;
+
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class LocalExportAsyncService {
+
+    private final ExportApplicationService exportApplicationService;
+
+    @Async
+    public void process(String taskId, String exportType, Map<String, Object> criteria) {
+        exportApplicationService.processLocalExport(taskId, exportType, criteria);
+    }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
index 4cf442b..344c06b 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
@@ -15,6 +15,7 @@
 import com.sinata.appointment.constants.SystemSettingKeys;
 import com.sinata.appointment.dto.MiniAppointmentCreateRequest;
 import com.sinata.appointment.dto.MiniAppointmentListVo;
+import com.sinata.appointment.dto.MiniApprovalHistoryRow;
 import com.sinata.appointment.dto.MiniAppointmentVehicleItemRequest;
 import com.sinata.appointment.dto.MiniApprovalDetailVisitorVo;
 import com.sinata.appointment.dto.MiniMyAppointmentDetailVo;
@@ -67,6 +68,8 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
+import org.springframework.context.ApplicationEventPublisher;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
 
 @Service
 @RequiredArgsConstructor
@@ -96,6 +99,7 @@
     private final PostApprovalOrchestrationService postApprovalOrchestrationService;
     private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
     private final ObjectMapper objectMapper;
+    private final ApplicationEventPublisher applicationEventPublisher;
 
     @Transactional(rollbackFor = Exception.class)
     public Long create(MiniAppointmentCreateRequest req) {
@@ -179,11 +183,11 @@
 
         List<String> chain = approvalEngineService.resolveEnabledNodeChain(order);
         if (chain.isEmpty()) {
-            postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+            applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
         } else {
             ApprovalInstance inst = approvalEngineService.startFlow(order);
             if (inst == null) {
-                postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+                applicationEventPublisher.publishEvent(new AppointmentApprovedEvent(order.getId()));
             }
         }
         return order.getId();
@@ -222,190 +226,94 @@
     public IPage<MiniAppointmentListVo> pageMine(
             long page, long size, String status, String visitedUserNameKeyword, LocalDate visitDate) {
         MpUser current = miniAuthApplicationService.currentMpUser();
-        LambdaQueryWrapper<ApprovalTask> tq =
-                new LambdaQueryWrapper<ApprovalTask>()
-                        .eq(ApprovalTask::getDelFlag, 0)
-                        .eq(ApprovalTask::getAssigneeUserId, current.getId())
-                        .in(
-                                ApprovalTask::getTaskStatus,
-                                ApprovalTaskStatus.APPROVED,
-                                ApprovalTaskStatus.REJECTED)
-                        .orderByDesc(ApprovalTask::getUpdateTime)
-                        .orderByDesc(ApprovalTask::getId);
-        if (StringUtils.hasText(status)) {
-            tq.eq(ApprovalTask::getTaskStatus, status.trim());
+        Page<MiniApprovalHistoryRow> rowPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+        IPage<MiniApprovalHistoryRow> pagedRows =
+                approvalTaskMapper.pageMiniApprovalHistory(
+                        rowPage,
+                        current.getId(),
+                        trimToNull(status),
+                        trimToNull(visitedUserNameKeyword),
+                        visitDate);
+        Page<MiniAppointmentListVo> result =
+                new Page<>(pagedRows.getCurrent(), pagedRows.getSize(), pagedRows.getTotal());
+        List<MiniApprovalHistoryRow> rows = pagedRows.getRecords();
+        if (rows == null || rows.isEmpty()) {
+            result.setRecords(List.of());
+            return result;
         }
-        List<ApprovalTask> tasks = approvalTaskMapper.selectList(tq);
-        if (tasks.isEmpty()) {
-            return new Page<>(page, size);
-        }
-
-        java.util.LinkedHashMap<Long, ApprovalTask> latestTaskByOrder = new java.util.LinkedHashMap<>();
-        for (ApprovalTask task : tasks) {
-            ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
-            if (inst == null || (inst.getDelFlag() != null && inst.getDelFlag() == 1) || inst.getOrderId() == null) {
-                continue;
-            }
-            latestTaskByOrder.putIfAbsent(inst.getOrderId(), task);
-        }
-        if (latestTaskByOrder.isEmpty()) {
-            return new Page<>(page, size);
-        }
-
-        List<AppointmentOrder> orders =
-                appointmentOrderMapper.selectList(
-                        new LambdaQueryWrapper<AppointmentOrder>()
-                                .eq(AppointmentOrder::getDelFlag, 0)
-                                .in(AppointmentOrder::getId, latestTaskByOrder.keySet()));
-        java.util.Map<Long, AppointmentOrder> orderMap =
-                orders.stream().collect(java.util.stream.Collectors.toMap(AppointmentOrder::getId, x -> x, (a, b) -> a));
-
-        List<MiniAppointmentListVo> all = new java.util.ArrayList<>();
-        for (var e : latestTaskByOrder.entrySet()) {
-            AppointmentOrder order = orderMap.get(e.getKey());
-            if (order == null) {
-                continue;
-            }
-            if (StringUtils.hasText(visitedUserNameKeyword)) {
-                if (!StringUtils.hasText(order.getVisitedUserName())
-                        || !order.getVisitedUserName().contains(visitedUserNameKeyword.trim())) {
-                    continue;
-                }
-            }
-            if (visitDate != null) {
-                if (order.getVisitStartDate() == null
-                        || order.getVisitEndDate() == null
-                        || visitDate.isBefore(order.getVisitStartDate())
-                        || visitDate.isAfter(order.getVisitEndDate())) {
-                    continue;
-                }
-            }
+        List<AppointmentOrder> orders = rows.stream().map(this::toOrderSnapshot).toList();
+        Map<Long, String> deptNameMap = loadDeptNameMap(orders);
+        Map<String, String> reasonNameMap = loadReasonNameMap(orders);
+        List<MiniAppointmentListVo> records = new ArrayList<>();
+        for (MiniApprovalHistoryRow row : rows) {
+            AppointmentOrder order = toOrderSnapshot(row);
             MiniAppointmentListVo vo = toListVo(order);
-            vo.setStatus(e.getValue().getTaskStatus());
-            all.add(vo);
+            vo.setStatus(row.getTaskStatus());
+            vo.setVisitedDeptName(deptNameMap.get(order.getVisitedDeptId()));
+            vo.setVisitedUserDisplay(buildVisitedUserDisplay(order.getVisitedUserName(), vo.getVisitedDeptName()));
+            vo.setVisitTimeText(buildVisitTimeText(order));
+            vo.setVisitReasonText(buildVisitReasonText(order, reasonNameMap));
+            vo.setAccessAreaText(buildAccessAreaText(order.getPassAccessAreaNames()));
+            records.add(vo);
         }
-        return slicePage(page, size, all);
+        result.setRecords(records);
+        return result;
     }
 
     public IPage<MiniVisitRecordListVo> pageVisitRecords(
             long page, long size, String visitorNameKeyword, LocalDate visitDate) {
         MpUser current = miniAuthApplicationService.currentMpUser();
-        List<AppointmentOrder> orders =
-                appointmentOrderMapper.selectList(
-                        new LambdaQueryWrapper<AppointmentOrder>()
-                                .eq(AppointmentOrder::getDelFlag, 0)
-                                .eq(AppointmentOrder::getStatus, AppointmentOrderStatus.APPROVED)
-                                .eq(AppointmentOrder::getVisitedUserId, current.getId()));
-        if (orders.isEmpty()) {
-            return new Page<>(page, size);
+        Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+        IPage<AppointmentOrder> pagedOrders =
+                appointmentOrderMapper.pageMiniVisitRecords(
+                        orderPage, current.getId(), trimToNull(visitorNameKeyword), visitDate);
+        Page<MiniVisitRecordListVo> result =
+                new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+        List<AppointmentOrder> orders = pagedOrders.getRecords();
+        if (orders == null || orders.isEmpty()) {
+            result.setRecords(List.of());
+            return result;
         }
-        List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
-        List<AppointmentVisitor> visitors =
-                appointmentVisitorMapper.selectList(
-                        new LambdaQueryWrapper<AppointmentVisitor>()
-                                .eq(AppointmentVisitor::getDelFlag, 0)
-                                .in(AppointmentVisitor::getOrderId, orderIds)
-                                .orderByAsc(AppointmentVisitor::getId));
-        Map<Long, List<String>> visitorNamesMap = new java.util.LinkedHashMap<>();
-        for (AppointmentVisitor row : visitors) {
-            visitorNamesMap.computeIfAbsent(row.getOrderId(), k -> new java.util.ArrayList<>());
-            if (StringUtils.hasText(row.getName())) {
-                visitorNamesMap.get(row.getOrderId()).add(row.getName().trim());
-            }
-        }
-
-        List<MiniVisitRecordListVo> rows = new java.util.ArrayList<>();
+        Map<Long, List<String>> visitorNamesMap = loadVisitorNamesMap(orders.stream().map(AppointmentOrder::getId).toList());
+        List<MiniVisitRecordListVo> records = new ArrayList<>();
         for (AppointmentOrder order : orders) {
-            List<String> names = visitorNamesMap.getOrDefault(order.getId(), List.of());
-            if (StringUtils.hasText(visitorNameKeyword)) {
-                String kw = visitorNameKeyword.trim();
-                boolean hit = false;
-                for (String n : names) {
-                    if (n.contains(kw)) {
-                        hit = true;
-                        break;
-                    }
-                }
-                if (!hit) {
-                    continue;
-                }
-            }
-            if (visitDate != null) {
-                if (order.getVisitStartDate() == null
-                        || order.getVisitEndDate() == null
-                        || visitDate.isBefore(order.getVisitStartDate())
-                        || visitDate.isAfter(order.getVisitEndDate())) {
-                    continue;
-                }
-            }
             MiniVisitRecordListVo vo = new MiniVisitRecordListVo();
             vo.setOrderId(order.getId());
             vo.setOrderNo(order.getOrderNo());
-            vo.setVisitorNames(names);
+            vo.setVisitorNames(visitorNamesMap.getOrDefault(order.getId(), List.of()));
             vo.setVisitStartDate(order.getVisitStartDate());
             vo.setVisitEndDate(order.getVisitEndDate());
             vo.setVisitTimeMode(order.getVisitTimeMode());
             vo.setVisitTimeSlot(order.getVisitTimeSlot());
             vo.setCreateTime(order.getCreateTime());
-            rows.add(vo);
+            records.add(vo);
         }
-
-        rows.sort(
-                java.util.Comparator
-                        .comparing(
-                                MiniVisitRecordListVo::getVisitEndDate,
-                                java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
-                        .reversed()
-                        .thenComparing(
-                                this::singleDaySlotSortWeight,
-                                java.util.Comparator.reverseOrder())
-                        .thenComparing(
-                                MiniVisitRecordListVo::getCreateTime,
-                                java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())));
-        return slicePageVisitRecord(page, size, rows);
+        result.setRecords(records);
+        return result;
     }
 
     public IPage<MiniAppointmentListVo> pageMyOrders(
             long page, long size, String status, String keyword) {
         MpUser current = miniAuthApplicationService.currentMpUser();
-        List<AppointmentOrder> orders =
-                appointmentOrderMapper.selectList(
-                        new LambdaQueryWrapper<AppointmentOrder>()
-                                .eq(AppointmentOrder::getDelFlag, 0)
-                                .eq(AppointmentOrder::getSubmitterId, current.getId())
-                                .orderByDesc(AppointmentOrder::getCreateTime)
-                                .orderByDesc(AppointmentOrder::getId));
-        if (orders.isEmpty()) {
-            return new Page<>(page, size);
+        Page<AppointmentOrder> orderPage = new Page<>(Math.max(1L, page), Math.max(1L, size));
+        IPage<AppointmentOrder> pagedOrders =
+                appointmentOrderMapper.pageMiniMyOrders(
+                        orderPage, current.getId(), trimToNull(status), trimToNull(keyword));
+        Page<MiniAppointmentListVo> result =
+                new Page<>(pagedOrders.getCurrent(), pagedOrders.getSize(), pagedOrders.getTotal());
+        List<AppointmentOrder> orders = pagedOrders.getRecords();
+        if (orders == null || orders.isEmpty()) {
+            result.setRecords(List.of());
+            return result;
         }
-
         Map<Long, String> deptNameMap = loadDeptNameMap(orders);
         Map<String, String> reasonNameMap = loadReasonNameMap(orders);
-        String normalizedKeyword = trimToNull(keyword);
-        String normalizedStatus = trimToNull(status);
-        List<MiniAppointmentListVo> rows = new ArrayList<>();
+        List<MiniAppointmentListVo> records = new ArrayList<>();
         for (AppointmentOrder order : orders) {
-            MiniAppointmentListVo vo = toMyOrderListVo(order, deptNameMap, reasonNameMap);
-            if (StringUtils.hasText(normalizedStatus)
-                    && !Objects.equals(normalizedStatus, vo.getDisplayStatus())) {
-                continue;
-            }
-            if (StringUtils.hasText(normalizedKeyword)
-                    && !containsIgnoreCase(vo.getVisitedUserDisplay(), normalizedKeyword)
-                    && !containsIgnoreCase(vo.getVisitReasonText(), normalizedKeyword)) {
-                continue;
-            }
-            rows.add(vo);
+            records.add(toMyOrderListVo(order, deptNameMap, reasonNameMap));
         }
-
-        rows.sort(
-                Comparator.comparing(
-                                MiniAppointmentListVo::getCreateTime,
-                                Comparator.nullsLast(Comparator.reverseOrder()))
-                        .thenComparing(
-                                MiniAppointmentListVo::getId,
-                                Comparator.nullsLast(Comparator.reverseOrder())));
-        return slicePage(page, size, rows);
+        result.setRecords(records);
+        return result;
     }
 
     public List<MiniMyVisitorCodeOrderVo> listMyVisitorCodes() {
@@ -809,6 +717,25 @@
         return vo;
     }
 
+    private AppointmentOrder toOrderSnapshot(MiniApprovalHistoryRow row) {
+        AppointmentOrder order = new AppointmentOrder();
+        order.setId(row.getId());
+        order.setUuid(row.getUuid());
+        order.setOrderNo(row.getOrderNo());
+        order.setVisitedUserName(row.getVisitedUserName());
+        order.setVisitedDeptId(row.getVisitedDeptId());
+        order.setVisitStartDate(row.getVisitStartDate());
+        order.setVisitEndDate(row.getVisitEndDate());
+        order.setVisitReasonCode(row.getVisitReasonCode());
+        order.setVisitReasonOther(row.getVisitReasonOther());
+        order.setVisitTimeMode(row.getVisitTimeMode());
+        order.setVisitTimeSlot(row.getVisitTimeSlot());
+        order.setPassAccessAreaNames(row.getPassAccessAreaNames());
+        order.setStatus(row.getStatus());
+        order.setCreateTime(row.getCreateTime());
+        return order;
+    }
+
     private Map<Long, String> loadDeptNameMap(List<AppointmentOrder> orders) {
         Set<Long> deptIds =
                 orders.stream()
@@ -852,6 +779,26 @@
                                 LinkedHashMap::new));
     }
 
+    private Map<Long, List<String>> loadVisitorNamesMap(List<Long> orderIds) {
+        if (orderIds == null || orderIds.isEmpty()) {
+            return Map.of();
+        }
+        List<AppointmentVisitor> visitors =
+                appointmentVisitorMapper.selectList(
+                        new LambdaQueryWrapper<AppointmentVisitor>()
+                                .eq(AppointmentVisitor::getDelFlag, 0)
+                                .in(AppointmentVisitor::getOrderId, orderIds)
+                                .orderByAsc(AppointmentVisitor::getId));
+        Map<Long, List<String>> result = new LinkedHashMap<>();
+        for (AppointmentVisitor row : visitors) {
+            result.computeIfAbsent(row.getOrderId(), k -> new ArrayList<>());
+            if (StringUtils.hasText(row.getName())) {
+                result.get(row.getOrderId()).add(row.getName().trim());
+            }
+        }
+        return result;
+    }
+
     private String buildVisitedUserDisplay(String userName, String deptName) {
         String name = trimToNull(userName);
         String dept = trimToNull(deptName);
diff --git a/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
index 2af6630..a4134cb 100644
--- a/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
@@ -24,7 +24,11 @@
 import com.sinata.appointment.util.SensitiveDataUtil;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.UUID;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -81,6 +85,8 @@
                                 .eq(AppointmentVisitor::getDelFlag, 0)
                                 .eq(AppointmentVisitor::getOrderId, order.getId())
                                 .orderByAsc(AppointmentVisitor::getId));
+        Map<Long, MpVisitor> visitorProfileMap = loadVisitorProfileMap(visitors);
+        Map<Long, QrcodeTicket> existingTicketMap = loadActiveUnusedTicketMap(order.getId(), visitors);
         int validMinutes = getMaxConsecutiveDays() * 24 * 60;
         LocalDateTime generateTime = resolveGenerateTime(order);
         LocalDateTime expireTime = generateTime.plusMinutes(validMinutes);
@@ -90,17 +96,17 @@
 
         int createdCount = 0;
         for (AppointmentVisitor visitor : visitors) {
-            QrcodeTicket existed = findActiveUnusedTicket(order.getId(), visitor.getMpVisitorId());
+            QrcodeTicket existed = existingTicketMap.get(visitor.getMpVisitorId());
             if (existed != null) {
                 log.info(
                         "{} orderId={} mpVisitorId={} reuse ticketId={}",
                         IntegrationConstant.HIKVISION_QRCODE_EVENT,
-                        order.getId(),
-                        visitor.getMpVisitorId(),
-                        existed.getId());
+                    order.getId(),
+                    visitor.getMpVisitorId(),
+                    existed.getId());
                 continue;
             }
-            MpVisitor visitorProfile = loadVisitor(visitor.getMpVisitorId());
+            MpVisitor visitorProfile = visitorProfileMap.get(visitor.getMpVisitorId());
             String ticketUuid = UUID.randomUUID().toString().replace("-", "");
             String visitorIdCardPlain = resolveVisitorIdCardPlain(visitor, visitorProfile);
             String payload =
@@ -125,6 +131,7 @@
             ticket.setGenerateTime(generateTime);
             ticket.setExpireTime(expireTime);
             qrcodeTicketMapper.insert(ticket);
+            existingTicketMap.put(visitor.getMpVisitorId(), ticket);
             createdCount++;
             log.info(
                     "{} raw-payload orderId={} mpVisitorId={} payload={}",
@@ -166,20 +173,58 @@
         }
     }
 
-    private QrcodeTicket findActiveUnusedTicket(Long orderId, Long mpVisitorId) {
-        LambdaQueryWrapper<QrcodeTicket> query =
-                new LambdaQueryWrapper<QrcodeTicket>()
-                        .eq(QrcodeTicket::getDelFlag, 0)
-                        .eq(QrcodeTicket::getOrderId, orderId)
-                        .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
-                        .orderByDesc(QrcodeTicket::getId)
-                        .last("limit 1");
-        if (mpVisitorId == null) {
-            query.isNull(QrcodeTicket::getMpVisitorId);
-        } else {
-            query.eq(QrcodeTicket::getMpVisitorId, mpVisitorId);
+    private Map<Long, MpVisitor> loadVisitorProfileMap(List<AppointmentVisitor> visitors) {
+        if (visitors == null || visitors.isEmpty()) {
+            return Map.of();
         }
-        return qrcodeTicketMapper.selectOne(query);
+        List<Long> visitorIds =
+                visitors.stream()
+                        .map(AppointmentVisitor::getMpVisitorId)
+                        .filter(Objects::nonNull)
+                        .distinct()
+                        .toList();
+        if (visitorIds.isEmpty()) {
+            return Map.of();
+        }
+        List<MpVisitor> rows =
+                mpVisitorMapper.selectList(
+                        new LambdaQueryWrapper<MpVisitor>()
+                                .eq(MpVisitor::getDelFlag, 0)
+                                .in(MpVisitor::getId, visitorIds));
+        Map<Long, MpVisitor> result = new LinkedHashMap<>();
+        for (MpVisitor row : rows) {
+            result.putIfAbsent(row.getId(), row);
+        }
+        return result;
+    }
+
+    private Map<Long, QrcodeTicket> loadActiveUnusedTicketMap(Long orderId, List<AppointmentVisitor> visitors) {
+        if (orderId == null || visitors == null || visitors.isEmpty()) {
+            return new LinkedHashMap<>();
+        }
+        LinkedHashSet<Long> visitorIds =
+                visitors.stream()
+                        .map(AppointmentVisitor::getMpVisitorId)
+                        .filter(Objects::nonNull)
+                        .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+        if (visitorIds.isEmpty()) {
+            return new LinkedHashMap<>();
+        }
+        List<QrcodeTicket> rows =
+                qrcodeTicketMapper.selectList(
+                        new LambdaQueryWrapper<QrcodeTicket>()
+                                .eq(QrcodeTicket::getDelFlag, 0)
+                                .eq(QrcodeTicket::getOrderId, orderId)
+                                .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
+                                .in(QrcodeTicket::getMpVisitorId, visitorIds)
+                                .orderByDesc(QrcodeTicket::getId));
+        Map<Long, QrcodeTicket> result = new LinkedHashMap<>();
+        for (QrcodeTicket row : rows) {
+            if (row.getMpVisitorId() != null) {
+                result.putIfAbsent(row.getMpVisitorId(), row);
+            }
+        }
+        return result;
     }
 
     private LocalDateTime resolveGenerateTime(AppointmentOrder order) {
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java
new file mode 100644
index 0000000..315d505
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentApprovedEvent.java
@@ -0,0 +1,3 @@
+package com.sinata.appointment.service.event;
+
+public record AppointmentApprovedEvent(Long orderId) {}
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java
new file mode 100644
index 0000000..24eb970
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentLifecycleEventListener.java
@@ -0,0 +1,42 @@
+package com.sinata.appointment.service.event;
+
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.service.PostApprovalOrchestrationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.event.TransactionPhase;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AppointmentLifecycleEventListener {
+
+    private final AppointmentOrderMapper appointmentOrderMapper;
+    private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleApproved(AppointmentApprovedEvent event) {
+        AppointmentOrder order = appointmentOrderMapper.selectById(event.orderId());
+        if (order == null) {
+            log.warn("Approved event skipped, order not found orderId={}", event.orderId());
+            return;
+        }
+        postApprovalOrchestrationService.onOrderFullyApproved(order);
+    }
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    public void handleRejected(AppointmentRejectedEvent event) {
+        AppointmentOrder order = appointmentOrderMapper.selectById(event.orderId());
+        if (order == null) {
+            log.warn("Rejected event skipped, order not found orderId={}", event.orderId());
+            return;
+        }
+        postApprovalOrchestrationService.onOrderRejected(order, event.reason());
+    }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java
new file mode 100644
index 0000000..00dcf46
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/event/AppointmentRejectedEvent.java
@@ -0,0 +1,3 @@
+package com.sinata.appointment.service.event;
+
+public record AppointmentRejectedEvent(Long orderId, String reason) {}
diff --git a/backend/src/main/resources/db/migration/V28__performance_indexes.sql b/backend/src/main/resources/db/migration/V28__performance_indexes.sql
new file mode 100644
index 0000000..3f3cdc1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V28__performance_indexes.sql
@@ -0,0 +1,20 @@
+ALTER TABLE appointment_order
+    ADD KEY idx_ao_del_create_id (del_flag, create_time, id),
+    ADD KEY idx_ao_del_visited_status (del_flag, visited_user_id, status),
+    ADD KEY idx_ao_del_submitter_create_id (del_flag, submitter_id, create_time, id),
+    ADD KEY idx_ao_del_visited_visit_range (del_flag, visited_user_id, visit_start_date, visit_end_date);
+
+ALTER TABLE approval_instance
+    ADD KEY idx_ai_del_order_id (del_flag, order_id, id);
+
+ALTER TABLE approval_task
+    ADD KEY idx_at_del_assignee_status_create (del_flag, assignee_user_id, task_status, create_time),
+    ADD KEY idx_at_del_assignee_status_update_id (del_flag, assignee_user_id, task_status, update_time, id),
+    ADD KEY idx_at_instance_node_group_status (instance_id, node_code, group_key, task_status);
+
+ALTER TABLE appointment_visitor
+    ADD KEY idx_av_del_order_id (del_flag, order_id, id),
+    ADD KEY idx_av_del_name (del_flag, name);
+
+ALTER TABLE qrcode_ticket
+    ADD KEY idx_qt_del_order_visitor_status_id (del_flag, order_id, mp_visitor_id, ticket_status, id);
diff --git a/backend/src/main/resources/mapper/AppointmentOrderMapper.xml b/backend/src/main/resources/mapper/AppointmentOrderMapper.xml
new file mode 100644
index 0000000..4cb7f84
--- /dev/null
+++ b/backend/src/main/resources/mapper/AppointmentOrderMapper.xml
@@ -0,0 +1,192 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.AppointmentOrderMapper">
+
+    <sql id="AdminOrderWhere">
+        o.del_flag = 0
+        <if test="visitedUserName != null and visitedUserName != ''">
+            AND o.visited_user_name LIKE CONCAT('%', #{visitedUserName}, '%')
+        </if>
+        <if test="visitedDeptId != null">
+            AND o.visited_dept_id = #{visitedDeptId}
+        </if>
+        <if test="accessArea != null and accessArea != ''">
+            AND o.pass_access_area_names LIKE CONCAT('%', #{accessArea}, '%')
+        </if>
+        <if test="localUnit != null and localUnit != ''">
+            AND o.pass_local_unit_names LIKE CONCAT('%', #{localUnit}, '%')
+        </if>
+        <if test="visitReasonCode != null and visitReasonCode != ''">
+            AND o.visit_reason_code = #{visitReasonCode}
+        </if>
+        <if test="visitDateEnd != null">
+            AND o.visit_start_date &lt;= #{visitDateEnd}
+        </if>
+        <if test="visitDateStart != null">
+            AND o.visit_end_date &gt;= #{visitDateStart}
+        </if>
+        <if test="visitorKeyword != null and visitorKeyword != ''">
+            AND EXISTS (
+                SELECT 1
+                FROM appointment_visitor av
+                WHERE av.del_flag = 0
+                  AND av.order_id = o.id
+                  AND av.name LIKE CONCAT('%', #{visitorKeyword}, '%')
+            )
+        </if>
+        <if test="displayStatus != null and displayStatus != ''">
+            <choose>
+                <when test="displayStatus == 'PENDING_VISIT'">
+                    AND (
+                        o.status = 'PENDING_APPROVAL'
+                        OR (o.status = 'APPROVED' AND CURDATE() &lt; o.visit_start_date)
+                    )
+                </when>
+                <when test="displayStatus == 'EFFECTIVE'">
+                    AND o.status = 'APPROVED'
+                    AND o.visit_start_date IS NOT NULL
+                    AND o.visit_end_date IS NOT NULL
+                    AND CURDATE() &gt;= o.visit_start_date
+                    AND CURDATE() &lt;= o.visit_end_date
+                </when>
+                <otherwise>
+                    AND NOT (
+                        o.status = 'PENDING_APPROVAL'
+                        OR (o.status = 'APPROVED' AND CURDATE() &lt; o.visit_start_date)
+                        OR (
+                            o.status = 'APPROVED'
+                            AND o.visit_start_date IS NOT NULL
+                            AND o.visit_end_date IS NOT NULL
+                            AND CURDATE() &gt;= o.visit_start_date
+                            AND CURDATE() &lt;= o.visit_end_date
+                        )
+                    )
+                </otherwise>
+            </choose>
+        </if>
+        <if test="approvalStatus != null and approvalStatus != ''">
+            <choose>
+                <when test="approvalStatus == 'APPROVED'">
+                    AND o.status IN ('APPROVED', 'VISITED', 'EXPIRED')
+                </when>
+                <when test="approvalStatus == 'REJECTED'">
+                    AND o.status = 'REJECTED'
+                </when>
+                <when test="approvalStatus == 'CANCELLED'">
+                    AND o.status = 'CANCELLED'
+                </when>
+                <when test="approvalStatus == 'PENDING_APPROVAL'">
+                    AND o.status = 'PENDING_APPROVAL'
+                    AND (ai.current_node_code IS NULL OR ai.current_node_code = '')
+                </when>
+                <otherwise>
+                    AND o.status = 'PENDING_APPROVAL'
+                    AND ai.current_node_code = #{approvalStatus}
+                </otherwise>
+            </choose>
+        </if>
+    </sql>
+
+    <select id="pageAdminOrders" resultType="com.sinata.appointment.entity.AppointmentOrder">
+        SELECT o.*
+        FROM appointment_order o
+        LEFT JOIN (
+            SELECT ai1.order_id, ai1.current_node_code
+            FROM approval_instance ai1
+            INNER JOIN (
+                SELECT order_id, MAX(id) AS max_id
+                FROM approval_instance
+                WHERE del_flag = 0
+                GROUP BY order_id
+            ) latest ON latest.max_id = ai1.id
+            WHERE ai1.del_flag = 0
+        ) ai ON ai.order_id = o.id
+        WHERE
+        <include refid="AdminOrderWhere"/>
+        ORDER BY o.create_time DESC, o.id DESC
+    </select>
+
+    <select id="pageMiniVisitRecords" resultType="com.sinata.appointment.entity.AppointmentOrder">
+        SELECT o.*
+        FROM appointment_order o
+        WHERE o.del_flag = 0
+          AND o.status = 'APPROVED'
+          AND o.visited_user_id = #{visitedUserId}
+        <if test="visitDate != null">
+            AND o.visit_start_date IS NOT NULL
+            AND o.visit_end_date IS NOT NULL
+            AND #{visitDate} &gt;= o.visit_start_date
+            AND #{visitDate} &lt;= o.visit_end_date
+        </if>
+        <if test="visitorNameKeyword != null and visitorNameKeyword != ''">
+            AND EXISTS (
+                SELECT 1
+                FROM appointment_visitor av
+                WHERE av.del_flag = 0
+                  AND av.order_id = o.id
+                  AND av.name LIKE CONCAT('%', #{visitorNameKeyword}, '%')
+            )
+        </if>
+        ORDER BY o.visit_end_date DESC,
+                 CASE
+                     WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'ALL_DAY' THEN 3
+                     WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'PM' THEN 2
+                     WHEN o.visit_time_mode = 'SINGLE' AND o.visit_time_slot = 'AM' THEN 1
+                     ELSE 0
+                 END DESC,
+                 o.create_time DESC,
+                 o.id DESC
+    </select>
+
+    <select id="pageMiniMyOrders" resultType="com.sinata.appointment.entity.AppointmentOrder">
+        SELECT o.*
+        FROM appointment_order o
+        LEFT JOIN biz_visit_reason bvr
+               ON bvr.del_flag = 0
+              AND bvr.reason_code = o.visit_reason_code
+        WHERE o.del_flag = 0
+          AND o.submitter_id = #{submitterId}
+        <if test="status != null and status != ''">
+            <choose>
+                <when test="status == 'PENDING_VISIT'">
+                    AND (
+                        o.status = 'PENDING_APPROVAL'
+                        OR (o.status = 'APPROVED' AND CURDATE() &lt; o.visit_start_date)
+                    )
+                </when>
+                <when test="status == 'EFFECTIVE'">
+                    AND o.status = 'APPROVED'
+                    AND o.visit_start_date IS NOT NULL
+                    AND o.visit_end_date IS NOT NULL
+                    AND CURDATE() &gt;= o.visit_start_date
+                    AND CURDATE() &lt;= o.visit_end_date
+                </when>
+                <otherwise>
+                    AND NOT (
+                        o.status = 'PENDING_APPROVAL'
+                        OR (o.status = 'APPROVED' AND CURDATE() &lt; o.visit_start_date)
+                        OR (
+                            o.status = 'APPROVED'
+                            AND o.visit_start_date IS NOT NULL
+                            AND o.visit_end_date IS NOT NULL
+                            AND CURDATE() &gt;= o.visit_start_date
+                            AND CURDATE() &lt;= o.visit_end_date
+                        )
+                    )
+                </otherwise>
+            </choose>
+        </if>
+        <if test="keyword != null and keyword != ''">
+            AND (
+                o.visited_user_name LIKE CONCAT('%', #{keyword}, '%')
+                OR o.visit_reason_code LIKE CONCAT('%', #{keyword}, '%')
+                OR o.visit_reason_other LIKE CONCAT('%', #{keyword}, '%')
+                OR bvr.reason_name LIKE CONCAT('%', #{keyword}, '%')
+            )
+        </if>
+        ORDER BY o.create_time DESC, o.id DESC
+    </select>
+
+</mapper>
diff --git a/backend/src/main/resources/mapper/ApprovalTaskMapper.xml b/backend/src/main/resources/mapper/ApprovalTaskMapper.xml
new file mode 100644
index 0000000..62dc275
--- /dev/null
+++ b/backend/src/main/resources/mapper/ApprovalTaskMapper.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.ApprovalTaskMapper">
+
+    <select id="pageMiniApprovalHistory" resultType="com.sinata.appointment.dto.MiniApprovalHistoryRow">
+        SELECT o.id,
+               o.uuid,
+               o.order_no AS orderNo,
+               o.visited_user_name AS visitedUserName,
+               o.visited_dept_id AS visitedDeptId,
+               o.visit_start_date AS visitStartDate,
+               o.visit_end_date AS visitEndDate,
+               o.visit_reason_code AS visitReasonCode,
+               o.visit_reason_other AS visitReasonOther,
+               o.visit_time_mode AS visitTimeMode,
+               o.visit_time_slot AS visitTimeSlot,
+               o.pass_access_area_names AS passAccessAreaNames,
+               o.status,
+               o.create_time AS createTime,
+               t.task_status AS taskStatus
+        FROM approval_task t
+        INNER JOIN approval_instance ai ON ai.id = t.instance_id AND ai.del_flag = 0
+        INNER JOIN appointment_order o ON o.id = ai.order_id AND o.del_flag = 0
+        INNER JOIN (
+            SELECT ai2.order_id AS orderId, MAX(t2.id) AS latestTaskId
+            FROM approval_task t2
+            INNER JOIN approval_instance ai2 ON ai2.id = t2.instance_id AND ai2.del_flag = 0
+            WHERE t2.del_flag = 0
+              AND t2.assignee_user_id = #{assigneeUserId}
+              AND t2.task_status IN ('APPROVED', 'REJECTED')
+            <if test="status != null and status != ''">
+                AND t2.task_status = #{status}
+            </if>
+            GROUP BY ai2.order_id
+        ) latest ON latest.latestTaskId = t.id
+        WHERE t.del_flag = 0
+        <if test="visitedUserNameKeyword != null and visitedUserNameKeyword != ''">
+            AND o.visited_user_name LIKE CONCAT('%', #{visitedUserNameKeyword}, '%')
+        </if>
+        <if test="visitDate != null">
+            AND o.visit_start_date IS NOT NULL
+            AND o.visit_end_date IS NOT NULL
+            AND #{visitDate} &gt;= o.visit_start_date
+            AND #{visitDate} &lt;= o.visit_end_date
+        </if>
+        ORDER BY t.update_time DESC, t.id DESC
+    </select>
+
+</mapper>
diff --git a/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java
new file mode 100644
index 0000000..404383d
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/ApprovalApplicationServiceTest.java
@@ -0,0 +1,156 @@
+package com.sinata.appointment.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.dto.ApprovalActionRequest;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.service.event.AppointmentApprovedEvent;
+import com.sinata.appointment.service.event.AppointmentRejectedEvent;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+@ExtendWith(MockitoExtension.class)
+class ApprovalApplicationServiceTest {
+
+    @Mock
+    private ApprovalTaskMapper approvalTaskMapper;
+    @Mock
+    private ApprovalInstanceMapper approvalInstanceMapper;
+    @Mock
+    private AppointmentOrderMapper appointmentOrderMapper;
+    @Mock
+    private ApprovalEngineService approvalEngineService;
+    @Mock
+    private ApplicationEventPublisher applicationEventPublisher;
+
+    @InjectMocks
+    private ApprovalApplicationService approvalApplicationService;
+
+    @Test
+    void handleActionRejectPublishesRejectedEvent() {
+        ApprovalTask task = buildTask();
+        ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+        AppointmentOrder order = buildOrder(200L);
+        ApprovalActionRequest request = new ApprovalActionRequest();
+        request.setTaskId(100L);
+        request.setTaskUuid("task-uuid");
+        request.setApprove(false);
+        request.setComment("不同意");
+
+        when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+        when(approvalInstanceMapper.selectById(300L)).thenReturn(instance);
+        when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+
+        try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+            mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+            approvalApplicationService.handleAction(request);
+        }
+
+        verify(approvalTaskMapper).updateById(task);
+        verify(approvalEngineService).rejectInstance(instance, "不同意");
+        ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
+        verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
+        AppointmentRejectedEvent event = (AppointmentRejectedEvent) eventCaptor.getValue();
+        assertEquals(200L, event.orderId());
+        assertEquals("不同意", event.reason());
+    }
+
+    @Test
+    void handleActionApprovePublishesApprovedEventWhenFlowCompletes() {
+        ApprovalTask task = buildTask();
+        ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+        ApprovalInstance latest = buildInstance(200L, ApprovalInstanceStatus.APPROVED);
+        AppointmentOrder order = buildOrder(200L);
+        ApprovalActionRequest request = new ApprovalActionRequest();
+        request.setTaskId(100L);
+        request.setTaskUuid("task-uuid");
+        request.setApprove(true);
+        request.setComment("通过");
+
+        when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+        when(approvalInstanceMapper.selectById(300L)).thenReturn(instance, latest);
+        when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+        when(approvalEngineService.isCurrentNodeComplete(instance)).thenReturn(true);
+
+        try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+            mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+            approvalApplicationService.handleAction(request);
+        }
+
+        verify(approvalEngineService).markGroupSkippedExcept(task);
+        verify(approvalEngineService).advanceOrComplete(instance, order);
+        ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
+        verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
+        AppointmentApprovedEvent event = (AppointmentApprovedEvent) eventCaptor.getValue();
+        assertEquals(200L, event.orderId());
+    }
+
+    @Test
+    void handleActionApproveDoesNotPublishEventWhenFlowStillInProgress() {
+        ApprovalTask task = buildTask();
+        ApprovalInstance instance = buildInstance(200L, ApprovalInstanceStatus.IN_PROGRESS);
+        AppointmentOrder order = buildOrder(200L);
+        ApprovalActionRequest request = new ApprovalActionRequest();
+        request.setTaskId(100L);
+        request.setTaskUuid("task-uuid");
+        request.setApprove(true);
+
+        when(approvalTaskMapper.selectById(100L)).thenReturn(task);
+        when(approvalInstanceMapper.selectById(300L)).thenReturn(instance);
+        when(appointmentOrderMapper.selectById(200L)).thenReturn(order);
+        when(approvalEngineService.isCurrentNodeComplete(instance)).thenReturn(false);
+
+        try (MockedStatic<StpUtil> mockedStatic = mockStatic(StpUtil.class)) {
+            mockedStatic.when(StpUtil::getLoginIdAsLong).thenReturn(99L);
+
+            approvalApplicationService.handleAction(request);
+        }
+
+        verify(applicationEventPublisher, never()).publishEvent(any());
+    }
+
+    private ApprovalTask buildTask() {
+        ApprovalTask task = new ApprovalTask();
+        task.setId(100L);
+        task.setUuid("task-uuid");
+        task.setInstanceId(300L);
+        task.setAssigneeUserId(99L);
+        task.setTaskStatus(ApprovalTaskStatus.PENDING);
+        return task;
+    }
+
+    private ApprovalInstance buildInstance(Long orderId, String status) {
+        ApprovalInstance instance = new ApprovalInstance();
+        instance.setId(300L);
+        instance.setOrderId(orderId);
+        instance.setStatus(status);
+        return instance;
+    }
+
+    private AppointmentOrder buildOrder(Long id) {
+        AppointmentOrder order = new AppointmentOrder();
+        order.setId(id);
+        return order;
+    }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java
new file mode 100644
index 0000000..5b15649
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/ExportApplicationServiceTest.java
@@ -0,0 +1,86 @@
+package com.sinata.appointment.service;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sinata.appointment.dto.ExportTaskStatusVo;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.ObjectProvider;
+
+@ExtendWith(MockitoExtension.class)
+class ExportApplicationServiceTest {
+
+    @Mock
+    private ObjectProvider<RabbitTemplate> rabbitTemplateProvider;
+    @Mock
+    private AppointmentApplicationService appointmentApplicationService;
+    @Mock
+    private LocalExportAsyncService localExportAsyncService;
+
+    @InjectMocks
+    private ExportApplicationService exportApplicationService;
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void enqueueExportWithoutRabbitStartsLocalAsyncAndQueuesTask() {
+        when(rabbitTemplateProvider.getIfAvailable()).thenReturn(null);
+
+        String taskId = exportApplicationService.enqueueExport("appointment", Map.of("status", "PENDING_APPROVAL"));
+
+        assertNotNull(taskId);
+        ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+        assertEquals("QUEUED", status.getStatus());
+        verify(localExportAsyncService).process(taskId, "appointment", Map.of("status", "PENDING_APPROVAL"));
+    }
+
+    @Test
+    void processLocalExportCreatesFileAndMarksCompleted() throws Exception {
+        String oldTmpDir = System.getProperty("java.io.tmpdir");
+        System.setProperty("java.io.tmpdir", tempDir.toString());
+        try {
+            when(appointmentApplicationService.exportExcel(null, null, null, null, null, null, null, null, null, null))
+                    .thenReturn("xlsx-content".getBytes());
+
+            String taskId = exportApplicationService.enqueueExport("appointment", Map.of());
+            exportApplicationService.processLocalExport(taskId, "appointment", Map.of());
+
+            ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+            assertEquals("COMPLETED", status.getStatus());
+            Path filePath = exportApplicationService.getExportFilePath(taskId);
+            assertNotNull(filePath);
+            assertTrue(Files.exists(filePath));
+            assertArrayEquals("xlsx-content".getBytes(), Files.readAllBytes(filePath));
+        } finally {
+            System.setProperty("java.io.tmpdir", oldTmpDir);
+        }
+    }
+
+    @Test
+    void processLocalExportMarksFailedForUnsupportedType() {
+        when(rabbitTemplateProvider.getIfAvailable()).thenReturn(null);
+
+        String taskId = exportApplicationService.enqueueExport("other", Map.of());
+        exportApplicationService.processLocalExport(taskId, "other", Map.of());
+
+        ExportTaskStatusVo status = exportApplicationService.getTaskStatus(taskId);
+        assertEquals("FAILED", status.getStatus());
+        assertTrue(status.getErrorMessage().contains("unsupported export type"));
+        assertNull(exportApplicationService.getExportFilePath(taskId));
+    }
+}
diff --git a/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java b/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java
new file mode 100644
index 0000000..8583718
--- /dev/null
+++ b/backend/src/test/java/com/sinata/appointment/service/PostApprovalOrchestrationServiceTest.java
@@ -0,0 +1,156 @@
+package com.sinata.appointment.service;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.QrcodeTicketStatus;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.QrcodeTicket;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.QrcodeTicketMapper;
+import java.time.LocalDate;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class PostApprovalOrchestrationServiceTest {
+
+    @Mock
+    private IntegrationHikvisionService integrationHikvisionService;
+    @Mock
+    private IntegrationWechatService integrationWechatService;
+    @Mock
+    private QrcodeTicketMapper qrcodeTicketMapper;
+    @Mock
+    private AppointmentOrderMapper appointmentOrderMapper;
+    @Mock
+    private ApprovalInstanceMapper approvalInstanceMapper;
+    @Mock
+    private AppointmentVisitorMapper appointmentVisitorMapper;
+    @Mock
+    private MpVisitorMapper mpVisitorMapper;
+    @Mock
+    private MpUserMapper mpUserMapper;
+    @Mock
+    private BizVisitReasonMapper bizVisitReasonMapper;
+    @Mock
+    private SysBizSettingCacheService sysBizSettingCacheService;
+
+    @InjectMocks
+    private PostApprovalOrchestrationService postApprovalOrchestrationService;
+
+    @Test
+    void onOrderFullyApprovedBatchesProfilesAndReusesExistingTickets() {
+        AppointmentOrder order = new AppointmentOrder();
+        order.setId(1L);
+        order.setVisitedUserId(8L);
+        order.setVisitStartDate(LocalDate.now().plusDays(1));
+
+        ApprovalInstance instance = new ApprovalInstance();
+        instance.setStatus(ApprovalInstanceStatus.APPROVED);
+
+        AppointmentVisitor reusedVisitor = new AppointmentVisitor();
+        reusedVisitor.setOrderId(1L);
+        reusedVisitor.setMpVisitorId(11L);
+        reusedVisitor.setName("张三");
+
+        AppointmentVisitor newVisitor = new AppointmentVisitor();
+        newVisitor.setOrderId(1L);
+        newVisitor.setMpVisitorId(12L);
+        newVisitor.setName("李四");
+
+        QrcodeTicket reusedTicket = new QrcodeTicket();
+        reusedTicket.setId(1001L);
+        reusedTicket.setOrderId(1L);
+        reusedTicket.setMpVisitorId(11L);
+        reusedTicket.setTicketStatus(QrcodeTicketStatus.UNUSED);
+
+        MpVisitor profile1 = new MpVisitor();
+        profile1.setId(11L);
+        profile1.setVisitorName("张三");
+
+        MpVisitor profile2 = new MpVisitor();
+        profile2.setId(12L);
+        profile2.setVisitorName("李四");
+
+        MpUser visitedUser = new MpUser();
+        visitedUser.setId(8L);
+        visitedUser.setNickName("员工A");
+
+        when(approvalInstanceMapper.selectOne(any())).thenReturn(instance);
+        when(appointmentVisitorMapper.selectList(any())).thenReturn(List.of(reusedVisitor, newVisitor));
+        when(mpVisitorMapper.selectList(any())).thenReturn(List.of(profile1, profile2));
+        when(qrcodeTicketMapper.selectList(any())).thenReturn(List.of(reusedTicket));
+        when(sysBizSettingCacheService.getValue(any())).thenReturn("7");
+        when(mpUserMapper.selectOne(any())).thenReturn(visitedUser);
+        when(integrationHikvisionService.requestQrcodePayload(
+                        eq(order),
+                        eq(newVisitor),
+                        eq(profile2),
+                        eq(visitedUser),
+                        any(),
+                        any(),
+                        any(),
+                        any(),
+                        eq(7 * 24 * 60),
+                        any(),
+                        any()))
+                .thenReturn("payload-2");
+
+        postApprovalOrchestrationService.onOrderFullyApproved(order);
+
+        verify(appointmentOrderMapper).updateById(order);
+        verify(mpVisitorMapper, times(1)).selectList(any());
+        verify(qrcodeTicketMapper, times(1)).selectList(any());
+        verify(qrcodeTicketMapper, times(1)).insert(any(QrcodeTicket.class));
+        verify(integrationHikvisionService, times(1))
+                .requestQrcodePayload(
+                        eq(order),
+                        eq(newVisitor),
+                        eq(profile2),
+                        eq(visitedUser),
+                        any(),
+                        any(),
+                        any(),
+                        any(),
+                        eq(7 * 24 * 60),
+                        any(),
+                        any());
+        verify(integrationWechatService).notifyApprovalFinalResult(order, true);
+    }
+
+    @Test
+    void onOrderFullyApprovedSkipsWhenLatestInstanceNotApproved() {
+        AppointmentOrder order = new AppointmentOrder();
+        order.setId(1L);
+
+        ApprovalInstance instance = new ApprovalInstance();
+        instance.setStatus(ApprovalInstanceStatus.IN_PROGRESS);
+
+        when(approvalInstanceMapper.selectOne(any())).thenReturn(instance);
+
+        postApprovalOrchestrationService.onOrderFullyApproved(order);
+
+        verify(appointmentOrderMapper, never()).updateById(any());
+        verify(appointmentVisitorMapper, never()).selectList(any());
+        verify(qrcodeTicketMapper, never()).insert(any());
+    }
+}

--
Gitblit v1.7.1