diff --git a/controller/user_migration.go b/controller/user_migration.go index 1ce812f..ea126e4 100644 --- a/controller/user_migration.go +++ b/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) diff --git a/controller/user_migration_test.go b/controller/user_migration_test.go index 390ef85..59529da 100644 --- a/controller/user_migration_test.go +++ b/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() diff --git a/web/src/components/table/users/migration/ConflictResolver.jsx b/web/src/components/table/users/migration/ConflictResolver.jsx index c0966ec..47b0b65 100644 --- a/web/src/components/table/users/migration/ConflictResolver.jsx +++ b/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) => ( { - 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) => ( - - - - - - ), + render: (_, record) => { + const targetUserId = getTargetUserId(record); + const mergeDisabled = isMergeDisabled(record) || !targetUserId; + return ( + + + + + + ); + }, }, ];