feat(system/client) :客户端管理扩展登录配置

This commit is contained in:
KAI
2025-11-25 08:14:00 +00:00
committed by Charles7c
parent ea9bc02988
commit 0ab31ba9f9
9 changed files with 223 additions and 12 deletions

View File

@@ -249,6 +249,7 @@ sa-token:
# 是否允许同一账号多地同时登录(为 true 时允许一起登录,为 false 时新登录挤掉旧登录) # 是否允许同一账号多地同时登录(为 true 时允许一起登录,为 false 时新登录挤掉旧登录)
is-concurrent: true is-concurrent: true
# 在多人登录同一账号时,是否共用一个 Token为 true 时所有登录共用一个 Token为 false 时每次登录新建一个 Token # 在多人登录同一账号时,是否共用一个 Token为 true 时所有登录共用一个 Token为 false 时每次登录新建一个 Token
# 使用 jwt-simple 模式后is-share 恒等于 false目前本项目使用 jwt-simple 模式,可通过 sa-token.extension.enableJwt: false 关闭并自行提供 StpLogic 配置)
is-share: false is-share: false
# 是否输出操作日志 # 是否输出操作日志
is-log: false is-log: false

View File

@@ -399,3 +399,11 @@ CREATE TABLE IF NOT EXISTS `sys_sms_log` (
INDEX `idx_config_id`(`config_id`), INDEX `idx_config_id`(`config_id`),
INDEX `idx_create_user`(`create_user`) INDEX `idx_create_user`(`create_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信日志表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短信日志表';
-- changeset KAI:20251125-01
-- comment 追加sys_client表字段
ALTER TABLE `sys_client`
ADD COLUMN `is_concurrent` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否允许同一账号多地同时登录',
ADD COLUMN `max_login_count` int NOT NULL DEFAULT -1 COMMENT '同一账号最大登录数量,-1代表不限',
ADD COLUMN `replaced_range` varchar(20) DEFAULT 'ALL_DEVICE_TYPE' COMMENT '顶人下线的范围CURR_DEVICE_TYPE=当前设备类型端ALL_DEVICE_TYPE=所有设备类型端)',
ADD COLUMN `overflow_logout_mode` varchar(20) DEFAULT 'KICKOUT' COMMENT '溢出人数的注销方式LOGOUT=注销下线KICKOUT=踢人下线REPLACED=顶人下线)';

View File

@@ -666,3 +666,16 @@ COMMENT ON COLUMN "sys_sms_log"."res_msg" IS '返回数据';
COMMENT ON COLUMN "sys_sms_log"."create_user" IS '创建人'; COMMENT ON COLUMN "sys_sms_log"."create_user" IS '创建人';
COMMENT ON COLUMN "sys_sms_log"."create_time" IS '创建时间'; COMMENT ON COLUMN "sys_sms_log"."create_time" IS '创建时间';
COMMENT ON TABLE "sys_sms_log" IS '短信日志表'; COMMENT ON TABLE "sys_sms_log" IS '短信日志表';
-- changeset kai:20251125-01
-- comment 追加sys_client表字段
ALTER TABLE "sys_client"
ADD COLUMN "is_concurrent" bool NOT NULL DEFAULT false,
ADD COLUMN "max_login_count" int4 NOT NULL DEFAULT -1,
ADD COLUMN "replaced_range" varchar(20) NOT NULL DEFAULT 'ALL_DEVICE_TYPE',
ADD COLUMN "overflow_logout_mode" varchar(20) NOT NULL DEFAULT 'KICKOUT';
COMMENT ON COLUMN "sys_client"."is_concurrent" IS '是否允许同一账号多地同时登录';
COMMENT ON COLUMN "sys_client"."max_login_count" IS '同一账号最大登录数量,-1代表不限';
COMMENT ON COLUMN "sys_client"."replaced_range" IS '顶人下线的范围CURR_DEVICE_TYPE=当前设备类型端ALL_DEVICE_TYPE=所有设备类型端)';
COMMENT ON COLUMN "sys_client"."overflow_logout_mode" IS '溢出人数的注销方式LOGOUT=注销下线KICKOUT=踢人下线REPLACED=顶人下线)';

View File

@@ -18,6 +18,8 @@ package top.continew.admin.auth;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter; import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.dev33.satoken.stp.parameter.enums.SaLogoutMode;
import cn.dev33.satoken.stp.parameter.enums.SaReplacedRange;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -110,28 +112,33 @@ public abstract class AbstractLoginHandler<T extends LoginReq> implements LoginH
return roles; return roles;
}, threadPoolTaskExecutor); }, threadPoolTaskExecutor);
CompletableFuture<Integer> passwordExpirationDaysFuture = CompletableFuture.supplyAsync(() -> optionService CompletableFuture<Integer> passwordExpirationDaysFuture = CompletableFuture.supplyAsync(() -> optionService
.getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()), threadPoolTaskExecutor); .getValueByCode2Int(PASSWORD_EXPIRATION_DAYS.name()), threadPoolTaskExecutor);
CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture); CompletableFuture.allOf(permissionFuture, roleFuture, passwordExpirationDaysFuture);
UserContext userContext = new UserContext(permissionFuture.join(), roleFuture UserContext userContext = new UserContext(permissionFuture.join(), roleFuture
.join(), passwordExpirationDaysFuture.join()); .join(), passwordExpirationDaysFuture.join());
BeanUtil.copyProperties(user, userContext); BeanUtil.copyProperties(user, userContext);
// 设置登录配置参数 // 设置登录配置参数
SaLoginParameter loginParameter = new SaLoginParameter(); SaLoginParameter loginParameter = new SaLoginParameter();
loginParameter.setActiveTimeout(client.getActiveTimeout()); loginParameter.setActiveTimeout(client.getActiveTimeout());
loginParameter.setTimeout(client.getTimeout()); loginParameter.setTimeout(client.getTimeout());
loginParameter.setDeviceType(client.getClientType()); loginParameter.setDeviceType(client.getClientType());
userContext.setClientType(client.getClientType());
loginParameter.setExtra(CLIENT_ID, client.getClientId()); loginParameter.setExtra(CLIENT_ID, client.getClientId());
// 设置并发登录相关参数
loginParameter.setIsConcurrent(client.getIsConcurrent());
loginParameter.setMaxLoginCount(client.getMaxLoginCount());
loginParameter.setOverflowLogoutMode(SaLogoutMode.valueOf(client.getOverflowLogoutMode().getValue()));
loginParameter.setReplacedRange(SaReplacedRange.valueOf(client.getReplacedRange().getValue()));
userContext.setClientType(client.getClientType());
userContext.setClientId(client.getClientId()); userContext.setClientId(client.getClientId());
userContext.setTenantId(tenantId); userContext.setTenantId(tenantId);
// 登录并缓存用户信息 // 登录并缓存用户信息
StpUtil.login(userContext.getId(), loginParameter.setExtraData(BeanUtil StpUtil.login(userContext.getId(), loginParameter.setExtraData(BeanUtil
.beanToMap(new UserExtraContext(ServletUtils.getRequest())))); .beanToMap(new UserExtraContext(ServletUtils.getRequest()))));
UserContextHolder.setContext(userContext); UserContextHolder.setContext(userContext);
return LoginResp.builder() return LoginResp.builder()
.token(StpUtil.getTokenValue()) .token(StpUtil.getTokenValue())
.tenantId(TenantContextHolder.isTenantEnabled() ? TenantContextHolder.getTenantId() : null) .tenantId(TenantContextHolder.isTenantEnabled() ? TenantContextHolder.getTenantId() : null)
.build(); .build();
} }
/** /**

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import top.continew.starter.core.enums.BaseEnum;
/**
* 注销模式枚举
*
* @author KAI
* @since 2025-10-28 14:04
*/
@Getter
@RequiredArgsConstructor
public enum LogoutModeEnum implements BaseEnum<String> {
/**
* 注销下线
*/
LOGOUT("LOGOUT", "注销下线"),
/**
* 踢人下线
*/
KICKOUT("KICKOUT", "踢人下线"),
/**
* 顶人下线
*/
REPLACED("REPLACED", "顶人下线");
private final String value;
private final String description;
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package top.continew.admin.system.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import top.continew.starter.core.enums.BaseEnum;
/**
* 顶人下线的范围枚举
*
* @author KAI
* @since 2025-10-28 14:05
*/
@Getter
@RequiredArgsConstructor
public enum ReplacedRangeEnum implements BaseEnum<String> {
/**
* 当前指定的设备类型端
*/
CURR_DEVICE_TYPE("CURR_DEVICE_TYPE", "当前指定的设备类型端"),
/**
* 所有设备类型端
*/
ALL_DEVICE_TYPE("ALL_DEVICE_TYPE", "所有设备类型端");
private final String value;
private final String description;
}

View File

@@ -20,8 +20,10 @@ import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data; import lombok.Data;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.entity.BaseDO; import top.continew.admin.common.base.model.entity.BaseDO;
import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
import java.io.Serial; import java.io.Serial;
import java.util.List; import java.util.List;
@@ -66,6 +68,26 @@ public class ClientDO extends BaseDO {
*/ */
private Long timeout; private Long timeout;
/**
* 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
*/
private Boolean isConcurrent;
/**
* 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置项才有意义)
*/
private int maxLoginCount;
/**
* 当 isConcurrent=false 时,顶人下线的范围 (CURR_DEVICE_TYPE=当前指定的设备类型端, ALL_DEVICE_TYPE=所有设备类型端)
*/
private ReplacedRangeEnum replacedRange;
/**
* 溢出 maxLoginCount 的客户端,将以何种方式注销下线 (LOGOUT=注销下线, KICKOUT=踢人下线, REPLACED=顶人下线)
*/
private LogoutModeEnum overflowLogoutMode;
/** /**
* 状态 * 状态
*/ */

View File

@@ -19,9 +19,12 @@ package top.continew.admin.system.model.req;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import top.continew.admin.common.enums.DisEnableStatusEnum; import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
@@ -60,14 +63,44 @@ public class ClientReq implements Serializable {
* Token 最低活跃频率(单位:秒,-1不限制永不冻结 * Token 最低活跃频率(单位:秒,-1不限制永不冻结
*/ */
@Schema(description = "Token 最低活跃频率(单位:秒,-1不限制永不冻结", example = "1800") @Schema(description = "Token 最低活跃频率(单位:秒,-1不限制永不冻结", example = "1800")
@NotNull(message = "Token 最低活跃频率不能为空")
private Long activeTimeout; private Long activeTimeout;
/** /**
* Token 有效期(单位:秒,-1永不过期 * Token 有效期(单位:秒,-1永不过期
*/ */
@Schema(description = "Token 有效期(单位:秒,-1永不过期", example = "86400") @Schema(description = "Token 有效期(单位:秒,-1永不过期", example = "86400")
@NotNull(message = "Token 有效期不能为空")
private Long timeout; private Long timeout;
/**
* 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
*/
@Schema(description = "是否允许同一账号多地同时登录", example = "true")
@NotNull(message = "是否运行同一账号多地同时登录不能为空")
private Boolean isConcurrent;
/**
* 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置项才有意义)
*/
@Schema(description = "同一账号最大登录数量, -1代表不限", example = "-1")
@NotNull(message = "同一账号最大登录数量不能为空")
private int maxLoginCount;
/**
* 当 isConcurrent=false 时,顶人下线的范围 (CURR_DEVICE_TYPE=当前指定的设备类型端, ALL_DEVICE_TYPE=所有设备类型端)
*/
@Schema(description = "顶人下线的范围", example = "ALL_DEVICE_TYPE")
@NotNull(message = "顶人下线的范围不能为空")
private ReplacedRangeEnum replacedRange;
/**
* 溢出 maxLoginCount 的客户端,将以何种方式注销下线 (LOGOUT=注销下线, KICKOUT=踢人下线, REPLACED=顶人下线)
*/
@Schema(description = "溢出人数的注销方式", example = "KICKOUT")
@NotNull(message = "溢出人数的注销方式不能为空")
private LogoutModeEnum overflowLogoutMode;
/** /**
* 状态 * 状态
*/ */

View File

@@ -20,10 +20,12 @@ import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import top.continew.admin.common.base.model.resp.BaseDetailResp;
import top.continew.admin.common.config.excel.DictExcelProperty; import top.continew.admin.common.config.excel.DictExcelProperty;
import top.continew.admin.common.config.excel.ExcelDictConverter; import top.continew.admin.common.config.excel.ExcelDictConverter;
import top.continew.admin.common.enums.DisEnableStatusEnum; import top.continew.admin.common.enums.DisEnableStatusEnum;
import top.continew.admin.common.base.model.resp.BaseDetailResp; import top.continew.admin.system.enums.LogoutModeEnum;
import top.continew.admin.system.enums.ReplacedRangeEnum;
import top.continew.starter.excel.converter.ExcelBaseEnumConverter; import top.continew.starter.excel.converter.ExcelBaseEnumConverter;
import top.continew.starter.excel.converter.ExcelListConverter; import top.continew.starter.excel.converter.ExcelListConverter;
@@ -81,10 +83,38 @@ public class ClientResp extends BaseDetailResp {
@ExcelProperty(value = "Token 有效期", order = 7) @ExcelProperty(value = "Token 有效期", order = 7)
private Long timeout; private Long timeout;
/**
* 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
*/
@Schema(description = "是否允许同一账号多地同时登录", example = "true")
@ExcelProperty(value = "是否允许同一账号多地同时登录", order = 8)
private Boolean isConcurrent;
/**
* 同一账号最大登录数量,-1代表不限 (只有在 isConcurrent=true, isShare=false 时此配置项才有意义)
*/
@Schema(description = "同一账号最大登录数量, -1代表不限", example = "-1")
@ExcelProperty(value = "同一账号最大登录数量", order = 10)
private int maxLoginCount;
/**
* 当 isConcurrent=false 时,顶人下线的范围 (CURR_DEVICE_TYPE=当前指定的设备类型端, ALL_DEVICE_TYPE=所有设备类型端)
*/
@Schema(description = "顶人下线的范围 (CURR_DEVICE_TYPE=当前指定的设备类型端, ALL_DEVICE_TYPE=所有设备类型端)", example = "ALL_DEVICE_TYPE")
@ExcelProperty(value = "顶人下线的范围", converter = ExcelBaseEnumConverter.class, order = 11)
private ReplacedRangeEnum replacedRange;
/**
* 溢出 maxLoginCount 的客户端,将以何种方式注销下线 (LOGOUT=注销下线, KICKOUT=踢人下线, REPLACED=顶人下线)
*/
@Schema(description = "溢出人数的注销方式", example = "KICKOUT")
@ExcelProperty(value = "溢出人数的注销方式", converter = ExcelBaseEnumConverter.class, order = 12)
private LogoutModeEnum overflowLogoutMode;
/** /**
* 状态 * 状态
*/ */
@Schema(description = "状态", example = "1") @Schema(description = "状态", example = "1")
@ExcelProperty(value = "状态", converter = ExcelBaseEnumConverter.class, order = 8) @ExcelProperty(value = "状态", converter = ExcelBaseEnumConverter.class, order = 13)
private DisEnableStatusEnum status; private DisEnableStatusEnum status;
} }