feat: add subscribe message push and refactor HTTP layer
This commit is contained in:
@@ -1,18 +0,0 @@
|
|||||||
package com.example.mini_program.config;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class RestTemplateConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public RestTemplate restTemplate() {
|
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
|
||||||
factory.setConnectTimeout(10000);
|
|
||||||
factory.setReadTimeout(30000);
|
|
||||||
return new RestTemplate(factory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 访客预约服务
|
||||||
|
* 创建预约 → 提交企微审批 → 推送订阅消息通知
|
||||||
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -17,118 +21,91 @@ public class AppointmentService {
|
|||||||
|
|
||||||
private final VisitApplicationMapper visitApplicationMapper;
|
private final VisitApplicationMapper visitApplicationMapper;
|
||||||
private final WxApprovalService wxApprovalService;
|
private final WxApprovalService wxApprovalService;
|
||||||
|
private final WxSubscribeMessageService wxSubscribeMessageService;
|
||||||
|
|
||||||
@Value("${wx.corp.creator-userid:}")
|
@Value("${wx.corp.creator-userid:}")
|
||||||
private String creatorUserId;
|
private String creatorUserId;
|
||||||
|
|
||||||
/**
|
/** 根据openid获取最新一条预约 */
|
||||||
* 根据openid获取最新的一条预约记录
|
|
||||||
*/
|
|
||||||
public VisitApplication getLatest(String openid) {
|
public VisitApplication getLatest(String openid) {
|
||||||
log.info("查询用户最新预约记录, openid: {}", openid);
|
return visitApplicationMapper.selectLatestByOpenid(openid);
|
||||||
VisitApplication result = visitApplicationMapper.selectLatestByOpenid(openid);
|
|
||||||
if (result != null) {
|
|
||||||
log.info("找到预约记录, id: {}", result.getId());
|
|
||||||
} else {
|
|
||||||
log.info("未找到预约记录");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 获取用户所有预约记录(按创建时间倒序) */
|
||||||
* 获取用户所有预约记录(按创建时间倒序)
|
|
||||||
*/
|
|
||||||
public List<VisitApplication> getList(String openid) {
|
public List<VisitApplication> getList(String openid) {
|
||||||
log.info("查询用户预约列表, openid: {}", openid);
|
return visitApplicationMapper.selectListByOpenid(openid);
|
||||||
List<VisitApplication> list = visitApplicationMapper.selectListByOpenid(openid);
|
|
||||||
log.info("查询到 {} 条预约记录", list.size());
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 创建预约记录 */
|
||||||
* 创建预约记录
|
|
||||||
*/
|
|
||||||
public VisitApplication create(VisitApplication record) {
|
public VisitApplication create(VisitApplication record) {
|
||||||
record.setId(UUID.randomUUID().toString().replace("-", ""));
|
record.setId(UUID.randomUUID().toString().replace("-", ""));
|
||||||
record.setStatus("pending");
|
record.setStatus("pending");
|
||||||
record.setStatusText("待审核");
|
record.setStatusText("待审核");
|
||||||
|
|
||||||
// 发起企业微信审批
|
// 提交企业微信审批
|
||||||
try {
|
try {
|
||||||
String visitTime = record.getVisitDate();
|
|
||||||
if (record.getVisitTime() != null && !record.getVisitTime().isEmpty()) {
|
|
||||||
visitTime = record.getVisitDate() + " " + record.getVisitTime();
|
|
||||||
}
|
|
||||||
String spNo = wxApprovalService.submitApproval(
|
String spNo = wxApprovalService.submitApproval(
|
||||||
creatorUserId,
|
creatorUserId, record.getName(), record.getPhone(), record.getCompany(),
|
||||||
record.getName(),
|
record.getReason(), formatVisitTime(record), record.getHostName(), record.getArea());
|
||||||
record.getPhone(),
|
|
||||||
record.getCompany(),
|
|
||||||
record.getReason(),
|
|
||||||
visitTime,
|
|
||||||
record.getHostName(),
|
|
||||||
record.getArea()
|
|
||||||
);
|
|
||||||
record.setSpNo(spNo);
|
record.setSpNo(spNo);
|
||||||
log.info("企业微信审批提交成功, spNo: {}", spNo);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("企业微信审批提交失败,预约记录仍会保存", e);
|
log.error("企微审批提交失败,预约仍会保存", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
visitApplicationMapper.insert(record);
|
visitApplicationMapper.insert(record);
|
||||||
log.info("创建预约记录成功, id: {}, openid: {}", record.getId(), record.getOpenid());
|
log.info("创建预约成功, id={}", record.getId());
|
||||||
|
|
||||||
|
// 推送订阅消息
|
||||||
|
try {
|
||||||
|
wxSubscribeMessageService.sendSubscribeMessage(
|
||||||
|
record.getOpenid(), record.getName(), record.getReason(),
|
||||||
|
formatVisitTime(record), record.getArea(), "待审核");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("订阅消息推送失败", e);
|
||||||
|
}
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 取消预约(仅 pending 状态可取消) */
|
||||||
* 取消预约(仅pending状态可取消,需校验openid)
|
|
||||||
*/
|
|
||||||
public boolean cancel(String id, String openid) {
|
public boolean cancel(String id, String openid) {
|
||||||
log.info("取消预约, id: {}, openid: {}", id, openid);
|
|
||||||
|
|
||||||
VisitApplication existing = visitApplicationMapper.selectByIdAndOpenid(id, openid);
|
VisitApplication existing = visitApplicationMapper.selectByIdAndOpenid(id, openid);
|
||||||
if (existing == null) {
|
if (existing == null || !"pending".equals(existing.getStatus())) {
|
||||||
log.warn("预约记录不存在或不属于该用户, id: {}, openid: {}", id, openid);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!"pending".equals(existing.getStatus())) {
|
return visitApplicationMapper.updateStatusToCancelled(id, openid) > 0;
|
||||||
log.warn("预约状态不允许取消, id: {}, status: {}", id, existing.getStatus());
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int rows = visitApplicationMapper.updateStatusToCancelled(id, openid);
|
/** 审批预约(通过/拒绝) */
|
||||||
if (rows > 0) {
|
|
||||||
log.info("取消预约成功, id: {}", id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
log.warn("取消预约失败, id: {}", id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 审批预约(通过/拒绝)
|
|
||||||
*/
|
|
||||||
public boolean approve(String id, String status) {
|
public boolean approve(String id, String status) {
|
||||||
log.info("审批预约, id: {}, status: {}", id, status);
|
|
||||||
|
|
||||||
VisitApplication existing = visitApplicationMapper.selectById(id);
|
VisitApplication existing = visitApplicationMapper.selectById(id);
|
||||||
if (existing == null) {
|
if (existing == null || !"pending".equals(existing.getStatus())) {
|
||||||
log.warn("预约记录不存在, id: {}", id);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!"pending".equals(existing.getStatus())) {
|
|
||||||
log.warn("预约状态不允许审批, id: {}, currentStatus: {}", id, existing.getStatus());
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
String statusText = "approved".equals(status) ? "已通过" : "已拒绝";
|
String statusText = "approved".equals(status) ? "已通过" : "已拒绝";
|
||||||
int rows = visitApplicationMapper.updateStatus(id, status, statusText);
|
if (visitApplicationMapper.updateStatus(id, status, statusText) <= 0) {
|
||||||
if (rows <= 0) {
|
|
||||||
log.warn("审批更新失败, id: {}", id);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("审批成功, id: {}, status: {}", id, statusText);
|
// 推送审批结果订阅消息
|
||||||
|
try {
|
||||||
|
wxSubscribeMessageService.sendSubscribeMessage(
|
||||||
|
existing.getOpenid(), existing.getName(), existing.getReason(),
|
||||||
|
formatVisitTime(existing), existing.getArea(), statusText);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("审批结果订阅消息推送失败", e);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拼接访问日期+时间 */
|
||||||
|
private String formatVisitTime(VisitApplication record) {
|
||||||
|
String time = record.getVisitDate();
|
||||||
|
if (record.getVisitTime() != null && !record.getVisitTime().isEmpty()) {
|
||||||
|
time = record.getVisitDate() + " " + record.getVisitTime();
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package com.example.mini_program.service;
|
package com.example.mini_program.service;
|
||||||
|
|
||||||
import com.example.mini_program.config.WxCorpConfig;
|
import com.example.mini_program.config.WxCorpConfig;
|
||||||
|
import com.example.mini_program.util.HttpUtil;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信审批服务
|
||||||
|
* 调用企微 /cgi-bin/oa/applyevent 提交审批,/cgi-bin/oa/getapprovaldetail 查询审批状态
|
||||||
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -19,228 +27,125 @@ public class WxApprovalService {
|
|||||||
|
|
||||||
private static final String GET_TOKEN_URL =
|
private static final String GET_TOKEN_URL =
|
||||||
"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s";
|
"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s";
|
||||||
private static final String SUBMIT_APPROVAL_URL =
|
private static final String SUBMIT_URL =
|
||||||
"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=%s";
|
"https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=%s";
|
||||||
private static final String GET_APPROVAL_DETAIL_URL =
|
private static final String DETAIL_URL =
|
||||||
"https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail?access_token=%s";
|
"https://qyapi.weixin.qq.com/cgi-bin/oa/getapprovaldetail?access_token=%s";
|
||||||
|
|
||||||
private final WxCorpConfig wxCorpConfig;
|
private final WxCorpConfig wxCorpConfig;
|
||||||
private final RestTemplate restTemplate;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/** 获取企业微信 access_token */
|
||||||
* 获取企业微信AccessToken
|
|
||||||
*/
|
|
||||||
public String getAccessToken() {
|
public String getAccessToken() {
|
||||||
String url = String.format(GET_TOKEN_URL, wxCorpConfig.getCorpid(), wxCorpConfig.getCorpsecret());
|
String url = String.format(GET_TOKEN_URL, wxCorpConfig.getCorpid(), wxCorpConfig.getCorpsecret());
|
||||||
log.info("获取企业微信access_token, corpid: {}", wxCorpConfig.getCorpid());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> response = restTemplate.getForObject(url, Map.class);
|
Map<String, Object> resp = objectMapper.readValue(HttpUtil.get(url), Map.class);
|
||||||
if (response != null && "0".equals(String.valueOf(response.get("errcode")))) {
|
if ("0".equals(String.valueOf(resp.get("errcode")))) {
|
||||||
String accessToken = (String) response.get("access_token");
|
return (String) resp.get("access_token");
|
||||||
log.info("获取access_token成功: {}", accessToken);
|
|
||||||
return accessToken;
|
|
||||||
} else {
|
|
||||||
log.error("获取access_token失败: {}", response);
|
|
||||||
throw new RuntimeException("获取access_token失败: " + response);
|
|
||||||
}
|
}
|
||||||
|
throw new RuntimeException("获取access_token失败: " + resp);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("调用企业微信接口失败", e);
|
throw new RuntimeException("获取access_token异常: " + e.getMessage(), e);
|
||||||
throw new RuntimeException("获取access_token异常: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 提交审批申请 */
|
||||||
* 提交审批申请(使用模板中配置的审批流程)
|
|
||||||
*
|
|
||||||
* @param creatorUserId 申请人用户ID
|
|
||||||
* @param visitorName 访客姓名
|
|
||||||
* @param visitorPhone 访客电话
|
|
||||||
* @param visitorCompany 访客公司(可选)
|
|
||||||
* @param visitPurpose 来访事由
|
|
||||||
* @param visitTime 日期+时间
|
|
||||||
* @param visiteeName 被访人(可选)
|
|
||||||
* @param visitArea 拜访区域(可选)
|
|
||||||
* @return 审批单号
|
|
||||||
*/
|
|
||||||
public String submitApproval(String creatorUserId, String visitorName, String visitorPhone,
|
public String submitApproval(String creatorUserId, String visitorName, String visitorPhone,
|
||||||
String visitorCompany, String visitPurpose, String visitTime,
|
String visitorCompany, String visitPurpose, String visitTime,
|
||||||
String visiteeName, String visitArea) {
|
String visiteeName, String visitArea) {
|
||||||
String accessToken = getAccessToken();
|
String url = String.format(SUBMIT_URL, getAccessToken());
|
||||||
String url = String.format(SUBMIT_APPROVAL_URL, accessToken);
|
|
||||||
|
|
||||||
// 构建审批表单数据(apply_data)
|
Map<String, Object> body = new HashMap<>();
|
||||||
Map<String, Object> applyData = buildApplyData(visitorName, visitorPhone, visitorCompany,
|
body.put("creator_userid", creatorUserId);
|
||||||
visitPurpose, visitTime, visiteeName, visitArea);
|
body.put("template_id", wxCorpConfig.getApprovalTemplateId());
|
||||||
|
body.put("use_template_approver", 1);
|
||||||
// 构建摘要信息
|
body.put("apply_data", buildApplyData(visitorName, visitorPhone, visitorCompany,
|
||||||
List<Map<String, Object>> summaryList = buildSummaryList(visitorName, visitPurpose);
|
visitPurpose, visitTime, visiteeName, visitArea));
|
||||||
|
body.put("summary_list", buildSummaryList(visitorName, visitPurpose));
|
||||||
// 构建完整的审批请求体(use_template_approver=1 使用模板中配置的审批流程)
|
|
||||||
Map<String, Object> requestBody = new HashMap<>();
|
|
||||||
requestBody.put("creator_userid", creatorUserId);
|
|
||||||
requestBody.put("template_id", wxCorpConfig.getApprovalTemplateId());
|
|
||||||
requestBody.put("use_template_approver", 1);
|
|
||||||
requestBody.put("apply_data", applyData);
|
|
||||||
requestBody.put("summary_list", summaryList);
|
|
||||||
|
|
||||||
log.info("提交审批申请, visitorName: {}, visitorPhone: {}", visitorName, visitorPhone);
|
|
||||||
log.debug("审批请求体: {}", requestBody);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> response = restTemplate.postForObject(url, requestBody, Map.class);
|
String json = objectMapper.writeValueAsString(body);
|
||||||
if (response != null && "0".equals(String.valueOf(response.get("errcode")))) {
|
Map<String, Object> resp = objectMapper.readValue(HttpUtil.postJson(url, json), Map.class);
|
||||||
String spNo = (String) response.get("sp_no");
|
if ("0".equals(String.valueOf(resp.get("errcode")))) {
|
||||||
log.info("审批提交成功, spNo: {}", spNo);
|
String spNo = (String) resp.get("sp_no");
|
||||||
|
log.info("审批提交成功, spNo={}", spNo);
|
||||||
return spNo;
|
return spNo;
|
||||||
} else {
|
|
||||||
log.error("审批提交失败: {}", response);
|
|
||||||
throw new RuntimeException("审批提交失败: " + response);
|
|
||||||
}
|
}
|
||||||
|
throw new RuntimeException("审批提交失败: " + resp);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("提交审批申请异常", e);
|
throw new RuntimeException("提交审批申请异常: " + e.getMessage(), e);
|
||||||
throw new RuntimeException("提交审批申请异常: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 查询审批状态 */
|
||||||
* 构建审批表单数据(apply_data)
|
public ApprovalStatus getApprovalStatus(String spNo) {
|
||||||
* 控件ID来源于"获取审批模板详情"接口,需与审批模板中的控件ID一致
|
String url = String.format(DETAIL_URL, getAccessToken());
|
||||||
*
|
try {
|
||||||
* 模板控件对应关系:
|
String json = objectMapper.writeValueAsString(Map.of("sp_no", spNo));
|
||||||
* Text-1776786661954 → 姓名
|
Map<String, Object> resp = objectMapper.readValue(HttpUtil.postJson(url, json), Map.class);
|
||||||
* Text-1776786666351 → 手机号
|
if ("0".equals(String.valueOf(resp.get("errcode")))) {
|
||||||
* Text-1776786668098 → 公司
|
@SuppressWarnings("unchecked")
|
||||||
* Text-1776786672408 → 来访事由
|
Map<String, Object> info = (Map<String, Object>) resp.get("info");
|
||||||
* Date-1776786680089 → 日期+时间
|
ApprovalStatus status = new ApprovalStatus();
|
||||||
* Text-1776786690968 → 被访人
|
if (info != null) {
|
||||||
* Text-1776786692400 → 拜访区域
|
status.setSpNo((String) info.get("sp_no"));
|
||||||
*/
|
status.setSpStatus((Integer) info.get("sp_status"));
|
||||||
|
status.setSpStatusText(toStatusText((Integer) info.get("sp_status")));
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
throw new RuntimeException("审批状态查询失败: " + resp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("查询审批状态异常: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部方法 ----
|
||||||
|
|
||||||
private Map<String, Object> buildApplyData(String visitorName, String visitorPhone,
|
private Map<String, Object> buildApplyData(String visitorName, String visitorPhone,
|
||||||
String visitorCompany, String visitPurpose,
|
String visitorCompany, String visitPurpose,
|
||||||
String visitTime, String visiteeName,
|
String visitTime, String visiteeName, String visitArea) {
|
||||||
String visitArea) {
|
|
||||||
List<Map<String, Object>> contents = new ArrayList<>();
|
List<Map<String, Object>> contents = new ArrayList<>();
|
||||||
contents.add(buildTextControl("Text-1776786661954", visitorName));
|
contents.add(textControl("Text-1776786661954", visitorName));
|
||||||
contents.add(buildTextControl("Text-1776786666351", visitorPhone));
|
contents.add(textControl("Text-1776786666351", visitorPhone));
|
||||||
contents.add(buildTextControl("Text-1776786668098", visitorCompany));
|
contents.add(textControl("Text-1776786668098", visitorCompany));
|
||||||
contents.add(buildTextControl("Text-1776786672408", visitPurpose));
|
contents.add(textControl("Text-1776786672408", visitPurpose));
|
||||||
contents.add(buildDateControl("Date-1776786680089", visitTime));
|
contents.add(dateControl("Date-1776786680089", visitTime));
|
||||||
contents.add(buildTextControl("Text-1776786690968", visiteeName));
|
contents.add(textControl("Text-1776786690968", visiteeName));
|
||||||
contents.add(buildTextControl("Text-1776786692400", visitArea));
|
contents.add(textControl("Text-1776786692400", visitArea));
|
||||||
|
|
||||||
return Map.of("contents", contents);
|
return Map.of("contents", contents);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private Map<String, Object> textControl(String id, String value) {
|
||||||
* 构建文本控件数据
|
return Map.of("control", "Text", "id", id,
|
||||||
* 对应 control 参数为 Text 或 Textarea
|
"value", Map.of("text", value != null ? value : ""));
|
||||||
*
|
|
||||||
* @param id 控件ID(需与审批模板中的控件ID一致)
|
|
||||||
* @param value 文本内容
|
|
||||||
*/
|
|
||||||
private Map<String, Object> buildTextControl(String id, String value) {
|
|
||||||
return Map.of(
|
|
||||||
"control", "Text",
|
|
||||||
"id", id,
|
|
||||||
"value", Map.of("text", value != null ? value : "")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private Map<String, Object> dateControl(String id, String dateTime) {
|
||||||
* 构建日期/日期+时间控件数据
|
|
||||||
* 对应 control 参数为 Date
|
|
||||||
*
|
|
||||||
* @param id 控件ID(需与审批模板中的控件ID一致)
|
|
||||||
* @param dateTime 日期时间字符串,格式:yyyy-MM-dd HH:mm
|
|
||||||
*/
|
|
||||||
private Map<String, Object> buildDateControl(String id, String dateTime) {
|
|
||||||
long timestamp = 0;
|
long timestamp = 0;
|
||||||
if (dateTime != null && !dateTime.isEmpty()) {
|
if (dateTime != null && !dateTime.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
java.time.LocalDateTime ldt = java.time.LocalDateTime.parse(dateTime,
|
timestamp = LocalDateTime.parse(dateTime,
|
||||||
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||||
timestamp = ldt.atZone(java.time.ZoneId.of("Asia/Shanghai"))
|
.atZone(ZoneId.of("Asia/Shanghai")).toInstant().getEpochSecond();
|
||||||
.toInstant().getEpochSecond();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("日期时间解析失败: {}", dateTime, e);
|
log.warn("日期解析失败: {}", dateTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Map.of(
|
return Map.of("control", "Date", "id", id,
|
||||||
"control", "Date",
|
"value", Map.of("date", Map.of("type", "hour", "s_timestamp", String.valueOf(timestamp))));
|
||||||
"id", id,
|
|
||||||
"value", Map.of(
|
|
||||||
"date", Map.of(
|
|
||||||
"type", "hour",
|
|
||||||
"s_timestamp", String.valueOf(timestamp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建摘要信息,显示在审批通知卡片和审批列表中,最多3行
|
|
||||||
*/
|
|
||||||
private List<Map<String, Object>> buildSummaryList(String visitorName, String visitPurpose) {
|
private List<Map<String, Object>> buildSummaryList(String visitorName, String visitPurpose) {
|
||||||
return List.of(
|
return List.of(
|
||||||
Map.of("summary_info", List.of(
|
Map.of("summary_info", List.of(
|
||||||
Map.of("text", "访客: " + (visitorName != null ? visitorName : ""), "lang", "zh_CN")
|
Map.of("text", "访客: " + (visitorName != null ? visitorName : ""), "lang", "zh_CN"))),
|
||||||
)),
|
|
||||||
Map.of("summary_info", List.of(
|
Map.of("summary_info", List.of(
|
||||||
Map.of("text", "目的: " + (visitPurpose != null ? visitPurpose : ""), "lang", "zh_CN")
|
Map.of("text", "目的: " + (visitPurpose != null ? visitPurpose : ""), "lang", "zh_CN")))
|
||||||
))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private static String toStatusText(Integer spStatus) {
|
||||||
* 获取审批状态
|
|
||||||
*
|
|
||||||
* @param spNo 审批单号
|
|
||||||
* @return 审批状态信息
|
|
||||||
*/
|
|
||||||
public ApprovalStatus getApprovalStatus(String spNo) {
|
|
||||||
String accessToken = getAccessToken();
|
|
||||||
String url = String.format(GET_APPROVAL_DETAIL_URL, accessToken);
|
|
||||||
|
|
||||||
Map<String, Object> requestBody = Map.of("sp_no", spNo);
|
|
||||||
|
|
||||||
log.info("查询审批状态, spNo: {}", spNo);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Map<String, Object> response = restTemplate.postForObject(url, requestBody, Map.class);
|
|
||||||
if (response != null && "0".equals(String.valueOf(response.get("errcode")))) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> info = (Map<String, Object>) response.get("info");
|
|
||||||
ApprovalStatus status = parseApprovalStatus(info);
|
|
||||||
log.info("审批状态查询成功, spNo: {}, status: {}", spNo, status);
|
|
||||||
return status;
|
|
||||||
} else {
|
|
||||||
log.error("审批状态查询失败: {}", response);
|
|
||||||
throw new RuntimeException("审批状态查询失败: " + response);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("查询审批状态异常", e);
|
|
||||||
throw new RuntimeException("查询审批状态异常: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析审批状态
|
|
||||||
*/
|
|
||||||
private ApprovalStatus parseApprovalStatus(Map<String, Object> spInfo) {
|
|
||||||
ApprovalStatus status = new ApprovalStatus();
|
|
||||||
if (spInfo != null) {
|
|
||||||
status.setSpNo((String) spInfo.get("sp_no"));
|
|
||||||
status.setSpStatus((Integer) spInfo.get("sp_status"));
|
|
||||||
status.setSpStatusText(getStatusText((Integer) spInfo.get("sp_status")));
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态码转文本
|
|
||||||
*/
|
|
||||||
private String getStatusText(Integer spStatus) {
|
|
||||||
if (spStatus == null) return "未知";
|
if (spStatus == null) return "未知";
|
||||||
return switch (spStatus) {
|
return switch (spStatus) {
|
||||||
case 1 -> "审批中";
|
case 1 -> "审批中";
|
||||||
@@ -251,9 +156,6 @@ public class WxApprovalService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 审批状态结果类
|
|
||||||
*/
|
|
||||||
@Data
|
@Data
|
||||||
public static class ApprovalStatus {
|
public static class ApprovalStatus {
|
||||||
private String spNo;
|
private String spNo;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.example.mini_program.service;
|
||||||
|
|
||||||
|
import com.example.mini_program.config.WxMiniAppConfig;
|
||||||
|
import com.example.mini_program.util.HttpUtil;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序订阅消息服务
|
||||||
|
* 调用微信 /cgi-bin/message/subscribe/send 接口推送订阅消息
|
||||||
|
*
|
||||||
|
* 模板字段映射:
|
||||||
|
* name1 (姓名) → 访客姓名
|
||||||
|
* thing3 (事物) → 来访事由
|
||||||
|
* date8 (日期) → 预约时间
|
||||||
|
* thing10 (事物) → 到访区域
|
||||||
|
* phrase18 (短语) → 审批状态
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WxSubscribeMessageService {
|
||||||
|
|
||||||
|
private static final String GET_TOKEN_URL =
|
||||||
|
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
|
||||||
|
private static final String SEND_MSG_URL =
|
||||||
|
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s";
|
||||||
|
|
||||||
|
private final WxMiniAppConfig wxMiniAppConfig;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${wx.miniapp.subscribe-template-id:}")
|
||||||
|
private String templateId;
|
||||||
|
|
||||||
|
/** 获取小程序 access_token */
|
||||||
|
public String getAccessToken() {
|
||||||
|
String url = String.format(GET_TOKEN_URL, wxMiniAppConfig.getAppid(), wxMiniAppConfig.getSecret());
|
||||||
|
try {
|
||||||
|
Map<String, Object> resp = objectMapper.readValue(HttpUtil.get(url), Map.class);
|
||||||
|
if (resp.containsKey("access_token")) {
|
||||||
|
return (String) resp.get("access_token");
|
||||||
|
}
|
||||||
|
throw new RuntimeException("获取access_token失败: " + resp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("获取access_token异常: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送订阅消息
|
||||||
|
* 所有字段不允许为空字符串(微信返回47003),name类型不接受纯数字
|
||||||
|
*/
|
||||||
|
public void sendSubscribeMessage(String openid, String visitorName, String reason,
|
||||||
|
String visitTime, String area, String status) {
|
||||||
|
if (templateId == null || templateId.isEmpty()) {
|
||||||
|
log.warn("未配置 subscribe-template-id,跳过订阅消息推送");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
String url = String.format(SEND_MSG_URL, accessToken);
|
||||||
|
|
||||||
|
// name1 是 name 类型,纯数字会被微信拒绝,需加前缀兜底
|
||||||
|
String safeName = blankToDefault(visitorName, "访客");
|
||||||
|
if (safeName.matches("^\\d+$")) {
|
||||||
|
safeName = "访客" + safeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("name1", Map.of("value", safeName));
|
||||||
|
data.put("thing3", Map.of("value", blankToDefault(reason, "来访")));
|
||||||
|
data.put("date8", Map.of("value", blankToDefault(visitTime, "待定")));
|
||||||
|
data.put("thing10", Map.of("value", blankToDefault(area, "未指定")));
|
||||||
|
data.put("phrase18", Map.of("value", blankToDefault(status, "待审核")));
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("touser", openid);
|
||||||
|
body.put("template_id", templateId);
|
||||||
|
body.put("page", "pages/records/records");
|
||||||
|
body.put("data", data);
|
||||||
|
body.put("miniprogram_state", "formal");
|
||||||
|
body.put("lang", "zh_CN");
|
||||||
|
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(body);
|
||||||
|
Map<String, Object> resp = objectMapper.readValue(HttpUtil.postJson(url, json), Map.class);
|
||||||
|
if ("0".equals(String.valueOf(resp.get("errcode")))) {
|
||||||
|
log.info("订阅消息推送成功, openid={}", openid);
|
||||||
|
} else {
|
||||||
|
log.error("订阅消息推送失败: {}", resp);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("订阅消息推送异常", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToDefault(String val, String def) {
|
||||||
|
return (val != null && !val.isEmpty()) ? val : def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.example.mini_program.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 HttpURLConnection 的轻量 HTTP 工具类
|
||||||
|
* 替代 RestTemplate,避免 JDK 默认 Expect:100-continue 导致微信 API 返回 412
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class HttpUtil {
|
||||||
|
|
||||||
|
private static final int CONNECT_TIMEOUT = 10_000;
|
||||||
|
private static final int READ_TIMEOUT = 30_000;
|
||||||
|
|
||||||
|
public static String get(String urlStr) throws Exception {
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
URL url = new URL(urlStr);
|
||||||
|
conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setConnectTimeout(CONNECT_TIMEOUT);
|
||||||
|
conn.setReadTimeout(READ_TIMEOUT);
|
||||||
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
|
return readResponse(conn);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String postJson(String urlStr, String jsonBody) throws Exception {
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
URL url = new URL(urlStr);
|
||||||
|
conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setConnectTimeout(CONNECT_TIMEOUT);
|
||||||
|
conn.setReadTimeout(READ_TIMEOUT);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
||||||
|
conn.setRequestProperty("Expect", ""); // 禁用 100-continue
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
||||||
|
os.flush();
|
||||||
|
}
|
||||||
|
return readResponse(conn);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readResponse(HttpURLConnection conn) throws Exception {
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||||
|
code < 300 ? conn.getInputStream() : conn.getErrorStream(), StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
sb.append(line);
|
||||||
|
}
|
||||||
|
String body = sb.toString();
|
||||||
|
log.debug("HTTP {} code={}", conn.getURL().getPath(), code);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: mini_program
|
name: mini_program
|
||||||
@@ -13,19 +13,14 @@ spring:
|
|||||||
# 微信小程序配置
|
# 微信小程序配置
|
||||||
wx:
|
wx:
|
||||||
miniapp:
|
miniapp:
|
||||||
# 小程序AppID
|
|
||||||
appid: wx50fe0c5c28dd3060
|
appid: wx50fe0c5c28dd3060
|
||||||
# 小程序AppSecret
|
|
||||||
secret: e82fa407fad13a9df35503f2d176e5a4
|
secret: e82fa407fad13a9df35503f2d176e5a4
|
||||||
corp: # 企业ID
|
subscribe-template-id: Csf_dJU7DhvVFt_03sphPPBCGlnmcWQSPhgqfxHZ5RQ
|
||||||
|
corp:
|
||||||
corpid: ww257614cff8a1b61b
|
corpid: ww257614cff8a1b61b
|
||||||
# 应用Secret
|
|
||||||
corpsecret: 2B0TAefYVewqjVHprLdGJQ8fNHz1drJq6235xN-mqNI
|
corpsecret: 2B0TAefYVewqjVHprLdGJQ8fNHz1drJq6235xN-mqNI
|
||||||
# 审批模板ID
|
|
||||||
approval-template-id: C4ej9uEntM19iNJbrtJsUqZakPFfjBNTPNLSKPno2
|
approval-template-id: C4ej9uEntM19iNJbrtJsUqZakPFfjBNTPNLSKPno2
|
||||||
# 审批回调URL(可选)
|
|
||||||
callback-url: http://your-domain.com/api/wx-corp/approval-callback
|
callback-url: http://your-domain.com/api/wx-corp/approval-callback
|
||||||
# 审批申请人用户ID(提交审批的企微用户)
|
|
||||||
creator-userid: i
|
creator-userid: i
|
||||||
|
|
||||||
# MyBatis配置
|
# MyBatis配置
|
||||||
@@ -34,5 +29,3 @@ mybatis:
|
|||||||
type-aliases-package: com.example.mini_program.entity
|
type-aliases-package: com.example.mini_program.entity
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
|
|
||||||
# 企业微信配置
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- --------------------------------------------------------
|
||||||
|
-- 访客预约系统数据库结构
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS `mini` DEFAULT CHARACTER SET utf8;
|
||||||
|
USE `mini`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `visit_application` (
|
||||||
|
`id` varchar(32) NOT NULL COMMENT '主键ID',
|
||||||
|
`name` varchar(50) NOT NULL COMMENT '访客姓名',
|
||||||
|
`phone` varchar(20) NOT NULL COMMENT '联系电话',
|
||||||
|
`company` varchar(100) DEFAULT NULL COMMENT '公司名称',
|
||||||
|
`reason` varchar(500) DEFAULT NULL COMMENT '来访原因',
|
||||||
|
`visit_date` date NOT NULL COMMENT '来访日期',
|
||||||
|
`visit_time` time DEFAULT '00:00:00' COMMENT '来访时间',
|
||||||
|
`host_name` varchar(50) DEFAULT NULL COMMENT '接待人姓名',
|
||||||
|
`area` varchar(50) DEFAULT NULL COMMENT '访问区域',
|
||||||
|
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT '状态',
|
||||||
|
`status_text` varchar(50) DEFAULT NULL COMMENT '状态文本描述',
|
||||||
|
`openid` varchar(64) DEFAULT NULL COMMENT '微信用户openid',
|
||||||
|
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`sp_no` varchar(64) DEFAULT NULL COMMENT '企业微信审批单号',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_visit_date` (`visit_date`),
|
||||||
|
KEY `idx_openid` (`openid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问申请记录表';
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE `visit_application` ADD COLUMN `notify_code` varchar(128) DEFAULT NULL COMMENT '订阅消息动态更新令牌' AFTER `sp_no`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user