From aa67b3cb0fe26fab576f7710c978ff3594426cca Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期四, 28 五月 2026 15:23:30 +0800
Subject: [PATCH] 添加微信公众号登录功能并优化系统配置

---
 backend/src/main/java/com/sinata/appointment/AppointmentApplication.java                   |    7 
 backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java            |   28 ++
 backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java             |   14 +
 backend/src/main/java/com/sinata/appointment/entity/MpUser.java                            |    3 
 backend/startup.cmd                                                                        |    1 
 backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java                  |    3 
 backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java   |   24 ++
 backend/src/main/resources/application.yml                                                 |   33 +-
 预约访客系统_概要设计说明书_可盖章版.docx                                                                   |    0 
 backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java |   93 +++++++
 backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql                  |    5 
 backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java         |   18 +
 backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java            |   20 +
 backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java        |   20 +
 backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java         |  248 ++++++++++++--------
 backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java       |  150 ++++++++++++
 16 files changed, 552 insertions(+), 115 deletions(-)

diff --git a/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java b/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java
index 180cc62..c94711f 100644
--- a/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java
+++ b/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java
@@ -2,6 +2,7 @@
 
 import com.sinata.appointment.config.AdminLoginCryptoProperties;
 import com.sinata.appointment.config.WechatMiniAppProperties;
+import com.sinata.appointment.config.WechatOfficialAccountProperties;
 import org.mybatis.spring.annotation.MapperScan;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -13,7 +14,11 @@
 @MapperScan("com.sinata.appointment.mapper")
 @EnableAsync
 @EnableCaching
-@EnableConfigurationProperties({WechatMiniAppProperties.class, AdminLoginCryptoProperties.class})
+@EnableConfigurationProperties({
+        WechatMiniAppProperties.class,
+        WechatOfficialAccountProperties.class,
+        AdminLoginCryptoProperties.class
+})
 public class AppointmentApplication {
 
     public static void main(String[] args) {
diff --git a/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java b/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java
index 436cdab..db2e940 100644
--- a/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java
+++ b/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java
@@ -33,6 +33,9 @@
                 .excludePathPatterns(
                         "/api/mini/auth/login",
                         "/api/mini/auth/phone-login",
+                        "/api/mini/auth/official-oauth-url",
+                        "/api/mini/auth/official-login",
+                        "/api/mini/auth/official-bind-phone",
                         "/api/mini/appointments/shared/detail",
                         "/api/mini/appointments/shared/visitor-codes",
                         "/api/mini/appointments/shared/ticket-code")
diff --git a/backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java b/backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java
new file mode 100644
index 0000000..da13665
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "wechat.official-account")
+public class WechatOfficialAccountProperties {
+
+    private String appId = "";
+
+    private String appSecret = "";
+
+    /** true 或未配置 appId/secret 时使用桩实现。 */
+    private boolean stub = true;
+
+    public boolean useStub() {
+        return stub
+                || appId == null
+                || appId.isBlank()
+                || appSecret == null
+                || appSecret.isBlank();
+    }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
index 324f6e0..b8a8963 100644
--- a/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
@@ -3,6 +3,9 @@
 import com.sinata.appointment.common.Result;
 import com.sinata.appointment.common.ResultUtil;
 import com.sinata.appointment.dto.MiniLoginResponse;
+import com.sinata.appointment.dto.MiniOfficialBindPhoneRequest;
+import com.sinata.appointment.dto.MiniOfficialLoginRequest;
+import com.sinata.appointment.dto.MiniOfficialLoginResponse;
 import com.sinata.appointment.dto.MiniPhoneLoginRequest;
 import com.sinata.appointment.dto.MiniProfileUpdateRequest;
 import com.sinata.appointment.dto.MiniWxLoginRequest;
@@ -51,6 +54,31 @@
         return ResultUtil.data(miniAuthApplicationService.loginByPhone(request.getMobile()));
     }
 
+    @Operation(summary = "公众号静默授权地址")
+    @GetMapping("/official-oauth-url")
+    public Result<Map<String, String>> officialOAuthUrl(
+            @RequestParam("redirectUri") String redirectUri,
+            @RequestParam(value = "state", required = false) String state) {
+        return ResultUtil.data(
+                Map.of("url", miniAuthApplicationService.buildOfficialOAuthUrl(redirectUri, state)));
+    }
+
+    @Operation(summary = "公众号静默授权登录")
+    @PostMapping("/official-login")
+    public Result<MiniOfficialLoginResponse> officialLogin(
+            @Valid @RequestBody MiniOfficialLoginRequest request) {
+        return ResultUtil.data(miniAuthApplicationService.loginByOfficialCode(request.getCode()));
+    }
+
+    @Operation(summary = "公众号首次登录手机号绑定")
+    @PostMapping("/official-bind-phone")
+    public Result<MiniLoginResponse> officialBindPhone(
+            @Valid @RequestBody MiniOfficialBindPhoneRequest request) {
+        return ResultUtil.data(
+                miniAuthApplicationService.bindOfficialPhoneAndLogin(
+                        request.getBindToken(), request.getMobile()));
+    }
+
     @Operation(summary = "当前小程序用户")
     @GetMapping("/me")
     public Result<Map<String, Object>> me() {
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java
new file mode 100644
index 0000000..1406800
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "公众号首次登录手机号绑定请求")
+public class MiniOfficialBindPhoneRequest {
+
+    @NotBlank
+    @Schema(description = "公众号登录返回的临时绑定凭证")
+    private String bindToken;
+
+    @NotBlank
+    @Schema(description = "手机号")
+    private String mobile;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java
new file mode 100644
index 0000000..0debc0e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "公众号静默授权登录请求")
+public class MiniOfficialLoginRequest {
+
+    @NotBlank
+    @Schema(description = "公众号 OAuth 回调 code")
+    private String code;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java
new file mode 100644
index 0000000..869b4af
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@Schema(description = "公众号静默授权登录响应")
+public class MiniOfficialLoginResponse {
+
+    @Schema(description = "是否需要手填手机号绑定")
+    private boolean mobileRequired;
+
+    @Schema(description = "手机号绑定临时凭证,仅 mobileRequired=true 时返回")
+    private String bindToken;
+
+    @Schema(description = "登录信息,仅 mobileRequired=false 时返回")
+    private MiniLoginResponse login;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpUser.java b/backend/src/main/java/com/sinata/appointment/entity/MpUser.java
index 421903b..87f8efd 100644
--- a/backend/src/main/java/com/sinata/appointment/entity/MpUser.java
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpUser.java
@@ -14,6 +14,9 @@
     @Schema(description = "微信 openId")
     private String openId;
 
+    @Schema(description = "公众号 openId")
+    private String officialOpenId;
+
     @Schema(description = "微信 unionId")
     private String unionId;
 
diff --git a/backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java b/backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java
new file mode 100644
index 0000000..3064128
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java
@@ -0,0 +1,93 @@
+package com.sinata.appointment.integration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.config.WechatOfficialAccountProperties;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.exception.BizException;
+import java.nio.charset.StandardCharsets;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WechatOfficialAccountGateway {
+
+    private static final String OAUTH_AUTHORIZE_URL =
+            "https://open.weixin.qq.com/connect/oauth2/authorize";
+    private static final String OAUTH_ACCESS_TOKEN_URL =
+            "https://api.weixin.qq.com/sns/oauth2/access_token";
+
+    private final WechatOfficialAccountProperties props;
+    @Qualifier("wechatRestTemplate")
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+
+    public String buildSilentOAuthUrl(String redirectUri, String state) {
+        if (redirectUri == null || redirectUri.isBlank()) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "redirectUri 不能为空");
+        }
+        if (props.useStub()) {
+            return UriComponentsBuilder.fromHttpUrl(redirectUri)
+                    .queryParam("code", "STUB-" + System.currentTimeMillis())
+                    .queryParam("state", state == null ? "" : state.trim())
+                    .build()
+                    .toUriString();
+        }
+        String encodedRedirect = UriUtils.encode(redirectUri, StandardCharsets.UTF_8);
+        String safeState = state == null ? "" : state.trim();
+        return OAUTH_AUTHORIZE_URL
+                + "?appid=" + props.getAppId()
+                + "&redirect_uri=" + encodedRedirect
+                + "&response_type=code"
+                + "&scope=snsapi_base"
+                + "&state=" + UriUtils.encode(safeState, StandardCharsets.UTF_8)
+                + "#wechat_redirect";
+    }
+
+    public WxOfficialOAuthResult exchangeCode(String code) {
+        if (code == null || code.isBlank()) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "code 不能为空");
+        }
+        if (props.useStub()) {
+            log.debug("Wechat official account stub: oauth code");
+            return WxOfficialOAuthResult.stub(code);
+        }
+        String url = UriComponentsBuilder.fromHttpUrl(OAUTH_ACCESS_TOKEN_URL)
+                .queryParam("appid", props.getAppId())
+                .queryParam("secret", props.getAppSecret())
+                .queryParam("code", code)
+                .queryParam("grant_type", "authorization_code")
+                .build()
+                .toUriString();
+        try {
+            String body = restTemplate.getForObject(url, String.class);
+            JsonNode root = objectMapper.readTree(body);
+            int err = root.path("errcode").asInt(0);
+            if (err != 0) {
+                String msg = root.path("errmsg").asText("wx error");
+                log.warn("official oauth errcode={} msg={}", err, msg);
+                throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "公众号授权失败: " + msg);
+            }
+            String openId = root.path("openid").asText(null);
+            if (openId == null || openId.isBlank()) {
+                throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "公众号未返回 openid");
+            }
+            return WxOfficialOAuthResult.builder()
+                    .openId(openId)
+                    .unionId(root.path("unionid").asText(null))
+                    .build();
+        } catch (BizException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("official oauth http error", e);
+            throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "公众号授权接口异常");
+        }
+    }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java b/backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java
new file mode 100644
index 0000000..39d333a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.integration;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class WxOfficialOAuthResult {
+
+    private String openId;
+
+    private String unionId;
+
+    public static WxOfficialOAuthResult stub(String code) {
+        return WxOfficialOAuthResult.builder()
+                .openId("OA-STUB-" + Integer.toHexString(code.hashCode()))
+                .unionId(null)
+                .build();
+    }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
index 09fad61..0b65f91 100644
--- a/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
@@ -19,6 +19,7 @@
 import java.util.Map;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
 
@@ -47,109 +48,164 @@
     private final ApprovalTaskMapper approvalTaskMapper;
     private final BizVisitReasonMapper bizVisitReasonMapper;
 
+    @Async
     public void notifyApprovalFinalResult(AppointmentOrder order, boolean approved) {
-        if (order == null || order.getSubmitterId() == null) {
-            return;
-        }
-        MpUser submitter =
-                mpUserMapper.selectOne(
-                        new LambdaQueryWrapper<MpUser>()
-                                .eq(MpUser::getDelFlag, 0)
-                                .eq(MpUser::getId, order.getSubmitterId())
-                                .last("limit 1"));
-        if (submitter == null || !StringUtils.hasText(submitter.getOpenId())) {
-            log.info(
-                    "Skip mini subscribe notify, submitter not found or openId empty. submitterId={} orderNo={}",
-                    order.getSubmitterId(),
-                    order.getOrderNo());
-            return;
-        }
-        Map<String, String> templateData = new LinkedHashMap<>();
-        templateData.put("thing1", approved ? "审核通过" : "审核未通过");
-        templateData.put("thing2", abbreviate(resolveSubmitterName(submitter), 20));
-        templateData.put("time3", buildVisitTemplateTimeValue(order));
-        templateData.put("thing4", abbreviate(buildVisitReasonText(order), 20));
-        wechatMiniGateway.sendSubscribeMessage(
-                submitter.getOpenId(), APPROVAL_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData);
-        log.info(
-                "Mini subscribe notify sent submitterId={} orderNo={} result={}",
-                order.getSubmitterId(),
-                order.getOrderNo(),
-                approved ? AppointmentOrderStatus.APPROVED : AppointmentOrderStatus.REJECTED);
-    }
-
-    public void notifyApprovalNoticeUsers(
-            AppointmentOrder order, boolean approved, java.util.Collection<MpUser> noticeUsers, int visitorCount) {
-        if (order == null || noticeUsers == null || noticeUsers.isEmpty()) {
-            return;
-        }
-        Map<String, String> templateData = new LinkedHashMap<>();
-        templateData.put("thing6", buildVisitorCountText(visitorCount));
-        templateData.put("time18", buildVisitTemplateTimeValue(order));
-        templateData.put("thing20", abbreviate(buildNoticeBusinessTypeText(order, approved), 20));
-        for (MpUser noticeUser : noticeUsers) {
-            if (noticeUser == null || !StringUtils.hasText(noticeUser.getOpenId())) {
-                continue;
+        try {
+            if (order == null || order.getSubmitterId() == null) {
+                log.info("Mini subscribe final result skipped, order or submitter empty. orderId={}",
+                        order == null ? null : order.getId());
+                return;
             }
-            wechatMiniGateway.sendSubscribeMessage(
-                    noticeUser.getOpenId(), NOTICE_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData);
-            log.info(
-                    "Mini subscribe notice sent noticeUserId={} orderNo={} result={}",
-                    noticeUser.getId(),
-                    order.getOrderNo(),
-                    approved ? AppointmentOrderStatus.APPROVED : AppointmentOrderStatus.REJECTED);
-        }
-    }
-
-    public void notifyCurrentApprovers(AppointmentOrder order, Long instanceId) {
-        if (order == null || instanceId == null) {
-            return;
-        }
-        java.util.List<ApprovalTask> pendingTasks =
-                approvalTaskMapper.selectList(
-                        new LambdaQueryWrapper<ApprovalTask>()
-                                .eq(ApprovalTask::getDelFlag, 0)
-                                .eq(ApprovalTask::getInstanceId, instanceId)
-                                .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
-                                .orderByAsc(ApprovalTask::getId));
-        if (pendingTasks.isEmpty()) {
-            return;
-        }
-        java.util.LinkedHashMap<Long, java.util.LinkedHashSet<String>> userNodeNames = new java.util.LinkedHashMap<>();
-        for (ApprovalTask task : pendingTasks) {
-            if (task.getAssigneeUserId() == null) {
-                continue;
-            }
-            userNodeNames.computeIfAbsent(task.getAssigneeUserId(), key -> new java.util.LinkedHashSet<>());
-            userNodeNames.get(task.getAssigneeUserId()).add(resolveNodeName(task.getNodeCode()));
-        }
-        if (userNodeNames.isEmpty()) {
-            return;
-        }
-        java.util.List<MpUser> users =
-                mpUserMapper.selectList(
-                        new LambdaQueryWrapper<MpUser>()
-                                .eq(MpUser::getDelFlag, 0)
-                                .in(MpUser::getId, userNodeNames.keySet()));
-        java.util.Map<Long, MpUser> userMap = new java.util.LinkedHashMap<>();
-        for (MpUser user : users) {
-            userMap.putIfAbsent(user.getId(), user);
-        }
-        for (java.util.Map.Entry<Long, java.util.LinkedHashSet<String>> entry : userNodeNames.entrySet()) {
-            MpUser user = userMap.get(entry.getKey());
-            if (user == null || !StringUtils.hasText(user.getOpenId())) {
-                continue;
+            MpUser submitter =
+                    mpUserMapper.selectOne(
+                            new LambdaQueryWrapper<MpUser>()
+                                    .eq(MpUser::getDelFlag, 0)
+                                    .eq(MpUser::getId, order.getSubmitterId())
+                                    .last("limit 1"));
+            if (submitter == null || !StringUtils.hasText(submitter.getOpenId())) {
+                log.info(
+                        "Mini subscribe final result skipped, submitter not found or openId empty. submitterId={} orderNo={}",
+                        order.getSubmitterId(),
+                        order.getOrderNo());
+                return;
             }
             Map<String, String> templateData = new LinkedHashMap<>();
-            templateData.put("thing1", abbreviate(buildTodoTaskName(order), 20));
-            templateData.put("thing3", abbreviate(String.join("、", entry.getValue()), 20));
+            templateData.put("thing1", approved ? "审核通过" : "审核未通过");
+            templateData.put("thing2", abbreviate(resolveSubmitterName(submitter), 20));
+            templateData.put("time3", buildVisitTemplateTimeValue(order));
+            templateData.put("thing4", abbreviate(buildVisitReasonText(order), 20));
             wechatMiniGateway.sendSubscribeMessage(
-                    user.getOpenId(), APPROVAL_TODO_TEMPLATE_ID, APPROVAL_TODO_PAGE, templateData);
+                    submitter.getOpenId(), APPROVAL_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData);
             log.info(
-                    "Mini subscribe todo notify sent approverId={} orderNo={} stages={}",
-                    user.getId(),
+                    "Mini subscribe final result success. submitterId={} orderNo={} result={}",
+                    order.getSubmitterId(),
                     order.getOrderNo(),
-                    entry.getValue());
+                    approved ? AppointmentOrderStatus.APPROVED : AppointmentOrderStatus.REJECTED);
+        } catch (Exception ex) {
+            log.warn("Mini subscribe final result failed. orderId={} orderNo={}",
+                    order == null ? null : order.getId(), order == null ? null : order.getOrderNo(), ex);
+        }
+    }
+
+    @Async
+    public void notifyApprovalNoticeUsers(
+            AppointmentOrder order, boolean approved, java.util.Collection<MpUser> noticeUsers, int visitorCount) {
+        try {
+            if (order == null || noticeUsers == null || noticeUsers.isEmpty()) {
+                log.info("Mini subscribe notice skipped, order or notice users empty. orderId={}",
+                        order == null ? null : order.getId());
+                return;
+            }
+            Map<String, String> templateData = new LinkedHashMap<>();
+            templateData.put("thing6", buildVisitorCountText(visitorCount));
+            templateData.put("time18", buildVisitTemplateTimeValue(order));
+            templateData.put("thing20", abbreviate(buildNoticeBusinessTypeText(order, approved), 20));
+            int successCount = 0;
+            int skippedCount = 0;
+            int failedCount = 0;
+            for (MpUser noticeUser : noticeUsers) {
+                if (noticeUser == null || !StringUtils.hasText(noticeUser.getOpenId())) {
+                    skippedCount++;
+                    continue;
+                }
+                try {
+                    wechatMiniGateway.sendSubscribeMessage(
+                            noticeUser.getOpenId(), NOTICE_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData);
+                    successCount++;
+                    log.info(
+                            "Mini subscribe notice success. noticeUserId={} orderNo={} result={}",
+                            noticeUser.getId(),
+                            order.getOrderNo(),
+                            approved ? AppointmentOrderStatus.APPROVED : AppointmentOrderStatus.REJECTED);
+                } catch (Exception ex) {
+                    failedCount++;
+                    log.warn("Mini subscribe notice failed. noticeUserId={} orderId={} orderNo={}",
+                            noticeUser.getId(), order.getId(), order.getOrderNo(), ex);
+                }
+            }
+            log.info("Mini subscribe notice completed. orderId={} orderNo={} success={} skipped={} failed={}",
+                    order.getId(), order.getOrderNo(), successCount, skippedCount, failedCount);
+        } catch (Exception ex) {
+            log.warn("Mini subscribe notice batch failed. orderId={} orderNo={}",
+                    order == null ? null : order.getId(), order == null ? null : order.getOrderNo(), ex);
+        }
+    }
+
+    @Async
+    public void notifyCurrentApprovers(AppointmentOrder order, Long instanceId) {
+        try {
+            if (order == null || instanceId == null) {
+                log.info("Mini subscribe todo skipped, order or instance empty. orderId={} instanceId={}",
+                        order == null ? null : order.getId(), instanceId);
+                return;
+            }
+            java.util.List<ApprovalTask> pendingTasks =
+                    approvalTaskMapper.selectList(
+                            new LambdaQueryWrapper<ApprovalTask>()
+                                    .eq(ApprovalTask::getDelFlag, 0)
+                                    .eq(ApprovalTask::getInstanceId, instanceId)
+                                    .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
+                                    .orderByAsc(ApprovalTask::getId));
+            if (pendingTasks.isEmpty()) {
+                log.info("Mini subscribe todo skipped, no pending tasks. orderId={} instanceId={}",
+                        order.getId(), instanceId);
+                return;
+            }
+            java.util.LinkedHashMap<Long, java.util.LinkedHashSet<String>> userNodeNames =
+                    new java.util.LinkedHashMap<>();
+            for (ApprovalTask task : pendingTasks) {
+                if (task.getAssigneeUserId() == null) {
+                    continue;
+                }
+                userNodeNames.computeIfAbsent(task.getAssigneeUserId(), key -> new java.util.LinkedHashSet<>());
+                userNodeNames.get(task.getAssigneeUserId()).add(resolveNodeName(task.getNodeCode()));
+            }
+            if (userNodeNames.isEmpty()) {
+                log.info("Mini subscribe todo skipped, no assignee users. orderId={} instanceId={}",
+                        order.getId(), instanceId);
+                return;
+            }
+            java.util.List<MpUser> users =
+                    mpUserMapper.selectList(
+                            new LambdaQueryWrapper<MpUser>()
+                                    .eq(MpUser::getDelFlag, 0)
+                                    .in(MpUser::getId, userNodeNames.keySet()));
+            java.util.Map<Long, MpUser> userMap = new java.util.LinkedHashMap<>();
+            for (MpUser user : users) {
+                userMap.putIfAbsent(user.getId(), user);
+            }
+            int successCount = 0;
+            int skippedCount = 0;
+            int failedCount = 0;
+            for (java.util.Map.Entry<Long, java.util.LinkedHashSet<String>> entry : userNodeNames.entrySet()) {
+                MpUser user = userMap.get(entry.getKey());
+                if (user == null || !StringUtils.hasText(user.getOpenId())) {
+                    skippedCount++;
+                    continue;
+                }
+                Map<String, String> templateData = new LinkedHashMap<>();
+                templateData.put("thing1", abbreviate(buildTodoTaskName(order), 20));
+                templateData.put("thing3", abbreviate(String.join("、", entry.getValue()), 20));
+                try {
+                    wechatMiniGateway.sendSubscribeMessage(
+                            user.getOpenId(), APPROVAL_TODO_TEMPLATE_ID, APPROVAL_TODO_PAGE, templateData);
+                    successCount++;
+                    log.info(
+                            "Mini subscribe todo success. approverId={} orderNo={} stages={}",
+                            user.getId(),
+                            order.getOrderNo(),
+                            entry.getValue());
+                } catch (Exception ex) {
+                    failedCount++;
+                    log.warn("Mini subscribe todo failed. approverId={} orderId={} orderNo={} stages={}",
+                            user.getId(), order.getId(), order.getOrderNo(), entry.getValue(), ex);
+                }
+            }
+            log.info("Mini subscribe todo completed. orderId={} orderNo={} success={} skipped={} failed={}",
+                    order.getId(), order.getOrderNo(), successCount, skippedCount, failedCount);
+        } catch (Exception ex) {
+            log.warn("Mini subscribe todo batch failed. orderId={} instanceId={}",
+                    order == null ? null : order.getId(), instanceId, ex);
         }
     }
 
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
index 42d87ab..1196970 100644
--- a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -6,18 +6,24 @@
 import com.sinata.appointment.constants.MpStaffConstants;
 import com.sinata.appointment.config.MiniStpUtil;
 import com.sinata.appointment.dto.MiniLoginResponse;
+import com.sinata.appointment.dto.MiniOfficialLoginResponse;
 import com.sinata.appointment.dto.MiniProfileUpdateRequest;
 import com.sinata.appointment.dto.MiniWxLoginRequest;
 import com.sinata.appointment.entity.MpUser;
 import com.sinata.appointment.entity.SysDept;
 import com.sinata.appointment.exception.BizException;
 import com.sinata.appointment.integration.WechatMiniGateway;
+import com.sinata.appointment.integration.WechatOfficialAccountGateway;
+import com.sinata.appointment.integration.WxOfficialOAuthResult;
 import com.sinata.appointment.integration.WxCode2SessionResult;
 import com.sinata.appointment.mapper.SysDeptMapper;
 import com.sinata.appointment.mapper.MpUserMapper;
+import java.time.Duration;
 import java.util.List;
 import java.util.Objects;
+import java.util.UUID;
 import com.sinata.appointment.util.PhoneNormalize;
+import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
@@ -28,17 +34,31 @@
 @Service
 public class MiniAuthApplicationService {
 
+    private static final String OFFICIAL_BIND_TOKEN_PREFIX = "official:bind:";
+    private static final Duration OFFICIAL_BIND_TOKEN_TTL = Duration.ofMinutes(10);
+    private static final String OFFICIAL_OPEN_ID_PLACEHOLDER_PREFIX = "OA_PLACEHOLDER_";
+
     private final MpUserMapper mpUserMapper;
     private final SysDeptMapper sysDeptMapper;
     private final WechatMiniGateway wechatMiniGateway;
+    private final WechatOfficialAccountGateway wechatOfficialAccountGateway;
+    private final StringRedisTemplate stringRedisTemplate;
 
     public MiniAuthApplicationService(
             MpUserMapper mpUserMapper,
             SysDeptMapper sysDeptMapper,
-            WechatMiniGateway wechatMiniGateway) {
+            WechatMiniGateway wechatMiniGateway,
+            WechatOfficialAccountGateway wechatOfficialAccountGateway,
+            StringRedisTemplate stringRedisTemplate) {
         this.mpUserMapper = mpUserMapper;
         this.sysDeptMapper = sysDeptMapper;
         this.wechatMiniGateway = wechatMiniGateway;
+        this.wechatOfficialAccountGateway = wechatOfficialAccountGateway;
+        this.stringRedisTemplate = stringRedisTemplate;
+    }
+
+    public String buildOfficialOAuthUrl(String redirectUri, String state) {
+        return wechatOfficialAccountGateway.buildSilentOAuthUrl(redirectUri, state);
     }
 
     @Transactional(rollbackFor = Exception.class)
@@ -61,6 +81,45 @@
         }
         assertUserCanLogin(user);
         syncUnionIdIfNeeded(user, session.getUnionId());
+        MiniStpUtil.login(user.getId());
+        return buildResponse(user);
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public MiniOfficialLoginResponse loginByOfficialCode(String code) {
+        WxOfficialOAuthResult session = wechatOfficialAccountGateway.exchangeCode(code);
+        MpUser user =
+                mpUserMapper.selectOne(
+                        new LambdaQueryWrapper<MpUser>()
+                                .eq(MpUser::getOfficialOpenId, session.getOpenId())
+                                .eq(MpUser::getDelFlag, 0)
+                                .last("limit 1"));
+        if (user == null) {
+            return MiniOfficialLoginResponse.builder()
+                    .mobileRequired(true)
+                    .bindToken(createOfficialBindToken(session.getOpenId(), session.getUnionId()))
+                    .login(null)
+                    .build();
+        }
+        assertUserCanLogin(user);
+        syncUnionIdIfNeeded(user, session.getUnionId());
+        MiniStpUtil.login(user.getId());
+        return MiniOfficialLoginResponse.builder()
+                .mobileRequired(false)
+                .bindToken(null)
+                .login(buildResponse(user))
+                .build();
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public MiniLoginResponse bindOfficialPhoneAndLogin(String bindToken, String mobileRaw) {
+        OfficialBindSession session = consumeOfficialBindToken(bindToken);
+        String phone = PhoneNormalize.mainland(mobileRaw);
+        if (!phone.matches("^\\d{11}$")) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号格式不正确");
+        }
+        MpUser user = resolveUserByPhoneForOfficialLogin(session.openId(), session.unionId(), phone, mobileRaw);
+        assertUserCanLogin(user);
         MiniStpUtil.login(user.getId());
         return buildResponse(user);
     }
@@ -121,6 +180,38 @@
             throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
         }
         bindOpenIdIfNeeded(user, openId, unionId, phoneRaw);
+        return mpUserMapper.selectById(user.getId());
+    }
+
+    private MpUser resolveUserByPhoneForOfficialLogin(
+            String officialOpenId, String unionId, String normalizedPhone, String phoneRaw) {
+        List<MpUser> users =
+                mpUserMapper.selectList(
+                        new LambdaQueryWrapper<MpUser>()
+                                .eq(MpUser::getDelFlag, 0));
+        MpUser user =
+                users.stream()
+                        .filter(Objects::nonNull)
+                        .filter(u -> normalizedPhone.equals(PhoneNormalize.mainland(u.getMobile())))
+                        .sorted(this::compareByLoginPriority)
+                        .findFirst()
+                        .orElse(null);
+        if (user == null) {
+            MpUser created = new MpUser();
+            created.setOpenId(OFFICIAL_OPEN_ID_PLACEHOLDER_PREFIX + UUID.randomUUID());
+            created.setOfficialOpenId(officialOpenId);
+            created.setUnionId(unionId);
+            created.setUserType(MiniUserType.VISITOR);
+            created.setMobile(phoneRaw);
+            created.setStatus(1);
+            mpUserMapper.insert(created);
+            return mpUserMapper.selectById(created.getId());
+        }
+        assertAccountEnabled(user);
+        if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() == null) {
+            throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
+        }
+        bindOfficialOpenIdIfNeeded(user, officialOpenId, unionId, phoneRaw);
         return mpUserMapper.selectById(user.getId());
     }
 
@@ -199,6 +290,61 @@
         if (needUpdate) {
             mpUserMapper.updateById(user);
         }
+    }
+
+    private void bindOfficialOpenIdIfNeeded(
+            MpUser user, String officialOpenId, String unionId, String phoneRaw) {
+        if (user == null) {
+            return;
+        }
+        String currentOfficialOpenId = user.getOfficialOpenId();
+        if (StringUtils.hasText(currentOfficialOpenId)
+                && !Objects.equals(currentOfficialOpenId, officialOpenId)) {
+            throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "该手机号已绑定其他微信账号");
+        }
+        boolean needUpdate = false;
+        if (!Objects.equals(currentOfficialOpenId, officialOpenId)) {
+            user.setOfficialOpenId(officialOpenId);
+            needUpdate = true;
+        }
+        if (!StringUtils.hasText(user.getUnionId()) && StringUtils.hasText(unionId)) {
+            user.setUnionId(unionId);
+            needUpdate = true;
+        }
+        if (!StringUtils.hasText(user.getMobile()) && StringUtils.hasText(phoneRaw)) {
+            user.setMobile(phoneRaw);
+            needUpdate = true;
+        }
+        if (needUpdate) {
+            mpUserMapper.updateById(user);
+        }
+    }
+
+    private String createOfficialBindToken(String openId, String unionId) {
+        String token = UUID.randomUUID().toString();
+        String value = openId + "\n" + (unionId == null ? "" : unionId);
+        stringRedisTemplate.opsForValue()
+                .set(OFFICIAL_BIND_TOKEN_PREFIX + token, value, OFFICIAL_BIND_TOKEN_TTL);
+        return token;
+    }
+
+    private OfficialBindSession consumeOfficialBindToken(String bindToken) {
+        if (!StringUtils.hasText(bindToken)) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证不能为空");
+        }
+        String key = OFFICIAL_BIND_TOKEN_PREFIX + bindToken.trim();
+        String value = stringRedisTemplate.opsForValue().get(key);
+        if (!StringUtils.hasText(value)) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证已失效,请重新登录");
+        }
+        stringRedisTemplate.delete(key);
+        String[] parts = value.split("\\n", 2);
+        String openId = parts.length > 0 ? parts[0] : "";
+        String unionId = parts.length > 1 ? parts[1] : null;
+        if (!StringUtils.hasText(openId)) {
+            throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "绑定凭证无效,请重新登录");
+        }
+        return new OfficialBindSession(openId, StringUtils.hasText(unionId) ? unionId : null);
     }
 
     private void assertUserCanLogin(MpUser user) {
@@ -333,4 +479,6 @@
         }
         return trimmed;
     }
+
+    private record OfficialBindSession(String openId, String unionId) {}
 }
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index f3dfd68..2c63c26 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -2,19 +2,19 @@
   application:
     name: appointment-backend
   datasource:
-    url: jdbc:mysql://192.168.110.80:3306/appointment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
+    url: jdbc:mysql://localhost:23306/appointment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
     username: root
-    password: 123456
+    password: yuyue520
     driver-class-name: com.mysql.cj.jdbc.Driver
   cache:
     type: redis
   # Spring Boot 3:Redis 配置前缀为 spring.data.redis(spring.redis.* 已移除,沿用会导致连默认 localhost)
   data:
     redis:
-      database: 2
-      host: 192.168.110.80
-      port: 6379
-      password: 123456
+      database: 0
+      host: localhost
+      port: 26379
+      password: yuyue520
       timeout: 10s
       lettuce:
         pool:
@@ -25,7 +25,6 @@
 mybatis-plus:
   configuration:
     map-underscore-to-camel-case: true
-    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
   global-config:
     db-config:
       logic-delete-field: delFlag
@@ -49,13 +48,13 @@
   # 独立 Redis(须引入 sa-token-alone-redis);与 spring.data.redis 互不干扰,可指向专用实例
   alone-redis:
     # Redis数据库索引 (默认为0)
-    database: 3
+    database: 1
     # Redis服务器地址
-    host: 192.168.110.80
+    host: localhost
     # Redis服务器连接端口
-    port: 6379
+    port: 26379
     # Redis服务器连接密码(默认为空)
-    password: 123456
+    password: yuyue520
     # 连接超时时间
     timeout: 10s
     lettuce:
@@ -71,10 +70,10 @@
     app-id: ${WECHAT_MINI_APPID:wxe91f1af7638aa5dd}
     app-secret: ${WECHAT_MINI_SECRET:a787e1a462715604e0c9528b6d8960d1}
     stub: ${WECHAT_MINI_STUB:false}
-
-springdoc:
-  api-docs:
-    path: /v3/api-docs
+  official-account:
+    app-id: ${WECHAT_OFFICIAL_APPID:wxd312008b67bf7292}
+    app-secret: ${WECHAT_OFFICIAL_SECRET:bc69bc0599e0bcd97351a4cd77704ea7}
+    stub: ${WECHAT_OFFICIAL_STUB:true}
 
 server:
   port: 8880
@@ -89,5 +88,5 @@
     iv: ${ADMIN_LOGIN_AES_IV:SinataAdminIv001}
     max-skew-millis: ${ADMIN_LOGIN_AES_MAX_SKEW_MILLIS:60000}
 
-file-upload-path: D:\tmp\
-picture-server-address: http://221.182.45.100:8881/
+file-upload-path: /opt/web/uploads
+picture-server-address: https://ytx.hongyu.com/resources/
diff --git a/backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql b/backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql
new file mode 100644
index 0000000..c86b859
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql
@@ -0,0 +1,5 @@
+ALTER TABLE mp_user
+    ADD COLUMN official_open_id VARCHAR(64) NULL COMMENT '公众号openId' AFTER open_id;
+
+ALTER TABLE mp_user
+    ADD UNIQUE KEY uk_mp_user_official_open_id (official_open_id);
diff --git a/backend/startup.cmd b/backend/startup.cmd
new file mode 100644
index 0000000..362b129
--- /dev/null
+++ b/backend/startup.cmd
@@ -0,0 +1 @@
+C:\Users\PC\.jdks\ms-17.0.18\bin\java -jar -Xmx512m -Xms512m .\target\appointment-backend-1.0.0-SNAPSHOT.jar
\ No newline at end of file
diff --git "a/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx" "b/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx"
new file mode 100644
index 0000000..e941f26
--- /dev/null
+++ "b/\351\242\204\347\272\246\350\256\277\345\256\242\347\263\273\347\273\237_\346\246\202\350\246\201\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246_\345\217\257\347\233\226\347\253\240\347\211\210.docx"
Binary files differ

--
Gitblit v1.7.1