Merge pull request #4434 from yan9651688/feat/group-one-click-copy

feat(group): add safe one-click duplication
This commit is contained in:
Wesley Liddick
2026-07-16 19:33:18 +08:00
committed by GitHub
32 changed files with 1797 additions and 37 deletions
@@ -293,6 +293,15 @@ func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.Creat
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
return nil, nil
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
@@ -2,12 +2,15 @@ package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -361,6 +364,53 @@ func (h *GroupHandler) Create(c *gin.Context) {
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Duplicate handles creating an inactive group copy with the source account bindings.
// POST /api/v1/admin/groups/:id/duplicate
func (h *GroupHandler) Duplicate(c *gin.Context) {
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID <= 0 {
response.BadRequest(c, "Invalid group ID")
return
}
actorScope := adminActorScope(c)
result, err := executeAdminIdempotent(
c,
"admin.groups.duplicate",
struct {
GroupID int64 `json:"group_id"`
}{GroupID: groupID},
service.DefaultWriteIdempotencyTTL(),
func(ctx context.Context) (any, error) {
group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key"))
if execErr != nil {
return nil, execErr
}
return dto.GroupFromServiceAdmin(group), nil
},
)
if err != nil {
reason := infraerrors.Reason(err)
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key"))
if recoverErr != nil {
slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
} else if recovered != nil {
c.Header("X-Idempotency-Recovered", "true")
response.Success(c, dto.GroupFromServiceAdmin(recovered))
return
}
}
response.ErrorFrom(c, err)
return
}
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
response.Success(c, result.Data)
}
// Update handles updating a group
// PUT /api/v1/admin/groups/:id
func (h *GroupHandler) Update(c *gin.Context) {
@@ -0,0 +1,163 @@
//go:build unit
package admin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type duplicateGroupAdminServiceStub struct {
service.AdminService
group *service.Group
calls int
recoverCalls int
groupID int64
actorScope string
operationKey string
recoverScope string
recoverKey string
created bool
}
func (s *duplicateGroupAdminServiceStub) DuplicateGroup(_ context.Context, groupID int64, actorScope, operationKey string) (*service.Group, error) {
s.calls++
s.groupID = groupID
s.actorScope = actorScope
s.operationKey = operationKey
s.created = true
return s.group, nil
}
func (s *duplicateGroupAdminServiceStub) RecoverDuplicateGroup(_ context.Context, _ int64, actorScope, operationKey string) (*service.Group, error) {
s.recoverCalls++
s.recoverScope = actorScope
s.recoverKey = operationKey
if !s.created {
return nil, nil
}
return s.group, nil
}
func setupDuplicateGroupRouter(t *testing.T, svc service.AdminService) *gin.Engine {
t.Helper()
previousCoordinator := service.DefaultIdempotencyCoordinator()
service.SetDefaultIdempotencyCoordinator(nil)
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
c.Next()
})
handler := NewGroupHandler(svc, nil, nil)
router.POST("/api/v1/admin/groups/:id/duplicate", handler.Duplicate)
return router
}
func duplicateGroupHandlerFixture() *service.Group {
return &service.Group{
ID: 43,
Name: "primary (Copy)",
Platform: service.PlatformAnthropic,
Status: "inactive",
RateMultiplier: 1,
AccountCount: 3,
ActiveAccountCount: 2,
DuplicateOperationID: "internal-operation-must-not-leak",
ModelRouting: map[string][]int64{"claude-*": {7}},
}
}
func TestDuplicateGroupHandlerReturnsAdminDTOWithoutOperationMetadata(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, 1, svc.calls)
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
require.Contains(t, recorder.Body.String(), `"status":"inactive"`)
require.Contains(t, recorder.Body.String(), `"account_count":3`)
require.NotContains(t, recorder.Body.String(), "duplicate_operation_id")
require.NotContains(t, recorder.Body.String(), "internal-operation-must-not-leak")
}
func TestDuplicateGroupHandlerRejectsInvalidID(t *testing.T) {
for _, id := range []string{"not-a-number", "0", "-1"} {
t.Run(id, func(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{}
router := setupDuplicateGroupRouter(t, svc)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/"+id+"/duplicate", nil)
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Zero(t, svc.calls)
})
}
}
func TestDuplicateGroupHandlerReplaysSameIdempotencyKey(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
call := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-group-42")
router.ServeHTTP(recorder, request)
return recorder
}
first := call()
second := call()
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code)
require.Equal(t, 1, svc.calls)
require.Equal(t, int64(42), svc.groupID)
require.Equal(t, "admin:77", svc.actorScope)
require.Equal(t, "duplicate-group-42", svc.operationKey)
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
}
func TestDuplicateGroupHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
svc := &duplicateGroupAdminServiceStub{group: duplicateGroupHandlerFixture()}
router := setupDuplicateGroupRouter(t, svc)
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
call := func() *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-group-42-recovery")
router.ServeHTTP(recorder, request)
return recorder
}
first := call()
second := call()
require.Equal(t, http.StatusOK, first.Code)
require.Equal(t, http.StatusOK, second.Code)
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
require.Equal(t, 2, svc.recoverCalls)
require.Equal(t, "admin:77", svc.recoverScope)
require.Equal(t, "duplicate-group-42-recovery", svc.recoverKey)
}