mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
Merge pull request #4434 from yan9651688/feat/group-one-click-copy
feat(group): add safe one-click duplication
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user