longz
2026-05-28 aa67b3cb0fe26fab576f7710c978ff3594426cca
添加微信公众号登录功能并优化系统配置
9个文件已添加
7个文件已修改
667 ■■■■■ 已修改文件
backend/src/main/java/com/sinata/appointment/AppointmentApplication.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/entity/MpUser.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java 150 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/application.yml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
backend/startup.cmd 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
预约访客系统_概要设计说明书_可盖章版.docx 补丁 | 查看 | 原始文档 | blame | 历史
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) {
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")
backend/src/main/java/com/sinata/appointment/config/WechatOfficialAccountProperties.java
New file
@@ -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();
    }
}
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() {
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialBindPhoneRequest.java
New file
@@ -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;
}
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginRequest.java
New file
@@ -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;
}
backend/src/main/java/com/sinata/appointment/dto/MiniOfficialLoginResponse.java
New file
@@ -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;
}
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;
backend/src/main/java/com/sinata/appointment/integration/WechatOfficialAccountGateway.java
New file
@@ -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, "公众号授权接口异常");
        }
    }
}
backend/src/main/java/com/sinata/appointment/integration/WxOfficialOAuthResult.java
New file
@@ -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();
    }
}
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);
        }
    }
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) {}
}
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/
backend/src/main/resources/db/migration/V37__mp_user_official_open_id.sql
New file
@@ -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);
backend/startup.cmd
New file
@@ -0,0 +1 @@
C:\Users\PC\.jdks\ms-17.0.18\bin\java -jar -Xmx512m -Xms512m .\target\appointment-backend-1.0.0-SNAPSHOT.jar
预约访客系统_概要设计说明书_可盖章版.docx
Binary files differ