This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new c7eda0a0b feat(credential): conflict-safe creation and paginated
inventory (#2306)
c7eda0a0b is described below
commit c7eda0a0beb107c0caf6eaaa881b7a0da9c60f94
Author: aias00 <[email protected]>
AuthorDate: Wed Aug 19 17:33:48 2026 +0800
feat(credential): conflict-safe creation and paginated inventory (#2306)
* fix: report cloud credential create conflicts
* feat: paginate cloud credential listings
* fix: keep cloud credential pagination contract consistent
Signed-off-by: liuhy <[email protected]>
* fix: consume paginated cloud credential results
* fix: sort cloud credential pages by gmt_modified instead of updated_at
---------
Signed-off-by: liuhy <[email protected]>
Co-authored-by: lizhimins <[email protected]>
---
.../credential/CloudCredentialController.java | 10 +-
.../credential/CloudCredentialRepository.java | 2 +
.../credential/CloudCredentialService.java | 16 ++-
.../MybatisPlusCloudCredentialRepository.java | 7 ++
.../credential/CloudCredentialServiceTest.java | 16 +++
web/src/api/cloudCredential.test.ts | 29 +++--
web/src/api/cloudCredential.ts | 18 ++-
.../pages/instance/__tests__/InstancePage.test.tsx | 132 ++++++++++++---------
web/src/pages/instance/index.tsx | 6 +-
web/src/pages/settings/CloudCredentialTab.tsx | 4 +-
.../settings/__tests__/CloudCredentialTab.test.tsx | 29 +++--
11 files changed, 176 insertions(+), 93 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
index e33128954..e657df96c 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialController.java
@@ -19,6 +19,8 @@ package org.apache.rocketmq.studio.provider.credential;
import jakarta.validation.Valid;
import org.apache.rocketmq.studio.common.domain.DeleteRequestDTO;
import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.EntityIds;
import org.springframework.http.CacheControl;
@@ -28,10 +30,10 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
-import java.util.List;
@RequiredArgsConstructor
@RestController
@@ -41,8 +43,10 @@ public class CloudCredentialController {
private final CloudCredentialService credentialService;
@GetMapping
- public Result<List<CloudCredentialVO>> listCredentials() {
- return Result.ok(credentialService.listMasked());
+ public Result<PageResult<CloudCredentialVO>>
listCredentials(@RequestParam(required = false) InstanceVendor vendor,
+ @RequestParam(required = false) String search,
@RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ return Result.ok(credentialService.listMasked(vendor, search, page,
pageSize));
}
@PostMapping("/create")
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
index 9e76d9f7c..44a03085f 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialRepository.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.provider.credential;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import java.util.List;
import java.util.Optional;
@@ -24,6 +25,7 @@ import java.util.Optional;
public interface CloudCredentialRepository {
List<CloudCredentialVO> findAll();
+ PageResult<CloudCredentialVO> findPage(InstanceVendor vendor, String
search, int page, int pageSize);
Optional<CloudCredentialVO> findById(Long id);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
index b0ad47bde..e8e6d6d71 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialService.java
@@ -19,12 +19,15 @@ package org.apache.rocketmq.studio.provider.credential;
import org.springframework.util.StringUtils;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.domain.PageResult;
+import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
import org.apache.rocketmq.studio.audit.OperationAuditService;
import org.apache.rocketmq.studio.instance.InstanceRepository;
import org.apache.rocketmq.studio.provider.alibaba.AliyunClientFactory;
import org.apache.rocketmq.studio.provider.tencent.TencentClientFactory;
import org.springframework.stereotype.Service;
+import org.springframework.dao.DuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -48,6 +51,11 @@ public class CloudCredentialService {
.map(this::maskAccessKey)
.toList();
}
+ public PageResult<CloudCredentialVO> listMasked(InstanceVendor vendor,
String search, int page, int pageSize) {
+ if (page < 1 || pageSize < 1 || pageSize > 100) throw new
BusinessException(400, "Invalid page or pageSize");
+ PageResult<CloudCredentialVO> result =
credentialRepository.findPage(vendor, search, page, pageSize);
+ return
PageResult.of(result.getItems().stream().map(this::maskAccessKey).toList(),
result.getTotal(), page, pageSize);
+ }
public CloudCredentialVO create(CloudCredentialVO credential) {
if (credential == null) {
@@ -71,7 +79,13 @@ public class CloudCredentialService {
log.info("Creating cloud credential name={}, vendor={}",
credential.getName(), credential.getVendor());
credential.setGmtCreate(LocalDateTime.now());
credential.setGmtModified(LocalDateTime.now());
- CloudCredentialVO saved = credentialRepository.save(credential);
+ CloudCredentialVO saved;
+ try {
+ saved = credentialRepository.save(credential);
+ } catch (DuplicateKeyException exception) {
+ throw new BusinessException(409, "Cloud credential already exists
for vendor "
+ + credential.getVendor() + " and accessKey " +
CredentialUtils.mask(credential.getAccessKey()));
+ }
recordAudit("CREATE_CLOUD_CREDENTIAL", "CLOUD_CREDENTIAL",
String.valueOf(saved.getId()), null,
credentialAuditDetail(saved));
return maskAccessKey(saved);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
index 36e555e49..f60e42251 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/credential/MybatisPlusCloudCredentialRepository.java
@@ -17,6 +17,8 @@
package org.apache.rocketmq.studio.provider.credential;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.domain.enums.InstanceVendor;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.apache.rocketmq.studio.common.util.CredentialUtils;
@@ -47,6 +49,11 @@ public class MybatisPlusCloudCredentialRepository implements
CloudCredentialRepo
.map(MybatisPlusCloudCredentialRepository::toVO)
.collect(Collectors.toList());
}
+ @Override public PageResult<CloudCredentialVO> findPage(InstanceVendor
vendor, String search, int page, int pageSize) {
+ QueryWrapper<RmqCloudCredential> q = new
QueryWrapper<RmqCloudCredential>().eq(vendor != null, "vendor", vendor == null
? null : vendor.name()).like(search != null && !search.isBlank(), "name",
search).orderByDesc("gmt_modified", "id");
+ Page<RmqCloudCredential> result = credentialMapper.selectPage(new
Page<>(page, pageSize), q);
+ return
PageResult.of(result.getRecords().stream().map(MybatisPlusCloudCredentialRepository::toVO).toList(),
result.getTotal(), page, pageSize);
+ }
@Override
public Optional<CloudCredentialVO> findById(Long id) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
index 89d248f98..9d84b129c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/credential/CloudCredentialServiceTest.java
@@ -28,6 +28,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
import java.util.List;
import java.util.Optional;
@@ -105,6 +106,21 @@ class CloudCredentialServiceTest {
verify(credentialRepository, never()).save(any());
}
+ @Test
+ void createShouldTranslateConcurrentDuplicateKeyToConflict() {
+ CloudCredentialVO request = new CloudCredentialVO();
+ request.setName("race");
+ request.setVendor(InstanceVendor.ALIYUN);
+ request.setAccessKey("LTAI5tRaceKey00000000001");
+ request.setSecretKey("sk");
+
when(credentialRepository.findByVendorAndAccessKey(InstanceVendor.ALIYUN,
request.getAccessKey()))
+ .thenReturn(Optional.empty());
+ when(credentialRepository.save(any())).thenThrow(new
DuplicateKeyException("duplicate"));
+
+ assertThatThrownBy(() ->
service.create(request)).isInstanceOf(BusinessException.class)
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(409));
+ }
+
@Test
void createShouldRejectApacheVendorTest() {
CloudCredentialVO request = new CloudCredentialVO();
diff --git a/web/src/api/cloudCredential.test.ts
b/web/src/api/cloudCredential.test.ts
index 162bfc48a..ea380f31a 100644
--- a/web/src/api/cloudCredential.test.ts
+++ b/web/src/api/cloudCredential.test.ts
@@ -41,22 +41,27 @@ describe('cloudCredential API', () => {
it('returns masked credentials from the backend', async () => {
mock.onGet('/cloud-credentials').reply(200, {
code: 200,
- data: [
- {
- id: 'cred-1',
- name: 'aliyun-test',
- vendor: 'ALIYUN',
- accessKey: 'LTAI****0001',
- createdAt: '2026-08-06T00:00:00Z',
- },
- ],
+ data: {
+ items: [
+ {
+ id: 'cred-1',
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI****0001',
+ createdAt: '2026-08-06T00:00:00Z',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ },
});
const credentials = await listCloudCredentials();
- expect(credentials).toHaveLength(1);
- expect(credentials[0].vendor).toBe('ALIYUN');
- expect(credentials[0].secretKey).toBeUndefined();
+ expect(credentials.items).toHaveLength(1);
+ expect(credentials.items[0].vendor).toBe('ALIYUN');
+ expect(credentials.items[0].secretKey).toBeUndefined();
});
it('creates a credential with the full payload', async () => {
diff --git a/web/src/api/cloudCredential.ts b/web/src/api/cloudCredential.ts
index 39890c43d..e08a357e2 100644
--- a/web/src/api/cloudCredential.ts
+++ b/web/src/api/cloudCredential.ts
@@ -16,8 +16,22 @@ export interface CloudCredential {
gmtCreate: string;
}
-export async function listCloudCredentials() {
- const res = await client.get<{ data: CloudCredential[]
}>('/cloud-credentials');
+export interface CloudCredentialPage {
+ items: CloudCredential[];
+ total: number;
+ page: number;
+ size: number;
+}
+
+export async function listCloudCredentials(
+ vendor?: InstanceVendor,
+ search?: string,
+ page = 1,
+ pageSize = 20,
+): Promise<CloudCredentialPage> {
+ const res = await client.get<{ data: CloudCredentialPage
}>('/cloud-credentials', {
+ params: { vendor, search, page, pageSize },
+ });
return res.data.data;
}
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 0558fd42e..a2f60a103 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -22,6 +22,7 @@ import { MemoryRouter } from 'react-router-dom';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import * as aliyunCatalogApi from '../../../api/aliyunCatalog';
import * as cloudCredentialApi from '../../../api/cloudCredential';
+import type { CloudCredential, CloudCredentialPage } from
'../../../api/cloudCredential';
import type { Instance } from '../../../api/instance';
import { LangProvider } from '../../../i18n/LangContext';
import * as instanceService from '../../../services/instanceService';
@@ -48,6 +49,13 @@ vi.mock('../../../services/instanceService', () => ({
updateInstance: vi.fn(),
}));
+const cloudCredentialPage = (items: CloudCredential[]): CloudCredentialPage =>
({
+ items,
+ total: items.length,
+ page: 1,
+ size: 20,
+});
+
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -105,7 +113,7 @@ const deferred = <T,>() => {
describe('InstancePage', () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([]);
+
vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(cloudCredentialPage([]));
vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([]);
vi.mocked(aliyunCatalogApi.listAliyunInstances).mockResolvedValue([]);
vi.mocked(instanceService.listInstances).mockResolvedValue([
@@ -394,22 +402,24 @@ describe('InstancePage', () => {
const user = userEvent.setup();
const oldRegions = deferred<Array<{ regionId: string; regionName: string
}>>();
const latestRegions = deferred<Array<{ regionId: string; regionName:
string }>>();
- vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
- {
- id: 101,
- name: 'old-account',
- vendor: 'ALIYUN',
- accessKey: 'LTAI-old',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- {
- id: 102,
- name: 'latest-account',
- vendor: 'ALIYUN',
- accessKey: 'LTAI-latest',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- ]);
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+ cloudCredentialPage([
+ {
+ id: 101,
+ name: 'old-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-old',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 102,
+ name: 'latest-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-latest',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ ]),
+ );
vi.mocked(aliyunCatalogApi.listAliyunRegions)
.mockReturnValueOnce(oldRegions.promise)
.mockReturnValueOnce(latestRegions.promise);
@@ -461,15 +471,17 @@ describe('InstancePage', () => {
deferred<
Array<{ instanceId: string; instanceName: string; status: string;
regionId: string }>
>();
- vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
- {
- id: 103,
- name: 'cloud-account',
- vendor: 'ALIYUN',
- accessKey: 'LTAI-one',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- ]);
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+ cloudCredentialPage([
+ {
+ id: 103,
+ name: 'cloud-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ ]),
+ );
vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([
{ regionId: 'cn-beijing', regionName: 'Beijing' },
{ regionId: 'cn-shanghai', regionName: 'Shanghai' },
@@ -541,22 +553,24 @@ describe('InstancePage', () => {
it('clears a pending region load when the cloud vendor changes', async () =>
{
const user = userEvent.setup();
const pendingRegions = deferred<Array<{ regionId: string; regionName:
string }>>();
- vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
- {
- id: 104,
- name: 'aliyun-account',
- vendor: 'ALIYUN',
- accessKey: 'LTAI-one',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- {
- id: 105,
- name: 'tencent-account',
- vendor: 'TENCENT',
- accessKey: 'AKID-one',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- ]);
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+ cloudCredentialPage([
+ {
+ id: 104,
+ name: 'aliyun-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 105,
+ name: 'tencent-account',
+ vendor: 'TENCENT',
+ accessKey: 'AKID-one',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ ]),
+ );
vi.mocked(aliyunCatalogApi.listAliyunRegions).mockReturnValue(pendingRegions.promise);
renderPage();
@@ -600,22 +614,24 @@ describe('InstancePage', () => {
deferred<
Array<{ instanceId: string; instanceName: string; status: string;
regionId: string }>
>();
- vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue([
- {
- id: 104,
- name: 'aliyun-account',
- vendor: 'ALIYUN',
- accessKey: 'LTAI-one',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- {
- id: 105,
- name: 'tencent-account',
- vendor: 'TENCENT',
- accessKey: 'AKID-one',
- gmtCreate: '2026-01-01T00:00:00Z',
- },
- ]);
+ vi.mocked(cloudCredentialApi.listCloudCredentials).mockResolvedValue(
+ cloudCredentialPage([
+ {
+ id: 104,
+ name: 'aliyun-account',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI-one',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 105,
+ name: 'tencent-account',
+ vendor: 'TENCENT',
+ accessKey: 'AKID-one',
+ gmtCreate: '2026-01-01T00:00:00Z',
+ },
+ ]),
+ );
vi.mocked(aliyunCatalogApi.listAliyunRegions).mockResolvedValue([
{ regionId: 'cn-beijing', regionName: 'Beijing' },
]);
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 63fbd7643..547f5a61e 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -178,10 +178,10 @@ const InstancePage = () => {
let active = true;
const timer = window.setTimeout(() => {
setCredentialsLoading(true);
- listCloudCredentials()
- .then((items) => {
+ listCloudCredentials(vendor)
+ .then((result) => {
if (active) {
- setCredentials(items.filter((item) => item.vendor === vendor));
+ setCredentials(result.items);
}
})
.catch(() => {
diff --git a/web/src/pages/settings/CloudCredentialTab.tsx
b/web/src/pages/settings/CloudCredentialTab.tsx
index 476bee009..2cc682f4d 100644
--- a/web/src/pages/settings/CloudCredentialTab.tsx
+++ b/web/src/pages/settings/CloudCredentialTab.tsx
@@ -76,8 +76,8 @@ export const CloudCredentialTab = () => {
useEffect(() => {
let cancelled = false;
void listCloudCredentials()
- .then((list) => {
- if (!cancelled) setCredentials(list);
+ .then((result) => {
+ if (!cancelled) setCredentials(result.items);
})
.catch(() => {
if (!cancelled) message.error('云凭据加载失败,请稍后重试');
diff --git a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
index 46ab48c99..47213b993 100644
--- a/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
+++ b/web/src/pages/settings/__tests__/CloudCredentialTab.test.tsx
@@ -19,7 +19,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi }
from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
-import type { CloudCredential } from '../../../api/cloudCredential';
+import type { CloudCredentialPage } from '../../../api/cloudCredential';
import {
createCloudCredential,
deleteCloudCredential,
@@ -36,16 +36,21 @@ vi.mock('../../../api/cloudCredential', () => ({
updateCloudCredential: vi.fn(),
}));
-const credentials: CloudCredential[] = [
- {
- id: 1,
- name: 'aliyun-test',
- vendor: 'ALIYUN',
- accessKey: 'LTAI****0001',
- remark: '测试账号',
- gmtCreate: '2026-08-18T10:00:00',
- },
-];
+const credentials: CloudCredentialPage = {
+ items: [
+ {
+ id: 1,
+ name: 'aliyun-test',
+ vendor: 'ALIYUN',
+ accessKey: 'LTAI****0001',
+ remark: '测试账号',
+ gmtCreate: '2026-08-18T10:00:00',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+};
const renderTab = () =>
render(
@@ -123,7 +128,7 @@ describe('CloudCredentialTab', () => {
it('updates name and remark while keeping the secret unchanged when blank',
async () => {
vi.mocked(updateCloudCredential).mockResolvedValue({
- ...credentials[0],
+ ...credentials.items[0],
name: 'aliyun-renamed',
remark: '新备注',
});