commit 4c18e13733b1f1903b0d81d2b23b3f1b033598f7 Author: 赵新乐 Date: Fri May 8 11:41:00 2026 +0800 初始化提交 Signed-off-by: 赵新乐 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2686844 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +.idea + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f14bc47 --- /dev/null +++ b/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + com.f8q8 + qyWechatArchive + 1.0 + + + 21 + 21 + UTF-8 + 3.5.16 + 0.12.6 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + com.mysql + mysql-connector-j + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.f8q8.qywechatarchive.QyWechatArchiveApplication + + + + + \ No newline at end of file diff --git a/src/main/java/com/f8q8/qywechatarchive/QyWechatArchiveApplication.java b/src/main/java/com/f8q8/qywechatarchive/QyWechatArchiveApplication.java new file mode 100644 index 0000000..db7b9cf --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/QyWechatArchiveApplication.java @@ -0,0 +1,12 @@ +package com.f8q8.qywechatarchive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class QyWechatArchiveApplication { + + public static void main(String[] args) { + SpringApplication.run(QyWechatArchiveApplication.class, args); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiTokenDao.java b/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiTokenDao.java new file mode 100644 index 0000000..72f46c0 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiTokenDao.java @@ -0,0 +1,9 @@ +package com.f8q8.qywechatarchive.common.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.f8q8.qywechatarchive.common.entity.SysApiToken; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysApiTokenDao extends BaseMapper { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiUserDao.java b/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiUserDao.java new file mode 100644 index 0000000..360d5de --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/dao/SysApiUserDao.java @@ -0,0 +1,9 @@ +package com.f8q8.qywechatarchive.common.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.f8q8.qywechatarchive.common.entity.SysApiUser; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysApiUserDao extends BaseMapper { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/dao/WxChatRecordDao.java b/src/main/java/com/f8q8/qywechatarchive/common/dao/WxChatRecordDao.java new file mode 100644 index 0000000..ae28a0b --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/dao/WxChatRecordDao.java @@ -0,0 +1,9 @@ +package com.f8q8.qywechatarchive.common.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.f8q8.qywechatarchive.common.entity.WxChatRecord; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface WxChatRecordDao extends BaseMapper { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/dao/WxSyncStateDao.java b/src/main/java/com/f8q8/qywechatarchive/common/dao/WxSyncStateDao.java new file mode 100644 index 0000000..24ab552 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/dao/WxSyncStateDao.java @@ -0,0 +1,9 @@ +package com.f8q8.qywechatarchive.common.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.f8q8.qywechatarchive.common.entity.WxSyncState; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface WxSyncStateDao extends BaseMapper { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiToken.java b/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiToken.java new file mode 100644 index 0000000..a8b236e --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiToken.java @@ -0,0 +1,20 @@ +package com.f8q8.qywechatarchive.common.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.f8q8.qywechatarchive.core.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +@EqualsAndHashCode(callSuper = true) +@Data +@TableName(value = "sys_api_token") +public class SysApiToken extends BaseEntity { + + private String userId; + private String accessToken; + private String refreshToken; + private Date expireTime; + private Integer status; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiUser.java b/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiUser.java new file mode 100644 index 0000000..3acb563 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/entity/SysApiUser.java @@ -0,0 +1,18 @@ +package com.f8q8.qywechatarchive.common.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.f8q8.qywechatarchive.core.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +@TableName(value = "sys_api_user") +public class SysApiUser extends BaseEntity { + + private String username; + private String password; + private String salt; + private String realName; + private Integer status; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/entity/WxChatRecord.java b/src/main/java/com/f8q8/qywechatarchive/common/entity/WxChatRecord.java new file mode 100644 index 0000000..55ae495 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/entity/WxChatRecord.java @@ -0,0 +1,27 @@ +package com.f8q8.qywechatarchive.common.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.f8q8.qywechatarchive.core.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@EqualsAndHashCode(callSuper = true) +@Data +@TableName(value = "wx_chat_record") +public class WxChatRecord extends BaseEntity { + + private String msgId; + private Long seq; + private String action; + private String fromType; + private String fromId; + private String roomId; + private String toList; + private String msgType; + private String content; + private LocalDateTime msgTime; + private LocalDateTime syncTime; + private String corpId; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/entity/WxSyncState.java b/src/main/java/com/f8q8/qywechatarchive/common/entity/WxSyncState.java new file mode 100644 index 0000000..c02e3df --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/entity/WxSyncState.java @@ -0,0 +1,22 @@ +package com.f8q8.qywechatarchive.common.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.f8q8.qywechatarchive.core.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@EqualsAndHashCode(callSuper = true) +@Data +@TableName(value = "wx_sync_state") +public class WxSyncState extends BaseEntity { + + private String corpId; + private String syncType; + private String nextCursor; + private LocalDateTime lastMsgTime; + private Long totalCount; + private Integer syncStatus; + private String errorMsg; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalException.java b/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalException.java new file mode 100644 index 0000000..b95543a --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalException.java @@ -0,0 +1,4 @@ +package com.f8q8.qywechatarchive.common.exception; + +public class GlobalException extends RuntimeException { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalExceptionHandler.java b/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..db094fa --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,18 @@ +package com.f8q8.qywechatarchive.common.exception; + +import com.f8q8.qywechatarchive.core.utils.Result; +import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(value = GlobalException.class) + public Result handleGlobalException(GlobalException e) { + log.error(e.getMessage(), e); + return ResultUtils.error(e.getMessage()); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiTokenService.java b/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiTokenService.java new file mode 100644 index 0000000..af731fa --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiTokenService.java @@ -0,0 +1,7 @@ +package com.f8q8.qywechatarchive.common.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.f8q8.qywechatarchive.common.entity.SysApiToken; + +public interface SysApiTokenService extends IService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiUserService.java b/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiUserService.java new file mode 100644 index 0000000..36392a9 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/SysApiUserService.java @@ -0,0 +1,7 @@ +package com.f8q8.qywechatarchive.common.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.f8q8.qywechatarchive.common.entity.SysApiUser; + +public interface SysApiUserService extends IService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/WxChatRecordService.java b/src/main/java/com/f8q8/qywechatarchive/common/service/WxChatRecordService.java new file mode 100644 index 0000000..8f49b93 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/WxChatRecordService.java @@ -0,0 +1,7 @@ +package com.f8q8.qywechatarchive.common.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.f8q8.qywechatarchive.common.entity.WxChatRecord; + +public interface WxChatRecordService extends IService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/WxSyncStateService.java b/src/main/java/com/f8q8/qywechatarchive/common/service/WxSyncStateService.java new file mode 100644 index 0000000..711a2d9 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/WxSyncStateService.java @@ -0,0 +1,7 @@ +package com.f8q8.qywechatarchive.common.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.f8q8.qywechatarchive.common.entity.WxSyncState; + +public interface WxSyncStateService extends IService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiTokenServiceImpl.java b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiTokenServiceImpl.java new file mode 100644 index 0000000..320e418 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiTokenServiceImpl.java @@ -0,0 +1,13 @@ +package com.f8q8.qywechatarchive.common.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.f8q8.qywechatarchive.common.dao.SysApiTokenDao; +import com.f8q8.qywechatarchive.common.entity.SysApiToken; +import com.f8q8.qywechatarchive.common.service.SysApiTokenService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SysApiTokenServiceImpl extends ServiceImpl implements SysApiTokenService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiUserServiceImpl.java b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiUserServiceImpl.java new file mode 100644 index 0000000..827c064 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/SysApiUserServiceImpl.java @@ -0,0 +1,13 @@ +package com.f8q8.qywechatarchive.common.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.f8q8.qywechatarchive.common.dao.SysApiUserDao; +import com.f8q8.qywechatarchive.common.entity.SysApiUser; +import com.f8q8.qywechatarchive.common.service.SysApiUserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SysApiUserServiceImpl extends ServiceImpl implements SysApiUserService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxChatRecordServiceImpl.java b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxChatRecordServiceImpl.java new file mode 100644 index 0000000..a2148a7 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxChatRecordServiceImpl.java @@ -0,0 +1,13 @@ +package com.f8q8.qywechatarchive.common.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.f8q8.qywechatarchive.common.dao.WxChatRecordDao; +import com.f8q8.qywechatarchive.common.entity.WxChatRecord; +import com.f8q8.qywechatarchive.common.service.WxChatRecordService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class WxChatRecordServiceImpl extends ServiceImpl implements WxChatRecordService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxSyncStateServiceImpl.java b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxSyncStateServiceImpl.java new file mode 100644 index 0000000..ed65909 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/common/service/impl/WxSyncStateServiceImpl.java @@ -0,0 +1,13 @@ +package com.f8q8.qywechatarchive.common.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.f8q8.qywechatarchive.common.dao.WxSyncStateDao; +import com.f8q8.qywechatarchive.common.entity.WxSyncState; +import com.f8q8.qywechatarchive.common.service.WxSyncStateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class WxSyncStateServiceImpl extends ServiceImpl implements WxSyncStateService { +} diff --git a/src/main/java/com/f8q8/qywechatarchive/core/entity/BaseEntity.java b/src/main/java/com/f8q8/qywechatarchive/core/entity/BaseEntity.java new file mode 100644 index 0000000..1ea3e1e --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/core/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package com.f8q8.qywechatarchive.core.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Data +public abstract class BaseEntity implements Serializable { + @TableId + private String id; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + @TableField(fill = FieldFill.INSERT) + private String createByName; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateByName; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + private String remark; + @Version + private Long version; + @TableLogic + private Integer delFlag; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/core/utils/Result.java b/src/main/java/com/f8q8/qywechatarchive/core/utils/Result.java new file mode 100644 index 0000000..5b948ac --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/core/utils/Result.java @@ -0,0 +1,15 @@ +package com.f8q8.qywechatarchive.core.utils; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Result { + + private Integer code; + private String message; + private T data; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/core/utils/ResultUtils.java b/src/main/java/com/f8q8/qywechatarchive/core/utils/ResultUtils.java new file mode 100644 index 0000000..b76722a --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/core/utils/ResultUtils.java @@ -0,0 +1,41 @@ +package com.f8q8.qywechatarchive.core.utils; + +public class ResultUtils { + + private static final Integer SUCCESS_CODE = 200; + private static final String SUCCESS_MESSAGE = "成功"; + + private static final Integer ERROR_CODE = 500; + + public static Result success(Integer code, String message, T data) { + return new Result<>(code, message, data); + } + + public static Result success(String message, T data) { + return success(SUCCESS_CODE, message, data); + } + + public static Result success(T data) { + return success(SUCCESS_CODE, SUCCESS_MESSAGE, data); + } + + public static Result success() { + return success(SUCCESS_CODE, SUCCESS_MESSAGE, null); + } + + public static Result error(Integer code, String message, T data) { + return new Result<>(code, message, data); + } + + public static Result error(String message, T data) { + return error(ERROR_CODE, message, data); + } + + public static Result error(String message) { + return error(ERROR_CODE, message, null); + } + + public static Result error(Integer code, String message) { + return error(code, message, null); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java new file mode 100644 index 0000000..283fb51 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/AuthController.java @@ -0,0 +1,73 @@ +package com.f8q8.qywechatarchive.modules.api.action; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.f8q8.qywechatarchive.common.entity.SysApiUser; +import com.f8q8.qywechatarchive.common.service.SysApiUserService; +import com.f8q8.qywechatarchive.core.utils.Result; +import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import com.f8q8.qywechatarchive.modules.api.security.JwtTokenProvider; +import com.f8q8.qywechatarchive.modules.api.vo.LoginRequest; +import com.f8q8.qywechatarchive.modules.api.vo.LoginResponse; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(value = "api/v1/auth") +public class AuthController { + + @Resource + SysApiUserService sysApiUserService; + @Resource + PasswordEncoder passwordEncoder; + @Resource + JwtTokenProvider jwtTokenProvider; + + /** + * 登录接口 — 用户名密码换取JWT Token + */ + @PostMapping("login") + public Result login(@Valid @RequestBody LoginRequest request) { + // 1. 查询用户 + SysApiUser user = sysApiUserService.getOne( + new LambdaQueryWrapper() + .eq(SysApiUser::getUsername, request.getUsername()) + ); + + // 2. 校验用户状态和密码 + if (user == null || user.getStatus() != 1) { + log.warn("登录失败 - 用户不存在或已禁用: {}", request.getUsername()); + return ResultUtils.error(400, "用户名或密码错误"); + } + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.warn("登录失败 - 密码错误: {}", request.getUsername()); + return ResultUtils.error(400, "用户名或密码错误"); + } + + // 3. 生成Token + String accessToken = jwtTokenProvider.generateAccessToken(user.getUsername()); + String refreshToken = jwtTokenProvider.generateRefreshToken(user.getUsername()); + + log.info("用户登录成功: {}", user.getUsername()); + + // 4. 返回Token信息 + + LoginResponse resp = LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(jwtTokenProvider.getAccessExpiration()) + .username(user.getUsername()) + .build(); + + return ResultUtils.success(resp); + } + +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java new file mode 100644 index 0000000..cb01c2c --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/CallbackController.java @@ -0,0 +1,21 @@ +package com.f8q8.qywechatarchive.modules.api.action; + +import com.f8q8.qywechatarchive.core.utils.Result; +import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(value = "api/v1/callback") +public class CallbackController { + + @PostMapping(value = "notify") + public Result notify(@RequestBody String data) { + log.info("企业微信回调数据:{}", data); + return ResultUtils.success(); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/action/ChatRecordController.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/ChatRecordController.java new file mode 100644 index 0000000..a9564a7 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/action/ChatRecordController.java @@ -0,0 +1,69 @@ +package com.f8q8.qywechatarchive.modules.api.action; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.f8q8.qywechatarchive.common.entity.WxChatRecord; +import com.f8q8.qywechatarchive.common.service.WxChatRecordService; +import com.f8q8.qywechatarchive.core.utils.Result; +import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import com.f8q8.qywechatarchive.modules.api.vo.ChatRecordQuery; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(value = "api/v1/chat-records") +public class ChatRecordController { + @Resource + WxChatRecordService wxChatRecordService; + + /** + * 分页查询会话记录 + */ + @GetMapping + public Result> queryRecords(@Valid ChatRecordQuery query) { + Page page = new Page<>(query.getPageNum(), query.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + // 按发送方查询 + wrapper.eq(StringUtils.hasText(query.getFromId()), + WxChatRecord::getFromId, query.getFromId()); + // 按群聊ID查询 + wrapper.eq(StringUtils.hasText(query.getRoomId()), + WxChatRecord::getRoomId, query.getRoomId()); + // 按消息类型查询 + wrapper.eq(StringUtils.hasText(query.getMsgType()), + WxChatRecord::getMsgType, query.getMsgType()); + // 按时间范围查询 + wrapper.ge(query.getStartTime() != null, + WxChatRecord::getMsgTime, query.getStartTime()); + wrapper.le(query.getEndTime() != null, + WxChatRecord::getMsgTime, query.getEndTime()); + // 核心索引按时间倒序 + wrapper.orderByDesc(WxChatRecord::getMsgTime); + + Page result = wxChatRecordService.page(page, wrapper); + return ResultUtils.success(result); + } + + /** + * 根据消息ID查询单条记录 + */ + @GetMapping("{msgId}") + public Result getByMsgId(@PathVariable String msgId) { + WxChatRecord record = wxChatRecordService.getOne( + new LambdaQueryWrapper() + .eq(WxChatRecord::getMsgId, msgId)); + if (record == null) { + return ResultUtils.error(404, "消息不存在"); + } + return ResultUtils.success(record); + } + +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/client/NativeLibraryLoader.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/client/NativeLibraryLoader.java new file mode 100644 index 0000000..0619333 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/client/NativeLibraryLoader.java @@ -0,0 +1,77 @@ +package com.f8q8.qywechatarchive.modules.api.client; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +@Slf4j +public class NativeLibraryLoader { + + private static final String TMP_DIR_NAME = "qyWechatArchive"; + private static boolean loaded = false; + + public static Path loadLibraryMulti(String classpathResource) { + // 1. 确定临时目录(/tmp/ww-audit-libs) + String tmpDir = System.getProperty("java.io.tmpdir"); + Path extractDir = Paths.get(tmpDir, TMP_DIR_NAME); + try { + Files.createDirectories(extractDir); + } catch (IOException e) { + throw new RuntimeException("无法创建临时库目录: " + extractDir, e); + } + + // 2. 提取 .so 文件(只提取资源文件名部分) + String libName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1); + Path targetFile = extractDir.resolve(libName); + + // 如果目标文件已存在,检查是否相同;同一 JVM 进程内文件被锁定,无需重复提取 + if (!Files.exists(targetFile)) { + try (InputStream in = NativeLibraryLoader.class.getClassLoader() + .getResourceAsStream(classpathResource)) { + if (in == null) { + throw new RuntimeException( + "classpath 中找不到资源: " + classpathResource); + } + Files.copy(in, targetFile, StandardCopyOption.REPLACE_EXISTING); + log.info("已提取 native 库到: {}", targetFile.toAbsolutePath()); + } catch (IOException e) { + throw new RuntimeException("提取 native 库失败: " + classpathResource, e); + } + } + + // 3. 设置可执行权限(Linux 环境) + if (!System.getProperty("os.name").toLowerCase().contains("win")) { + try { + targetFile.toFile().setExecutable(true); + targetFile.toFile().setReadable(true); + } catch (SecurityException e) { + log.warn("无法设置可执行权限,将尝试直接加载: {}", targetFile.toAbsolutePath()); + } + } + + // 4. 使用 System.load 加载 + System.load(targetFile.toAbsolutePath().toString()); + return targetFile.toAbsolutePath(); + } + + /** + * 从 classpath 加载指定的 .so 文件 + * + * @param classpathResource .so 文件在 classpath 中的路径, + * 例如 "native/linux-x86-64/libWeWorkFinanceSdk_Java.so" + */ + public static synchronized void loadLibrary(String classpathResource) { + if (loaded) { + log.info("Native 库已加载,跳过重复加载"); + return; + } + Path path = loadLibraryMulti(classpathResource); + loaded = true; + log.info("Native 库加载成功: {}", path); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/client/WeComSdkClient.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/client/WeComSdkClient.java new file mode 100644 index 0000000..bf35adb --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/client/WeComSdkClient.java @@ -0,0 +1,92 @@ +package com.f8q8.qywechatarchive.modules.api.client; + +import com.tencent.wework.Finance; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Getter +@Component +public class WeComSdkClient { + + private long sdk; + + @Value("${wecom.corp-id}") + String corpId; + @Value("${wecom.secret}") + String secret; + + static { + final String os = System.getProperty("os.name").toLowerCase(); + + if (os.contains("linux")) { + NativeLibraryLoader.loadLibrary("native/linux/libWeWorkFinanceSdk_Java.so"); + } else if (os.contains("win")) { + NativeLibraryLoader.loadLibraryMulti("native/win/libcrypto-3-x64.dll"); + NativeLibraryLoader.loadLibraryMulti("native/win/libcurl-x64.dll"); + NativeLibraryLoader.loadLibraryMulti("native/win/WeWorkFinanceSdk.dll"); + // 加载指定路径下文件 + } else { + throw new UnsupportedOperationException("不支持的操作系统:" + os); + } + } + + @PostConstruct + public void init() { + sdk = Finance.NewSdk(); + int ret = Finance.Init(sdk, corpId, secret); + if (ret != 0) { + throw new RuntimeException("WeWork SDK 初始化失败,错误码:" + ret); + } + log.info("企业微信会话存档SDK初始化完成, sdk={}", sdk); + } + + @PreDestroy + public void destroy() { + if (sdk != 0) { + Finance.DestroySdk(sdk); + log.info("企业微信会话存档SDK已销毁"); + } + } + + /** + * 获取聊天记录(每次调用返回0或1的Slice,需循环拉取) + * + * @param seq 起始序号 + * @param limit 最大条数 + * @param proxy 代理(可为空) + * @param paswd 代理密码(可为空) + * @param timeout 超时秒数 + * @return 切片数据(需调用 Finance.GetContentFromSlice 解析) + */ + public long getChatData(long seq, int limit, String proxy, String paswd, int timeout) { + return Finance.GetChatData(sdk, seq, limit, proxy, paswd, timeout, null); + } + + public String getContentFromSlice(long slice) { + return Finance.GetContentFromSlice(slice); + } + + public void freeSlice(long slice) { + Finance.FreeSlice(slice); + } + + /** + * 解密消息内容(RSA解密后的AES密钥 + 密文) + */ + public String decryptChatData(String encryptRandomKey, String encryptChatMsg) { + long msgPtr = Finance.NewSlice(); + int ret = Finance.DecryptData(sdk, encryptRandomKey, encryptChatMsg, msgPtr); + if (ret != 0) { + log.error("消息解密失败, ret={}", ret); + return null; + } + String decrypted = Finance.GetContentFromSlice(msgPtr); + Finance.FreeSlice(msgPtr); + return decrypted; + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java new file mode 100644 index 0000000..ea80cd6 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package com.f8q8.qywechatarchive.modules.api.config; + +import com.f8q8.qywechatarchive.modules.api.security.JwtAuthenticationEntryPoint; +import com.f8q8.qywechatarchive.modules.api.security.JwtAuthenticationFilter; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Resource + JwtAuthenticationFilter jwtAuthenticationFilter; + @Resource + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + /** + * 密码编码器 + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * 安全过滤链 — Spring Boot 4 推荐链式配置 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // 禁用CSRF(API接口场景) + .csrf(AbstractHttpConfigurer::disable) + // 无状态会话 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 异常处理 — Token过期/无效时返回401 + .exceptionHandling(ex -> ex + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) + // 路由权限配置 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/login").permitAll() + .requestMatchers("/actuator/health").permitAll() // 健康检查放行 + .requestMatchers("/api/v1/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated()) + // JWT过滤器 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..9d7d516 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,36 @@ +package com.f8q8.qywechatarchive.modules.api.security; + +import com.f8q8.qywechatarchive.core.utils.Result; +import com.f8q8.qywechatarchive.core.utils.ResultUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; + +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Resource + ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + Result result = ResultUtils.error(401, "Token已过期或无效,请重新登录"); + objectMapper.writeValue(response.getWriter(), result); + + log.warn("认证失败 - URI: {}, 原因: {}", request.getRequestURI(), authException.getMessage()); + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..0f90d59 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtAuthenticationFilter.java @@ -0,0 +1,61 @@ +package com.f8q8.qywechatarchive.modules.api.security; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Resource + JwtTokenProvider jwtTokenProvider; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = extractTokenFromHeader(request); + + if (StringUtils.hasText(token)) { + try { + if (jwtTokenProvider.validateToken(token)) { + String username = jwtTokenProvider.extractUsername(token); + // 设置认证信息到Spring Security上下文 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + username, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 可选:将剩余有效时间放入响应头 + long remaining = jwtTokenProvider.getRemainingTime(token); + response.setHeader("X-Token-Remaining", String.valueOf(remaining)); + } + } catch (ExpiredJwtException e) { + // 交由 AuthenticationEntryPoint 统一返回401 + SecurityContextHolder.clearContext(); + log.debug("Token已过期: {}", e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } + + private String extractTokenFromHeader(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java new file mode 100644 index 0000000..c21967d --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/security/JwtTokenProvider.java @@ -0,0 +1,100 @@ +package com.f8q8.qywechatarchive.modules.api.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Getter +@Component +public class JwtTokenProvider { + + @Value("${jwt.access-expiration}") + long accessExpiration; + @Value("${jwt.secret}") + String secret; + @Value("${jwt.refresh-expiration}") + long refreshExpiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 生成token + * + * @param username 用户名 + * @return + */ + public String generateAccessToken(String username) { + return buildToken(username, accessExpiration * 1000); + } + + public String generateRefreshToken(String username) { + return buildToken(username, refreshExpiration * 1000); + } + + private String buildToken(String subject, long expirationMs) { + Map claims = new HashMap<>(); + claims.put("type", expirationMs == accessExpiration * 1000 ? "ACCESS" : "REFRESH"); + + return Jwts.builder() + .claims(claims) + .subject(subject) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(getSigningKey()) + .compact(); + } + + /** + * 从Token中提取用户名 + */ + public String extractUsername(String token) { + return extractAllClaims(token).getSubject(); + } + + /** + * 校验Token是否有效 + */ + public boolean validateToken(String token) { + try { + extractAllClaims(token); + return true; + } catch (ExpiredJwtException e) { + // Token 过期 — 外层捕获后返回401 + throw e; + } catch (JwtException | IllegalArgumentException e) { + // Token 格式错误或签名无效 + return false; + } + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + /** + * 获取Token剩余有效时间(秒) + */ + public long getRemainingTime(String token) { + Claims claims = extractAllClaims(token); + return (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000; + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/service/WeComSyncService.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/service/WeComSyncService.java new file mode 100644 index 0000000..acaf57e --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/service/WeComSyncService.java @@ -0,0 +1,11 @@ +package com.f8q8.qywechatarchive.modules.api.service; + +public interface WeComSyncService { + /** + * 执行一次会话消息同步 + * + * @param corpId 企业ID(支持多企业扩展) + * @return 同步条数 + */ + int syncMessages(String corpId); +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/service/impl/WeComSyncServiceImpl.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/service/impl/WeComSyncServiceImpl.java new file mode 100644 index 0000000..9f758fb --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/service/impl/WeComSyncServiceImpl.java @@ -0,0 +1,237 @@ +package com.f8q8.qywechatarchive.modules.api.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.f8q8.qywechatarchive.common.entity.WxChatRecord; +import com.f8q8.qywechatarchive.common.entity.WxSyncState; +import com.f8q8.qywechatarchive.common.service.WxChatRecordService; +import com.f8q8.qywechatarchive.common.service.WxSyncStateService; +import com.f8q8.qywechatarchive.modules.api.client.WeComSdkClient; +import com.f8q8.qywechatarchive.modules.api.service.WeComSyncService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; + +@Slf4j +@Service +public class WeComSyncServiceImpl implements WeComSyncService { + + @Resource + WxSyncStateService wxSyncStateService; + @Resource + WeComSdkClient weComSdkClient; + @Resource + WxChatRecordService wxChatRecordService; + @Resource + ObjectMapper objectMapper; + + @Value("${wecom.sync.batch-size}") + int batchSize; + @Value("${wecom.sync.interval}") + int interval; + // 防止同企业并发拉取(单机) + private final ReentrantLock lock = new ReentrantLock(); + + @Override + public int syncMessages(String corpId) { + if (!lock.tryLock()) { + log.warn("同步任务正在进行中,跳过本次执行 corpId={}", corpId); + return 0; + } + try { + return doSync(corpId); + } finally { + lock.unlock(); + } + } + + @Transactional(rollbackFor = Exception.class) + public int doSync(String corpId) { + // 1. 获取上次同步游标 + WxSyncState state = wxSyncStateService.getOne( + new LambdaQueryWrapper() + .eq(WxSyncState::getCorpId, corpId) + .eq(WxSyncState::getSyncType, "chat")); + long lastSeq = 0; + if (state != null && state.getNextCursor() != null) { + try { + lastSeq = Long.parseLong(state.getNextCursor()); + } catch (NumberFormatException e) { + log.warn("游标格式异常,从0开始拉取 corpId={}", corpId); + } + } + + // 2. 获取SDK连接(实际生产中需要先调用 GetChatData 获取slice,此处模拟) + int limit = batchSize; + int timeout = 5000; + + log.info("开始拉取会话消息 corpId={}, 起始seq={}, 每批{}条", corpId, lastSeq, limit); + + // 3. 循环拉取消息切片 + long currentSeq = lastSeq; + int totalSynced = 0; + long maxSeq = 0; // 本批次拉取到的最大seq + List recordsToInsert = new ArrayList<>(); + + while (true) { + long slice = weComSdkClient.getChatData(currentSeq, limit, null, null, timeout); + if (slice == 0) { + log.warn("获取slice为空,seq={}", currentSeq); + break; + } + + String rawJson = weComSdkClient.getContentFromSlice(slice); + weComSdkClient.freeSlice(slice); + + if (rawJson == null || rawJson.isEmpty()) { + log.info("slice内容为空,可能已拉取完毕 seq={}", currentSeq); + break; + } + + // 解析企业微信返回的JSON结构 + ObjectNode objectNode = objectMapper.readTree(rawJson).asObject(); + int errcode = objectNode.get("errcode").asInt(); + if (errcode != 0) { + log.error("拉取消息失败, errcode={}, errmsg={}", errcode, objectNode.get("errmsg").asString()); + // 保存异常状态 + updateSyncState(corpId, String.valueOf(currentSeq), null, totalSynced, false, objectNode.get("errmsg").asString()); + return totalSynced; + } + + ArrayNode chatDataList = objectNode.get("chatdata").asArray(); + if (chatDataList == null || chatDataList.isEmpty()) { + log.info("本次拉取chatdata为空,同步结束"); + break; + } + + // 4. 遍历每条消息 + for (int i = 0; i < chatDataList.size(); i++) { + ObjectNode chatData = chatDataList.get(i).asObject(); + String msgId = chatData.get("msgid").asString(); + long seq = chatData.get("seq").asLong(); + String encryptRandomKey = chatData.get("encrypt_random_key").asString(); + String encryptChatMsg = chatData.get("encrypt_chat_msg").asString(); + + // 去重检查 + if (wxChatRecordService.count( + new LambdaQueryWrapper().eq(WxChatRecord::getMsgId, msgId)) > 0) { + log.debug("消息已存在,跳过 msgId={}", msgId); + currentSeq = Math.max(currentSeq, seq + 1); + maxSeq = Math.max(maxSeq, seq); + continue; + } + + // 5. 解密消息 + String decryptedMsg = weComSdkClient.decryptChatData(encryptRandomKey, encryptChatMsg); + if (decryptedMsg == null) { + log.error("消息解密失败,跳过 msgId={}", msgId); + currentSeq = Math.max(currentSeq, seq + 1); + continue; + } + + // 6. 解析消息内容并转换为实体 + WxChatRecord record = parseRecord(msgId, seq, decryptedMsg, corpId); + if (record != null) { + recordsToInsert.add(record); + totalSynced++; + } + + // 更新当前seq(取当前seq+1,因为SDK要求从上次最大seq+1继续拉) + currentSeq = Math.max(currentSeq, seq + 1); + maxSeq = Math.max(maxSeq, seq); + } + + // 如果返回条数小于limit,通常表示已拉完 + if (chatDataList.size() < limit) { + log.info("返回条数({})小于拉取限制({}),结束拉取", chatDataList.size(), limit); + break; + } + } + + // 7. 批量入库 + if (!recordsToInsert.isEmpty()) { + wxChatRecordService.saveBatch(recordsToInsert); // 需自定义批量插入方法,或使用MyBatis-Plus的saveBatch + log.info("批量插入{}条会话消息", recordsToInsert.size()); + } + + // 8. 更新同步状态(游标记录最后已拉取的最大seq,下次从此处开始) + String nextCursor = String.valueOf(Math.max(currentSeq, maxSeq + 1)); + updateSyncState(corpId, nextCursor, + recordsToInsert.isEmpty() ? null : recordsToInsert.get(recordsToInsert.size() - 1).getMsgTime(), + totalSynced, true, null); + + log.info("同步完成 corpId={}, 本批插入{}条, 新游标={}", corpId, totalSynced, nextCursor); + return totalSynced; + } + + /** + * 解析解密后的消息JSON并构建实体 + */ + private WxChatRecord parseRecord(String msgId, long seq, String decryptedJson, String corpId) { + try { + ObjectNode msgObj = objectMapper.readTree(decryptedJson).asObject(); + WxChatRecord record = new WxChatRecord(); + record.setMsgId(msgId); + record.setSeq(seq); + record.setAction(msgObj.get("action").asString()); + record.setFromType(msgObj.get("from_type").asString()); + record.setFromId(msgObj.get("from").asString()); + record.setRoomId(msgObj.get("roomid").asString()); + // toList 可能是字符串数组 + JsonNode toList = msgObj.get("tolist"); + record.setToList(toList.asString()); + record.setMsgType(msgObj.get("msgtype").asString()); + // 整个消息内容以JSON存储 + record.setContent(decryptedJson); + + // 时间处理(企微消息时间通常为毫秒时间戳) + long msgTimeMillis = msgObj.get("msgtime").asLong(); + record.setMsgTime(LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(msgTimeMillis), + ZoneId.systemDefault())); + record.setCorpId(corpId); + record.setSyncTime(LocalDateTime.now()); + return record; + } catch (Exception e) { + log.error("解析消息失败 msgId={}", msgId, e); + return null; + } + } + + /** + * 更新或插入同步状态记录 + */ + private void updateSyncState(String corpId, String nextCursor, LocalDateTime lastMsgTime, + int addCount, boolean success, String errorMsg) { + WxSyncState state = wxSyncStateService.getOne( + new LambdaQueryWrapper() + .eq(WxSyncState::getCorpId, corpId) + .eq(WxSyncState::getSyncType, "chat")); + if (state == null) { + state = new WxSyncState(); + state.setCorpId(corpId); + state.setSyncType("chat"); + state.setTotalCount(0L); + } + state.setNextCursor(nextCursor); + if (lastMsgTime != null) { + state.setLastMsgTime(lastMsgTime); + } + state.setTotalCount(state.getTotalCount() + addCount); + state.setSyncStatus(success ? 1 : 0); + state.setErrorMsg(errorMsg); + state.setUpdateTime(LocalDateTime.now()); + wxSyncStateService.saveOrUpdate(state); // 需自行实现或使用MyBatis-Plus的saveOrUpdate + } +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/ChatRecordQuery.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/ChatRecordQuery.java new file mode 100644 index 0000000..69ea747 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/ChatRecordQuery.java @@ -0,0 +1,20 @@ +package com.f8q8.qywechatarchive.modules.api.vo; + +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +@Data +public class ChatRecordQuery { + + private Integer pageNum; + private Integer pageSize; + private String fromId; + private String roomId; + private String msgType; + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginRequest.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginRequest.java new file mode 100644 index 0000000..947edc9 --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginRequest.java @@ -0,0 +1,10 @@ +package com.f8q8.qywechatarchive.modules.api.vo; + +import lombok.Data; + +@Data +public class LoginRequest { + + private String username; + private String password; +} diff --git a/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginResponse.java b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginResponse.java new file mode 100644 index 0000000..d33589c --- /dev/null +++ b/src/main/java/com/f8q8/qywechatarchive/modules/api/vo/LoginResponse.java @@ -0,0 +1,15 @@ +package com.f8q8.qywechatarchive.modules.api.vo; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LoginResponse { + + private String accessToken; + private String refreshToken; + private String tokenType; + private long expiresIn; + private String username; +} diff --git a/src/main/java/com/tencent/wework/Finance.java b/src/main/java/com/tencent/wework/Finance.java new file mode 100644 index 0000000..ff9528d --- /dev/null +++ b/src/main/java/com/tencent/wework/Finance.java @@ -0,0 +1,128 @@ +package com.tencent.wework; + +/* sdk返回数据 +typedef struct Slice_t { + char* buf; + int len; +} Slice_t; + +typedef struct MediaData { + char* outindexbuf; + int out_len; + char* data; + int data_len; + int is_finish; +} MediaData_t; +*/ + +public class Finance { + public native static long NewSdk(); + + /** + * 初始化函数 + * Return值=0表示该API调用成功 + * + * @param [in] sdk NewSdk返回的sdk指针 + * @param [in] corpid 调用企业的企业id,例如:wwd08c8exxxx5ab44d,可以在企业微信管理端--我的企业--企业信息查看 + * @param [in] secret 聊天内容存档的Secret,可以在企业微信管理端--管理工具--聊天内容存档查看 + * @return 返回是否初始化成功 + * 0 - 成功 + * !=0 - 失败 + */ + public native static int Init(long sdk, String corpid, String secret); + + /** + * 拉取聊天记录函数 + * Return值=0表示该API调用成功 + * + * @param [in] sdk NewSdk返回的sdk指针 + * @param [in] seq 从指定的seq开始拉取消息,注意的是返回的消息从seq+1开始返回,seq为之前接口返回的最大seq值。首次使用请使用seq:0 + * @param [in] limit 一次拉取的消息条数,最大值1000条,超过1000条会返回错误 + * @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081 + * @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123 + * @param [out] chatDatas 返回本次拉取消息的数据,slice结构体.内容包括errcode/errmsg,以及每条消息内容。 + * @return 返回是否调用成功 + * 0 - 成功 + * !=0 - 失败 + */ + public native static int GetChatData(long sdk, long seq, long limit, String proxy, String passwd, long timeout, Long chatData); + + /** + * 拉取媒体消息函数 + * Return值=0表示该API调用成功 + * + * @param [in] sdk NewSdk返回的sdk指针 + * @param [in] sdkFileid 从GetChatData返回的聊天消息中,媒体消息包括的sdkfileid + * @param [in] proxy 使用代理的请求,需要传入代理的链接。如:socks5://10.0.0.1:8081 或者 http://10.0.0.1:8081 + * @param [in] passwd 代理账号密码,需要传入代理的账号密码。如 user_name:passwd_123 + * @param [in] indexbuf 媒体消息分片拉取,需要填入每次拉取的索引信息。首次不需要填写,默认拉取512k,后续每次调用只需要将上次调用返回的outindexbuf填入即可。 + * @param [out] media_data 返回本次拉取的媒体数据.MediaData结构体.内容包括data(数据内容)/outindexbuf(下次索引)/is_finish(拉取完成标记) + * @return 返回是否调用成功 + * 0 - 成功 + * !=0 - 失败 + */ + public native static int GetMediaData(long sdk, String indexbuf, String sdkField, String proxy, String passwd, long timeout, long mediaData); + + /** + * @param [in] encrypt_key, getchatdata返回的encrypt_key + * @param [in] encrypt_msg, getchatdata返回的content + * @param [out] msg, 解密的消息明文 + * @return 返回是否调用成功 + * 0 - 成功 + * !=0 - 失败 + * @brief 解析密文 + */ + public native static int DecryptData(long sdk, String encrypt_key, String encrypt_msg, long msg); + + public native static void DestroySdk(long sdk); + + public native static long NewSlice(); + + /** + * @return + * @brief 释放slice,和NewSlice成对使用 + */ + public native static void FreeSlice(long slice); + + /** + * @return 内容 + * @brief 获取slice内容 + */ + public native static String GetContentFromSlice(long slice); + + /** + * @return 内容 + * @brief 获取slice内容长度 + */ + public native static int GetSliceLen(long slice); + + public native static long NewMediaData(); + + public native static void FreeMediaData(long mediaData); + + /** + * @return outindex + * @brief 获取mediadata outindex + */ + public native static String GetOutIndexBuf(long mediaData); + + /** + * @return data + * @brief 获取mediadata data数据 + */ + public native static byte[] GetData(long mediaData); + + public native static int GetIndexLen(long mediaData); + + public native static int GetDataLen(long mediaData); + + /** + * @return 1完成、0未完成 + * @brief 判断mediadata是否结束 + */ + public native static int IsMediaDataFinish(long mediaData); + + static { + System.loadLibrary("WeWorkFinanceSdk"); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..e80f7c7 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:mysql://frps.zxl78585.com:3306/qy_wechat_archive?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false + username: qyWechat + password: qyWechat@2026Test + hikari: + maximum-pool-size: 20 + minimum-idle: 2 +jwt: + secret: cd95c74d1b2c4897aeb47bb2f3c84def|3d11ad80ed54444aa9df6b93cd52dca6||3dc5eaa6d86c464aacbf583bc1bd88f5|73853cf87bab4d77b284b42322408582|994a243c19f14e56a03c2761a2f29557|fc8b51a9940d4fe1bde8031bf7cc10b5|d3ed7dfa7c2e4247918ac3502d317435|563387d19ca94287bcfcb5034cdb2eb4 + # 2小时 + access-expiration: 7200 + # 7天 + refresh-expiration: 604800 +wecom: + corp-id: 111 + secret: 2222 + # 消息同步配置 + sync: + # 是否启用自动同步 + enabled: true + # 同步间隔(秒) + interval: 300 + # 每次拉取条数 + batch-size: 500 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3d74d6b --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,25 @@ +spring: + datasource: + url: jdbc:mysql://127.0.0.1:3306/wecom?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false + username: wecom + password: wecom@2026Prod + hikari: + maximum-pool-size: 20 + minimum-idle: 2 +jwt: + secret: cd95c74d1b2c4897aeb47bb2f3c84def|3d11ad80ed54444aa9df6b93cd52dca6||3dc5eaa6d86c464aacbf583bc1bd88f5|73853cf87bab4d77b284b42322408582|994a243c19f14e56a03c2761a2f29557|fc8b51a9940d4fe1bde8031bf7cc10b5|d3ed7dfa7c2e4247918ac3502d317435|563387d19ca94287bcfcb5034cdb2eb4 + # 2小时 + access-expiration: 7200 + # 7天 + refresh-expiration: 604800 +wecom: + corp-id: wwb42d275eae6910cc + secret: KyFHB6q9g-8X5pH9fUhojSk8xifkGzWmvn96UnwMMgo + # 消息同步配置 + sync: + # 是否启用自动同步 + enabled: true + # 同步间隔(秒) + interval: 300 + # 每次拉取条数 + batch-size: 500 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..899531e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,18 @@ +server: + port: 8088 +spring: + profiles: + active: dev + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: GMT+8 + datasource: + type: com.zaxxer.hikari.HikariDataSource +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + global-config: + db-config: + id-type: assign_uuid + logic-delete-field: del_flag + logic-delete-value: 1 + logic-not-delete-value: 0 \ No newline at end of file diff --git a/src/main/resources/native/linux/libWeWorkFinanceSdk_Java.so b/src/main/resources/native/linux/libWeWorkFinanceSdk_Java.so new file mode 100644 index 0000000..d506486 Binary files /dev/null and b/src/main/resources/native/linux/libWeWorkFinanceSdk_Java.so differ diff --git a/src/main/resources/native/win/WeWorkFinanceSdk.dll b/src/main/resources/native/win/WeWorkFinanceSdk.dll new file mode 100644 index 0000000..4ce7286 Binary files /dev/null and b/src/main/resources/native/win/WeWorkFinanceSdk.dll differ diff --git a/src/main/resources/native/win/libcrypto-3-x64.dll b/src/main/resources/native/win/libcrypto-3-x64.dll new file mode 100644 index 0000000..b1a6dbe Binary files /dev/null and b/src/main/resources/native/win/libcrypto-3-x64.dll differ diff --git a/src/main/resources/native/win/libcurl-x64.dll b/src/main/resources/native/win/libcurl-x64.dll new file mode 100644 index 0000000..8b79e41 Binary files /dev/null and b/src/main/resources/native/win/libcurl-x64.dll differ