Просмотр исходного кода

fix(user-migration): 禁止冲突用户非法合并

- 前端冲突处理页面禁用 cn_already_synced_to_ov 场景的 merge 操作
- 后端 resolve 接口拒绝将已存在 synced copy 的 CN 用户作为 merge 目标
- 补充对应控制器测试

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 2 дней назад
Родитель
Сommit
8f89828ca3
3 измененных файлов: 128 добавлений и 35 удалений
  1. +25
    -1
      controller/user_migration.go
  2. +40
    -0
      controller/user_migration_test.go
  3. +63
    -34
      web/src/components/table/users/migration/ConflictResolver.jsx

+ 25
- 1
controller/user_migration.go Просмотреть файл

@@ -198,7 +198,7 @@ func ResolveUserMigrationItem(c *gin.Context) {
itemID, _ := strconv.Atoi(c.Param("id"))

var item model.UserMigrationItem
if err := model.DB.Select("id", "batch_id").First(&item, itemID).Error; err != nil {
if err := model.DB.Select("id", "batch_id", "conflict_flags").First(&item, itemID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "item not found"})
return
}
@@ -231,6 +231,14 @@ func ResolveUserMigrationItem(c *gin.Context) {
})
return
}
if req.ResolutionStrategy == model.UserMigrationStrategyMergeExisting &&
hasMigrationConflictFlag(item.ConflictFlags, "cn_already_synced_to_ov") {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": "merge_into_existing is not allowed when target CN user already has a synced OV copy",
})
return
}

updates := map[string]any{
"resolution_strategy": req.ResolutionStrategy,
@@ -251,6 +259,22 @@ func ResolveUserMigrationItem(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}

func hasMigrationConflictFlag(flagsJSON string, want string) bool {
if flagsJSON == "" || want == "" {
return false
}
var flags []string
if err := common.UnmarshalJsonStr(flagsJSON, &flags); err != nil {
return false
}
for _, flag := range flags {
if flag == want {
return true
}
}
return false
}

func ExecuteUserMigrationBatch(c *gin.Context) {
batchID, _ := strconv.Atoi(c.Param("id"))
locked, err := model.TryLockBatchForExecution(batchID)


+ 40
- 0
controller/user_migration_test.go Просмотреть файл

@@ -546,6 +546,46 @@ func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) {
require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status)
}

func TestResolveUserMigrationItem_RejectsMergeWhenCNAlreadyHasSyncedCopy(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/items/:id/resolve", ResolveUserMigrationItem)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 1,
BatchId: 1,
SourceUserId: 1001,
SourceUsername: "ov-u1",
Status: model.UserMigrationItemStatusConflict,
ConflictFlags: `["email","cn_already_synced_to_ov"]`,
TargetUserId: 24,
}).Error)

req := httptest.NewRequest(
http.MethodPost,
"/api/user-migrations/items/1/resolve",
bytes.NewBufferString(`{"resolution_strategy":"merge_into_existing","target_user_id":24}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusConflict, w.Code)

var item model.UserMigrationItem
require.NoError(t, db.First(&item, 1).Error)
require.Equal(t, model.UserMigrationItemStatusConflict, item.Status)
require.Equal(t, 24, item.TargetUserId)
}

func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()


+ 63
- 34
web/src/components/table/users/migration/ConflictResolver.jsx Просмотреть файл

@@ -11,6 +11,15 @@ function formatFlags(text) {
}
}

function parseFlags(text) {
try {
const value = JSON.parse(text || '[]');
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}

export default function ConflictResolver({ items, resolveItem }) {
const { t } = useTranslation();
const [targets, setTargets] = useState({});
@@ -20,6 +29,20 @@ export default function ConflictResolver({ items, resolveItem }) {
[items],
);

const getTargetUserId = (record) => {
const draftTarget = targets[record.id];
if (typeof draftTarget === 'number' && draftTarget > 0) {
return draftTarget;
}
if (typeof record.target_user_id === 'number' && record.target_user_id > 0) {
return record.target_user_id;
}
return undefined;
};

const isMergeDisabled = (record) =>
parseFlags(record.conflict_flags).includes('cn_already_synced_to_ov');

const columns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' },
{ title: t('用户名'), dataIndex: 'source_username' },
@@ -34,9 +57,13 @@ export default function ConflictResolver({ items, resolveItem }) {
render: (_, record) => (
<InputNumber
min={1}
value={targets[record.id] ?? record.target_user_id ?? 0}
value={getTargetUserId(record)}
onChange={(value) => {
setTargets((prev) => ({ ...prev, [record.id]: value || 0 }));
setTargets((prev) => ({
...prev,
[record.id]:
typeof value === 'number' && value > 0 ? value : undefined,
}));
}}
placeholder={t('请输入目标国内用户 ID')}
data-testid={`migration-resolve-target-${record.source_user_id}`}
@@ -45,38 +72,40 @@ export default function ConflictResolver({ items, resolveItem }) {
},
{
title: t('操作'),
render: (_, record) => (
<Space>
<Button
size='small'
onClick={() => resolveItem(record.id, 'create_new')}
data-testid={`migration-resolve-create-${record.source_user_id}`}
>
{t('新建国内主账号')}
</Button>
<Button
size='small'
onClick={() =>
resolveItem(
record.id,
'merge_into_existing',
targets[record.id] || record.target_user_id || 0,
)
}
data-testid={`migration-resolve-merge-${record.source_user_id}`}
>
{t('合并到现有国内账号')}
</Button>
<Button
size='small'
type='danger'
onClick={() => resolveItem(record.id, 'skip')}
data-testid={`migration-resolve-skip-${record.source_user_id}`}
>
{t('跳过此用户')}
</Button>
</Space>
),
render: (_, record) => {
const targetUserId = getTargetUserId(record);
const mergeDisabled = isMergeDisabled(record) || !targetUserId;
return (
<Space>
<Button
size='small'
onClick={() => resolveItem(record.id, 'create_new')}
data-testid={`migration-resolve-create-${record.source_user_id}`}
>
{t('新建国内主账号')}
</Button>
<Button
size='small'
disabled={mergeDisabled}
onClick={() =>
targetUserId &&
resolveItem(record.id, 'merge_into_existing', targetUserId)
}
data-testid={`migration-resolve-merge-${record.source_user_id}`}
>
{t('合并到现有国内账号')}
</Button>
<Button
size='small'
type='danger'
onClick={() => resolveItem(record.id, 'skip')}
data-testid={`migration-resolve-skip-${record.source_user_id}`}
>
{t('跳过此用户')}
</Button>
</Space>
);
},
},
];



Загрузка…
Отмена
Сохранить