diff --git a/ForJdk17/src/main/java/cn/whaifree/interview/KS1/P1.java b/ForJdk17/src/main/java/cn/whaifree/interview/KS1/P1.java new file mode 100644 index 0000000..d3bec59 --- /dev/null +++ b/ForJdk17/src/main/java/cn/whaifree/interview/KS1/P1.java @@ -0,0 +1,33 @@ +package cn.whaifree.interview.KS1; + +import org.junit.Test; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/16 13:53 + * @注释 + */ +public class P1 { + @Test + public void test() { + int[] prices = {7, 1, 5, 3, 6, 4}; + int maxProfit = maxProfit(prices); + System.out.println(maxProfit); + } + + public int maxProfit(int[] prices) { + int maxProfit = 0; + int left = 0; + int right = 0; + while (right < prices.length) { + if (prices[right] < prices[left]) { + left = right; + }else { + maxProfit = Math.max(maxProfit, prices[right] - prices[left]); + } + right++; + } + return maxProfit; + } +} diff --git a/ForJdk17/src/main/java/cn/whaifree/leetCode/Array/LCR119.java b/ForJdk17/src/main/java/cn/whaifree/leetCode/Array/LCR119.java new file mode 100644 index 0000000..bfc63df --- /dev/null +++ b/ForJdk17/src/main/java/cn/whaifree/leetCode/Array/LCR119.java @@ -0,0 +1,49 @@ +package cn.whaifree.leetCode.Array; + +import org.junit.Test; + +import java.util.HashSet; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/15 16:37 + * @注释 + */ +public class LCR119 { + @Test + public void test() { + int[] nums = {100, 4, 200, 1, 3, 2}; + System.out.println(new Solution().longestConsecutive(nums)); + } + + class Solution { + public int longestConsecutive(int[] nums) { + HashSet set = new HashSet<>(); + for (int num : nums) { + set.add(num); + } + + int max = 0; + for (int num : nums) { + if (set.contains(num- 1)) { // 1 2 3 4 只要保证set里没有0 + continue; + } +// int base = num; +// int tmp = 0; +// while (set.contains(base++)) { +// tmp++; +// } +// max = Math.max(max, tmp); + + int maxnum = num; + while (set.contains(maxnum + 1)) { + maxnum++; + } + max = Math.max(max, maxnum - num + 1); + } + return max; + + } + } +} diff --git a/ForJdk17/src/main/java/cn/whaifree/redo/redo_all_241016/LCR074.java b/ForJdk17/src/main/java/cn/whaifree/redo/redo_all_241016/LCR074.java new file mode 100644 index 0000000..364029c --- /dev/null +++ b/ForJdk17/src/main/java/cn/whaifree/redo/redo_all_241016/LCR074.java @@ -0,0 +1,61 @@ +package cn.whaifree.redo.redo_all_241016; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/16 12:10 + * @注释 + */ +public class LCR074 { + + @Test + public void test() { + + StringBuilder stringBuilder = new StringBuilder(); + int[][] intervals = {{1, 4}, {4, 6}}; + Solution solution = new Solution(); + int[][] result = solution.merge(intervals); + for (int[] interval : result) { + System.out.println(Arrays.toString(interval)); + + } + } + + + class Solution { + /** + * | | + * | | + * | | + * | | + * + * @param intervals + * @return + */ + public int[][] merge(int[][] intervals) { + Arrays.sort(intervals, Comparator.comparingInt(o -> o[0])); + + List result = new ArrayList<>(); + for (int i = 1; i < intervals.length; i++) { + int[] before = intervals[i - 1]; + int[] current = intervals[i]; + if (before[1] >= current[0]) { + current[0] = Math.min(before[0], current[0]); + current[1] = Math.max(before[1], current[1]); + }else { + result.add(before); + } + } + result.add(intervals[intervals.length - 1]); + return result.toArray(new int[result.size()][]); + } + } + +} diff --git a/springDemo/.gitignore b/springDemo/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/springDemo/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/springDemo/pom.xml b/springDemo/pom.xml index f980632..781fc2b 100644 --- a/springDemo/pom.xml +++ b/springDemo/pom.xml @@ -37,18 +37,42 @@ 4.4.0 - - - - - - + + + + + + - org.redisson + org.redisson redisson 3.16.8 + + + org.springframework.boot + spring-boot-starter-amqp + + + + io.minio + minio + 8.4.3 + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.13.0 + + + + + com.google.guava + guava + 31.0.1-jre + @@ -104,11 +128,11 @@ spring-boot-starter-test test - - - - - + + + + + diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/controller/WhiteListController.java b/springDemo/src/main/java/cn/whaifree/springdemo/controller/WhiteListController.java new file mode 100644 index 0000000..d6c6163 --- /dev/null +++ b/springDemo/src/main/java/cn/whaifree/springdemo/controller/WhiteListController.java @@ -0,0 +1,23 @@ +package cn.whaifree.springdemo.controller; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/15 21:29 + * @注释 + */ +@RestController +public class WhiteListController { + + @Resource + private RedisTemplate redisTemplate; + @PostMapping("/queryIn") + public boolean query(String userId) { + return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember("whiteList", userId)); + } +} diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/controller/minio/MinioController.java b/springDemo/src/main/java/cn/whaifree/springdemo/controller/minio/MinioController.java new file mode 100644 index 0000000..62200ad --- /dev/null +++ b/springDemo/src/main/java/cn/whaifree/springdemo/controller/minio/MinioController.java @@ -0,0 +1,196 @@ +package cn.whaifree.springdemo.controller.minio; + +import cn.hutool.crypto.digest.MD5; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import io.minio.MinioClient; +import io.minio.ObjectWriteResponse; +import io.minio.PutObjectArgs; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Headers; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/16 9:38 + * @注释 + */ +@RestController +@Slf4j +public class MinioController { + @Resource + private ImageUploader imageUploader; + /** + * 外网图片转存缓存 + */ + private LoadingCache imgReplaceCache = CacheBuilder.newBuilder().maximumSize(300).expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader() { + @Override + public String load(String img) { + try { + InputStream stream = null; + if (img.startsWith("http")) { + // 下载变输入 + HttpRequest get = HttpUtil.createGet(img); + get.header("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"); + HttpResponse response = get.execute(); + stream = response.bodyStream(); + }else { + return ""; + } + URI uri = URI.create(img); + String path = uri.getPath(); + int index = path.lastIndexOf("."); + String fileType = null; + if (index > 0) { + // 从url中获取文件类型 + fileType = path.substring(index + 1); + } + return imageUploader.upload(stream, fileType); + } catch (Exception e) { + log.error("外网图片转存异常! img:{}", img, e); + return ""; + } + } + }); + + + @PostMapping("/changeLink") + public String changeLink(String url) throws ExecutionException { + return imgReplaceCache.get(url); + } +} +@Slf4j +@ConditionalOnExpression(value = "#{'minio'.equals(environment.getProperty('image.oss.type'))}") +@Component +class MinioOssWrapper implements ImageUploader, InitializingBean { + + private MinioClient minioClient; + @Autowired + @Setter + @Getter + private ImageProperties properties; + + + @Override + public String upload(InputStream inputStream, String fileType) { + try { + byte[] bytes = StreamUtils.copyToByteArray(inputStream); + // 计算md5作为文件名,避免重复上传 + String fileName = MD5.create().digestHex(bytes); + ByteArrayInputStream input = new ByteArrayInputStream(bytes); + + fileName = fileName + "." + fileType; + log.info("上传文件名:{}", fileName); + + PutObjectArgs args = PutObjectArgs.builder() + .bucket(properties.getOss().getBucket()) + .object(fileName) + .stream(input, input.available(), -1) + .contentType(fileType) + .build(); + + ObjectWriteResponse response = minioClient.putObject(args); + + // 获取response状态码 + Headers headers = response.headers(); + log.info(headers.toString()); + + + StringBuilder sb = new StringBuilder(); + sb.append(properties.getOss().getEndpoint()).append("/").append(response.bucket()).append("/").append(response.object()); + + return sb.toString(); + + } catch (Exception e) { + log.error(e.getMessage()); + return ""; + } + } + + @Override + public boolean uploadIgnore(String fileUrl) { + return false; + } + + @Override + public void afterPropertiesSet() throws Exception { + // 创建OSSClient实例。 + log.info("init ossClient"); + minioClient = MinioClient.builder() + .credentials( + properties.getOss().getAk(), + properties.getOss().getSk() + ) + .endpoint(properties.getOss().getEndpoint()) + .build(); + } +} + +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "image") +class ImageProperties { + private String absTmpPath; // 存储绝对路径 + private String webImgPath; // 存储相对路径 + private String tmpUploadPath; // 上传文件的临时存储目录 + private String cdnHost; // 访问图片的host + private OssProperties oss; +} + +@Data +class OssProperties { + private String prefix; // 上传文件前缀路径 + private String type; // oss类型 + //下面几个是oss的配置参数 + private String endpoint; + private String ak; + private String sk; + private String bucket; + private String host; +} + + +interface ImageUploader { + String DEFAULT_FILE_TYPE = "txt"; +// Set STATIC_IMG_TYPE = new HashSet<>(Arrays.asList(MediaType.ImagePng, MediaType.ImageJpg, MediaType.ImageWebp, MediaType.ImageGif)); + + /** + * 文件上传 + * + * @param input + * @param fileType + * @return + */ + String upload(InputStream input, String fileType); + + /** + * 判断外网图片是否依然需要处理 + * + * @param fileUrl + * @return true 表示忽略,不需要转存 + */ + boolean uploadIgnore(String fileUrl); +} diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/EventController.java b/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/EventController.java new file mode 100644 index 0000000..36aa3c0 --- /dev/null +++ b/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/EventController.java @@ -0,0 +1,106 @@ +package cn.whaifree.springdemo.controller.rabbitMqEvent; + +import cn.hutool.extra.spring.SpringUtil; +import cn.whaifree.springdemo.utils.ResVo; +import lombok.Getter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/15 19:12 + * @注释 + */ +@RestController +public class EventController { + + /** + * 根据不同的青桔 + * + * @param msg + * @return + */ + @PostMapping("/sendMsg") + public ResVo sendMsgNotify(NotifyTypeEnum notifyType, String msg) { + // 发送异步消息 + SpringUtil.publishEvent(new NotifyMsgEvent<>(this, notifyType, msg)); + return ResVo.success(); + } +} + +@Getter +@ToString +class NotifyMsgEvent extends ApplicationEvent { + private NotifyTypeEnum notifyType; + private T content; + public NotifyMsgEvent(Object source, NotifyTypeEnum notifyType, T content) { + super(source); + this.notifyType = notifyType; + this.content = content; + } +} + + +@Service +class NotifyMsgListener implements ApplicationListener> { + @Override + public void onApplicationEvent(NotifyMsgEvent event) { + System.out.println(event); // 获取到发送的消息,做下一步处理 + } +} + + + +@Getter +enum NotifyTypeEnum { + COMMENT(1, "评论"), + REPLY(2, "回复"), + PRAISE(3, "点赞"), + COLLECT(4, "收藏"), + FOLLOW(5, "关注消息"), + SYSTEM(6, "系统消息"), + DELETE_COMMENT(1, "删除评论"), + DELETE_REPLY(2, "删除回复"), + CANCEL_PRAISE(3, "取消点赞"), + CANCEL_COLLECT(4, "取消收藏"), + CANCEL_FOLLOW(5, "取消关注"), + + // 注册、登录添加系统相关提示消息 + REGISTER(6, "用户注册"), + LOGIN(6, "用户登录"), + ; + + + private int type; + private String msg; + + private static Map mapper; + + static { + mapper = new HashMap<>(); + for (NotifyTypeEnum type : values()) { + mapper.put(type.type, type); + } + } + + NotifyTypeEnum(int type, String msg) { + this.type = type; + this.msg = msg; + } + + public static NotifyTypeEnum typeOf(int type) { + return mapper.get(type); + } + + public static NotifyTypeEnum typeOf(String type) { + return valueOf(type.toUpperCase().trim()); + } +} diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/RabbitMQController.java b/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/RabbitMQController.java new file mode 100644 index 0000000..3d380ca --- /dev/null +++ b/springDemo/src/main/java/cn/whaifree/springdemo/controller/rabbitMqEvent/RabbitMQController.java @@ -0,0 +1,268 @@ +package cn.whaifree.springdemo.controller.rabbitMqEvent; + +import cn.hutool.core.date.DateUtil; +import cn.whaifree.springdemo.utils.ResVo; +import com.alibaba.fastjson.JSON; +import com.rabbitmq.client.Channel; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/15 19:20 + * @注释 + */ +@RestController +@Slf4j +public class RabbitMQController { + @Resource + private RabbitTemplate rabbitTemplate; + @Resource + private RedisTemplate redisTemplate; + + @PostMapping("/send") + public String send(NotifyTypeEnum type, String msg) { + rabbitTemplate.convertAndSend(RabbitMQConstants.EXCHANGE, RabbitMQConstants.ROUTER, new NotifyMsgEvent<>(null, type, msg)); + return "success"; + } + + @RabbitListener(queues = RabbitMQConstants.QUEUE) + void synBlogConsumer(Message msg , Channel channel) throws IOException { + try { + log.info("synBlogConsumer 接收到消息:{}", msg.toString()); + consumer(msg, "user1"); // 某个用户 + + channel.basicAck(msg.getMessageProperties().getDeliveryTag(), false); + } catch (Exception e) { + log.error("synBlogConsumer 接收消息失败:{}", e.getMessage()); + channel.basicNack(msg.getMessageProperties().getDeliveryTag(), false, true); + } + } + + private final String oprKey = "oprLog"; + + /** + * 消息重复消费?幂等性 + * @param message + * @return + */ + public void consumer(Message message,String user) { + // 转为NotifyMsgEvent + NotifyMsgEvent event = JSON.parseObject(message.getBody(), NotifyMsgEvent.class); + + // 构造活跃度增加的通知 + } + + + final String todayRankKey = DateUtil.format(DateUtil.date(), "yyyyMMdd"); + @PostMapping("/activity") + public ResVo getRank(int k) { + // 获取前k个 + Set execute = redisTemplate.execute((RedisCallback>) connection -> { + Set collect = connection.zRange(todayRankKey.getBytes(), 0, k - 1).stream().map(new Function() { + @Override + public String apply(byte[] bytes) { + return new String(bytes); + } + }).collect(Collectors.toSet()); + return collect; + }); + return ResVo.success(execute); + } + + public void incrDecrByActivity(ActivityScoreBo activityScore,String userId) { + + if (userId == null) { + return; + } + + String field; + int score = 0; + if (activityScore.getPath() != null) { // 关于页面 + field = "path_" + activityScore.getPath(); + score = 1; + } else if (activityScore.getArticleId() != null) { // 关于文章 + field = activityScore.getArticleId() + "_"; + if (activityScore.getPraise() != null) { + field += "praise"; + score = BooleanUtils.isTrue(activityScore.getPraise()) ? 2 : -2; + } else if (activityScore.getCollect() != null) { + field += "collect"; + score = BooleanUtils.isTrue(activityScore.getCollect()) ? 2 : -2; + } else if (activityScore.getRate() != null) { + // 评论回复 + field += "rate"; + score = BooleanUtils.isTrue(activityScore.getRate()) ? 3 : -3; + } else if (BooleanUtils.isTrue(activityScore.getPublishArticle())) { + // 发布文章 + field += "publish"; + score += 10; + } + } else if (activityScore.getFollowedUserId() != null) { // 关于关注 + field = activityScore.getFollowedUserId() + "_follow"; + score = BooleanUtils.isTrue(activityScore.getFollow()) ? 2 : -2; + } else { + return; + } + + + // 幂等性 + final String userActionKey = "ActivityCore:" + userId + DateUtil.format(DateUtil.date(), ":yyyyMMdd"); + + // {user:{action1,action2}} + Integer opr = (Integer) redisTemplate.opsForHash().get(userActionKey, field); + if (opr == null) { // 某个用户在之前是否做过field这个操作 + // 没有操作过 + // 加记录 + redisTemplate.opsForHash().put(userActionKey, field, score); + redisTemplate.execute((RedisCallback) connection -> { + connection.expire(userActionKey.getBytes(), 31 * 24 * 60 * 60); // 保存一个月 + return null; + }); + + // 加分 + // 更新当天和当月的活跃度排行榜 + final String todayRankKey = DateUtil.format(DateUtil.date(), "yyyyMMdd"); + final String monthRankKey = DateUtil.format(DateUtil.date(), "yyyyMM"); + + + Double newAns = redisTemplate.execute(new RedisCallback() { + @Override + public Double doInRedis(RedisConnection connection) throws DataAccessException { + return connection.zScore(todayRankKey.getBytes(), userId.getBytes()); + } + }); + Object execute = redisTemplate.execute(new RedisCallback() { + @Override + public Object doInRedis(RedisConnection connection) throws DataAccessException { + return connection.zScore(monthRankKey.getBytes(), userId.getBytes()); + } + }); + + redisTemplate.execute((RedisCallback) connection -> { + connection.expire(todayRankKey.getBytes(), 31 * 24 * 60 * 60); // 保存一个月 + return null; + }); + redisTemplate.execute((RedisCallback) connection -> { + connection.expire(monthRankKey.getBytes(), 31 * 24 * 60 * 60 * 12); // 保存一年 + return null; + }); + } else if (opr > 0 && score < 0) { + // 减分 + Long delete = redisTemplate.opsForHash().delete(userActionKey, field); + if (delete == 1) { + // 减分成功 + // 更新日、月排行榜 + final String todayRankKey = DateUtil.format(DateUtil.date(), "yyyyMMdd"); + final String monthRankKey = DateUtil.format(DateUtil.date(), "yyyyMM"); + + redisTemplate.opsForHash().increment(todayRankKey, userId, -score); + redisTemplate.opsForHash().increment(monthRankKey, userId, -score); + } + + } + + } + + @Data + @Accessors(chain = true) + public class ActivityScoreBo { + /** + * 访问页面增加活跃度 + */ + private String path; + + /** + * 目标文章 + */ + private Long articleId; + + /** + * 评论增加活跃度 + */ + private Boolean rate; + + /** + * 点赞增加活跃度 + */ + private Boolean praise; + + /** + * 收藏增加活跃度 + */ + private Boolean collect; + + /** + * 发布文章增加活跃度 + */ + private Boolean publishArticle; + + /** + * 被关注的用户 + */ + private Long followedUserId; + + /** + * 关注增加活跃度 + */ + private Boolean follow; + } + + +} + + + +@Configuration +class RabbitMQConfig { + @Bean + Queue aqueue() { + return QueueBuilder.durable(RabbitMQConstants.QUEUE) + .ttl(1000).maxLength(5) + .deadLetterExchange(RabbitMQConstants.EXCHANGE).deadLetterRoutingKey(RabbitMQConstants.FAIL_ROUTER) + .build(); + } + @Bean + Queue failQueue() { + return QueueBuilder.durable(RabbitMQConstants.FAIL_QUEUE) + .build(); + } + + @Bean + Exchange commentExchange() { + return ExchangeBuilder.directExchange(RabbitMQConstants.EXCHANGE).durable(true).build(); + } + + @Bean + Binding commentBinding() { + return BindingBuilder.bind(aqueue()).to(commentExchange()).with(RabbitMQConstants.ROUTER).noargs(); + } +} + +class RabbitMQConstants{ + public static final String QUEUE = "queue"; + public static final String FAIL_QUEUE = "fail_queue"; + public static final String EXCHANGE = "exchange"; + public static final String ROUTER = "router"; + public static final String FAIL_ROUTER = "fail_router"; +} diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/controller/wxQrLogin/WxQrLoginController.java b/springDemo/src/main/java/cn/whaifree/springdemo/controller/wxQrLogin/WxQrLoginController.java new file mode 100644 index 0000000..e29f7ec --- /dev/null +++ b/springDemo/src/main/java/cn/whaifree/springdemo/controller/wxQrLogin/WxQrLoginController.java @@ -0,0 +1,284 @@ +package cn.whaifree.springdemo.controller.wxQrLogin; + +import cn.hutool.core.util.RandomUtil; +import cn.hutool.jwt.JWT; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import jakarta.annotation.PostConstruct; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Date; + +/** + * @version 1.0 + * @Author whai文海 + * @Date 2024/10/15 17:07 + * @注释 + */ +@Slf4j +@RestController +@RequestMapping("/wxQrLogin") +public class WxQrLoginController { + + /** + * 1. 客户端请求网站,/subscribe,响应SSEEmitter + * 获取客户端deviceId + * 生成验证码numCode + * 存放Cache < deviceId,numCode > + * 存放Cache < numCode, SSEEmitter> + * SSE发送验证码给客户端 + * + * 2. 客户端扫描公众号,发送numCode到公众号 + * wx会请求/callback接口,带有numCode + * 使用numCode找到deviceId,再找到SSEEmitter,发送生成的最新Token + * + */ + + + /** + * sse的超时时间,默认15min + */ + private final static Long SSE_EXPIRE_TIME = 15 * 60 * 1000L; + private final RedisTemplate redisTemplate; + + + /** + * key = 验证码, value = 长连接 + */ + private LoadingCache verifyCodeCache; + /** + * key = 设备 value = 验证码 + */ + private LoadingCache deviceCodeCache; + + public WxQrLoginController(@Qualifier("redisTemplate") RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + + @PostConstruct + public void init() { + // 验证码,SSE + verifyCodeCache = CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public SseEmitter load(String key) throws Exception { + // 如果缓存未命中,则抛出异常,提示缓存未命中 + throw new RuntimeException("no val: " + key); + } + }); + // 设备,验证码 + deviceCodeCache = CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public String load(String key) throws Exception { + // 生成id + int cnt = 0; + while (true) { + String code = "deviceId#" + cnt++; // 可以是其他生成算法 + // 如果verifyCodeCache中已经有这个缓存,证明这个Code已经被使用了 + if (!verifyCodeCache.asMap().containsKey(code)) { + return code; + } + } + } + }); + } + + + /** + * deviceId code + * code sse + * @return + * @throws IOException + */ + @GetMapping(path = "subscribe", produces = {org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE}) + public SseEmitter subscribe() throws IOException { + String deviceId = String.valueOf(RandomUtil.randomInt(100)); // 随机生成一个UUID + + String realCode = deviceCodeCache.getUnchecked(deviceId) ; + // 生成验证码 + + + // fixme 设置15min的超时时间, 超时时间一旦设置不能修改;因此导致刷新验证码并不会增加连接的有效期 + SseEmitter sseEmitter = new SseEmitter(SSE_EXPIRE_TIME); + SseEmitter oldSse = verifyCodeCache.getIfPresent(realCode); // 是否已经存在旧的半长连接 + + if (oldSse != null) { + oldSse.complete(); // 旧的长连接 + } + + + verifyCodeCache.put(realCode, sseEmitter); + sseEmitter.onTimeout(() -> { + log.info("sse 超时中断 --> {}", realCode); + verifyCodeCache.invalidate(realCode); + sseEmitter.complete(); + }); + sseEmitter.onError((e) -> { + log.warn("sse error! --> {}", realCode, e); + verifyCodeCache.invalidate(realCode); + sseEmitter.complete(); + }); + // 若实际的验证码与前端显示的不同,则通知前端更新 + sseEmitter.send("initCode!"); + sseEmitter.send("init#" + realCode); + return sseEmitter; + } + + + /** + * fixme: 需要做防刷校验 + * + * + * 微信的响应返回 + * 本地测试访问: + * curl -X POST 'http://localhost:8080/wx/callback' + * -H 'content-type:application/xml' -d + * ' + * + * + * + * 1655700579 + * + * + * 11111111 + * ' -i + * + * @param msg + * @return 返回给微信,微信会给客户端 + */ + @PostMapping(path = "callback", + consumes = {"application/xml", "text/xml"}, + produces = "application/xml;charset=utf-8") + public BaseWxMsgResVo callBack(@RequestBody WxTxtMsgReqVo msg) throws IOException { + BaseWxMsgResVo res = new BaseWxMsgResVo(); + res.setToUserName(msg.getFromUserName()); + res.setFromUserName(msg.getToUserName()); + res.setCreateTime(System.currentTimeMillis()); + + String content = msg.getContent(); + if ("subscribe".equals(msg.getEvent()) || "scan".equalsIgnoreCase(msg.getEvent())) { + String key = msg.getEventKey(); + if (StringUtils.isNotBlank(key) || key.startsWith("qrscene_")) { + + // 带参数的二维码,扫描、关注事件拿到之后,直接登录,省却输入验证码这一步 + // fixme 带参数二维码需要 微信认证,个人公众号无权限 + String code = key.substring("qrscene_".length()); + + + // TODO sessionService.autoRegisterWxUserInfo(msg.getFromUserName()); + // 自动注册一个用户,获得用户ID + + // 找到对应的SSE,实现登录 + SseEmitter sseEmitter = verifyCodeCache.getIfPresent(code); + + // 生成Token + String session = genSession(100L); + // 登录成功,写入session + sseEmitter.send(session); + + // 设置cookie的路径 + sseEmitter.send("login#Session=" + session + ";path=/;"); // session告诉前端,跳转到/根目录 + + + return res; + } + } + return res; + } + + + + public String genSession(Long userId) { + // 1.生成jwt格式的会话,内部持有有效期,用户信息 + String session = String.valueOf(userId); + String token = JWT.create() + .setIssuer("issuer") + .setPayload("session", session) + .setExpiresAt(new Date(System.currentTimeMillis() + 2592000000L)).sign(); + + // 2.使用jwt生成的token时,后端可以不存储这个session信息, 完全依赖jwt的信息 + // 但是需要考虑到用户登出,需要主动失效这个token,而jwt本身无状态,所以再这里的redis做一个简单的token -> userId的缓存,用于双重判定 + redisTemplate.opsForValue().set("UserId:Session:" + session, token); + return token; + } + + + @Data + @JacksonXmlRootElement(localName = "xml") + public class WxTxtMsgReqVo { + @JacksonXmlProperty(localName = "ToUserName") + private String toUserName; + @JacksonXmlProperty(localName = "FromUserName") + private String fromUserName; + @JacksonXmlProperty(localName = "CreateTime") + private Long createTime; + @JacksonXmlProperty(localName = "MsgType") + private String msgType; + @JacksonXmlProperty(localName = "Event") + private String event; + @JacksonXmlProperty(localName = "EventKey") + private String eventKey; + @JacksonXmlProperty(localName = "Ticket") + private String ticket; + @JacksonXmlProperty(localName = "Content") + private String content; + @JacksonXmlProperty(localName = "MsgId") + private String msgId; + @JacksonXmlProperty(localName = "MsgDataId") + private String msgDataId; + @JacksonXmlProperty(localName = "Idx") + private String idx; + } + + + @Data + @JacksonXmlRootElement(localName = "xml") + public class BaseWxMsgResVo { + + @JacksonXmlProperty(localName = "ToUserName") + private String toUserName; + @JacksonXmlProperty(localName = "FromUserName") + private String fromUserName; + @JacksonXmlProperty(localName = "CreateTime") + private Long createTime; + @JacksonXmlProperty(localName = "MsgType") + private String msgType; + } + + +// +// /** +// * 初始化设备id +// * +// * @return +// */ +// private String getOrInitDeviceId(HttpServletRequest request, HttpServletResponse response) { +// String deviceId = request.getParameter("deviceId"); +// if (StringUtils.isNotBlank(deviceId) && !"null".equalsIgnoreCase(deviceId)) { +// return deviceId; +// } +// +// Cookie device = SessionUtil.findCookieByName(request, LoginOutService.USER_DEVICE_KEY); +// if (device == null) { +// deviceId = UUID.randomUUID().toString(); +// if (response != null) { +// response.addCookie(SessionUtil.newCookie(LoginOutService.USER_DEVICE_KEY, deviceId)); +// } +// return deviceId; +// } +// return device.getValue(); +// } + + + +} diff --git a/springDemo/src/main/java/cn/whaifree/springdemo/utils/Filter/SelfFilter.java b/springDemo/src/main/java/cn/whaifree/springdemo/utils/Filter/SelfFilter.java index e004eac..8e86cb6 100644 --- a/springDemo/src/main/java/cn/whaifree/springdemo/utils/Filter/SelfFilter.java +++ b/springDemo/src/main/java/cn/whaifree/springdemo/utils/Filter/SelfFilter.java @@ -22,9 +22,9 @@ public class SelfFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { - System.out.println("SelfFilter doFilter"); +// System.out.println("SelfFilter doFilter"); filterChain.doFilter(servletRequest, servletResponse); - System.out.println("SelfFilter doFilter end"); +// System.out.println("SelfFilter doFilter end"); } @Override diff --git a/springDemo/src/main/resources/application.yaml b/springDemo/src/main/resources/application.yaml index b9b8c3b..bc6aca4 100644 --- a/springDemo/src/main/resources/application.yaml +++ b/springDemo/src/main/resources/application.yaml @@ -16,11 +16,24 @@ spring: redisson: file: classpath:redisson.yaml - rabbitmq: + host: localhost + port: 5672 + username: whai + password: whai + publisher-confirm-type: correlated + # 不可达到 返回给生产者 + # 当启用publisher-returns时,如果发送者发送的消息无法被消费者确认,消息会返回发送者。否则发送者是不知道的 + template: + mandatory: true + publisher-returns: true listener: + simple: + acknowledge-mode: manual direct: - auto-startup: false + acknowledge-mode: manual + + # springdoc-openapi项目配置 @@ -42,3 +55,10 @@ knife4j: language: zh_cn server: port: 8080 +image: + oss: + type: minio + bucket: picgo + ak: wOSfawBbzug2S3qz9u6W + sk: CCxIopdXdBRNPloaFV7l8XplKpVLPzjSnMxlKcru + endpoint: http://42.192.130.83:9000