Преглед изворни кода

feat(user-migration): add root migration dashboard

master
fengsilin пре 1 недеља
родитељ
комит
a406c94b93
10 измењених фајлова са 714 додато и 3 уклоњено
  1. +2
    -1
      model/user_migration.go
  2. +71
    -0
      web/src/components/table/users/migration/BatchDetail.jsx
  3. +121
    -0
      web/src/components/table/users/migration/BatchList.jsx
  4. +81
    -0
      web/src/components/table/users/migration/ConflictResolver.jsx
  5. +74
    -0
      web/src/components/table/users/migration/ExecutionPanel.jsx
  6. +26
    -0
      web/src/components/table/users/migration/index.jsx
  7. +238
    -0
      web/src/hooks/users/useUserMigrationData.jsx
  8. +47
    -1
      web/src/i18n/locales/en.json
  9. +47
    -1
      web/src/i18n/locales/zh-CN.json
  10. +7
    -0
      web/src/pages/User/index.jsx

+ 2
- 1
model/user_migration.go Прегледај датотеку

@@ -8,6 +8,7 @@ import (

const (
UserMigrationBatchStatusDraft = "draft"
UserMigrationBatchStatusScanning = "scanning"
UserMigrationBatchStatusScanned = "scanned"
UserMigrationBatchStatusReady = "ready"
UserMigrationBatchStatusRunning = "running"
@@ -221,7 +222,7 @@ func TryLockBatchForScan(batchId int) (bool, error) {
UserMigrationBatchStatusScanned,
UserMigrationBatchStatusReady,
}).
Update("status", UserMigrationBatchStatusScanned)
Update("status", UserMigrationBatchStatusScanning)
return result.RowsAffected == 1, result.Error
}



+ 71
- 0
web/src/components/table/users/migration/BatchDetail.jsx Прегледај датотеку

@@ -0,0 +1,71 @@
import React from 'react';
import { Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';

function renderFlags(text) {
try {
const value = JSON.parse(text || '[]');
return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-';
} catch {
return text || '-';
}
}

const statusColorMap = {
conflict: 'orange',
ready: 'green',
migrating: 'blue',
migrated: 'green',
skipped: 'grey',
failed: 'red',
};

export default function BatchDetail({
selectedBatch,
items,
itemsTotal,
itemsPage,
itemsPageSize,
fetchItems,
}) {
const { t } = useTranslation();

const columns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' },
{ title: t('用户名'), dataIndex: 'source_username' },
{ title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' },
{ title: t('海外额度'), dataIndex: 'source_quota' },
{ title: t('冲突类型'), dataIndex: 'conflict_flags', render: renderFlags },
{ title: t('处理策略'), dataIndex: 'resolution_strategy', render: (value) => value || '-' },
{
title: t('状态'),
dataIndex: 'status',
render: (value) => <Tag color={statusColorMap[value] || 'grey'}>{value}</Tag>,
},
{ title: t('目标国内账号'), dataIndex: 'target_user_id', render: (value) => value || '-' },
{ title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' },
];

return (
<div style={{ width: '100%' }}>
<Typography.Title heading={5} style={{ marginBottom: 12 }}>
{t('批次详情')} #{selectedBatch.id}
</Typography.Title>
<Table
rowKey='id'
columns={columns}
dataSource={items}
size='small'
pagination={{
currentPage: itemsPage + 1,
pageSize: itemsPageSize,
total: itemsTotal,
pageSizeOpts: [20, 50, 100],
showSizeChanger: true,
onPageChange: (page) => fetchItems(selectedBatch.id, page - 1, itemsPageSize),
onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size),
}}
/>
</div>
);
}

+ 121
- 0
web/src/components/table/users/migration/BatchList.jsx Прегледај датотеку

@@ -0,0 +1,121 @@
import React, { useRef, useState } from 'react';
import {
Button,
Form,
Modal,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { IconPlus, IconRefresh } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { timestamp2string } from '../../../../helpers';

const statusColorMap = {
draft: 'grey',
scanning: 'blue',
scanned: 'light-blue',
ready: 'green',
running: 'cyan',
completed: 'green',
failed: 'red',
cancelled: 'grey',
};

export default function BatchList({
batches,
selectedBatch,
createBatch,
refresh,
selectBatch,
}) {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const formApiRef = useRef(null);

const columns = [
{ title: t('批次名称'), dataIndex: 'name' },
{
title: t('状态'),
dataIndex: 'status',
render: (status) => <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag>,
},
{ title: t('总用户数'), dataIndex: 'total_users' },
{ title: t('冲突数'), dataIndex: 'conflict_users' },
{ title: t('已迁移'), dataIndex: 'migrated_users' },
{ title: t('失败数'), dataIndex: 'failed_users' },
{
title: t('创建时间'),
dataIndex: 'created_at',
render: (value) => (value ? timestamp2string(value) : '-'),
},
];

const handleCreate = async () => {
const values = await formApiRef.current.validate();
const success = await createBatch(values);
if (success) {
setVisible(false);
}
};

return (
<div style={{ width: '100%' }}>
<Space style={{ marginBottom: 12 }}>
<Typography.Title heading={5} style={{ margin: 0 }}>
{t('迁移批次')}
</Typography.Title>
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
{t('新建批次')}
</Button>
<Button icon={<IconRefresh />} onClick={refresh}>
{t('刷新')}
</Button>
</Space>
<Table
rowKey='id'
columns={columns}
dataSource={batches}
pagination={false}
size='small'
rowSelection={{
type: 'radio',
selectedRowKeys: selectedBatch ? [selectedBatch.id] : [],
onChange: (_, rows) => rows[0] && selectBatch(rows[0]),
}}
empty={t('暂无数据')}
/>

<Modal
visible={visible}
title={t('创建批次')}
onCancel={() => setVisible(false)}
onOk={handleCreate}
>
<Form
getFormApi={(api) => {
formApiRef.current = api;
}}
initValues={{ source_region: 'overseas', target_region: 'cn' }}
>
<Form.Input
field='name'
label={t('批次名称')}
rules={[{ required: true, message: t('请输入批次名称') }]}
/>
<Form.Input
field='source_region'
label={t('源区域')}
rules={[{ required: true, message: t('请输入源区域') }]}
/>
<Form.Input
field='target_region'
label={t('目标区域')}
rules={[{ required: true, message: t('请输入目标区域') }]}
/>
</Form>
</Modal>
</div>
);
}

+ 81
- 0
web/src/components/table/users/migration/ConflictResolver.jsx Прегледај датотеку

@@ -0,0 +1,81 @@
import React, { useMemo, useState } from 'react';
import { Button, InputNumber, Space, Table, Typography } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';

function formatFlags(text) {
try {
const value = JSON.parse(text || '[]');
return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-';
} catch {
return text || '-';
}
}

export default function ConflictResolver({ items, resolveItem }) {
const { t } = useTranslation();
const [targets, setTargets] = useState({});

const conflictItems = useMemo(
() => items.filter((item) => item.status === 'conflict'),
[items],
);

const columns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' },
{ title: t('用户名'), dataIndex: 'source_username' },
{ title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' },
{
title: t('冲突类型'),
dataIndex: 'conflict_flags',
render: formatFlags,
},
{
title: t('目标国内账号'),
render: (_, record) => (
<InputNumber
min={1}
value={targets[record.id] ?? record.target_user_id ?? 0}
onChange={(value) => {
setTargets((prev) => ({ ...prev, [record.id]: value || 0 }));
}}
placeholder={t('请输入目标国内用户 ID')}
/>
),
},
{
title: t('操作'),
render: (_, record) => (
<Space>
<Button size='small' onClick={() => resolveItem(record.id, 'create_new')}>
{t('新建国内主账号')}
</Button>
<Button
size='small'
onClick={() => resolveItem(record.id, 'merge_into_existing', targets[record.id] || record.target_user_id || 0)}
>
{t('合并到现有国内账号')}
</Button>
<Button size='small' type='danger' onClick={() => resolveItem(record.id, 'skip')}>
{t('跳过此用户')}
</Button>
</Space>
),
},
];

return (
<div style={{ width: '100%' }}>
<Typography.Title heading={5} style={{ marginBottom: 12 }}>
{t('冲突处理')}
</Typography.Title>
<Table
rowKey='id'
columns={columns}
dataSource={conflictItems}
pagination={false}
size='small'
empty={t('暂无数据')}
/>
</div>
);
}

+ 74
- 0
web/src/components/table/users/migration/ExecutionPanel.jsx Прегледај датотеку

@@ -0,0 +1,74 @@
import React from 'react';
import {
Button,
Popconfirm,
Space,
Table,
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';

const runningStatusMap = {
scanning: 'blue',
running: 'cyan',
};

export default function ExecutionPanel({
selectedBatch,
scanBatch,
executeBatch,
retryBatch,
verifyBatch,
verifyResults,
}) {
const { t } = useTranslation();

const verifyColumns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' },
{ title: t('目标国内账号'), dataIndex: 'target_user_id' },
{
title: t('状态'),
render: (_, record) => (
<Tag color={record.error ? 'red' : 'green'}>{record.error ? t('失败数') : t('验证')}</Tag>
),
},
{
title: t('错误信息'),
render: (_, record) => record.error || '-',
},
];

return (
<div style={{ width: '100%' }}>
<Space style={{ marginBottom: 12 }}>
<Typography.Title heading={5} style={{ margin: 0 }}>
{t('执行与验证')}
</Typography.Title>
<Tag color={runningStatusMap[selectedBatch.status] || 'grey'}>
{selectedBatch.status}
</Tag>
<Button onClick={() => scanBatch(selectedBatch.id)}>{t('预检查')}</Button>
<Popconfirm
title={t('执行前请确认已进入冻结窗口')}
onConfirm={() => executeBatch(selectedBatch.id)}
>
<Button theme='solid' type='primary'>
{t('开始迁移')}
</Button>
</Popconfirm>
<Button onClick={() => retryBatch(selectedBatch.id)}>{t('重试失败项')}</Button>
<Button onClick={() => verifyBatch(selectedBatch.id)}>{t('迁移后验证')}</Button>
</Space>
{verifyResults.length > 0 && (
<Table
rowKey={(record) => `${record.source_user_id}-${record.target_user_id}`}
columns={verifyColumns}
dataSource={verifyResults}
pagination={false}
size='small'
/>
)}
</div>
);
}

+ 26
- 0
web/src/components/table/users/migration/index.jsx Прегледај датотеку

@@ -0,0 +1,26 @@
import React from 'react';
import { Space, Spin } from '@douyinfe/semi-ui';
import { useUserMigrationData } from '../../../../hooks/users/useUserMigrationData';
import BatchList from './BatchList';
import BatchDetail from './BatchDetail';
import ConflictResolver from './ConflictResolver';
import ExecutionPanel from './ExecutionPanel';

export default function UserMigrationCenter() {
const data = useUserMigrationData();

return (
<Spin spinning={data.loading}>
<Space vertical align='start' style={{ width: '100%' }} spacing='medium'>
<BatchList {...data} />
{data.selectedBatch && (
<>
<ExecutionPanel {...data} />
<ConflictResolver {...data} />
<BatchDetail {...data} />
</>
)}
</Space>
</Spin>
);
}

+ 238
- 0
web/src/hooks/users/useUserMigrationData.jsx Прегледај датотеку

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../helpers';

const POLL_INTERVAL_MS = 3000;
const RUNNING_STATUSES = new Set(['running', 'scanning']);

export function useUserMigrationData() {
const { t } = useTranslation();
const [batches, setBatches] = useState([]);
const [selectedBatch, setSelectedBatch] = useState(null);
const [items, setItems] = useState([]);
const [itemsTotal, setItemsTotal] = useState(0);
const [itemsPage, setItemsPage] = useState(0);
const [itemsPageSize, setItemsPageSize] = useState(50);
const [loading, setLoading] = useState(false);
const [verifyResults, setVerifyResults] = useState([]);
const pollTimerRef = useRef(null);
const selectedBatchIdRef = useRef(null);
const itemsPageRef = useRef(0);
const itemsPageSizeRef = useRef(50);

useEffect(() => {
selectedBatchIdRef.current = selectedBatch?.id || null;
}, [selectedBatch]);

useEffect(() => {
itemsPageRef.current = itemsPage;
}, [itemsPage]);

useEffect(() => {
itemsPageSizeRef.current = itemsPageSize;
}, [itemsPageSize]);

const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);

const fetchBatches = useCallback(async () => {
try {
const res = await API.get('/api/user-migrations/batches');
if (!res.data.success) {
showError(res.data.error || t('获取批次失败'));
return [];
}
const data = res.data.data || [];
setBatches(data);
if (selectedBatchIdRef.current) {
const current = data.find((batch) => batch.id === selectedBatchIdRef.current) || null;
setSelectedBatch(current);
}
return data;
} catch (error) {
showError(error.message);
return [];
}
}, []);

const fetchItems = useCallback(async (batchId, page = 0, pageSize = 50) => {
if (!batchId) {
return;
}
try {
const res = await API.get(`/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`);
if (!res.data.success) {
showError(res.data.error || t('获取批次详情失败'));
return;
}
setItems(res.data.items || []);
setItemsTotal(res.data.total || 0);
setItemsPage(page);
setItemsPageSize(pageSize);
setSelectedBatch(res.data.data || null);
} catch (error) {
showError(error.message);
}
}, []);

const refresh = useCallback(async (silent = false) => {
if (!silent) {
setLoading(true);
}
const latestBatches = await fetchBatches();
if (selectedBatchIdRef.current) {
await fetchItems(
selectedBatchIdRef.current,
itemsPageRef.current,
itemsPageSizeRef.current,
);
}
const hasRunning = latestBatches.some((batch) => RUNNING_STATUSES.has(batch.status));
if (hasRunning && !pollTimerRef.current) {
pollTimerRef.current = setInterval(() => {
void refresh(true);
}, POLL_INTERVAL_MS);
}
if (!hasRunning) {
stopPolling();
}
if (!silent) {
setLoading(false);
}
}, [fetchBatches, fetchItems, stopPolling]);

useEffect(() => {
void refresh();
return () => stopPolling();
}, [refresh, stopPolling]);

const selectBatch = useCallback(async (batch) => {
setVerifyResults([]);
setSelectedBatch(batch);
await fetchItems(batch.id, 0, itemsPageSizeRef.current);
}, [fetchItems]);

const createBatch = async (payload) => {
try {
const res = await API.post('/api/user-migrations/batches', payload);
if (!res.data.success) {
showError(res.data.error || t('创建批次失败'));
return false;
}
showSuccess(t('批次已创建'));
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const scanBatch = async (batchId) => {
try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/scan`);
if (!res.data.success) {
showError(res.data.error || t('预检查启动失败'));
return false;
}
showSuccess(t('预检查已启动'));
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const resolveItem = async (itemId, strategy, targetUserId = 0) => {
try {
const res = await API.post(`/api/user-migrations/items/${itemId}/resolve`, {
resolution_strategy: strategy,
target_user_id: targetUserId,
});
if (!res.data.success) {
showError(res.data.error || t('更新策略失败'));
return false;
}
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const executeBatch = async (batchId) => {
try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/execute`);
if (!res.data.success) {
showError(res.data.error || t('迁移启动失败'));
return false;
}
showSuccess(t('迁移已启动'));
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const retryBatch = async (batchId) => {
try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/retry`);
if (!res.data.success) {
showError(res.data.error || t('重试启动失败'));
return false;
}
showSuccess(t('重试已启动'));
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const verifyBatch = async (batchId) => {
try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/verify`);
if (!res.data.success) {
showError(res.data.error || t('验证失败'));
return [];
}
const results = res.data.data || [];
setVerifyResults(results);
showSuccess(t('验证完成'));
return results;
} catch (error) {
showError(error.message);
return [];
}
};

return {
batches,
selectedBatch,
items,
itemsTotal,
itemsPage,
itemsPageSize,
loading,
verifyResults,
refresh,
selectBatch,
fetchItems,
createBatch,
scanBatch,
resolveItem,
executeBatch,
retryBatch,
verifyBatch,
t,
};
}

+ 47
- 1
web/src/i18n/locales/en.json Прегледај датотеку

@@ -3122,5 +3122,51 @@
"该邮箱后缀已存在": "This email suffix already exists",
"必须以 @ 开头,如 @company.com、@edu.cn": "Must start with @, e.g. @company.com, @edu.cn",
"匹配该后缀的用户注册时获得的初始额度": "Initial quota for users registering with this email suffix",
"确认删除该规则?": "Confirm delete this rule?"
"确认删除该规则?": "Confirm delete this rule?",
"用户迁移": "User Migration",
"迁移批次": "Migration Batches",
"新建批次": "New Batch",
"创建批次": "Create Batch",
"批次名称": "Batch Name",
"源区域": "Source Region",
"目标区域": "Target Region",
"请输入批次名称": "Enter a batch name",
"请输入源区域": "Enter the source region",
"请输入目标区域": "Enter the target region",
"总用户数": "Total Users",
"冲突数": "Conflicts",
"已迁移": "Migrated",
"失败数": "Failed",
"创建时间": "Created At",
"执行与验证": "Execution and Verification",
"预检查": "Pre-check",
"开始迁移": "Start Migration",
"重试失败项": "Retry Failed",
"迁移后验证": "Verify Migration",
"执行前请确认已进入冻结窗口": "Confirm the freeze window is active before execution",
"海外用户ID": "Overseas User ID",
"目标国内账号": "Target CN Account",
"错误信息": "Error",
"冲突处理": "Conflict Resolution",
"冲突类型": "Conflict Flags",
"请输入目标国内用户 ID": "Enter target CN user ID",
"新建国内主账号": "Create New CN Account",
"合并到现有国内账号": "Merge Into Existing CN Account",
"跳过此用户": "Skip User",
"批次详情": "Batch Detail",
"海外额度": "Overseas Quota",
"处理策略": "Strategy",
"获取批次失败": "Failed to fetch batches",
"获取批次详情失败": "Failed to fetch batch detail",
"创建批次失败": "Failed to create batch",
"批次已创建": "Batch created",
"预检查启动失败": "Failed to start pre-check",
"预检查已启动": "Pre-check started",
"更新策略失败": "Failed to update strategy",
"迁移启动失败": "Failed to start migration",
"迁移已启动": "Migration started",
"重试启动失败": "Failed to start retry",
"重试已启动": "Retry started",
"验证完成": "Verification complete",
"暂无数据": "No data"
}

+ 47
- 1
web/src/i18n/locales/zh-CN.json Прегледај датотеку

@@ -3099,5 +3099,51 @@
"该邮箱后缀已存在": "该邮箱后缀已存在",
"必须以 @ 开头,如 @company.com、@edu.cn": "必须以 @ 开头,如 @company.com、@edu.cn",
"匹配该后缀的用户注册时获得的初始额度": "匹配该后缀的用户注册时获得的初始额度",
"确认删除该规则?": "确认删除该规则?"
"确认删除该规则?": "确认删除该规则?",
"用户迁移": "用户迁移",
"迁移批次": "迁移批次",
"新建批次": "新建批次",
"创建批次": "创建批次",
"批次名称": "批次名称",
"源区域": "源区域",
"目标区域": "目标区域",
"请输入批次名称": "请输入批次名称",
"请输入源区域": "请输入源区域",
"请输入目标区域": "请输入目标区域",
"总用户数": "总用户数",
"冲突数": "冲突数",
"已迁移": "已迁移",
"失败数": "失败数",
"创建时间": "创建时间",
"执行与验证": "执行与验证",
"预检查": "预检查",
"开始迁移": "开始迁移",
"重试失败项": "重试失败项",
"迁移后验证": "迁移后验证",
"执行前请确认已进入冻结窗口": "执行前请确认已进入冻结窗口",
"海外用户ID": "海外用户ID",
"目标国内账号": "目标国内账号",
"错误信息": "错误信息",
"冲突处理": "冲突处理",
"冲突类型": "冲突类型",
"请输入目标国内用户 ID": "请输入目标国内用户 ID",
"新建国内主账号": "新建国内主账号",
"合并到现有国内账号": "合并到现有国内账号",
"跳过此用户": "跳过此用户",
"批次详情": "批次详情",
"海外额度": "海外额度",
"处理策略": "处理策略",
"获取批次失败": "获取批次失败",
"获取批次详情失败": "获取批次详情失败",
"创建批次失败": "创建批次失败",
"批次已创建": "批次已创建",
"预检查启动失败": "预检查启动失败",
"预检查已启动": "预检查已启动",
"更新策略失败": "更新策略失败",
"迁移启动失败": "迁移启动失败",
"迁移已启动": "迁移已启动",
"重试启动失败": "重试启动失败",
"重试已启动": "重试已启动",
"验证完成": "验证完成",
"暂无数据": "暂无数据"
}

+ 7
- 0
web/src/pages/User/index.jsx Прегледај датотеку

@@ -22,6 +22,8 @@ import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import UsersTable from '../../components/table/users';
import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule';
import UserMigrationCenter from '../../components/table/users/migration';
import { isRoot } from '../../helpers';

const User = () => {
const { t } = useTranslation();
@@ -34,6 +36,11 @@ const User = () => {
<TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'>
<SettingsEmailQuotaRule />
</TabPane>
{isRoot() && (
<TabPane tab={t('用户迁移')} itemKey='user-migration'>
<UserMigrationCenter />
</TabPane>
)}
</Tabs>
</div>
);


Loading…
Откажи
Сачувај