mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-17 12:30:59 +08:00
Compare commits
16 Commits
codex/add-
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d25e87fbc | ||
|
|
d02c316a70 | ||
|
|
c189c80c05 | ||
|
|
07db73c9c7 | ||
|
|
c296e25927 | ||
|
|
61fc9d799d | ||
|
|
20c6c73f8c | ||
|
|
81d1f79aae | ||
|
|
4ff76d2586 | ||
|
|
f24bc239cc | ||
|
|
143691206d | ||
|
|
843e53143d | ||
|
|
16c94690bd | ||
|
|
5be00e7013 | ||
|
|
a3201f05fb | ||
|
|
da311806c1 |
@@ -249,6 +249,6 @@ https://resend.com/emails 创建账号并登录
|
|||||||
|
|
||||||
## 开源共建和API文档
|
## 开源共建和API文档
|
||||||
|
|
||||||
- API文档: https://openisle-docs.netlify.app/docs/openapi
|
- API文档: https://docs.open-isle.com/openapi
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ public class CachingConfig {
|
|||||||
public static final String VERIFY_CACHE_NAME="openisle_verify";
|
public static final String VERIFY_CACHE_NAME="openisle_verify";
|
||||||
// 发帖频率限制
|
// 发帖频率限制
|
||||||
public static final String LIMIT_CACHE_NAME="openisle_limit";
|
public static final String LIMIT_CACHE_NAME="openisle_limit";
|
||||||
|
// 用户访问统计
|
||||||
|
public static final String VISIT_CACHE_NAME="openisle_visit";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自定义Redis的序列化器
|
* 自定义Redis的序列化器
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.openisle.config;
|
package com.openisle.config;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.amqp.core.Binding;
|
import org.springframework.amqp.core.Binding;
|
||||||
import org.springframework.amqp.core.BindingBuilder;
|
import org.springframework.amqp.core.BindingBuilder;
|
||||||
import org.springframework.amqp.core.Queue;
|
import org.springframework.amqp.core.Queue;
|
||||||
@@ -23,6 +24,7 @@ import java.util.List;
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class RabbitMQConfig {
|
public class RabbitMQConfig {
|
||||||
|
|
||||||
public static final String EXCHANGE_NAME = "openisle-exchange";
|
public static final String EXCHANGE_NAME = "openisle-exchange";
|
||||||
@@ -38,7 +40,7 @@ public class RabbitMQConfig {
|
|||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
|
log.info("RabbitMQ配置初始化: 队列数量={}, 持久化={}", queueCount, queueDurable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -51,7 +53,7 @@ public class RabbitMQConfig {
|
|||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public List<Queue> shardedQueues() {
|
public List<Queue> shardedQueues() {
|
||||||
System.out.println("开始创建分片队列 Bean...");
|
log.info("开始创建分片队列 Bean...");
|
||||||
|
|
||||||
List<Queue> queues = new ArrayList<>();
|
List<Queue> queues = new ArrayList<>();
|
||||||
for (int i = 0; i < queueCount; i++) {
|
for (int i = 0; i < queueCount; i++) {
|
||||||
@@ -61,7 +63,7 @@ public class RabbitMQConfig {
|
|||||||
queues.add(queue);
|
queues.add(queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
|
log.info("分片队列 Bean 创建完成,总数: {}", queues.size());
|
||||||
return queues;
|
return queues;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ public class RabbitMQConfig {
|
|||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
|
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
|
||||||
System.out.println("开始创建分片绑定 Bean...");
|
log.info("开始创建分片绑定 Bean...");
|
||||||
List<Binding> bindings = new ArrayList<>();
|
List<Binding> bindings = new ArrayList<>();
|
||||||
if (shardedQueues != null) {
|
if (shardedQueues != null) {
|
||||||
for (Queue queue : shardedQueues) {
|
for (Queue queue : shardedQueues) {
|
||||||
@@ -82,7 +84,7 @@ public class RabbitMQConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
|
log.info("分片绑定 Bean 创建完成,总数: {}", bindings.size());
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +137,14 @@ public class RabbitMQConfig {
|
|||||||
@Qualifier("shardedBindings") List<Binding> shardedBindings,
|
@Qualifier("shardedBindings") List<Binding> shardedBindings,
|
||||||
Binding legacyBinding) {
|
Binding legacyBinding) {
|
||||||
return args -> {
|
return args -> {
|
||||||
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
|
log.info("=== 开始主动声明 RabbitMQ 组件 ===");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 声明交换
|
// 声明交换
|
||||||
rabbitAdmin.declareExchange(exchange);
|
rabbitAdmin.declareExchange(exchange);
|
||||||
|
|
||||||
// 声明分片队列 - 检查存在性
|
// 声明分片队列 - 检查存在性
|
||||||
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列...");
|
log.info("开始检查并声明 {} 个分片队列...", shardedQueues.size());
|
||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
int skippedCount = 0;
|
int skippedCount = 0;
|
||||||
|
|
||||||
@@ -159,45 +161,44 @@ public class RabbitMQConfig {
|
|||||||
skippedCount++;
|
skippedCount++;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
|
log.error("队列声明失败: {}, 错误: {}", queueName, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
|
log.info("分片队列处理完成: 成功 {}, 跳过 {}, 总数 {}", successCount, skippedCount, shardedQueues.size());
|
||||||
|
|
||||||
// 声明分片绑定
|
// 声明分片绑定
|
||||||
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
|
log.info("开始声明 {} 个分片绑定...", shardedBindings.size());
|
||||||
int bindingSuccessCount = 0;
|
int bindingSuccessCount = 0;
|
||||||
for (Binding binding : shardedBindings) {
|
for (Binding binding : shardedBindings) {
|
||||||
try {
|
try {
|
||||||
rabbitAdmin.declareBinding(binding);
|
rabbitAdmin.declareBinding(binding);
|
||||||
bindingSuccessCount++;
|
bindingSuccessCount++;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("绑定声明失败: " + e.getMessage());
|
log.error("绑定声明失败: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
|
log.info("分片绑定声明完成: 成功 {}/{}", bindingSuccessCount, shardedBindings.size());
|
||||||
|
|
||||||
// 声明遗留队列和绑定 - 检查存在性
|
// 声明遗留队列和绑定 - 检查存在性
|
||||||
try {
|
try {
|
||||||
rabbitAdmin.declareQueue(legacyQueue);
|
rabbitAdmin.declareQueue(legacyQueue);
|
||||||
rabbitAdmin.declareBinding(legacyBinding);
|
rabbitAdmin.declareBinding(legacyBinding);
|
||||||
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
|
log.info("遗留队列和绑定就绪: {} (已存在或新创建)", QUEUE_NAME);
|
||||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
} catch (org.springframework.amqp.AmqpIOException e) {
|
||||||
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
|
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
|
||||||
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
|
log.warn("遗留队列已存在但 durable 设置不匹配: {}, 保持现有队列", QUEUE_NAME);
|
||||||
} else {
|
} else {
|
||||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
log.error("遗留队列声明失败: {}, 错误: {}", QUEUE_NAME, e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("=== RabbitMQ 组件声明完成 ===");
|
log.info("=== RabbitMQ 组件声明完成 ===");
|
||||||
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
log.info("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
|
log.error("RabbitMQ 组件声明过程中发生严重错误", e);
|
||||||
e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -26,6 +27,8 @@ import org.springframework.web.cors.CorsConfiguration;
|
|||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
@@ -44,6 +47,8 @@ public class SecurityConfig {
|
|||||||
@Value("${app.website-url}")
|
@Value("${app.website-url}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
|
private final RedisTemplate redisTemplate;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
@@ -208,7 +213,8 @@ public class SecurityConfig {
|
|||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
|
||||||
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
|
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
|
||||||
userVisitService.recordVisit(auth.getName());
|
String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now();
|
||||||
|
redisTemplate.opsForSet().add(key, auth.getName());
|
||||||
}
|
}
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,10 +155,10 @@ public class PostController {
|
|||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
tids = java.util.List.of(tagId);
|
tids = java.util.List.of(tagId);
|
||||||
}
|
}
|
||||||
|
// 只需要在请求的一开始统计一次
|
||||||
if (auth != null) {
|
// if (auth != null) {
|
||||||
userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
}
|
// }
|
||||||
|
|
||||||
boolean hasCategories = ids != null && !ids.isEmpty();
|
boolean hasCategories = ids != null && !ids.isEmpty();
|
||||||
boolean hasTags = tids != null && !tids.isEmpty();
|
boolean hasTags = tids != null && !tids.isEmpty();
|
||||||
@@ -195,10 +195,10 @@ public class PostController {
|
|||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
tids = java.util.List.of(tagId);
|
tids = java.util.List.of(tagId);
|
||||||
}
|
}
|
||||||
|
// 只需要在请求的一开始统计一次
|
||||||
if (auth != null) {
|
// if (auth != null) {
|
||||||
userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
}
|
// }
|
||||||
|
|
||||||
return postService.listPostsByViews(ids, tids, page, pageSize)
|
return postService.listPostsByViews(ids, tids, page, pageSize)
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||||
@@ -223,10 +223,10 @@ public class PostController {
|
|||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
tids = java.util.List.of(tagId);
|
tids = java.util.List.of(tagId);
|
||||||
}
|
}
|
||||||
|
// 只需要在请求的一开始统计一次
|
||||||
if (auth != null) {
|
// if (auth != null) {
|
||||||
userVisitService.recordVisit(auth.getName());
|
// userVisitService.recordVisit(auth.getName());
|
||||||
}
|
// }
|
||||||
|
|
||||||
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
|
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||||
@@ -251,9 +251,10 @@ public class PostController {
|
|||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
tids = java.util.List.of(tagId);
|
tids = java.util.List.of(tagId);
|
||||||
}
|
}
|
||||||
if (auth != null) {
|
// 只需要在请求的一开始统计一次
|
||||||
userVisitService.recordVisit(auth.getName());
|
// if (auth != null) {
|
||||||
}
|
// userVisitService.recordVisit(auth.getName());
|
||||||
|
// }
|
||||||
return postService.listFeaturedPosts(ids, tids, page, pageSize)
|
return postService.listFeaturedPosts(ids, tids, page, pageSize)
|
||||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ public class UserController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 这个方法似乎没有使用?
|
||||||
@PostMapping("/me/signin")
|
@PostMapping("/me/signin")
|
||||||
@SecurityRequirement(name = "JWT")
|
@SecurityRequirement(name = "JWT")
|
||||||
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
|
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.openisle.schdule;
|
||||||
|
|
||||||
|
import com.openisle.config.CachingConfig;
|
||||||
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.model.UserVisit;
|
||||||
|
import com.openisle.repository.UserRepository;
|
||||||
|
import com.openisle.repository.UserVisitRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行计划
|
||||||
|
* 将每天用户访问落库
|
||||||
|
* @author smallclover
|
||||||
|
* @since 2025-09-09
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserVisitScheduler {
|
||||||
|
private final RedisTemplate redisTemplate;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserVisitRepository userVisitRepository;
|
||||||
|
|
||||||
|
@Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行
|
||||||
|
public void persistDailyVisits(){
|
||||||
|
LocalDate yesterday = LocalDate.now().minusDays(1);
|
||||||
|
String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday;
|
||||||
|
Set<String> usernames = redisTemplate.opsForSet().members(key);
|
||||||
|
if(!CollectionUtils.isEmpty(usernames)){
|
||||||
|
for(String username: usernames){
|
||||||
|
User user = userRepository.findByUsername(username).orElse(null);
|
||||||
|
if(user != null){
|
||||||
|
UserVisit userVisit = new UserVisit();
|
||||||
|
userVisit.setUser(user);
|
||||||
|
userVisit.setVisitDate(yesterday);
|
||||||
|
userVisitRepository.save(userVisit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.model.UserVisit;
|
import com.openisle.model.UserVisit;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.repository.UserVisitRepository;
|
import com.openisle.repository.UserVisitRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.cache.annotation.CacheConfig;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -17,6 +24,8 @@ public class UserVisitService {
|
|||||||
private final UserVisitRepository userVisitRepository;
|
private final UserVisitRepository userVisitRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
private final RedisTemplate redisTemplate;
|
||||||
|
|
||||||
public boolean recordVisit(String username) {
|
public boolean recordVisit(String username) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
@@ -30,10 +39,36 @@ public class UserVisitService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计访问次数,改为从缓存获取/数据库获取
|
||||||
|
* @param username
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
public long countVisits(String username) {
|
public long countVisits(String username) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
return userVisitRepository.countByUser(user);
|
|
||||||
|
// 如果缓存存在就返回
|
||||||
|
String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username;
|
||||||
|
Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
|
||||||
|
if(cached != null){
|
||||||
|
return cached.longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis Set 检查今天是否访问
|
||||||
|
String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
|
||||||
|
boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username);
|
||||||
|
|
||||||
|
Long visitCount = userVisitRepository.countByUser(user);
|
||||||
|
if (todayVisited) visitCount += 1;
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
|
||||||
|
long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds();
|
||||||
|
|
||||||
|
// 写入缓存,设置 TTL,当天剩余时间
|
||||||
|
redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay));
|
||||||
|
return visitCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countDau(LocalDate date) {
|
public long countDau(LocalDate date) {
|
||||||
|
|||||||
@@ -239,8 +239,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text img {
|
.info-content-text img {
|
||||||
max-width: 100%;
|
max-width: min(800px, 100%);
|
||||||
|
max-height: 600px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.11);
|
||||||
|
transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text img:hover {
|
||||||
|
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text table {
|
.info-content-text table {
|
||||||
@@ -346,25 +354,41 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust diff2html layout on mobile */
|
.d2h-file-name {
|
||||||
@media (max-width: 768px) {
|
font-size: 12px !important;
|
||||||
.content-diff .d2h-wrapper,
|
|
||||||
.content-diff .d2h-code-line,
|
|
||||||
.content-diff .d2h-code-side-line,
|
|
||||||
.content-diff .d2h-code-line-ctn,
|
|
||||||
.content-diff .d2h-code-side-line-ctn,
|
|
||||||
.content-diff .d2h-file-header {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-diff .d2h-wrapper {
|
.d2h-file-header {
|
||||||
overflow-x: auto;
|
height: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.d2h-code-linenumber {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d2h-code-line {
|
||||||
|
padding-left: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d2h-diff-table {
|
||||||
|
font-size: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d2h-code-line ins {
|
||||||
|
height: 100%;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .d2h-code-line {
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d2h-code-line-ctn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
} */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition API */
|
|
||||||
::view-transition-old(root),
|
::view-transition-old(root),
|
||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default {
|
|||||||
|
|
||||||
.timeline-content {
|
.timeline-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 42px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -70,23 +70,6 @@ export default {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
vditorInstance.value = createVditor(editorId.value, {
|
vditorInstance.value = createVditor(editorId.value, {
|
||||||
placeholder: '输入消息...',
|
placeholder: '输入消息...',
|
||||||
height: 150,
|
|
||||||
toolbar: [
|
|
||||||
'emoji',
|
|
||||||
'bold',
|
|
||||||
'italic',
|
|
||||||
'strike',
|
|
||||||
'link',
|
|
||||||
'|',
|
|
||||||
'list',
|
|
||||||
'|',
|
|
||||||
'line',
|
|
||||||
'quote',
|
|
||||||
'code',
|
|
||||||
'inline-code',
|
|
||||||
'|',
|
|
||||||
'upload',
|
|
||||||
],
|
|
||||||
preview: {
|
preview: {
|
||||||
actions: [],
|
actions: [],
|
||||||
markdown: { toc: false },
|
markdown: { toc: false },
|
||||||
@@ -149,11 +132,17 @@ export default {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vditor {
|
||||||
|
min-height: 50px;
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
.message-bottom-container {
|
.message-bottom-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 10px;
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
background-color: var(--bg-color-soft);
|
background-color: var(--bg-color-soft);
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
border-bottom-left-radius: 8px;
|
border-bottom-left-radius: 8px;
|
||||||
|
|||||||
@@ -110,11 +110,13 @@ const diffHtml = computed(() => {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-log-text {
|
.change-log-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-log-user {
|
.change-log-user {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -132,6 +134,7 @@ const diffHtml = computed(() => {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.change-log-time {
|
.change-log-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="about-page">
|
<div class="about-page">
|
||||||
<BaseTabs v-model="selectedTab" :tabs="tabs">
|
<BaseTabs v-model="selectedTab" :tabs="tabs">
|
||||||
<div class="about-loading" v-if="isFetching">
|
<template v-if="selectedTab === 'api'">
|
||||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
<div class="about-api">
|
||||||
</div>
|
<div class="about-api-title">调试Token</div>
|
||||||
<div
|
<div v-if="!authState.loggedIn" class="about-api-login">
|
||||||
v-else
|
请<NuxtLink to="/login" class="about-api-login-link">登录</NuxtLink>后查看 Token
|
||||||
class="about-content"
|
</div>
|
||||||
v-html="renderMarkdown(content)"
|
<div v-else class="about-api-token">
|
||||||
@click="handleContentClick"
|
<div class="token-row">
|
||||||
></div>
|
<span class="token-text">{{ shortToken }}</span>
|
||||||
|
<span @click="copyToken"><copy class="copy-icon" /></span>
|
||||||
|
</div>
|
||||||
|
<div class="warning-row">
|
||||||
|
<info-icon class="warning-icon" />
|
||||||
|
<div class="token-warning">请不要将 Token 泄露给他人</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="about-api-title">API文档和调试入口</div>
|
||||||
|
<div class="about-api-link">API Playground <share /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="about-loading" v-if="isFetching">
|
||||||
|
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="about-content"
|
||||||
|
v-html="renderMarkdown(content)"
|
||||||
|
@click="handleContentClick"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
</BaseTabs>
|
</BaseTabs>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from '#imports'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
import BaseTabs from '~/components/BaseTabs.vue'
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
|
import { toast } from '~/composables/useToast'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AboutPageView',
|
name: 'AboutPageView',
|
||||||
@@ -44,11 +69,25 @@ export default {
|
|||||||
label: '隐私政策',
|
label: '隐私政策',
|
||||||
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
|
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'api',
|
||||||
|
label: 'API与调试',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const selectedTab = ref(tabs[0].key)
|
const selectedTab = ref(tabs[0].key)
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
|
const token = computed(() => (authState.loggedIn ? getToken() : ''))
|
||||||
|
|
||||||
|
const shortToken = computed(() => {
|
||||||
|
if (!token.value) return ''
|
||||||
|
if (token.value.length <= 20) return token.value
|
||||||
|
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
|
||||||
|
})
|
||||||
|
|
||||||
const loadContent = async (file) => {
|
const loadContent = async (file) => {
|
||||||
|
if (!file) return
|
||||||
try {
|
try {
|
||||||
isFetching.value = true
|
isFetching.value = true
|
||||||
const res = await fetch(file)
|
const res = await fetch(file)
|
||||||
@@ -65,19 +104,58 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadContent(tabs[0].file)
|
const initTab = route.query.tab
|
||||||
|
if (initTab && tabs.find((t) => t.key === initTab)) {
|
||||||
|
selectedTab.value = initTab
|
||||||
|
const tab = tabs.find((t) => t.key === initTab)
|
||||||
|
if (tab && tab.file) loadContent(tab.file)
|
||||||
|
} else {
|
||||||
|
loadContent(tabs[0].file)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTab, (name) => {
|
watch(selectedTab, (name) => {
|
||||||
const tab = tabs.find((t) => t.key === name)
|
const tab = tabs.find((t) => t.key === name)
|
||||||
if (tab) loadContent(tab.file)
|
if (tab && tab.file) loadContent(tab.file)
|
||||||
|
router.replace({ query: { ...route.query, tab: name } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(name) => {
|
||||||
|
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
|
||||||
|
selectedTab.value = name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const copyToken = async () => {
|
||||||
|
if (import.meta.client && token.value) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token.value)
|
||||||
|
toast.success('已复制 Token')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleContentClick = (e) => {
|
const handleContentClick = (e) => {
|
||||||
handleMarkdownClick(e)
|
handleMarkdownClick(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
|
return {
|
||||||
|
tabs,
|
||||||
|
selectedTab,
|
||||||
|
content,
|
||||||
|
renderMarkdown,
|
||||||
|
isFetching,
|
||||||
|
handleContentClick,
|
||||||
|
authState,
|
||||||
|
token,
|
||||||
|
copyToken,
|
||||||
|
shortToken,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -101,6 +179,66 @@ export default {
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.about-api {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-api-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-api-login-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-api-login-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-warning {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-api-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-api-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.about-tabs {
|
.about-tabs {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-container" :class="{ float: isFloatMode }">
|
<div class="chat-container" :class="{ float: isFloatMode }">
|
||||||
|
<vue-easy-lightbox
|
||||||
|
:visible="lightboxVisible"
|
||||||
|
:index="lightboxIndex"
|
||||||
|
:imgs="lightboxImgs"
|
||||||
|
@hide="lightboxVisible = false"
|
||||||
|
/>
|
||||||
<div v-if="!loading" class="chat-header">
|
<div v-if="!loading" class="chat-header">
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<div class="back-button" @click="goBack">
|
<div class="back-button" @click="goBack">
|
||||||
@@ -14,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages-list" ref="messagesListEl">
|
<div class="messages-list" ref="messagesListEl" @click="handleContentClick">
|
||||||
<div v-if="loading" class="loading-container">
|
<div v-if="loading" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +107,7 @@ import {
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
import { renderMarkdown, stripMarkdownLength, handleMarkdownClick } from '~/utils/markdown'
|
||||||
import MessageEditor from '~/components/MessageEditor.vue'
|
import MessageEditor from '~/components/MessageEditor.vue'
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
import { useWebSocket } from '~/composables/useWebSocket'
|
import { useWebSocket } from '~/composables/useWebSocket'
|
||||||
@@ -110,6 +116,7 @@ import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
|||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -135,6 +142,9 @@ const isFloatMode = computed(() => route.query.float !== undefined)
|
|||||||
const floatRoute = useState('messageFloatRoute')
|
const floatRoute = useState('messageFloatRoute')
|
||||||
const replyTo = ref(null)
|
const replyTo = ref(null)
|
||||||
const newMessagesCount = ref(0)
|
const newMessagesCount = ref(0)
|
||||||
|
const lightboxVisible = ref(false)
|
||||||
|
const lightboxIndex = ref(0)
|
||||||
|
const lightboxImgs = ref([])
|
||||||
|
|
||||||
const isUserNearBottom = ref(true)
|
const isUserNearBottom = ref(true)
|
||||||
function updateNearBottom() {
|
function updateNearBottom() {
|
||||||
@@ -451,6 +461,17 @@ function minimize() {
|
|||||||
navigateTo('/')
|
navigateTo('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleContentClick(e) {
|
||||||
|
handleMarkdownClick(e)
|
||||||
|
if (e.target.tagName === 'IMG') {
|
||||||
|
const container = e.target.parentNode
|
||||||
|
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
|
||||||
|
lightboxImgs.value = imgs
|
||||||
|
lightboxIndex.value = imgs.indexOf(e.target.src)
|
||||||
|
lightboxVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openUser(id) {
|
function openUser(id) {
|
||||||
if (isFloatMode.value) {
|
if (isFloatMode.value) {
|
||||||
// 先不处理...
|
// 先不处理...
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
Open,
|
Open,
|
||||||
Dislike,
|
Dislike,
|
||||||
CheckOne,
|
CheckOne,
|
||||||
|
Share,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -157,4 +158,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('OpenIcon', Open)
|
nuxtApp.vueApp.component('OpenIcon', Open)
|
||||||
nuxtApp.vueApp.component('Dislike', Dislike)
|
nuxtApp.vueApp.component('Dislike', Dislike)
|
||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user