- 前端冲突处理页面禁用 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")) | itemID, _ := strconv.Atoi(c.Param("id")) | ||||
| var item model.UserMigrationItem | 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"}) | c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "item not found"}) | ||||
| return | return | ||||
| } | } | ||||
| @@ -231,6 +231,14 @@ func ResolveUserMigrationItem(c *gin.Context) { | |||||
| }) | }) | ||||
| return | 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{ | updates := map[string]any{ | ||||
| "resolution_strategy": req.ResolutionStrategy, | "resolution_strategy": req.ResolutionStrategy, | ||||
| @@ -251,6 +259,22 @@ func ResolveUserMigrationItem(c *gin.Context) { | |||||
| c.JSON(http.StatusOK, gin.H{"success": true}) | 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) { | func ExecuteUserMigrationBatch(c *gin.Context) { | ||||
| batchID, _ := strconv.Atoi(c.Param("id")) | batchID, _ := strconv.Atoi(c.Param("id")) | ||||
| locked, err := model.TryLockBatchForExecution(batchID) | locked, err := model.TryLockBatchForExecution(batchID) | ||||
| @@ -546,6 +546,46 @@ func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) { | |||||
| require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status) | 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) { | func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | 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 }) { | export default function ConflictResolver({ items, resolveItem }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [targets, setTargets] = useState({}); | const [targets, setTargets] = useState({}); | ||||
| @@ -20,6 +29,20 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| [items], | [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 = [ | const columns = [ | ||||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | ||||
| { title: t('用户名'), dataIndex: 'source_username' }, | { title: t('用户名'), dataIndex: 'source_username' }, | ||||
| @@ -34,9 +57,13 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| render: (_, record) => ( | render: (_, record) => ( | ||||
| <InputNumber | <InputNumber | ||||
| min={1} | min={1} | ||||
| value={targets[record.id] ?? record.target_user_id ?? 0} | |||||
| value={getTargetUserId(record)} | |||||
| onChange={(value) => { | onChange={(value) => { | ||||
| setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); | |||||
| setTargets((prev) => ({ | |||||
| ...prev, | |||||
| [record.id]: | |||||
| typeof value === 'number' && value > 0 ? value : undefined, | |||||
| })); | |||||
| }} | }} | ||||
| placeholder={t('请输入目标国内用户 ID')} | placeholder={t('请输入目标国内用户 ID')} | ||||
| data-testid={`migration-resolve-target-${record.source_user_id}`} | data-testid={`migration-resolve-target-${record.source_user_id}`} | ||||
| @@ -45,38 +72,40 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| }, | }, | ||||
| { | { | ||||
| title: t('操作'), | 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> | |||||
| ); | |||||
| }, | |||||
| }, | }, | ||||
| ]; | ]; | ||||