feat: 完成分享、单会话与发布审批改造

- 增加工作流协作分享与知识库卡片分享入口,统一低版本浏览器复制反馈

- Web 新登录替换旧会话,并保持 API Key 会话隔离

- 发布审批增加必填说明并在审批详情展示

- 账号重置与导入改用可配置默认强密码
This commit is contained in:
2026-07-23 16:09:31 +08:00
parent caa1f07b66
commit 5a42826d44
71 changed files with 3191 additions and 132 deletions

View File

@@ -0,0 +1,59 @@
package tech.easyflow.auth.service.impl;
import cn.dev33.satoken.stp.SaLoginModel;
/**
* 登录会话来源隔离策略。
*/
public final class AuthLoginSessionPolicy {
/**
* 人类用户 Web 会话设备类型。
*/
public static final String WEB_DEVICE = "WEB";
/**
* 升级前未显式指定设备类型的历史 Web 会话设备类型。
*/
public static final String LEGACY_WEB_DEVICE = "default-device";
/**
* API Key 机器会话设备类型。
*/
public static final String API_KEY_DEVICE = "API_KEY";
private AuthLoginSessionPolicy() {
}
/**
* 构建 Web 登录模型。
*
* @return Web 登录模型
*/
public static SaLoginModel webLoginModel() {
return new SaLoginModel().setDevice(WEB_DEVICE);
}
/**
* 构建 API Key 登录模型。
*
* @param timeoutSeconds 会话有效期秒数,可为空
* @return API Key 登录模型
*/
public static SaLoginModel apiKeyLoginModel(Long timeoutSeconds) {
SaLoginModel loginModel = new SaLoginModel().setDevice(API_KEY_DEVICE);
if (timeoutSeconds != null) {
loginModel.setTimeout(timeoutSeconds);
}
return loginModel;
}
/**
* 判断 Web 登录是否需要替换既有 Web 会话。
*
* @return 始终为 true
*/
public static boolean shouldReplaceExistingWebSession() {
return true;
}
}

View File

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import tech.easyflow.auth.entity.LoginDTO;
import tech.easyflow.auth.entity.LoginVO;
import tech.easyflow.auth.service.AuthService;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.Constants;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.common.entity.LoginAccount;
@@ -26,6 +27,7 @@ import cn.hutool.crypto.digest.BCrypt;
import javax.annotation.Resource;
import java.math.BigInteger;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@@ -33,6 +35,10 @@ import java.util.stream.Collectors;
@Service
public class AuthServiceImpl implements AuthService, StpInterface {
private static final String WEB_LOGIN_LOCK_KEY_PREFIX = "easyflow:lock:auth:web-login:";
private static final Duration WEB_LOGIN_LOCK_WAIT = Duration.ofSeconds(3);
private static final Duration WEB_LOGIN_LOCK_LEASE = Duration.ofSeconds(10);
@Resource
private SysAccountService sysAccountService;
@Resource
@@ -41,6 +47,8 @@ public class AuthServiceImpl implements AuthService, StpInterface {
private SysMenuService sysMenuService;
@Resource
private SysApiKeyService sysApiKeyService;
@Resource
private RedisLockExecutor redisLockExecutor;
@Override
public LoginVO login(LoginDTO loginDTO) {
@@ -52,7 +60,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
if (!BCrypt.checkpw(pwd, pwdDb)) {
throw new BusinessException("用户名/密码错误");
}
return createLoginVO(record);
return createWebLoginVO(record);
} finally {
TenantManager.restoreTenantCondition();
}
@@ -63,7 +71,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
try {
TenantManager.ignoreTenantCondition();
SysAccount record = getAvailableAccount(account, "开发免登账号不存在");
return createLoginVO(record);
return createWebLoginVO(record);
} finally {
TenantManager.restoreTenantCondition();
}
@@ -89,7 +97,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
@Override
public LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds) {
SysAccount record = getAvailableAccount(accountId, "账号不存在或不可用");
return createLoginVO(record, timeoutSeconds);
return createApiKeyLoginVO(record, timeoutSeconds);
}
@Override
@@ -107,18 +115,48 @@ public class AuthServiceImpl implements AuthService, StpInterface {
return roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList());
}
private LoginVO createLoginVO(SysAccount record) {
return createLoginVO(record, null);
/**
* 创建互斥的 Web 登录会话。
*
* @param record 登录账号
* @return 登录结果
*/
private LoginVO createWebLoginVO(SysAccount record) {
return redisLockExecutor.executeWithLock(
WEB_LOGIN_LOCK_KEY_PREFIX + record.getId(),
WEB_LOGIN_LOCK_WAIT,
WEB_LOGIN_LOCK_LEASE,
() -> {
if (AuthLoginSessionPolicy.shouldReplaceExistingWebSession()) {
StpUtil.replaced(record.getId(), AuthLoginSessionPolicy.WEB_DEVICE);
// 首次升级后的新登录同时淘汰旧版本未标记设备类型的浏览器会话。
StpUtil.replaced(record.getId(), AuthLoginSessionPolicy.LEGACY_WEB_DEVICE);
}
return createLoginVO(record, AuthLoginSessionPolicy.webLoginModel());
}
);
}
private LoginVO createLoginVO(SysAccount record, Long timeoutSeconds) {
if (timeoutSeconds != null) {
SaLoginModel loginModel = new SaLoginModel();
loginModel.setTimeout(timeoutSeconds);
StpUtil.login(record.getId(), loginModel);
} else {
StpUtil.login(record.getId());
}
/**
* 创建独立的 API Key 机器会话。
*
* @param record 登录账号
* @param timeoutSeconds 会话有效期秒数
* @return 登录结果
*/
private LoginVO createApiKeyLoginVO(SysAccount record, Long timeoutSeconds) {
return createLoginVO(record, AuthLoginSessionPolicy.apiKeyLoginModel(timeoutSeconds));
}
/**
* 创建指定来源的登录会话。
*
* @param record 登录账号
* @param loginModel 登录参数
* @return 登录结果
*/
private LoginVO createLoginVO(SysAccount record, SaLoginModel loginModel) {
StpUtil.login(record.getId(), loginModel);
LoginAccount loginAccount = new LoginAccount();
BeanUtil.copyProperties(record, loginAccount);
StpUtil.getSession().set(Constants.LOGIN_USER_KEY, loginAccount);

View File

@@ -0,0 +1,34 @@
package tech.easyflow.auth.service.impl;
import cn.dev33.satoken.stp.SaLoginModel;
import org.junit.Assert;
import org.junit.Test;
/**
* {@link AuthLoginSessionPolicy} 测试。
*/
public class AuthLoginSessionPolicyTest {
/**
* 验证 Web 登录使用独立设备类型并要求替换旧会话。
*/
@Test
public void shouldBuildExclusiveWebLoginModel() {
SaLoginModel model = AuthLoginSessionPolicy.webLoginModel();
Assert.assertEquals("WEB", model.getDevice());
Assert.assertEquals("default-device", AuthLoginSessionPolicy.LEGACY_WEB_DEVICE);
Assert.assertTrue(AuthLoginSessionPolicy.shouldReplaceExistingWebSession());
}
/**
* 验证 API Key 会话使用独立设备类型和指定有效期。
*/
@Test
public void shouldBuildIndependentApiKeyLoginModel() {
SaLoginModel model = AuthLoginSessionPolicy.apiKeyLoginModel(120L);
Assert.assertEquals("API_KEY", model.getDevice());
Assert.assertEquals(Long.valueOf(120L), model.getTimeout());
}
}