- 前端冲突处理页面禁用 cn_already_synced_to_ov 场景的 merge 操作 - 后端 resolve 接口拒绝将已存在 synced copy 的 CN 用户作为 merge 目标 - 补充对应控制器测试 Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -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) | |||
| @@ -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() | |||
| @@ -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> | |||
| ); | |||
| }, | |||
| }, | |||
| ]; | |||