增加登陆验证码,24小时流量统计

This commit is contained in:
qaq
2025-08-14 15:07:53 +08:00
parent 24dcd58d80
commit 6b65a7dd2d
60 changed files with 1108 additions and 148 deletions
@@ -0,0 +1,18 @@
package com.admin.common.dto;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 验证码验证请求DTO
*/
@Data
public class CaptchaVerifyDto {
private String id;
private ImageCaptchaTrack data;
}
@@ -14,4 +14,7 @@ public class LoginDto {
@NotBlank(message = "密码不能为空")
private String password;
private String captchaId;
}
@@ -1,5 +1,6 @@
package com.admin.common.dto;
import com.admin.entity.StatisticsFlow;
import lombok.Data;
import java.util.List;
@@ -24,6 +25,11 @@ public class UserPackageDto {
* 用户转发列表
*/
private List<UserForwardDetailDto> forwards;
/**
* 用户转发列表
*/
private List<StatisticsFlow> statisticsFlows;
/**
* 用户基本信息
@@ -48,7 +48,7 @@ public class ResetFlowAsync {
* - 不会影响DelayQueueManager的到期任务对status等其他字段的更新
* - 避免了并发修改导致的数据覆盖问题
*/
@Scheduled(cron = "0 0 0 * * ?")
@Scheduled(cron = "5 0 0 * * ?")
public void reset_flow(){
log.info("开始执行流量重置任务");
@@ -0,0 +1,75 @@
package com.admin.common.task;
import com.admin.entity.StatisticsFlow;
import com.admin.entity.User;
import com.admin.service.StatisticsFlowService;
import com.admin.service.UserService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Configuration
@EnableScheduling
public class StatisticsFlowAsync {
@Resource
UserService userService;
@Resource
StatisticsFlowService statisticsFlowService;
@Scheduled(cron = "0 0 * * * ?")
public void statistics_flow() {
LocalDateTime currentHour = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
String hourString = currentHour.format(DateTimeFormatter.ofPattern("HH:mm"));
List<User> list = userService.list();
List<StatisticsFlow> statisticsFlowList = new ArrayList<>();
for (User user : list) {
long currentFlow = user.getInFlow() + user.getOutFlow();
// 从数据库获取上一次记录
StatisticsFlow lastFlowRecord = statisticsFlowService.getOne(
new LambdaQueryWrapper<StatisticsFlow>()
.eq(StatisticsFlow::getUserId, user.getId())
.orderByDesc(StatisticsFlow::getId)
.last("LIMIT 1")
);
long currentTotalFlow = currentFlow;
long incrementFlow = currentTotalFlow;
if (lastFlowRecord != null) {
long lastTotalFlow = lastFlowRecord.getTotalFlow();
incrementFlow = currentTotalFlow - lastTotalFlow;
if (incrementFlow < 0) {
incrementFlow = currentTotalFlow;
}
}
StatisticsFlow statisticsFlow = new StatisticsFlow();
statisticsFlow.setUserId(user.getId());
statisticsFlow.setFlow(incrementFlow);
statisticsFlow.setTotalFlow(currentTotalFlow);
statisticsFlow.setTime(hourString);
statisticsFlowList.add(statisticsFlow);
}
statisticsFlowService.saveBatch(statisticsFlowList);
}
}
@@ -0,0 +1,37 @@
package com.admin.config;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.resource.FontCache;
import cloud.tianai.captcha.resource.ResourceStore;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@Component
@RequiredArgsConstructor
public class CaptchaResourceConfiguration {
private final ResourceStore resourceStore;
private static final String[] OPTIONS = {
"SLIDER", "WORD_IMAGE_CLICK", "ROTATE", "CONCAT"
};
@PostConstruct
public void init() {
// 添加自定义背景图片
for (String option : OPTIONS) {
for (int i = 1; i <= 26; i++) {
resourceStore.addResource(option, new Resource("classpath", "bgimages/"+i + ".jpg", "default"));
}
}
//添加自定义字体
resourceStore.addResource(FontCache.FONT_TYPE,new Resource("classpath", "fonts/SIMSUN.TTC", "default"));
}
}
@@ -59,6 +59,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
.excludePathPatterns("/flow/**")
.excludePathPatterns("/api/v1/open_api/**")
.excludePathPatterns("/api/v1/config/get")
.excludePathPatterns("/api/v1/user/login");
.excludePathPatterns("/api/v1/user/login")
.excludePathPatterns("/api/v1/captcha/**");
}
}
@@ -0,0 +1,77 @@
package com.admin.controller;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.common.constant.CaptchaTypeConstant;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.application.vo.ImageCaptchaVO;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cloud.tianai.captcha.validator.common.model.dto.MatchParam;
import com.admin.common.dto.CaptchaVerifyDto;
import com.admin.common.lang.R;
import com.admin.entity.ViteConfig;
import com.admin.service.ViteConfigService;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import cloud.tianai.captcha.application.vo.CaptchaResponse;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
/**
* 验证码控制器
*/
@Slf4j
@RestController
@CrossOrigin
@RequestMapping("/api/v1/captcha")
public class CaptchaController extends BaseController {
@Resource
ImageCaptchaApplication application;
private static final String[] OPTIONS = {
"SLIDER", "WORD_IMAGE_CLICK", "ROTATE", "CONCAT"
};
@PostMapping("/check")
public R check() {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
if (viteConfig == null) return R.ok(0);
if (!Objects.equals(viteConfig.getValue(), "true")) return R.ok(0);
return R.ok(1);
}
@PostMapping("/generate")
public CaptchaResponse<ImageCaptchaVO> genCaptcha() {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_type"));
String captchaType;
if (viteConfig == null || Objects.equals(viteConfig.getValue(), "RANDOM")) {
captchaType = getRandomOption();
}else {
captchaType = viteConfig.getValue();
}
return application.generateCaptcha(captchaType);
}
@PostMapping("/verify")
public ApiResponse<?> verify(@Valid @RequestBody CaptchaVerifyDto verifyDto) {
ApiResponse<?> response = application.matching(verifyDto.getId(), verifyDto.getData());
if (response.isSuccess()) {
return ApiResponse.ofSuccess(Collections.singletonMap("validToken", verifyDto.getId()));
}
return response;
}
public static String getRandomOption() {
int index = ThreadLocalRandom.current().nextInt(OPTIONS.length);
return OPTIONS[index];
}
}
@@ -0,0 +1,32 @@
package com.admin.entity;
import com.admin.entity.BaseEntity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* <p>
*
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
@Data
public class StatisticsFlow {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long userId;
private Long flow;
private Long totalFlow;
private String time;
}
@@ -0,0 +1,16 @@
package com.admin.mapper;
import com.admin.entity.StatisticsFlow;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* <p>
* Mapper 接口
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
public interface StatisticsFlowMapper extends BaseMapper<StatisticsFlow> {
}
@@ -0,0 +1,16 @@
package com.admin.service;
import com.admin.entity.StatisticsFlow;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* <p>
* 服务类
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
public interface StatisticsFlowService extends IService<StatisticsFlow> {
}
@@ -7,10 +7,12 @@ import com.admin.common.dto.NodeUpdateDto;
import com.admin.common.lang.R;
import com.admin.entity.Node;
import com.admin.entity.Tunnel;
import com.admin.entity.ViteConfig;
import com.admin.mapper.NodeMapper;
import com.admin.mapper.TunnelMapper;
import com.admin.service.NodeService;
import com.admin.service.TunnelService;
import com.admin.service.ViteConfigService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -69,8 +71,9 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
@Lazy
private TunnelService tunnelService;
@Value("${server-addr}")
private String serverAddr;
@Resource
ViteConfigService viteConfigService;
// ========== 公共接口实现 ==========
@@ -321,9 +324,7 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
}
// 2. 构建安装命令
String installCommand = buildInstallCommand(node);
return R.ok(installCommand);
return buildInstallCommand(node);
}
/**
@@ -332,7 +333,10 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
* @param node 节点对象
* @return 格式化的安装命令
*/
private String buildInstallCommand(Node node) {
private R buildInstallCommand(Node node) {
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "ip"));
if (viteConfig == null) return R.err("请先前往网站配置中设置ip");
StringBuilder command = new StringBuilder();
// 第一部分:下载安装脚本
@@ -340,14 +344,14 @@ public class NodeServiceImpl extends ServiceImpl<NodeMapper, Node> implements No
.append(" -o ./install.sh && chmod +x ./install.sh && ");
// 处理服务器地址,如果是IPv6需要添加方括号
String processedServerAddr = processServerAddress(serverAddr);
String processedServerAddr = processServerAddress(viteConfig.getValue());
// 第二部分:执行安装脚本(去掉-u参数)
command.append("./install.sh")
.append(" -a ").append(processedServerAddr) // 服务器地址
.append(" -s ").append(node.getSecret()); // 节点密钥
return command.toString();
return R.ok(command.toString());
}
/**
@@ -0,0 +1,20 @@
package com.admin.service.impl;
import com.admin.entity.StatisticsFlow;
import com.admin.mapper.StatisticsFlowMapper;
import com.admin.service.StatisticsFlowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
/**
* <p>
* 服务实现类
* </p>
*
* @author QAQ
* @since 2025-08-14
*/
@Service
public class StatisticsFlowServiceImpl extends ServiceImpl<StatisticsFlowMapper, StatisticsFlow> implements StatisticsFlowService {
}
@@ -1,5 +1,7 @@
package com.admin.service.impl;
import cloud.tianai.captcha.application.ImageCaptchaApplication;
import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import com.admin.common.dto.*;
@@ -7,28 +9,24 @@ import com.admin.common.lang.R;
import com.admin.common.utils.GostUtil;
import com.admin.common.utils.JwtUtil;
import com.admin.common.utils.Md5Util;
import com.admin.entity.Forward;
import com.admin.entity.Node;
import com.admin.entity.Tunnel;
import com.admin.entity.User;
import com.admin.entity.UserTunnel;
import com.admin.entity.*;
import com.admin.mapper.ForwardMapper;
import com.admin.mapper.UserMapper;
import com.admin.mapper.UserTunnelMapper;
import com.admin.service.NodeService;
import com.admin.service.TunnelService;
import com.admin.service.UserService;
import com.admin.service.UserTunnelService;
import com.admin.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* <p>
@@ -77,8 +75,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
private static final String ERROR_GET_PACKAGE_INFO_FAILED = "获取套餐信息失败";
private static final String ERROR_CURRENT_PASSWORD_WRONG = "当前密码错误";
private static final String ERROR_PASSWORD_NOT_MATCH = "新密码和确认密码不匹配";
private static final String SUCCESS_PASSWORD_UPDATE = "密码修改成功";
/** 默认账号密码 */
private static final String DEFAULT_USERNAME = "admin_user";
private static final String DEFAULT_PASSWORD = "admin_user";
@@ -112,28 +109,48 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
@Resource
UserTunnelService userTunnelService;
@Resource
ViteConfigService viteConfigService;
@Resource
StatisticsFlowService statisticsFlowService;
@Resource
private ImageCaptchaApplication application;
// ========== 公共接口实现 ==========
/**
* 用户登录
* 验证用户名密码,检查账户状态,生成JWT令牌
* 验证验证码、用户名密码,检查账户状态,生成JWT令牌
*
* @param loginDto 登录数据传输对象
* @return 登录结果响应,包含令牌和用户信息
*/
@Override
public R login(LoginDto loginDto) {
// 1. 验证用户凭据
// 1. 验证验证码
ViteConfig viteConfig = viteConfigService.getOne(new QueryWrapper<ViteConfig>().eq("name", "captcha_enabled"));
if (viteConfig != null && Objects.equals(viteConfig.getValue(), "true")) {
if (StringUtils.isBlank(loginDto.getCaptchaId())) return R.err("验证码校验失败");
boolean valid = ((SecondaryVerificationApplication) application).secondaryVerification(loginDto.getCaptchaId());
if (!valid) return R.err("验证码校验失败");
}
// 2. 验证用户凭据
LoginValidationResult validationResult = validateUserCredentials(loginDto);
if (validationResult.isHasError()) {
return R.err(validationResult.getErrorMessage());
}
// 2. 生成令牌并返回用户信息
// 3. 生成令牌并返回用户信息
User user = validationResult.getUser();
String token = JwtUtil.generateToken(user);
// 3. 检查是否使用默认账号密码
// 4. 检查是否使用默认账号密码
boolean requirePasswordChange = isDefaultCredentials(loginDto.getUsername(), loginDto.getPassword());
return R.ok(MapUtil.builder()
@@ -238,6 +255,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
try {
// 2. 级联删除用户相关数据
deleteUserRelatedData(id);
statisticsFlowService.remove(new QueryWrapper<StatisticsFlow>().eq("user_id", id));
// 3. 删除用户
boolean result = this.removeById(id);
return result ? R.ok(SUCCESS_DELETE_MSG) : R.err(ERROR_DELETE_FAILED);
@@ -345,6 +363,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
// ========== 私有辅助方法 ==========
/**
* 验证用户登录凭据
*
@@ -655,12 +674,16 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
// 3. 获取转发详情
List<UserPackageDto.UserForwardDetailDto> forwards = userMapper.getUserForwardDetails(user.getId().intValue());
// 4. 查询最近24小时流量信息,没有的补0
List<StatisticsFlow> statisticsFlows = getLast24HoursFlowStatistics(user.getId());
// 4. 构造返回结果
// 5. 构造返回结果
UserPackageDto packageDto = new UserPackageDto();
packageDto.setUserInfo(userInfo);
packageDto.setTunnelPermissions(tunnelPermissions);
packageDto.setForwards(forwards);
packageDto.setStatisticsFlows(statisticsFlows);
return packageDto;
}
@@ -697,6 +720,104 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
return userMapper.getUserTunnelDetails(userId.intValue());
}
/**
* 获取用户最近24小时的流量统计数据,没有数据的时间点补0
*
* @param userId 用户ID
* @return 最近24小时流量统计列表
*/
private List<StatisticsFlow> getLast24HoursFlowStatistics(Long userId) {
try {
// 按ID倒序查最近24条记录(ID越大越新,时间就是23:00, 22:00, 21:00...这样倒序)
List<StatisticsFlow> recentFlows = statisticsFlowService.list(
new QueryWrapper<StatisticsFlow>()
.eq("user_id", userId)
.orderByDesc("id")
.last("LIMIT 24")
);
List<StatisticsFlow> result = new ArrayList<>(recentFlows);
// 如果查出来的记录不足24条,需要补0和对应的时间
if (result.size() < 24) {
// 获取最早记录的时间,继续往前推
int startHour = getCurrentHour();
if (!result.isEmpty()) {
// 从最后一条记录的时间继续往前推
String lastTime = result.get(result.size() - 1).getTime();
startHour = parseHour(lastTime) - 1;
}
// 补0到24条
while (result.size() < 24) {
if (startHour < 0) startHour = 23; // 跨天处理
StatisticsFlow emptyFlow = new StatisticsFlow();
emptyFlow.setUserId(userId);
emptyFlow.setFlow(0L);
emptyFlow.setTotalFlow(0L);
emptyFlow.setTime(String.format("%02d:00", startHour));
result.add(emptyFlow);
startHour--;
}
}
log.debug("用户 {} 获取到 {} 条实际记录,补齐为 {} 条24小时记录", userId, recentFlows.size(), result.size());
return result;
} catch (Exception e) {
log.error("获取用户 {} 最近24小时流量统计失败", userId, e);
// 返回24条全0数据,时间从当前小时往前推
return generateEmpty24HourData(userId);
}
}
/**
* 获取当前小时(0-23)
*/
private int getCurrentHour() {
return java.time.LocalDateTime.now().getHour();
}
/**
* 解析时间字符串获取小时数
*/
private int parseHour(String timeStr) {
try {
if (timeStr != null && timeStr.contains(":")) {
return Integer.parseInt(timeStr.split(":")[0]);
}
} catch (Exception e) {
// 解析失败,返回当前小时
}
return getCurrentHour();
}
/**
* 生成24小时全0数据(异常情况使用)
*/
private List<StatisticsFlow> generateEmpty24HourData(Long userId) {
List<StatisticsFlow> result = new ArrayList<>();
int currentHour = getCurrentHour();
for (int i = 0; i < 24; i++) {
StatisticsFlow emptyFlow = new StatisticsFlow();
emptyFlow.setUserId(userId);
emptyFlow.setFlow(0L);
emptyFlow.setTotalFlow(0L);
emptyFlow.setTime(String.format("%02d:00", currentHour));
result.add(emptyFlow);
currentHour--;
if (currentHour < 0) currentHour = 23;
}
return result;
}