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