拆分顺序:数据模型 → 接口协议 → 底层实现 → 上层编排 → 入口层 每个任务 = 可独立提交的原子变更(3-5 个文件) 每个任务必须精确到文件路径和函数签名
spec.md)涉及文件:
forge/forge-admin/sql/sys_client.sql — 新增,表结构和初始化数据forge/forge-framework/forge-starter-parent/forge-starter-auth/sql/sys_client.sql — 新增,表结构定义关键签名:
``sql
CREATE TABLEsys_client(
idbigint NOT NULL AUTO_INCREMENT,
client_code` varchar(50) NOT NULL,
-- ...
);
INSERT INTO sys_client VALUES
(1, 'pc', 'PC端', ...),
(2, 'app', 'APP端', ...);
- [ ] **Step 1: 创建表结构SQL文件**
创建 `forge/forge-framework/forge-starter-parent/forge-starter-auth/sql/sys_client.sql`
```sql
CREATE TABLE `sys_client` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`client_code` varchar(50) NOT NULL COMMENT '客户端编码',
`client_name` varchar(100) NOT NULL COMMENT '客户端名称',
`app_id` varchar(64) NOT NULL COMMENT '应用ID',
`app_secret` varchar(128) NOT NULL COMMENT '应用密钥(加密存储)',
-- Token配置
`token_timeout` bigint DEFAULT 7200 COMMENT 'Token有效期(秒)',
`token_activity_timeout` bigint DEFAULT -1 COMMENT 'Token活跃超时(秒)',
`token_prefix` varchar(20) DEFAULT 'Bearer' COMMENT 'Token前缀',
`token_name` varchar(50) DEFAULT 'Authorization' COMMENT 'Token名称',
`concurrent_login` tinyint(1) DEFAULT 0 COMMENT '是否允许并发登录(0-否 1-是)',
`share_token` tinyint(1) DEFAULT 0 COMMENT '是否共享Token(0-否 1-是)',
-- 安全配置
`enable_ip_limit` tinyint(1) DEFAULT 0 COMMENT '是否启用IP限制',
`ip_whitelist` text COMMENT 'IP白名单(JSON数组)',
`enable_encrypt` tinyint(1) DEFAULT 0 COMMENT '是否启用加密传输',
`encrypt_algorithm` varchar(50) DEFAULT 'AES' COMMENT '加密算法',
-- 业务配置
`max_user_count` bigint DEFAULT -1 COMMENT '最大用户数(-1不限)',
`max_online_count` bigint DEFAULT -1 COMMENT '最大在线数(-1不限)',
`auth_types` varchar(255) DEFAULT 'password' COMMENT '支持的认证方式(逗号分隔)',
-- 状态与描述
`status` tinyint DEFAULT 1 COMMENT '状态(0-禁用 1-启用)',
`description` varchar(500) COMMENT '客户端描述',
`tenant_id` bigint DEFAULT NULL COMMENT '租户ID',
-- 基础字段
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`create_by` bigint,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`update_by` bigint,
`create_dept` bigint,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_client_code` (`client_code`),
UNIQUE KEY `uk_app_id` (`app_id`),
KEY `idx_tenant_id` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端管理表';
创建 forge/forge-admin/sql/sys_client_init.sql
INSERT INTO `sys_client` VALUES
(1, 'pc', 'PC端', 'forge_pc_001', 'pc_secret_encrypted', 86400, 7200, 'Bearer', 'Authorization', 0, 0, 0, NULL, 0, 'AES', -1, -1, 'password,password_captcha', 1, 'PC客户端', NULL, NOW(), 1, NOW(), 1, NULL),
(2, 'app', 'APP端', 'forge_app_001', 'app_secret_encrypted', 2592000, -1, 'Bearer', 'Authorization', 1, 0, 0, NULL, 1, 'AES', -1, -1, 'password,phone_captcha', 1, '移动APP客户端', NULL, NOW(), 1, NOW(), 1, NULL),
(3, 'h5', 'H5端', 'forge_h5_001', 'h5_secret_encrypted', 604800, 3600, 'Bearer', 'Authorization', 1, 0, 0, NULL, 0, 'AES', -1, -1, 'password,wechat', 1, 'H5网页客户端', NULL, NOW(), 1, NOW(), 1, NULL),
(4, 'wechat', '微信小程序', 'forge_wx_001', 'wx_secret_encrypted', 2592000, -1, 'Bearer', 'Authorization', 1, 0, 0, NULL, 1, 'AES', -1, -1, 'wechat', 1, '微信小程序客户端', NULL, NOW(), 1, NOW(), 1, NULL);
mysql -u root -p forge_admin < forge/forge-framework/forge-starter-parent/forge-starter-auth/sql/sys_client.sql
mysql -u root -p forge_admin < forge/forge-admin/sql/sys_client_init.sql
Expected: 表创建成功,初始数据插入成功
mysql -u root -p -e "DESC sys_client;"
Expected: 显示表字段和索引
git add forge/forge-framework/forge-starter-parent/forge-starter-auth/sql/sys_client.sql
git add forge/forge-admin/sql/sys_client_init.sql
git commit -m "feat(client): add sys_client table and init data"
涉及文件:
forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/entity/SysClient.java — 新增forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/dto/SysClientDTO.java — 新增forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/vo/SysClientVO.java — 新增关键签名:
@Data
@TableName("sys_client")
public class SysClient extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String clientCode;
// ...
}
[ ] Step 1: 使用代码生成器生成实体类
参考项目代码生成器文档,生成sys_client表相关代码
或手动创建实体类:SysClient.java
package com.mdframe.forge.plugin.system.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.mdframe.forge.starter.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName(value = "sys_client", autoResultMap = true)
public class SysClient extends BaseEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String clientCode;
private String clientName;
private String appId;
@TableField(typeHandler = com.mdframe.forge.starter.crypto.handler.EncryptTypeHandler.class)
private String appSecret;
private Long tokenTimeout;
private Long tokenActivityTimeout;
private String tokenPrefix;
private String tokenName;
private Boolean concurrentLogin;
private Boolean shareToken;
private Boolean enableIpLimit;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private List<String> ipWhitelist;
private Boolean enableEncrypt;
private String encryptAlgorithm;
private Long maxUserCount;
private Long maxOnlineCount;
private String authTypes;
private Integer status;
private String description;
private Long tenantId;
}
SysClientDTO.java
package com.mdframe.forge.plugin.system.dto;
import lombok.Data;
import java.util.List;
@Data
public class SysClientDTO {
private Long id;
private String clientCode;
private String clientName;
private String appId;
private String appSecret;
private Long tokenTimeout;
private Long tokenActivityTimeout;
private String tokenPrefix;
private String tokenName;
private Boolean concurrentLogin;
private Boolean shareToken;
private Boolean enableIpLimit;
private List<String> ipWhitelist;
private Boolean enableEncrypt;
private String encryptAlgorithm;
private Long maxUserCount;
private Long maxOnlineCount;
private String authTypes;
private Integer status;
private String description;
private Long tenantId;
}
SysClientVO.java
package com.mdframe.forge.plugin.system.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class SysClientVO {
private Long id;
private String clientCode;
private String clientName;
private String appId;
private String appSecretMasked; // 脱敏后的密钥
private Long tokenTimeout;
private Long tokenActivityTimeout;
private String tokenPrefix;
private String tokenName;
private Boolean concurrentLogin;
private Boolean shareToken;
private Boolean enableIpLimit;
private List<String> ipWhitelist;
private Boolean enableEncrypt;
private String encryptAlgorithm;
private Long maxUserCount;
private Long maxOnlineCount;
private String authTypes;
private Integer status;
private String description;
private Long tenantId;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
cd forge && mvn clean compile
Expected: 编译成功,无错误
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/entity/SysClient.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/dto/SysClientDTO.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/vo/SysClientVO.java
git commit -m "feat(client): add SysClient entity, DTO and VO classes"
涉及文件:
forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/mapper/SysClientMapper.java — 新增forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/IClientService.java — 新增forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/ClientServiceImpl.java — 新增关键签名: ```java public interface IClientService extends IService { SysClient getByCode(String clientCode); boolean validateAppSecret(String appId, String appSecret); }
public class ClientServiceImpl extends ServiceImpl implements IClientService {
@Override
public SysClient getByCode(String clientCode) { }
}
- [ ] **Step 1: 创建Mapper接口**
`SysClientMapper.java`
```java
package com.mdframe.forge.plugin.system.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mdframe.forge.plugin.system.entity.SysClient;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysClientMapper extends BaseMapper<SysClient> {
}
IClientService.java
package com.mdframe.forge.plugin.system.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.mdframe.forge.plugin.system.entity.SysClient;
import java.util.List;
public interface IClientService extends IService<SysClient> {
SysClient getByCode(String clientCode);
SysClient getByAppId(String appId);
boolean validateAppSecret(String appId, String appSecret);
List<SysClient> listByTenant(Long tenantId);
void reloadClientConfigCache(String clientCode);
String getMaskedAppSecret(Long clientId);
}
ClientServiceImpl.java
package com.mdframe.forge.plugin.system.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mdframe.forge.plugin.system.entity.SysClient;
import com.mdframe.forge.plugin.system.mapper.SysClientMapper;
import com.mdframe.forge.plugin.system.service.IClientService;
import com.mdframe.forge.starter.cache.service.ICacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class ClientServiceImpl extends ServiceImpl<SysClientMapper, SysClient> implements IClientService {
private final ICacheService cacheService;
private static final String CLIENT_CONFIG_CACHE_KEY = "client:config:";
private static final long CLIENT_CONFIG_CACHE_EXPIRE = 3600;
@Override
public SysClient getByCode(String clientCode) {
if (StrUtil.isBlank(clientCode)) {
return null;
}
String cacheKey = CLIENT_CONFIG_CACHE_KEY + clientCode;
SysClient cached = cacheService.get(cacheKey);
if (cached != null) {
return cached;
}
LambdaQueryWrapper<SysClient> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysClient::getClientCode, clientCode);
SysClient client = this.getOne(wrapper);
if (client != null) {
cacheService.set(cacheKey, client, CLIENT_CONFIG_CACHE_EXPIRE, TimeUnit.SECONDS);
}
return client;
}
@Override
public SysClient getByAppId(String appId) {
if (StrUtil.isBlank(appId)) {
return null;
}
LambdaQueryWrapper<SysClient> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SysClient::getAppId, appId);
return this.getOne(wrapper);
}
@Override
public boolean validateAppSecret(String appId, String appSecret) {
SysClient client = getByAppId(appId);
if (client == null) {
return false;
}
return client.getAppSecret().equals(appSecret);
}
@Override
public List<SysClient> listByTenant(Long tenantId) {
LambdaQueryWrapper<SysClient> wrapper = new LambdaQueryWrapper<>();
if (tenantId != null) {
wrapper.eq(SysClient::getTenantId, tenantId);
}
wrapper.eq(SysClient::getStatus, 1);
return this.list(wrapper);
}
@Override
public void reloadClientConfigCache(String clientCode) {
String cacheKey = CLIENT_CONFIG_CACHE_KEY + clientCode;
cacheService.delete(cacheKey);
SysClient client = getByCode(clientCode);
if (client != null) {
cacheService.set(cacheKey, client, CLIENT_CONFIG_CACHE_EXPIRE, TimeUnit.SECONDS);
log.info("客户端配置缓存已刷新: {}", clientCode);
}
}
@Override
public String getMaskedAppSecret(Long clientId) {
SysClient client = this.getById(clientId);
if (client == null || StrUtil.isBlank(client.getAppSecret())) {
return "";
}
String secret = client.getAppSecret();
if (secret.length() > 8) {
return secret.substring(0, 4) + "****" + secret.substring(secret.length() - 4);
}
return "****";
}
}
ClientServiceImplTest.java
package com.mdframe.forge.plugin.system.service.impl;
import com.mdframe.forge.plugin.system.entity.SysClient;
import com.mdframe.forge.plugin.system.service.IClientService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class ClientServiceImplTest {
@Autowired
private IClientService clientService;
@Test
void testGetByCode() {
SysClient client = clientService.getByCode("pc");
assertNotNull(client);
assertEquals("PC端", client.getClientName());
assertEquals(86400L, client.getTokenTimeout());
}
@Test
void testGetByCodeNotFound() {
SysClient client = clientService.getByCode("invalid_code");
assertNull(client);
}
@Test
void testValidateAppSecret() {
SysClient client = clientService.getByCode("app");
assertNotNull(client);
boolean valid = clientService.validateAppSecret(client.getAppId(), client.getAppSecret());
assertTrue(valid);
}
}
cd forge && mvn test -Dtest=ClientServiceImplTest
Expected: 测试通过
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/mapper/SysClientMapper.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/IClientService.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/ClientServiceImpl.java
git commit -m "feat(client): implement ClientService with cache support"
涉及文件:
forge/forge-framework/forge-starter-parent/forge-starter-auth/src/main/java/com/mdframe/forge/starter/auth/domain/LoginRequest.java — 修改,增加appId字段forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SystemAuthServiceImpl.java — 修改,增加客户端验证和配置应用逻辑forge/forge-framework/forge-starter-parent/forge-starter-core/src/main/java/com/mdframe/forge/starter/core/session/LoginUser.java — 修改,增加userClient字段关键签名:
public LoginResult login(LoginRequest request) {
SysClient client = validateAndLoadClient(request.getUserClient(), request.getAppId());
applyClientTokenConfig(client);
StpUtil.login(loginUser.getUserId(), new SaLoginModel().setDevice(request.getUserClient()).setTimeout(client.getTokenTimeout()));
}
[ ] Step 1: LoginRequest增加appId字段
LoginRequest.java
// 在现有字段基础上增加
private String appId; // 客户端AppId(可选,用于客户端验证)
LoginUser.java
// 在现有字段基础上增加
private String userClient; // 用户客户端类型(pc/app/h5/wechat)
// 在SystemAuthServiceImpl类中增加以下方法
@Autowired
private IClientService clientService;
private SysClient validateAndLoadClient(String userClient, String appId) {
if (StrUtil.isBlank(userClient)) {
userClient = "pc"; // 默认PC端
}
SysClient client = clientService.getByCode(userClient);
if (client == null) {
throw new RuntimeException("客户端不存在: " + userClient);
}
if (client.getStatus() == 0) {
throw new RuntimeException("客户端已禁用: " + userClient);
}
// 验证支持的认证方式
if (StrUtil.isNotBlank(client.getAuthTypes())) {
String[] supportedAuthTypes = client.getAuthTypes().split(",");
String requestAuthType = authProperties.getDefaultAuthType();
boolean supported = false;
for (String authType : supportedAuthTypes) {
if (authType.equals(requestAuthType)) {
supported = true;
break;
}
}
if (!supported) {
throw new RuntimeException("客户端不支持该认证方式: " + requestAuthType);
}
}
// 可选:验证AppId
if (authProperties.getEnableClientValidation() && StrUtil.isNotBlank(appId)) {
if (!client.getAppId().equals(appId)) {
throw new RuntimeException("客户端AppId不匹配");
}
}
return client;
}
private void applyClientTokenConfig(SysClient client) {
cn.dev33.satoken.config.SaTokenConfig config = SaManager.getConfig();
config.setTimeout(client.getTokenTimeout());
config.setActiveTimeout(client.getTokenActivityTimeout());
config.setIsConcurrent(client.getConcurrentLogin());
config.setIsShare(client.getShareToken());
log.debug("应用客户端Token配置: client={}, timeout={}s, concurrent={}",
client.getClientCode(), client.getTokenTimeout(), client.getConcurrentLogin());
}
修改 SystemAuthServiceImpl.login() 方法:
@Override
public LoginResult login(LoginRequest request) {
if (StrUtil.isBlank(request.getAuthType())) {
request.setAuthType("password");
}
if (StrUtil.isBlank(request.getUserClient())) {
request.setUserClient("pc"); // 默认PC端
}
// 1. 验证客户端并加载配置
SysClient client = validateAndLoadClient(request.getUserClient(), request.getAppId());
// 2. 应用客户端Token配置
applyClientTokenConfig(client);
// 3. 获取认证策略
IAuthStrategy strategy = authStrategyFactory.getStrategy(
request.getAuthType(), request.getUserClient()
);
log.info("开始认证: authType={}, userClient={}, strategy={}",
request.getAuthType(), request.getUserClient(), strategy.getClass().getSimpleName());
// 4. 执行认证策略
LoginUser loginUser = strategy.authenticate(request);
// 5. 处理同一账号登录策略(考虑客户端并发配置)
handleSameAccountLogin(loginUser.getUserId(), client);
// 6. 设置登录时间和IP
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setUserClient(request.getUserClient());
// 7. 执行Sa-Token登录(使用设备类型标记和客户端超时时间)
StpUtil.login(loginUser.getUserId(), new SaLoginModel()
.setDevice(request.getUserClient())
.setTimeout(client.getTokenTimeout())
);
// 8. 将完整的用户信息设置到Session中
SessionHelper.setLoginUser(loginUser);
log.info("用户登录成功: username={}, userId={}, client={}, tokenTimeout={}s",
loginUser.getUsername(), loginUser.getUserId(), request.getUserClient(), client.getTokenTimeout());
// 9. 构建返回结果
return buildLoginResult(loginUser, client);
}
private void handleSameAccountLogin(Long userId, SysClient client) {
if (!authProperties.getEnableOnlineUserManagement()) {
return;
}
// 根据客户端的并发登录配置决定策略
if (client.getConcurrentLogin()) {
log.debug("客户端允许并发登录: client={}, userId={}", client.getClientCode(), userId);
return;
}
String strategy = authProperties.getSameAccountLoginStrategy();
switch (strategy) {
case "replace_old":
String currentToken = StpUtil.getTokenValue();
onlineUserService.kickoutAllSessions(userId, currentToken);
log.info("踢出旧登录: userId={}, client={}", userId, client.getClientCode());
break;
case "reject_new":
if (!onlineUserService.getUserTokens(userId).isEmpty()) {
throw new RuntimeException("该账号已在其他地方登录,请先退出后再登录");
}
break;
default:
log.debug("允许并发登录策略: {}", strategy);
}
}
private LoginResult buildLoginResult(LoginUser loginUser, SysClient client) {
String token = StpUtil.getTokenValue();
long tokenTimeout = StpUtil.getTokenTimeout();
return LoginResult.builder()
.accessToken(token)
.expiresIn(tokenTimeout)
.tokenType(client.getTokenPrefix())
.build();
}
SystemAuthServiceImplClientTest.java
package com.mdframe.forge.plugin.system.service.impl;
import com.mdframe.forge.starter.auth.domain.LoginRequest;
import com.mdframe.forge.starter.auth.domain.LoginResult;
import com.mdframe.forge.starter.auth.service.IAuthService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class SystemAuthServiceImplClientTest {
@Autowired
private IAuthService authService;
@Test
void testLoginPCClient() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("123456");
request.setUserClient("pc");
LoginResult result = authService.login(request);
assertNotNull(result.getAccessToken());
assertTrue(result.getExpiresIn() <= 86400); // PC端8小时
}
@Test
void testLoginAPPClient() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("123456");
request.setUserClient("app");
LoginResult result = authService.login(request);
assertNotNull(result.getAccessToken());
assertTrue(result.getExpiresIn() <= 2592000); // APP端30天
}
@Test
void testLoginInvalidClient() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("123456");
request.setUserClient("invalid_client");
assertThrows(RuntimeException.class, () -> authService.login(request));
}
}
cd forge && mvn test -Dtest=SystemAuthServiceImplClientTest
Expected: 测试通过
git add forge/forge-framework/forge-starter-parent/forge-starter-auth/src/main/java/com/mdframe/forge/starter/auth/domain/LoginRequest.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SystemAuthServiceImpl.java
git add forge/forge-framework/forge-starter-parent/forge-starter-core/src/main/java/com/mdframe/forge/starter/core/session/LoginUser.java
git commit -m "feat(auth): apply client config in login process with device type marking"
涉及文件:
forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/ISysOnlineUserService.java — 修改,增加新方法forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysOnlineUserServiceImpl.java — 修改,实现新方法关键签名:
List<OnlineUser> getOnlineUsersByClient(String clientType);
void kickoutByClient(Long userId, String clientType);
Map<String, Long> getOnlineCountByClient();
[ ] Step 1: ISysOnlineUserService增加新方法
// 在接口中增加以下方法
List<OnlineUser> getOnlineUsersByClient(String clientType);
void kickoutByClient(Long userId, String clientType);
Map<String, Long> getOnlineCountByClient();
// 在实现类中增加以下方法
@Override
public List<OnlineUser> getOnlineUsersByClient(String clientType) {
List<OnlineUser> result = new ArrayList<>();
List<String> tokens = StpUtil.searchTokenValue("", 0, -1, false);
for (String token : tokens) {
try {
SaSession session = StpUtil.getTokenSessionByToken(token);
LoginUser loginUser = (LoginUser) session.get(SessionHelper.LOGIN_USER_KEY);
if (loginUser != null && loginUser.getUserClient() != null
&& loginUser.getUserClient().equals(clientType)) {
OnlineUser user = buildOnlineUser(loginUser, token);
result.add(user);
}
} catch (Exception e) {
log.warn("获取token session失败: {}", token, e);
}
}
return result;
}
@Override
public void kickoutByClient(Long userId, String clientType) {
StpUtil.kickout(userId, clientType);
log.info("踢出用户客户端会话: userId={}, client={}", userId, clientType);
}
@Override
public Map<String, Long> getOnlineCountByClient() {
Map<String, Long> result = new HashMap<>();
List<SysClient> clients = clientService.list();
for (SysClient client : clients) {
long count = getOnlineUsersByClient(client.getClientCode()).size();
result.put(client.getClientCode(), count);
}
return result;
}
private OnlineUser buildOnlineUser(LoginUser loginUser, String token) {
OnlineUser user = new OnlineUser();
user.setUserId(loginUser.getUserId());
user.setUsername(loginUser.getUsername());
user.setRealName(loginUser.getRealName());
user.setTokenValue(token);
user.setLoginTime(LocalDateTime.ofInstant(
Instant.ofEpochMilli(loginUser.getLoginTime()),
ZoneId.systemDefault()
));
user.setExpireTime(LocalDateTime.now().plusSeconds(StpUtil.getTokenTimeout(token)));
user.setUserClient(loginUser.getUserClient());
return user;
}
OnlineUser.java(如果需要)
// 在OnlineUser类中增加
private String userClient;
SysOnlineUserServiceClientTest.java
package com.mdframe.forge.plugin.system.service.impl;
import com.mdframe.forge.plugin.system.service.ISysOnlineUserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class SysOnlineUserServiceClientTest {
@Autowired
private ISysOnlineUserService onlineUserService;
@Test
void testGetOnlineUsersByClient() {
List onlineUsers = onlineUserService.getOnlineUsersByClient("pc");
assertNotNull(onlineUsers);
}
@Test
void testGetOnlineCountByClient() {
Map<String, Long> counts = onlineUserService.getOnlineCountByClient();
assertNotNull(counts);
assertTrue(counts.containsKey("pc"));
assertTrue(counts.containsKey("app"));
}
}
cd forge && mvn test -Dtest=SysOnlineUserServiceClientTest
Expected: 测试通过
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/ISysOnlineUserService.java
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/service/impl/SysOnlineUserServiceImpl.java
git commit -m "feat(online): add client-based online user query and kickout"
涉及文件:
forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/controller/SysClientController.java — 新增关键签名:
@RestController
@RequestMapping("/api/system/client")
public class SysClientController {
@GetMapping("/page")
public RespInfo<PageResult<SysClientVO>> page(@RequestParam Map<String, Object> params);
@GetMapping("/online/stats")
public RespInfo<Map<String, Long>> getOnlineStats();
}
[ ] Step 1: 创建SysClientController
SysClientController.java
package com.mdframe.forge.plugin.system.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mdframe.forge.plugin.system.dto.SysClientDTO;
import com.mdframe.forge.plugin.system.entity.SysClient;
import com.mdframe.forge.plugin.system.service.IClientService;
import com.mdframe.forge.plugin.system.service.ISysOnlineUserService;
import com.mdframe.forge.plugin.system.vo.SysClientVO;
import com.mdframe.forge.starter.core.domain.RespInfo;
import com.mdframe.forge.starter.core.domain.PageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/system/client")
@RequiredArgsConstructor
public class SysClientController {
private final IClientService clientService;
private final ISysOnlineUserService onlineUserService;
@GetMapping("/page")
public RespInfo<PageResult<SysClientVO>> page(@RequestParam Map<String, Object> params) {
Page<SysClient> page = new Page<>();
page.setSize(Long.parseLong(params.getOrDefault("size", "10").toString()));
page.setCurrent(Long.parseLong(params.getOrDefault("current", "1").toString()));
LambdaQueryWrapper<SysClient> wrapper = new LambdaQueryWrapper<>();
String clientCode = (String) params.get("clientCode");
if (clientCode != null && !clientCode.isEmpty()) {
wrapper.like(SysClient::getClientCode, clientCode);
}
String clientName = (String) params.get("clientName");
if (clientName != null && !clientName.isEmpty()) {
wrapper.like(SysClient::getClientName, clientName);
}
String status = (String) params.get("status");
if (status != null && !status.isEmpty()) {
wrapper.eq(SysClient::getStatus, Integer.parseInt(status));
}
wrapper.orderByDesc(SysClient::getCreateTime);
Page<SysClient> result = clientService.page(page, wrapper);
List<SysClientVO> voList = result.getRecords().stream()
.map(this::convertToVO)
.toList();
PageResult<SysClientVO> pageResult = new PageResult<>();
pageResult.setRecords(voList);
pageResult.setTotal(result.getTotal());
pageResult.setSize(result.getSize());
pageResult.setCurrent(result.getCurrent());
return RespInfo.success(pageResult);
}
@GetMapping("/{id}")
public RespInfo<SysClientVO> getById(@PathVariable Long id) {
SysClient client = clientService.getById(id);
if (client == null) {
return RespInfo.error("客户端不存在");
}
return RespInfo.success(convertToVO(client));
}
@PostMapping
public RespInfo<Boolean> create(@RequestBody SysClientDTO dto) {
SysClient client = new SysClient();
BeanUtils.copyProperties(dto, client);
boolean success = clientService.save(client);
return RespInfo.success(success);
}
@PutMapping
public RespInfo<Boolean> update(@RequestBody SysClientDTO dto) {
SysClient client = new SysClient();
BeanUtils.copyProperties(dto, client);
boolean success = clientService.updateById(client);
if (success) {
clientService.reloadClientConfigCache(client.getClientCode());
}
return RespInfo.success(success);
}
@DeleteMapping("/{id}")
public RespInfo<Boolean> delete(@PathVariable Long id) {
boolean success = clientService.removeById(id);
return RespInfo.success(success);
}
@GetMapping("/online/{clientCode}")
public RespInfo<List> getOnlineUsers(@PathVariable String clientCode) {
List onlineUsers = onlineUserService.getOnlineUsersByClient(clientCode);
return RespInfo.success(onlineUsers);
}
@GetMapping("/online/stats")
public RespInfo<Map<String, Long>> getOnlineStats() {
Map<String, Long> stats = onlineUserService.getOnlineCountByClient();
return RespInfo.success(stats);
}
@PostMapping("/kickout/{userId}/{clientCode}")
public RespInfo<Boolean> kickout(@PathVariable Long userId, @PathVariable String clientCode) {
onlineUserService.kickoutByClient(userId, clientCode);
return RespInfo.success(true);
}
@GetMapping("/secret/{id}")
public RespInfo<String> getAppSecret(@PathVariable Long id) {
String maskedSecret = clientService.getMaskedAppSecret(id);
return RespInfo.success(maskedSecret);
}
@PostMapping("/reload-cache/{clientCode}")
public RespInfo<Boolean> reloadCache(@PathVariable String clientCode) {
clientService.reloadClientConfigCache(clientCode);
return RespInfo.success(true);
}
private SysClientVO convertToVO(SysClient client) {
SysClientVO vo = new SysClientVO();
BeanUtils.copyProperties(client, vo);
vo.setAppSecretMasked(clientService.getMaskedAppSecret(client.getId()));
return vo;
}
}
cd forge && mvn clean compile
Expected: 编译成功
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/main/java/com/mdframe/forge/plugin/system/controller/SysClientController.java
git commit -m "feat(client): add SysClientController with CRUD and online user management"
涉及文件:
forge-admin-ui/src/views/system/client/index.vue — 新增,客户端管理页面关键签名:
<template>
<AiCrudPage ... />
</template>
[ ] Step 1: 使用代码生成器生成前端代码
参考项目代码生成器文档,生成sys_client表的前端CRUD页面
或手动创建页面:index.vue
(此处建议使用项目的代码生成器,前端代码较为复杂,不适合手动编写)
cd forge-admin-ui && pnpm run lint:fix
Expected: 无lint错误
git add forge-admin-ui/src/views/system/client/
git commit -m "feat(ui): add client management vue page"
涉及文件:
forge/forge-admin/sql/sys_client_menu.sql — 新增,菜单权限初始化脚本关键签名:
INSERT INTO sys_resource VALUES (客户端管理菜单);
[ ] Step 1: 创建菜单权限SQL脚本
sys_client_menu.sql
INSERT INTO sys_resource VALUES
(null, '客户端管理', 'client', 'system', '/system/client', 'system/client/index', 'menu', 0, 3, 'desktop', 1, NOW(), 1, NOW(), 1, NULL);
INSERT INTO sys_resource VALUES
(null, '客户端查询', 'system:client:list', 'client', NULL, NULL, 'button', 0, 1, NULL, 1, NOW(), 1, NOW(), 1, NULL),
(null, '客户端新增', 'system:client:create', 'client', NULL, NULL, 'button', 0, 2, NULL, 1, NOW(), 1, NOW(), 1, NULL),
(null, '客户端修改', 'system:client:update', 'client', NULL, NULL, 'button', 0, 3, NULL, 1, NOW(), 1, NOW(), 1, NULL),
(null, '客户端删除', 'system:client:delete', 'client', NULL, NULL, 'button', 0, 4, NULL, 1, NOW(), 1, NOW(), 1, NULL),
(null, '在线用户查询', 'system:client:online', 'client', NULL, NULL, 'button', 0, 5, NULL, 1, NOW(), 1, NOW(), 1, NULL),
(null, '踢出用户', 'system:client:kickout', 'client', NULL, NULL, 'button', 0, 6, NULL, 1, NOW(), 1, NOW(), 1, NULL);
mysql -u root -p forge_admin < forge/forge-admin/sql/sys_client_menu.sql
Expected: 菜单和权限数据插入成功
git add forge/forge-admin/sql/sys_client_menu.sql
git commit -m "feat(client): add client management menu and permissions"
涉及文件:
forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/test/java/com/mdframe/forge/plugin/system/integration/ClientIntegrationTest.java — 新增code-copilot/changes/client-management/usage-guide.md — 新增,使用指南[ ] Step 1: 编写集成测试
ClientIntegrationTest.java
package com.mdframe.forge.plugin.system.integration;
import com.mdframe.forge.starter.auth.domain.LoginRequest;
import com.mdframe.forge.starter.auth.domain.LoginResult;
import com.mdframe.forge.starter.auth.service.IAuthService;
import com.mdframe.forge.plugin.system.service.IClientService;
import com.mdframe.forge.plugin.system.service.ISysOnlineUserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class ClientIntegrationTest {
@Autowired
private IAuthService authService;
@Autowired
private IClientService clientService;
@Autowired
private ISysOnlineUserService onlineUserService;
@Test
void testFullLoginFlowWithClientConfig() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("123456");
request.setUserClient("app");
LoginResult result = authService.login(request);
assertNotNull(result.getAccessToken());
Map<String, Long> onlineStats = onlineUserService.getOnlineCountByClient();
assertTrue(onlineStats.get("app") >= 1);
}
@Test
void testKickoutByClient() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("123456");
request.setUserClient("h5");
LoginResult result = authService.login(request);
assertNotNull(result.getAccessToken());
onlineUserService.kickoutByClient(1L, "h5");
Map<String, Long> stats = onlineUserService.getOnlineCountByClient();
assertEquals(0L, stats.get("h5"));
}
}
cd forge && mvn test -Dtest=ClientIntegrationTest
Expected: 所有测试通过
usage-guide.md
参考之前创建的使用指南模板,编写客户端管理功能的使用指南,包括:
如何配置客户端Token策略
[ ] Step 4: Commit
git add forge/forge-framework/forge-plugin-parent/forge-plugin-system/src/test/java/com/mdframe/forge/plugin/system/integration/ClientIntegrationTest.java
git add code-copilot/changes/client-management/usage-guide.md
git commit -m "test(client): add integration test and usage guide"
涉及文件: 无新增文件
[ ] Step 1: 整体编译
cd forge && mvn clean install
Expected: 编译成功,无错误
cd forge/forge-admin && mvn spring-boot:run
Expected: 服务启动成功
cd forge-admin-ui && pnpm dev
Expected: 前端启动成功
Expected: 所有功能正常
git add -A
git commit -m "feat(client): complete client management implementation"