Skip to content

Commit 6c5f66e

Browse files
authored
feat: 添加分组复制功能 (#172)
* feat: 复制分组 * fix: 前端代码调整 * feat: 优化精简复制逻辑
1 parent 3dd98f4 commit 6c5f66e

File tree

6 files changed

+478
-3
lines changed

6 files changed

+478
-3
lines changed

internal/handler/group_handler.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,165 @@ func (s *Server) GetGroupStats(c *gin.Context) {
753753
response.Success(c, resp)
754754
}
755755

756+
// GroupCopyRequest defines the payload for copying a group.
757+
type GroupCopyRequest struct {
758+
CopyKeys string `json:"copy_keys"` // "none"|"valid_only"|"all"
759+
}
760+
761+
// GroupCopyResponse defines the response for group copy operation.
762+
type GroupCopyResponse struct {
763+
Group *GroupResponse `json:"group"`
764+
}
765+
766+
// generateUniqueGroupName generates a unique group name by appending _copy and numbers if needed.
767+
func (s *Server) generateUniqueGroupName(baseName string) string {
768+
var groups []models.Group
769+
if err := s.DB.Select("name").Find(&groups).Error; err != nil {
770+
return baseName + "_copy"
771+
}
772+
773+
// Create a map of existing names for quick lookup
774+
existingNames := make(map[string]bool)
775+
for _, group := range groups {
776+
existingNames[group.Name] = true
777+
}
778+
779+
// Try base name with _copy suffix first
780+
copyName := baseName + "_copy"
781+
if !existingNames[copyName] {
782+
return copyName
783+
}
784+
785+
// Try appending numbers to _copy suffix
786+
for i := 2; i <= 1000; i++ {
787+
candidate := fmt.Sprintf("%s_copy_%d", baseName, i)
788+
if !existingNames[candidate] {
789+
return candidate
790+
}
791+
}
792+
793+
return copyName
794+
}
795+
796+
// CopyGroup handles copying a group with optional content.
797+
func (s *Server) CopyGroup(c *gin.Context) {
798+
id, err := strconv.Atoi(c.Param("id"))
799+
if err != nil {
800+
response.Error(c, app_errors.NewAPIError(app_errors.ErrBadRequest, "Invalid group ID format"))
801+
return
802+
}
803+
sourceGroupID := uint(id)
804+
805+
var req GroupCopyRequest
806+
if err := c.ShouldBindJSON(&req); err != nil {
807+
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
808+
return
809+
}
810+
811+
// Validate copy keys option
812+
if req.CopyKeys != "" && req.CopyKeys != "none" && req.CopyKeys != "valid_only" && req.CopyKeys != "all" {
813+
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid copy_keys value. Must be 'none', 'valid_only', or 'all'"))
814+
return
815+
}
816+
if req.CopyKeys == "" {
817+
req.CopyKeys = "all"
818+
}
819+
820+
// Check if source group exists
821+
var sourceGroup models.Group
822+
if err := s.DB.First(&sourceGroup, sourceGroupID).Error; err != nil {
823+
response.Error(c, app_errors.ParseDBError(err))
824+
return
825+
}
826+
827+
// Start transaction
828+
tx := s.DB.Begin()
829+
if tx.Error != nil {
830+
response.Error(c, app_errors.ErrDatabase)
831+
return
832+
}
833+
defer tx.Rollback()
834+
835+
// Create new group by copying source group and overriding specific fields
836+
newGroup := sourceGroup
837+
newGroup.ID = 0
838+
newGroup.Name = s.generateUniqueGroupName(sourceGroup.Name)
839+
if sourceGroup.DisplayName != "" {
840+
newGroup.DisplayName = sourceGroup.DisplayName + " Copy"
841+
}
842+
newGroup.CreatedAt = time.Time{}
843+
newGroup.UpdatedAt = time.Time{}
844+
newGroup.LastValidatedAt = nil
845+
846+
// Create the new group
847+
if err := tx.Create(&newGroup).Error; err != nil {
848+
response.Error(c, app_errors.ParseDBError(err))
849+
return
850+
}
851+
852+
// Prepare key data for async import task
853+
var sourceKeyValues []string
854+
855+
if req.CopyKeys != "none" {
856+
var sourceKeys []models.APIKey
857+
query := tx.Where("group_id = ?", sourceGroupID)
858+
859+
// Filter by status if only copying valid keys
860+
if req.CopyKeys == "valid_only" {
861+
query = query.Where("status = ?", models.KeyStatusActive)
862+
}
863+
864+
if err := query.Find(&sourceKeys).Error; err != nil {
865+
response.Error(c, app_errors.ParseDBError(err))
866+
return
867+
}
868+
869+
// Extract key values for async import task
870+
for _, sourceKey := range sourceKeys {
871+
sourceKeyValues = append(sourceKeyValues, sourceKey.KeyValue)
872+
}
873+
}
874+
875+
// Commit transaction
876+
if err := tx.Commit().Error; err != nil {
877+
response.Error(c, app_errors.ErrDatabase)
878+
return
879+
}
880+
881+
// Update caches after successful transaction
882+
if err := s.GroupManager.Invalidate(); err != nil {
883+
logrus.WithContext(c.Request.Context()).WithError(err).Error("failed to invalidate group cache")
884+
}
885+
886+
// Start async key import task if there are keys to copy (reuse existing logic)
887+
if len(sourceKeyValues) > 0 {
888+
// Convert key values array to text format expected by KeyImportService
889+
keysText := strings.Join(sourceKeyValues, "\n")
890+
891+
// Directly reuse the AddMultipleKeysAsync logic from key_handler.go
892+
if _, err := s.KeyImportService.StartImportTask(&newGroup, keysText); err != nil {
893+
logrus.WithFields(logrus.Fields{
894+
"groupId": newGroup.ID,
895+
"keyCount": len(sourceKeyValues),
896+
"error": err,
897+
}).Error("Failed to start async key import task for group copy")
898+
} else {
899+
logrus.WithFields(logrus.Fields{
900+
"groupId": newGroup.ID,
901+
"keyCount": len(sourceKeyValues),
902+
}).Info("Started async key import task for group copy")
903+
}
904+
}
905+
906+
// Prepare response
907+
groupResponse := s.newGroupResponse(&newGroup)
908+
copyResponse := &GroupCopyResponse{
909+
Group: groupResponse,
910+
}
911+
912+
response.Success(c, copyResponse)
913+
}
914+
756915
// List godoc
757916
func (s *Server) List(c *gin.Context) {
758917
var groups []models.Group

internal/router/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser
111111
groups.PUT("/:id", serverHandler.UpdateGroup)
112112
groups.DELETE("/:id", serverHandler.DeleteGroup)
113113
groups.GET("/:id/stats", serverHandler.GetGroupStats)
114+
groups.POST("/:id/copy", serverHandler.CopyGroup)
114115
}
115116

116117
// Key Management Routes

web/src/api/keys.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ export const keysApi = {
4444
return res.data || [];
4545
},
4646

47+
// 复制分组
48+
async copyGroup(
49+
groupId: number,
50+
copyData: {
51+
copy_keys: "none" | "valid_only" | "all";
52+
}
53+
): Promise<{
54+
group: Group;
55+
}> {
56+
const res = await http.post(`/groups/${groupId}/copy`, copyData);
57+
return res.data;
58+
},
59+
60+
// 获取分组列表(简化版)
61+
async listGroups(): Promise<Group[]> {
62+
const res = await http.get("/groups/list");
63+
return res.data || [];
64+
},
65+
4766
// 获取分组的密钥列表
4867
async getGroupKeys(params: {
4968
group_id: number;

0 commit comments

Comments
 (0)