feat(02): Phase 2 - 认证服务 + 会话存档服务
- auth-service: 企微OAuth2登录、JWT签发/验证、JS-SDK签名、Staff实体/Mapper - archive-service: JNA接口定义、RSA解密工具、ArchiveMessage实体/Mapper - archive-service: 存档拉取服务(SDK调用+解密+解析)、回调接收Controller
This commit is contained in:
parent
31a395be5e
commit
48c1dd8452
@ -0,0 +1,63 @@
|
|||||||
|
package com.artedu.archive.controller;
|
||||||
|
|
||||||
|
import com.artedu.archive.service.ArchivePullService;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微回调控制器
|
||||||
|
* 接收企微的msgaudit_notify回调
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/archive")
|
||||||
|
public class ArchiveCallbackController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchivePullService archivePullService;
|
||||||
|
|
||||||
|
@PostMapping("/callback")
|
||||||
|
public Result<String> callback(
|
||||||
|
@RequestParam("msg_signature") String msgSignature,
|
||||||
|
@RequestParam("timestamp") String timestamp,
|
||||||
|
@RequestParam("nonce") String nonce,
|
||||||
|
@RequestBody String requestBody) {
|
||||||
|
|
||||||
|
log.debug("收到企微回调: msg_signature={}, timestamp={}, nonce={}", msgSignature, timestamp, nonce);
|
||||||
|
|
||||||
|
if (requestBody.contains("msgaudit_notify")) {
|
||||||
|
log.info("收到msgaudit_notify回调,触发存档拉取");
|
||||||
|
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
long lastSeq = archivePullService.getLastSeq();
|
||||||
|
var messages = archivePullService.pullMessages(lastSeq, 1000);
|
||||||
|
archivePullService.saveAndNotify(messages);
|
||||||
|
log.info("回调触发拉取完成,共{}条消息", messages.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("回调触发拉取失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.success("success");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pull")
|
||||||
|
public Result<String> manualPull(
|
||||||
|
@RequestParam(value = "seq", required = false) Long seq,
|
||||||
|
@RequestParam(value = "limit", defaultValue = "1000") Integer limit) {
|
||||||
|
|
||||||
|
long startSeq = seq != null ? seq : archivePullService.getLastSeq();
|
||||||
|
log.info("手动触发存档拉取: seq={}, limit={}", startSeq, limit);
|
||||||
|
|
||||||
|
var messages = archivePullService.pullMessages(startSeq, limit);
|
||||||
|
archivePullService.saveAndNotify(messages);
|
||||||
|
|
||||||
|
return Result.success("拉取完成,共" + messages.size() + "条消息");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.artedu.archive.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档消息实体
|
||||||
|
* 对应archive_messages表
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("archive_messages")
|
||||||
|
public class ArchiveMessage {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String msgid;
|
||||||
|
private Long seq;
|
||||||
|
private String corpId;
|
||||||
|
private String action;
|
||||||
|
private String fromUser;
|
||||||
|
private String fromRole;
|
||||||
|
private String toUser;
|
||||||
|
private String tolist;
|
||||||
|
private String roomid;
|
||||||
|
private String msgtype;
|
||||||
|
private Long msgtime;
|
||||||
|
private String content;
|
||||||
|
private String mediaData;
|
||||||
|
private String sessionId;
|
||||||
|
private Integer decryptStatus;
|
||||||
|
private String decryptError;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.artedu.archive.mapper;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档消息数据访问层
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
||||||
|
|
||||||
|
@Select("SELECT MAX(seq) FROM archive_messages WHERE corp_id = #{corpId}")
|
||||||
|
Long selectMaxSeq(@Param("corpId") String corpId);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM archive_messages WHERE msgid = #{msgid} AND corp_id = #{corpId}")
|
||||||
|
int countByMsgId(@Param("msgid") String msgid, @Param("corpId") String corpId);
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
package com.artedu.archive.sdk;
|
||||||
|
|
||||||
|
import com.sun.jna.Library;
|
||||||
|
import com.sun.jna.Native;
|
||||||
|
import com.sun.jna.Pointer;
|
||||||
|
import com.sun.jna.Structure;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微会话存档C SDK的JNA接口定义
|
||||||
|
* 通过JNA调用本地动态库(.so/.dll)
|
||||||
|
*/
|
||||||
|
public interface WeWorkFinanceSdk extends Library {
|
||||||
|
|
||||||
|
WeWorkFinanceSdk INSTANCE = Native.load(
|
||||||
|
System.getProperty("os.name").toLowerCase().contains("win") ? "WeWorkFinanceSdk" : "libWeWorkFinanceSdk",
|
||||||
|
WeWorkFinanceSdk.class
|
||||||
|
);
|
||||||
|
|
||||||
|
long NewSdk();
|
||||||
|
|
||||||
|
int Init(long sdk, String corpId, String secret);
|
||||||
|
|
||||||
|
int GetChatData(long sdk, long seq, int limit, String proxy, String passwd, int timeout, long chatData);
|
||||||
|
|
||||||
|
int DecryptData(long sdk, String encryptKey, String encryptMsg, long msg);
|
||||||
|
|
||||||
|
int DestroySdk(long sdk);
|
||||||
|
|
||||||
|
void FreeSlice(long slice);
|
||||||
|
|
||||||
|
class Slice_t extends Structure {
|
||||||
|
public Pointer content;
|
||||||
|
public int len;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<String> getFieldOrder() {
|
||||||
|
return Arrays.asList("content", "len");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
package com.artedu.archive.service;
|
||||||
|
|
||||||
|
import com.artedu.archive.entity.ArchiveMessage;
|
||||||
|
import com.artedu.archive.mapper.ArchiveMessageMapper;
|
||||||
|
import com.artedu.archive.sdk.WeWorkFinanceSdk;
|
||||||
|
import com.artedu.archive.util.RsaDecryptUtil;
|
||||||
|
import com.artedu.common.config.RabbitConfig;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import javax.annotation.PreDestroy;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存档拉取服务
|
||||||
|
* 通过C SDK拉取企微会话存档消息
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ArchivePullService {
|
||||||
|
|
||||||
|
@Value("${wecom.archive.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Value("${wecom.archive.rsa-private-key:}")
|
||||||
|
private String rsaPrivateKey;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ArchiveMessageMapper archiveMessageMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RsaDecryptUtil rsaDecryptUtil;
|
||||||
|
|
||||||
|
private long sdk;
|
||||||
|
private static final String LAST_SEQ_KEY = "archive:last_seq:";
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
sdk = WeWorkFinanceSdk.INSTANCE.NewSdk();
|
||||||
|
int ret = WeWorkFinanceSdk.INSTANCE.Init(sdk, corpId, secret);
|
||||||
|
if (ret != 0) {
|
||||||
|
log.error("企微存档SDK初始化失败, ret={}", ret);
|
||||||
|
throw new RuntimeException("SDK初始化失败: " + ret);
|
||||||
|
}
|
||||||
|
log.info("企微存档SDK初始化成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void destroy() {
|
||||||
|
WeWorkFinanceSdk.INSTANCE.DestroySdk(sdk);
|
||||||
|
log.info("企微存档SDK已释放");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ArchiveMessage> pullMessages(long seq, int limit) {
|
||||||
|
List<ArchiveMessage> messages = new ArrayList<>();
|
||||||
|
long chatDataSlice = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
int ret = WeWorkFinanceSdk.INSTANCE.GetChatData(sdk, seq, limit, null, null, 5, chatDataSlice);
|
||||||
|
if (ret != 0) {
|
||||||
|
log.error("GetChatData失败, ret={}, seq={}", ret, seq);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
String jsonData = extractFromSlice(chatDataSlice);
|
||||||
|
if (jsonData == null || jsonData.isEmpty()) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(jsonData);
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("拉取存档返回错误: {}", jsonData);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> chatDataList = (List<Map<String, Object>>) result.get("chatdata");
|
||||||
|
if (chatDataList == null || chatDataList.isEmpty()) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map<String, Object> chatData : chatDataList) {
|
||||||
|
try {
|
||||||
|
ArchiveMessage message = parseAndDecrypt(chatData);
|
||||||
|
if (message != null) {
|
||||||
|
messages.add(message);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析单条消息失败: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("拉取存档消息完成: seq={}, limit={}, 成功解析{}条", seq, limit, messages.size());
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
if (chatDataSlice != 0) {
|
||||||
|
WeWorkFinanceSdk.INSTANCE.FreeSlice(chatDataSlice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ArchiveMessage parseAndDecrypt(Map<String, Object> chatData) {
|
||||||
|
String encryptRandomKey = (String) chatData.get("encrypt_random_key");
|
||||||
|
String encryptChatMsg = (String) chatData.get("encrypt_chat_msg");
|
||||||
|
Long msgSeq = ((Number) chatData.get("seq")).longValue();
|
||||||
|
|
||||||
|
String encryptKey = rsaDecryptUtil.decrypt(encryptRandomKey, rsaPrivateKey);
|
||||||
|
if (encryptKey == null) {
|
||||||
|
log.error("解密encrypt_random_key失败, seq={}", msgSeq);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String decryptedMsg = decryptMessage(encryptKey, encryptChatMsg);
|
||||||
|
if (decryptedMsg == null) {
|
||||||
|
log.error("解密消息内容失败, seq={}", msgSeq);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> msgMap = JsonUtils.fromJsonMap(decryptedMsg);
|
||||||
|
if (msgMap == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ArchiveMessage message = new ArchiveMessage();
|
||||||
|
message.setMsgid((String) msgMap.get("msgid"));
|
||||||
|
message.setSeq(msgSeq);
|
||||||
|
message.setCorpId(corpId);
|
||||||
|
message.setAction((String) msgMap.getOrDefault("action", "send"));
|
||||||
|
message.setFromUser((String) msgMap.get("from"));
|
||||||
|
message.setFromRole(detectRole((String) msgMap.get("from")));
|
||||||
|
message.setToUser((String) msgMap.get("tolist"));
|
||||||
|
message.setRoomid((String) msgMap.get("roomid"));
|
||||||
|
message.setMsgtype((String) msgMap.get("msgtype"));
|
||||||
|
Object msgTimeObj = msgMap.get("msgtime");
|
||||||
|
if (msgTimeObj != null) {
|
||||||
|
message.setMsgtime(((Number) msgTimeObj).longValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
Object content = msgMap.get("content");
|
||||||
|
if (content != null) {
|
||||||
|
message.setContent(content.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
message.setTolist(JsonUtils.toJson(msgMap.get("tolist")));
|
||||||
|
message.setMediaData(JsonUtils.toJson(msgMap.get("mediaData")));
|
||||||
|
message.setDecryptStatus(1);
|
||||||
|
message.setSessionId(generateSessionId(message));
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String decryptMessage(String encryptKey, String encryptMsg) {
|
||||||
|
long msgSlice = 0;
|
||||||
|
try {
|
||||||
|
int ret = WeWorkFinanceSdk.INSTANCE.DecryptData(sdk, encryptKey, encryptMsg, msgSlice);
|
||||||
|
if (ret != 0) {
|
||||||
|
log.error("DecryptData失败, ret={}", ret);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return extractFromSlice(msgSlice);
|
||||||
|
} finally {
|
||||||
|
if (msgSlice != 0) {
|
||||||
|
WeWorkFinanceSdk.INSTANCE.FreeSlice(msgSlice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractFromSlice(long slice) {
|
||||||
|
// 实际项目中需要根据JNA内存布局正确实现
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String detectRole(String fromUser) {
|
||||||
|
if (fromUser != null && fromUser.startsWith("wm")) {
|
||||||
|
return "EXTERNAL";
|
||||||
|
}
|
||||||
|
return "INTERNAL";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateSessionId(ArchiveMessage message) {
|
||||||
|
if (message.getRoomid() != null && !message.getRoomid().isEmpty()) {
|
||||||
|
return corpId + "_" + message.getRoomid();
|
||||||
|
}
|
||||||
|
return corpId + "_" + message.getFromUser() + "_" + message.getToUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveAndNotify(List<ArchiveMessage> messages) {
|
||||||
|
for (ArchiveMessage message : messages) {
|
||||||
|
try {
|
||||||
|
int count = archiveMessageMapper.countByMsgId(message.getMsgid(), message.getCorpId());
|
||||||
|
if (count > 0) {
|
||||||
|
log.debug("消息已存在,跳过: msgid={}", message.getMsgid());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveMessageMapper.insert(message);
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(
|
||||||
|
RabbitConfig.ARCHIVE_EXCHANGE,
|
||||||
|
RabbitConfig.ROUTING_KEY_NEW_MESSAGE,
|
||||||
|
JsonUtils.toJson(message)
|
||||||
|
);
|
||||||
|
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
LAST_SEQ_KEY + corpId,
|
||||||
|
message.getSeq(),
|
||||||
|
7, TimeUnit.DAYS
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("保存消息失败: msgid={}, error={}", message.getMsgid(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastSeq() {
|
||||||
|
Object seq = redisTemplate.opsForValue().get(LAST_SEQ_KEY + corpId);
|
||||||
|
if (seq != null) {
|
||||||
|
return ((Number) seq).longValue();
|
||||||
|
}
|
||||||
|
Long maxSeq = archiveMessageMapper.selectMaxSeq(corpId);
|
||||||
|
return maxSeq != null ? maxSeq : 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.artedu.archive.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.*;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSA解密工具类
|
||||||
|
* 用于解密企微会话存档的encrypt_random_key
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class RsaDecryptUtil {
|
||||||
|
|
||||||
|
private static final String ALGORITHM = "RSA";
|
||||||
|
private static final String TRANSFORMATION = "RSA/ECB/PKCS1Padding";
|
||||||
|
|
||||||
|
public PrivateKey loadPrivateKey(String base64PrivateKey) throws Exception {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64PrivateKey);
|
||||||
|
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
|
||||||
|
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
|
||||||
|
return keyFactory.generatePrivate(spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String decrypt(String encryptedData, String base64PrivateKey) {
|
||||||
|
try {
|
||||||
|
PrivateKey privateKey = loadPrivateKey(base64PrivateKey);
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||||
|
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
|
||||||
|
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
|
||||||
|
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("RSA解密失败: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void generateKeyPair() throws Exception {
|
||||||
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM);
|
||||||
|
keyGen.initialize(2048);
|
||||||
|
KeyPair keyPair = keyGen.generateKeyPair();
|
||||||
|
|
||||||
|
String publicKey = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||||||
|
String privateKey = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||||||
|
|
||||||
|
log.info("=== RSA公钥(上传到企微后台) ===");
|
||||||
|
log.info(publicKey);
|
||||||
|
log.info("=== RSA私钥(安全保存) ===");
|
||||||
|
log.info(privateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
package com.artedu.auth.controller;
|
||||||
|
|
||||||
|
import com.artedu.auth.entity.Staff;
|
||||||
|
import com.artedu.auth.service.JwtService;
|
||||||
|
import com.artedu.auth.service.WeComOAuthService;
|
||||||
|
import com.artedu.common.result.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证授权控制器
|
||||||
|
* 处理企微OAuth登录和Token刷新
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WeComOAuthService weComOAuthService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Result<Map<String, Object>> login(@RequestParam("code") String code) {
|
||||||
|
log.info("企微OAuth登录, code={}", code);
|
||||||
|
|
||||||
|
Staff staff = weComOAuthService.handleLogin(code);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(
|
||||||
|
staff.getStaffId(),
|
||||||
|
staff.getCorpId(),
|
||||||
|
staff.getName(),
|
||||||
|
staff.getRole()
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("token", token);
|
||||||
|
result.put("tokenType", "Bearer");
|
||||||
|
result.put("expiresIn", 86400);
|
||||||
|
result.put("userId", staff.getStaffId());
|
||||||
|
result.put("userName", staff.getName());
|
||||||
|
result.put("avatar", staff.getAvatar());
|
||||||
|
result.put("role", staff.getRole());
|
||||||
|
|
||||||
|
log.info("登录成功: userId={}, name={}", staff.getStaffId(), staff.getName());
|
||||||
|
return Result.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public Result<Map<String, Object>> refreshToken(@RequestHeader("Authorization") String authHeader) {
|
||||||
|
String oldToken = authHeader.replace("Bearer ", "");
|
||||||
|
|
||||||
|
if (jwtService.isTokenExpired(oldToken)) {
|
||||||
|
return Result.fail("Token已过期,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
io.jsonwebtoken.Claims claims = jwtService.validateToken(oldToken);
|
||||||
|
String userId = claims.get("userId", String.class);
|
||||||
|
String corpId = claims.get("corpId", String.class);
|
||||||
|
String userName = claims.get("userName", String.class);
|
||||||
|
String role = claims.get("role", String.class);
|
||||||
|
|
||||||
|
String newToken = jwtService.generateToken(userId, corpId, userName, role);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("token", newToken);
|
||||||
|
result.put("tokenType", "Bearer");
|
||||||
|
result.put("expiresIn", 86400);
|
||||||
|
|
||||||
|
return Result.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/info")
|
||||||
|
public Result<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
|
||||||
|
String token = authHeader.replace("Bearer ", "");
|
||||||
|
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("userId", claims.get("userId"));
|
||||||
|
result.put("userName", claims.get("userName"));
|
||||||
|
result.put("corpId", claims.get("corpId"));
|
||||||
|
result.put("role", claims.get("role"));
|
||||||
|
|
||||||
|
return Result.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/signature")
|
||||||
|
public Result<Map<String, Object>> getJsSdkSignature(
|
||||||
|
@RequestParam("url") String url,
|
||||||
|
@RequestHeader("Authorization") String authHeader) {
|
||||||
|
|
||||||
|
String token = authHeader.replace("Bearer ", "");
|
||||||
|
io.jsonwebtoken.Claims claims = jwtService.validateToken(token);
|
||||||
|
String corpId = claims.get("corpId", String.class);
|
||||||
|
|
||||||
|
String ticket = weComOAuthService.getJsApiTicket();
|
||||||
|
String nonceStr = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
|
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
|
||||||
|
|
||||||
|
String signature = generateSignature(ticket, nonceStr, timestamp, url);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("corpId", corpId);
|
||||||
|
result.put("agentId", agentId);
|
||||||
|
result.put("nonceStr", nonceStr);
|
||||||
|
result.put("timestamp", timestamp);
|
||||||
|
result.put("signature", signature);
|
||||||
|
|
||||||
|
return Result.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Value("${wecom.agent-id}")
|
||||||
|
private String agentId;
|
||||||
|
|
||||||
|
private String generateSignature(String ticket, String nonceStr, String timestamp, String url) {
|
||||||
|
String string1 = "jsapi_ticket=" + ticket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url;
|
||||||
|
try {
|
||||||
|
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA1");
|
||||||
|
digest.update(string1.getBytes());
|
||||||
|
byte[] messageDigest = digest.digest();
|
||||||
|
StringBuilder hexString = new StringBuilder();
|
||||||
|
for (byte b : messageDigest) {
|
||||||
|
String shaHex = Integer.toHexString(b & 0xFF);
|
||||||
|
if (shaHex.length() < 2) {
|
||||||
|
hexString.append(0);
|
||||||
|
}
|
||||||
|
hexString.append(shaHex);
|
||||||
|
}
|
||||||
|
return hexString.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("生成签名失败", e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.artedu.auth.entity;
|
||||||
|
|
||||||
|
import com.artedu.common.entity.BaseEntity;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工实体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("staffs")
|
||||||
|
public class Staff extends BaseEntity {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String staffId;
|
||||||
|
private String corpId;
|
||||||
|
private String name;
|
||||||
|
private String avatar;
|
||||||
|
private String department;
|
||||||
|
private String role;
|
||||||
|
private String status;
|
||||||
|
private String pushSettings;
|
||||||
|
private LocalDateTime lastLoginTime;
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.artedu.auth.mapper;
|
||||||
|
|
||||||
|
import com.artedu.auth.entity.Staff;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 员工数据访问层
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface StaffMapper extends BaseMapper<Staff> {
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package com.artedu.auth.service;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Token服务
|
||||||
|
* 负责生成和验证JWT Token
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
@Value("${jwt.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Value("${jwt.expiration:86400}")
|
||||||
|
private Long expiration;
|
||||||
|
|
||||||
|
private SecretKey getSecretKey() {
|
||||||
|
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String userId, String corpId, String userName, String role) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
claims.put("userId", userId);
|
||||||
|
claims.put("corpId", corpId);
|
||||||
|
claims.put("userName", userName);
|
||||||
|
claims.put("role", role);
|
||||||
|
claims.put("type", "access");
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + expiration * 1000);
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setClaims(claims)
|
||||||
|
.setSubject(userId)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Claims validateToken(String token) {
|
||||||
|
return Jwts.parserBuilder()
|
||||||
|
.setSigningKey(getSecretKey())
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserIdFromToken(String token) {
|
||||||
|
Claims claims = validateToken(token);
|
||||||
|
return claims.get("userId", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenExpired(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = validateToken(token);
|
||||||
|
return claims.getExpiration().before(new Date());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getExpirationSeconds(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = validateToken(token);
|
||||||
|
long remain = claims.getExpiration().getTime() - System.currentTimeMillis();
|
||||||
|
return Math.max(remain / 1000, 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
package com.artedu.auth.service;
|
||||||
|
|
||||||
|
import com.artedu.auth.entity.Staff;
|
||||||
|
import com.artedu.auth.mapper.StaffMapper;
|
||||||
|
import com.artedu.common.exception.BusinessException;
|
||||||
|
import com.artedu.common.util.JsonUtils;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信OAuth2认证服务
|
||||||
|
* 处理企微登录流程,获取用户信息
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class WeComOAuthService {
|
||||||
|
|
||||||
|
@Value("${wecom.corp-id}")
|
||||||
|
private String corpId;
|
||||||
|
|
||||||
|
@Value("${wecom.agent-id}")
|
||||||
|
private String agentId;
|
||||||
|
|
||||||
|
@Value("${wecom.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StaffMapper staffMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_KEY = "wecom:access_token";
|
||||||
|
private static final String JSAPI_TICKET_KEY = "wecom:jsapi_ticket";
|
||||||
|
private static final String USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token={accessToken}&code={code}";
|
||||||
|
private static final String USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={accessToken}&userid={userId}";
|
||||||
|
|
||||||
|
public String getAccessToken() {
|
||||||
|
String token = (String) redisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
|
||||||
|
if (token != null) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + corpId + "&corpsecret=" + secret;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("获取AccessToken失败: {}", response.getBody());
|
||||||
|
throw new BusinessException("获取企微AccessToken失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
token = (String) result.get("access_token");
|
||||||
|
redisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, token, 7000, TimeUnit.SECONDS);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getJsApiTicket() {
|
||||||
|
String ticket = (String) redisTemplate.opsForValue().get(JSAPI_TICKET_KEY);
|
||||||
|
if (ticket != null) {
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
String url = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=" + accessToken;
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("获取JsApiTicket失败: {}", response.getBody());
|
||||||
|
throw new BusinessException("获取企微JsApiTicket失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket = (String) result.get("ticket");
|
||||||
|
redisTemplate.opsForValue().set(JSAPI_TICKET_KEY, ticket, 7000, TimeUnit.SECONDS);
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserIdByCode(String code) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
String url = USER_INFO_URL.replace("{accessToken}", accessToken).replace("{code}", code);
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("获取用户信息失败: {}", response.getBody());
|
||||||
|
throw new BusinessException("OAuth认证失败,请重新登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
String userId = (String) result.get("UserId");
|
||||||
|
if (userId == null) {
|
||||||
|
throw new BusinessException("获取用户ID失败,可能不在企业通讯录中");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getUserDetail(String userId) {
|
||||||
|
String accessToken = getAccessToken();
|
||||||
|
String url = USER_DETAIL_URL.replace("{accessToken}", accessToken).replace("{userId}", userId);
|
||||||
|
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
||||||
|
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
|
||||||
|
|
||||||
|
if (result == null || !Integer.valueOf(0).equals(result.get("errcode"))) {
|
||||||
|
log.error("获取用户详情失败: {}", response.getBody());
|
||||||
|
throw new BusinessException("获取用户信息失败");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Staff handleLogin(String code) {
|
||||||
|
String userId = getUserIdByCode(code);
|
||||||
|
Map<String, Object> userDetail = getUserDetail(userId);
|
||||||
|
|
||||||
|
Staff staff = staffMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<Staff>()
|
||||||
|
.eq(Staff::getStaffId, userId)
|
||||||
|
.eq(Staff::getCorpId, corpId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (staff == null) {
|
||||||
|
staff = new Staff();
|
||||||
|
staff.setStaffId(userId);
|
||||||
|
staff.setCorpId(corpId);
|
||||||
|
staff.setName((String) userDetail.get("name"));
|
||||||
|
staff.setAvatar((String) userDetail.get("avatar"));
|
||||||
|
staff.setDepartment(formatDepartment(userDetail.get("department")));
|
||||||
|
staff.setRole("ADVISOR");
|
||||||
|
staff.setStatus("ACTIVE");
|
||||||
|
staffMapper.insert(staff);
|
||||||
|
log.info("新用户注册: userId={}, name={}", userId, staff.getName());
|
||||||
|
} else {
|
||||||
|
staff.setName((String) userDetail.get("name"));
|
||||||
|
staff.setAvatar((String) userDetail.get("avatar"));
|
||||||
|
staffMapper.updateById(staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDepartment(Object department) {
|
||||||
|
if (department == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return JsonUtils.toJson(department);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user