发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
8 changed files with 253 additions and 8 deletions
Showing only changes of commit 5f11219226 - Show all commits

View File

@@ -203,11 +203,17 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return value == null ? null : String.valueOf(value);
}
/**
* 将指定账号密码重置为系统默认强密码。
*
* @param id 账号 ID
* @return 本次重置后使用的明文密码
*/
@PostMapping("/resetPassword")
@SaCheckPermission("/api/v1/sysAccount/save")
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok();
public Result<String> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
String password = service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
return Result.ok(password);
}
@PostMapping("/removeBatchWithResult")

View File

@@ -32,7 +32,14 @@ public interface SysAccountService extends IService<SysAccount> {
SysAccount getByUsername(String userKey);
void resetPassword(BigInteger accountId, BigInteger operatorId);
/**
* 将账号密码重置为系统默认强密码。
*
* @param accountId 账号 ID
* @param operatorId 操作人账号 ID
* @return 本次重置后使用的明文密码
*/
String resetPassword(BigInteger accountId, BigInteger operatorId);
SysAccountBatchActionResultVo removeBatchWithResult(Collection<BigInteger> ids);

View File

@@ -231,21 +231,30 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
return getOne(w);
}
/**
* 将账号密码重置为系统默认强密码。
*
* @param accountId 账号 ID
* @param operatorId 操作人账号 ID
* @return 本次重置后使用的明文密码
*/
@Override
public void resetPassword(BigInteger accountId, BigInteger operatorId) {
public String resetPassword(BigInteger accountId, BigInteger operatorId) {
SysAccount record = getById(accountId);
if (record == null) {
throw new BusinessException("用户不存在");
}
validateResetPasswordAllowed(record);
String plainPassword = accountSecurityProperties.getDefaultResetPassword();
SysAccount update = new SysAccount();
update.setId(accountId);
update.setPassword(BCrypt.hashpw(accountSecurityProperties.getDefaultResetPassword()));
update.setPassword(BCrypt.hashpw(plainPassword));
update.setPasswordResetRequired(true);
update.setModified(new Date());
update.setModifiedBy(operatorId);
updateById(update);
StpUtil.kickout(accountId);
return plainPassword;
}
@Override

View File

@@ -1,17 +1,26 @@
package tech.easyflow.system.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import tech.easyflow.common.constant.enums.EnumAccountType;
import tech.easyflow.system.config.AccountSecurityProperties;
import tech.easyflow.system.entity.SysAccount;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -51,4 +60,54 @@ public class SysAccountServiceImplTest {
assertEquals("3", displayNameMap.get(BigInteger.valueOf(3)));
verify(service, times(1)).list(any(QueryWrapper.class));
}
/**
* 验证重置密码返回实际明文,同时持久化对应密文并踢出旧登录态。
*
* @throws Exception 注入测试配置失败
*/
@Test
public void shouldReturnPlainPasswordAfterReset() throws Exception {
BigInteger accountId = BigInteger.valueOf(10);
BigInteger operatorId = BigInteger.valueOf(20);
String expectedPassword = "!Reset123";
SysAccountServiceImpl service = spy(new SysAccountServiceImpl());
AccountSecurityProperties properties = new AccountSecurityProperties();
properties.setDefaultResetPassword(expectedPassword);
properties.afterPropertiesSet();
setField(service, "accountSecurityProperties", properties);
SysAccount account = new SysAccount();
account.setId(accountId);
account.setAccountType(EnumAccountType.NORMAL.getCode());
doReturn(account).when(service).getById(accountId);
doReturn(true).when(service).updateById(any(SysAccount.class));
try (MockedStatic<StpUtil> stp = mockStatic(StpUtil.class)) {
String actualPassword = service.resetPassword(accountId, operatorId);
assertEquals(expectedPassword, actualPassword);
ArgumentCaptor<SysAccount> updateCaptor = ArgumentCaptor.forClass(SysAccount.class);
verify(service).updateById(updateCaptor.capture());
SysAccount update = updateCaptor.getValue();
assertTrue(BCrypt.checkpw(expectedPassword, update.getPassword()));
assertEquals(Boolean.TRUE, update.getPasswordResetRequired());
assertEquals(operatorId, update.getModifiedBy());
stp.verify(() -> StpUtil.kickout(accountId), times(1));
}
}
/**
* 设置测试对象的私有字段。
*
* @param target 目标对象
* @param fieldName 字段名
* @param value 字段值
* @throws Exception 字段不存在或不可访问
*/
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = SysAccountServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}

View File

@@ -29,6 +29,11 @@
"resetPassword": "Reset Password",
"resetPasswordConfirm": "Reset this account password to the system default strong password? The user will be required to change it on next login.",
"resetPasswordSuccess": "Password has been reset to the system default strong password and must be changed on next login",
"resetPasswordResultTitle": "Password Reset Successful",
"resetPasswordResultDescription": "Copy the password and share it securely. The user must change it at the next sign-in.",
"resetPasswordResultLabel": "New Password",
"resetPasswordResultMissing": "The password was reset, but the new password was not returned. Contact an administrator.",
"copyPassword": "Copy password",
"batchSelectedCount": "{count} selected",
"batchToolbarHint": "Batch actions are available for selected accounts",
"batchActionSelectRequired": "Please select at least one account",

View File

@@ -30,6 +30,11 @@
"resetPassword": "重置密码",
"resetPasswordConfirm": "确认将该用户密码重置为系统默认强密码吗?重置后用户下次登录必须先修改密码。",
"resetPasswordSuccess": "密码已重置为系统默认强密码,用户下次登录需修改密码",
"resetPasswordResultTitle": "密码重置成功",
"resetPasswordResultDescription": "请复制密码并安全地告知用户,用户下次登录时需修改密码。",
"resetPasswordResultLabel": "新密码",
"resetPasswordResultMissing": "密码已重置,但接口未返回新密码,请联系管理员",
"copyPassword": "复制密码",
"batchSelectedCount": "已选择 {count} 项",
"batchToolbarHint": "可对选中账号执行批量操作",
"batchActionSelectRequired": "请先选择要操作的账号",

View File

@@ -32,6 +32,7 @@ import { useDictStore } from '#/store';
import SysAccountImportModal from './SysAccountImportModal.vue';
import SysAccountModal from './SysAccountModal.vue';
import SysAccountPasswordResultModal from './SysAccountPasswordResultModal.vue';
onMounted(() => {
initDict();
@@ -40,6 +41,7 @@ onMounted(() => {
const pageDataRef = ref();
const tableRef = ref();
const importDialog = ref();
const passwordResultDialog = ref();
const saveDialog = ref();
const selectedRows = ref<any[]>([]);
const batchActionLoading = ref(false);
@@ -124,6 +126,7 @@ function remove(row: any) {
}).catch(() => {});
}
function resetPassword(row: any) {
let plainPassword = '';
ElMessageBox.confirm(
$t('sysAccount.resetPasswordConfirm'),
$t('message.noticeTitle'),
@@ -139,7 +142,7 @@ function resetPassword(row: any) {
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
plainPassword = typeof res.data === 'string' ? res.data : '';
clearSelection();
done();
}
@@ -152,7 +155,15 @@ function resetPassword(row: any) {
}
},
},
).catch(() => {});
)
.then(() => {
if (!plainPassword) {
ElMessage.error($t('sysAccount.resetPasswordResultMissing'));
return;
}
passwordResultDialog.value?.openDialog(plainPassword);
})
.catch(() => {});
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows;
@@ -260,6 +271,7 @@ function isAdmin(data: any) {
<div class="flex h-full flex-col gap-6 p-6">
<SysAccountImportModal ref="importDialog" @reload="reset" />
<SysAccountModal ref="saveDialog" @reload="reset" />
<SysAccountPasswordResultModal ref="passwordResultDialog" />
<ListPageShell>
<template #filters>
<HeaderSearch

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui';
import { CopyDocument } from '@element-plus/icons-vue';
import { ElButton, ElInput } from 'element-plus';
import { $t } from '#/locales';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
const dialogVisible = ref(false);
const password = ref('');
watch(dialogVisible, (visible) => {
if (!visible) {
password.value = '';
}
});
function openDialog(value: string) {
password.value = value;
dialogVisible.value = true;
}
function closeDialog() {
dialogVisible.value = false;
}
function selectPassword(event: FocusEvent) {
if (event.target instanceof HTMLInputElement) {
event.target.select();
}
}
async function copyPassword() {
await copyTextWithFeedback(
password.value,
$t('message.copySuccess'),
$t('message.copyFail'),
);
}
defineExpose({
openDialog,
});
</script>
<template>
<EasyFlowPanelModal
v-model:open="dialogVisible"
centered
width="482px"
:confirm-text="$t('message.ok')"
:description="$t('sysAccount.resetPasswordResultDescription')"
:show-cancel-button="false"
:title="$t('sysAccount.resetPasswordResultTitle')"
@confirm="closeDialog"
>
<div class="password-result">
<label class="password-result__label" for="reset-password-result">
{{ $t('sysAccount.resetPasswordResultLabel') }}
</label>
<ElInput
id="reset-password-result"
class="password-result__input"
:model-value="password"
readonly
size="large"
@focus="selectPassword"
>
<template #suffix>
<ElButton
class="password-result__copy"
:aria-label="$t('sysAccount.copyPassword')"
:icon="CopyDocument"
link
:title="$t('sysAccount.copyPassword')"
@click="copyPassword"
/>
</template>
</ElInput>
</div>
</EasyFlowPanelModal>
</template>
<style scoped>
.password-result {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.password-result__label {
font-size: 13px;
font-weight: 600;
line-height: 20px;
color: hsl(var(--text-strong));
}
:deep(.password-result__input .el-input__wrapper) {
padding-inline: var(--space-4) var(--space-2);
background: hsl(var(--surface-subtle));
border-radius: var(--radius-control);
box-shadow: 0 0 0 1px hsl(var(--line-subtle)) inset;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
:deep(.password-result__input .el-input__wrapper:hover) {
box-shadow: 0 0 0 1px hsl(var(--border)) inset;
}
:deep(.password-result__input .el-input__wrapper.is-focus) {
background: hsl(var(--surface-panel));
box-shadow: 0 0 0 2px hsl(var(--primary) / 18%) inset;
}
:deep(.password-result__input .el-input__inner) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.04em;
color: hsl(var(--text-strong));
}
:deep(.password-result__copy) {
width: 32px;
height: 32px;
padding: 0;
font-size: 16px;
color: hsl(var(--text-muted));
border-radius: var(--radius-control);
}
:deep(.password-result__copy:not(.is-disabled):hover),
:deep(.password-result__copy:not(.is-disabled):focus-visible) {
color: hsl(var(--primary));
background: hsl(var(--primary) / 8%);
}
</style>