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,8 +48,12 @@ private final ApprovalTaskMapper approvalTaskMapper; private final BizVisitReasonMapper bizVisitReasonMapper; @Async public void notifyApprovalFinalResult(AppointmentOrder order, boolean approved) { 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; } MpUser submitter = @@ -59,7 +64,7 @@ .last("limit 1")); if (submitter == null || !StringUtils.hasText(submitter.getOpenId())) { log.info( "Skip mini subscribe notify, submitter not found or openId empty. submitterId={} orderNo={}", "Mini subscribe final result skipped, submitter not found or openId empty. submitterId={} orderNo={}", order.getSubmitterId(), order.getOrderNo()); return; @@ -72,37 +77,66 @@ wechatMiniGateway.sendSubscribeMessage( submitter.getOpenId(), APPROVAL_RESULT_TEMPLATE_ID, APPROVAL_RESULT_PAGE, templateData); log.info( "Mini subscribe notify sent submitterId={} orderNo={} result={}", "Mini subscribe final result success. submitterId={} orderNo={} result={}", order.getSubmitterId(), order.getOrderNo(), 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 sent noticeUserId={} orderNo={} result={}", "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 = @@ -113,9 +147,12 @@ .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<>(); java.util.LinkedHashMap<Long, java.util.LinkedHashSet<String>> userNodeNames = new java.util.LinkedHashMap<>(); for (ApprovalTask task : pendingTasks) { if (task.getAssigneeUserId() == null) { continue; @@ -124,6 +161,8 @@ 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 = @@ -135,21 +174,38 @@ 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 notify sent approverId={} orderNo={} stages={}", "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 预约访客系统_概要设计说明书_可盖章版.docxBinary files differ