package service import ( "crypto/rand" "encoding/base64" "errors" "fmt" "regexp" "strings" "time" "affiliate_dash/internal/model" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" ) var merchantCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,63}$`) type MerchantService struct { db *gorm.DB codec *SecretCodec tenant *TenantService } func NewMerchantService(db *gorm.DB, codec *SecretCodec, tenant *TenantService) *MerchantService { return &MerchantService{db: db, codec: codec, tenant: tenant} } type CreateMerchantInput struct { Code string Name string ContactName string ContactInfo string OwnerUserID uint OwnerUsername string OwnerPassword string OwnerNickname string Features string FeeType string FeeRateBP int64 FeeFixedAmount int64 } func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uint) (*model.Merchant, error) { in.Code = strings.ToLower(strings.TrimSpace(in.Code)) in.Name = strings.TrimSpace(in.Name) in.Features = NormalizeMerchantFeatures(in.Features) if !merchantCodePattern.MatchString(in.Code) { return nil, errors.New("商户编码需为 3-64 位小写字母、数字或连字符") } if in.Name == "" { return nil, errors.New("商户名称不能为空") } if in.OwnerUserID == 0 && strings.TrimSpace(in.OwnerUsername) == "" { return nil, errors.New("商户负责人不能为空") } if in.FeeRateBP < 0 || in.FeeRateBP > 10000 { return nil, errors.New("手续费比例需在 0-10000 BP 之间") } if in.FeeFixedAmount < 0 { return nil, errors.New("固定手续费不能小于零") } feeType := in.FeeType if feeType == "" { feeType = model.FeeTypeRate } if feeType != model.FeeTypeRate && feeType != model.FeeTypeFixed { return nil, errors.New("手续费类型仅支持 rate 或 fixed") } merchant := &model.Merchant{ Code: in.Code, Name: in.Name, Status: model.MerchantStatusActive, ContactName: in.ContactName, ContactInfo: in.ContactInfo, Features: in.Features, FeeType: feeType, FeeRateBP: in.FeeRateBP, FeeFixedAmount: in.FeeFixedAmount, } err := s.db.Transaction(func(tx *gorm.DB) error { owner, err := s.resolveOrCreateOwner(tx, in) if err != nil { return err } if owner.Status != 1 { return errors.New("商户负责人已禁用") } if err := tx.Create(merchant).Error; err != nil { return err } if err := tx.Create(&model.WalletAccount{MerchantID: merchant.ID, Currency: "POINT"}).Error; err != nil { return err } if err := tx.Create(&model.MerchantMember{ MerchantID: merchant.ID, UserID: owner.ID, Role: model.MemberRoleOwner, Status: 1, IsDefault: true, }).Error; err != nil { return err } if err := copyDefaultProducts(tx, merchant.ID); err != nil { return err } return writeAudit(tx, &merchant.ID, &actorUserID, nil, "merchant.create", "merchant", fmt.Sprint(merchant.ID), map[string]string{"code": merchant.Code}) }) if err != nil { return nil, err } return merchant, nil } func (s *MerchantService) resolveOrCreateOwner(tx *gorm.DB, in CreateMerchantInput) (*model.User, error) { if in.OwnerUserID != 0 { var owner model.User if err := tx.First(&owner, in.OwnerUserID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("商户负责人不存在") } return nil, err } return &owner, nil } username := strings.TrimSpace(in.OwnerUsername) password := strings.TrimSpace(in.OwnerPassword) nickname := strings.TrimSpace(in.OwnerNickname) if username == "" || len(username) < 3 { return nil, errors.New("负责人用户名至少 3 位") } if len(password) < 6 { return nil, errors.New("负责人密码至少 6 位") } if nickname == "" { nickname = username } var count int64 if err := tx.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, errors.New("负责人用户名已存在") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } owner := &model.User{ Username: username, PasswordHash: string(hash), Nickname: nickname, Role: model.RoleMerchant, Status: 1, } if err := tx.Create(owner).Error; err != nil { return nil, err } return owner, nil } type UpdateMerchantSettingsInput struct { Name *string Status *string ContactName *string ContactInfo *string Features *string FeeType *string FeeRateBP *int64 FeeFixedAmount *int64 } func (s *MerchantService) UpdateMerchantSettings(merchantID uint, in UpdateMerchantSettingsInput, actorUserID uint) error { updates := map[string]interface{}{} if in.Name != nil { name := strings.TrimSpace(*in.Name) if name == "" { return errors.New("商户名称不能为空") } updates["name"] = name } if in.Status != nil { if *in.Status != model.MerchantStatusActive && *in.Status != model.MerchantStatusDisabled { return errors.New("无效的商户状态") } updates["status"] = *in.Status } if in.ContactName != nil { updates["contact_name"] = strings.TrimSpace(*in.ContactName) } if in.ContactInfo != nil { updates["contact_info"] = strings.TrimSpace(*in.ContactInfo) } if in.Features != nil { updates["features"] = NormalizeMerchantFeatures(*in.Features) } if in.FeeRateBP != nil { if *in.FeeRateBP < 0 || *in.FeeRateBP > 10000 { return errors.New("手续费比例需在 0-10000 BP 之间") } updates["fee_rate_bp"] = *in.FeeRateBP } if in.FeeFixedAmount != nil { if *in.FeeFixedAmount < 0 { return errors.New("固定手续费不能小于零") } updates["fee_fixed_amount"] = *in.FeeFixedAmount } if in.FeeType != nil { if *in.FeeType != model.FeeTypeRate && *in.FeeType != model.FeeTypeFixed { return errors.New("手续费类型仅支持 rate 或 fixed") } updates["fee_type"] = *in.FeeType } if len(updates) == 0 { return errors.New("没有可更新字段") } return s.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Merchant{}).Where("id = ?", merchantID).Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("商户不存在") } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.settings.update", "merchant", fmt.Sprint(merchantID), updates) }) } func (s *MerchantService) ListMerchants(page, size int) ([]model.Merchant, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.Merchant{}) var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } var merchants []model.Merchant err := tx.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&merchants).Error return merchants, total, err } func (s *MerchantService) GetMerchant(merchantID uint) (*model.Merchant, error) { var merchant model.Merchant if err := s.db.First(&merchant, merchantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("商户不存在") } return nil, err } return &merchant, nil } type AddMemberInput struct { UserID uint Role string IsDefault bool } func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) { if !isValidMemberRole(in.Role) { return nil, errors.New("无效的商户成员角色") } member := &model.MerchantMember{ MerchantID: merchantID, UserID: in.UserID, Role: in.Role, Status: 1, IsDefault: in.IsDefault, } err := s.db.Transaction(func(tx *gorm.DB) error { var merchant model.Merchant if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { return errors.New("商户不存在或已禁用") } var user model.User if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil { return errors.New("用户不存在或已禁用") } if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{ "role": in.Role, "status": 1, "is_default": in.IsDefault, }), }).Create(member).Error; err != nil { return err } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role}) }) if err != nil { return nil, err } if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil { return nil, err } return member, nil } func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { var members []model.MerchantMember err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error return members, err } type CreateMerchantProductInput struct { ProductCode string ProductName string Category string Description string Attributes string SKU string DisplayName string PriceAmount int64 CostAmount int64 Currency string Stock int64 Status string FulfillmentConfig string } func (s *MerchantService) CreateMerchantProduct(merchantID uint, in CreateMerchantProductInput, actorUserID uint) (*model.MerchantProduct, error) { in.SKU = strings.TrimSpace(in.SKU) in.ProductCode = strings.TrimSpace(in.ProductCode) in.ProductName = strings.TrimSpace(in.ProductName) if in.SKU == "" { return nil, errors.New("商户商品 SKU 不能为空") } if in.PriceAmount < 0 || in.CostAmount < 0 { return nil, errors.New("商品金额不能小于零") } if in.Stock < -1 { return nil, errors.New("库存只能为 -1 或非负整数") } if in.Currency == "" { in.Currency = "POINT" } in.Currency = strings.ToUpper(in.Currency) if in.Status == "" { in.Status = model.ProductStatusActive } if in.Status != model.ProductStatusActive && in.Status != model.ProductStatusInactive { return nil, errors.New("无效的商品状态") } merchantProduct := &model.MerchantProduct{} err := s.db.Transaction(func(tx *gorm.DB) error { var merchant model.Merchant if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { return errors.New("商户不存在或已禁用") } product, err := ensureProduct(tx, in) if err != nil { return err } merchantProduct = &model.MerchantProduct{ MerchantID: merchantID, ProductID: product.ID, SKU: in.SKU, DisplayName: fallbackName(in.DisplayName, product.Name), PriceAmount: in.PriceAmount, CostAmount: in.CostAmount, Currency: in.Currency, Stock: in.Stock, Status: in.Status, FulfillmentConfig: in.FulfillmentConfig, } if err := tx.Create(merchantProduct).Error; err != nil { return err } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.create", "merchant_product", fmt.Sprint(merchantProduct.ID), map[string]string{"sku": in.SKU}) }) if err != nil { return nil, err } return merchantProduct, nil } func (s *MerchantService) ListMerchantProducts(merchantID uint, page, size int, activeOnly bool) ([]model.MerchantProduct, int64, error) { page, size = normalizePage(page, size) tx := s.db.Model(&model.MerchantProduct{}).Where("merchant_id = ?", merchantID) if activeOnly { tx = tx.Where("status = ?", model.ProductStatusActive) } var total int64 if err := tx.Count(&total).Error; err != nil { return nil, 0, err } var products []model.MerchantProduct err := tx.Preload("Product").Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&products).Error return products, total, err } type UpdateMerchantProductInput struct { DisplayName *string PriceAmount *int64 CostAmount *int64 Stock *int64 Status *string FulfillmentConfig *string } func (s *MerchantService) UpdateMerchantProduct(merchantID, id uint, in UpdateMerchantProductInput, actorUserID uint) error { updates := make(map[string]interface{}) if in.DisplayName != nil { updates["display_name"] = *in.DisplayName } if in.PriceAmount != nil { if *in.PriceAmount < 0 { return errors.New("商品售价不能小于零") } updates["price_amount"] = *in.PriceAmount } if in.CostAmount != nil { if *in.CostAmount < 0 { return errors.New("商品成本不能小于零") } updates["cost_amount"] = *in.CostAmount } if in.Stock != nil { if *in.Stock < -1 { return errors.New("库存只能为 -1 或非负整数") } updates["stock"] = *in.Stock } if in.Status != nil { if *in.Status != model.ProductStatusActive && *in.Status != model.ProductStatusInactive { return errors.New("无效的商品状态") } updates["status"] = *in.Status } if in.FulfillmentConfig != nil { updates["fulfillment_config"] = *in.FulfillmentConfig } if len(updates) == 0 { return errors.New("没有可更新字段") } return s.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.MerchantProduct{}).Where("id = ? AND merchant_id = ?", id, merchantID).Updates(updates) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("商户商品不存在") } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant_product.update", "merchant_product", fmt.Sprint(id), nil) }) } type APICredential struct { Client *model.APIClient `json:"client"` Secret string `json:"secret"` } type CreateAPIClientInput struct { Name string Scopes string SignatureVersion string ExpiresAt *time.Time } // MaxAPIClientsPerMerchant 每个商户最多可创建的 API 密钥数量,防止密钥滥用。 const MaxAPIClientsPerMerchant = 5 func (s *MerchantService) CreateAPIClient(merchantID uint, in CreateAPIClientInput, actorUserID uint) (*APICredential, error) { in.Name = strings.TrimSpace(in.Name) if in.Name == "" { return nil, errors.New("API 客户端名称不能为空") } if len(ParseScopes(in.Scopes)) == 0 { return nil, errors.New("至少配置一个 API 权限") } if in.SignatureVersion == "" { in.SignatureVersion = "v1" } if in.SignatureVersion != "v1" { return nil, errors.New("无效的签名版本") } appKey, err := randomToken("ak_", 24) if err != nil { return nil, err } secret, err := randomToken("sk_", 32) if err != nil { return nil, err } ciphertext, err := s.codec.Encrypt(secret) if err != nil { return nil, err } client := &model.APIClient{ MerchantID: merchantID, Name: in.Name, AppKey: appKey, SecretCiphertext: ciphertext, SignatureVersion: in.SignatureVersion, Scopes: strings.Join(scopeList(in.Scopes), ","), Status: model.APIClientStatusActive, ExpiresAt: in.ExpiresAt, } err = s.db.Transaction(func(tx *gorm.DB) error { var merchant model.Merchant if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil { return errors.New("商户不存在或已禁用") } var clientCount int64 if err := tx.Model(&model.APIClient{}).Where("merchant_id = ?", merchantID).Count(&clientCount).Error; err != nil { return err } if clientCount >= MaxAPIClientsPerMerchant { return fmt.Errorf("每个商户最多可创建 %d 个 API 密钥", MaxAPIClientsPerMerchant) } if err := tx.Create(client).Error; err != nil { return err } return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.create", "api_client", fmt.Sprint(client.ID), map[string]string{"name": in.Name}) }) if err != nil { return nil, err } return &APICredential{Client: client, Secret: secret}, nil } func (s *MerchantService) ListAPIClients(merchantID uint) ([]model.APIClient, error) { var clients []model.APIClient err := s.db.Where("merchant_id = ?", merchantID).Order("id DESC").Find(&clients).Error return clients, err } func (s *MerchantService) UpdateAPIClientStatus(merchantID, id uint, status string, actorUserID uint) error { if status != model.APIClientStatusActive && status != model.APIClientStatusDisabled { return errors.New("无效的 API 客户端状态") } return s.db.Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.APIClient{}).Where("id = ? AND merchant_id = ?", id, merchantID).Update("status", status) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("API 客户端不存在") } return writeAudit(tx, &merchantID, &actorUserID, nil, "api_client.status.update", "api_client", fmt.Sprint(id), map[string]string{"status": status}) }) } // ProductCatalogItem 是商品目录(自营商户可售商品)的展示项,供平台管理员分配商品时勾选。 type ProductCatalogItem struct { ID uint `json:"id"` ProductID uint `json:"product_id"` SKU string `json:"sku"` DisplayName string `json:"display_name"` Category string `json:"category"` PriceAmount int64 `json:"price_amount"` CostAmount int64 `json:"cost_amount"` Currency string `json:"currency"` Stock int64 `json:"stock"` Status string `json:"status"` } // ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。 func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) { var selfMerchant model.Merchant if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("平台商品目录尚未初始化") } return nil, err } var products []model.MerchantProduct if err := s.db.Preload("Product").Where("merchant_id = ?", selfMerchant.ID).Order("id ASC").Find(&products).Error; err != nil { return nil, err } items := make([]ProductCatalogItem, 0, len(products)) for _, p := range products { category := "" if p.Product != nil { category = p.Product.Category } items = append(items, ProductCatalogItem{ ID: p.ID, ProductID: p.ProductID, SKU: p.SKU, DisplayName: p.DisplayName, Category: category, PriceAmount: p.PriceAmount, CostAmount: p.CostAmount, Currency: p.Currency, Stock: p.Stock, Status: p.Status, }) } return items, nil } // ListMerchantProductsByAdmin 供平台管理员查看指定商户的可售商品(不限功能开关)。 func (s *MerchantService) ListMerchantProductsByAdmin(merchantID uint) ([]ProductCatalogItem, error) { var products []model.MerchantProduct if err := s.db.Preload("Product").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&products).Error; err != nil { return nil, err } items := make([]ProductCatalogItem, 0, len(products)) for _, p := range products { category := "" if p.Product != nil { category = p.Product.Category } items = append(items, ProductCatalogItem{ ID: p.ID, ProductID: p.ProductID, SKU: p.SKU, DisplayName: p.DisplayName, Category: category, PriceAmount: p.PriceAmount, CostAmount: p.CostAmount, Currency: p.Currency, Stock: p.Stock, Status: p.Status, }) } return items, nil } // AssignProductsInput 批量分配商品给商户的入参。 type AssignProductsInput struct { // CatalogIDs 为自营商户商品目录 ID 列表;为空表示清空该商户全部商品。 CatalogIDs []uint } // AssignProducts 按自营商户商品目录 ID 批量同步商户的可售商品: // 目录中勾选的商品会被复制(已存在则跳过),未勾选的已有商品会被移除。 func (s *MerchantService) AssignProducts(merchantID uint, in AssignProductsInput, actorUserID uint) (int, error) { assigned := 0 err := s.db.Transaction(func(tx *gorm.DB) error { var merchant model.Merchant if err := tx.Where("id = ?", merchantID).First(&merchant).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("商户不存在") } return err } if merchant.Code == model.MerchantCodeSelfOperated { return errors.New("自营商户的商品目录由平台维护,不可分配") } var selfMerchant model.Merchant if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { return errors.New("平台商品目录尚未初始化") } // 读取目录全量,构造 id -> 模板 的映射 var templates []model.MerchantProduct if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { return err } tmplByID := make(map[uint]model.MerchantProduct, len(templates)) for _, t := range templates { tmplByID[t.ID] = t } // 读取商户已有商品,构造 sku -> 已有 的映射 var existing []model.MerchantProduct if err := tx.Where("merchant_id = ?", merchantID).Find(&existing).Error; err != nil { return err } existBySKU := make(map[string]model.MerchantProduct, len(existing)) for _, e := range existing { existBySKU[e.SKU] = e } // 计算需要新增的 SKU 集合 wantSKUs := make(map[string]bool, len(in.CatalogIDs)) toCreate := make([]model.MerchantProduct, 0, len(in.CatalogIDs)) for _, id := range in.CatalogIDs { t, ok := tmplByID[id] if !ok { continue } wantSKUs[t.SKU] = true if _, has := existBySKU[t.SKU]; !has { toCreate = append(toCreate, model.MerchantProduct{ MerchantID: merchantID, ProductID: t.ProductID, SKU: t.SKU, DisplayName: t.DisplayName, PriceAmount: t.PriceAmount, CostAmount: t.CostAmount, Currency: t.Currency, Stock: t.Stock, Status: t.Status, FulfillmentConfig: t.FulfillmentConfig, }) } } // 移除未勾选的已有商品 var removeIDs []uint for _, e := range existing { if !wantSKUs[e.SKU] { removeIDs = append(removeIDs, e.ID) } } if len(removeIDs) > 0 { if err := tx.Where("merchant_id = ? AND id IN ?", merchantID, removeIDs).Delete(&model.MerchantProduct{}).Error; err != nil { return err } } // 新增勾选但尚未拥有的商品 if len(toCreate) > 0 { if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&toCreate).Error; err != nil { return err } } assigned = len(wantSKUs) return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.products.assign", "merchant", fmt.Sprint(merchantID), map[string]string{"assigned": fmt.Sprint(assigned)}) }) if err != nil { return 0, err } return assigned, nil } // copyDefaultProducts 将自营商户的全部可售商品复制给新建商户,作为默认商品目录。 // 自营商户(self-operated)充当平台默认商品模板,新商户开箱即用。 // 使用 OnConflict DoNothing 保证幂等:即使重复调用也不会报唯一索引冲突。 func copyDefaultProducts(tx *gorm.DB, merchantID uint) error { var selfMerchant model.Merchant if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil // 自营商户不存在时跳过,不阻断建商户 } return err } if selfMerchant.ID == merchantID { return nil // 自营商户自身无需复制 } var templates []model.MerchantProduct if err := tx.Where("merchant_id = ?", selfMerchant.ID).Find(&templates).Error; err != nil { return err } if len(templates) == 0 { return nil } products := make([]model.MerchantProduct, 0, len(templates)) for _, t := range templates { products = append(products, model.MerchantProduct{ MerchantID: merchantID, ProductID: t.ProductID, SKU: t.SKU, DisplayName: t.DisplayName, PriceAmount: t.PriceAmount, CostAmount: t.CostAmount, Currency: t.Currency, Stock: t.Stock, Status: t.Status, FulfillmentConfig: t.FulfillmentConfig, }) } return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&products).Error } func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product, error) { if in.ProductCode != "" { var product model.Product err := tx.Where("code = ?", in.ProductCode).First(&product).Error if err == nil { return &product, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } if in.ProductName == "" { return nil, errors.New("新建平台商品时商品名称不能为空") } code := in.ProductCode if code == "" { token, err := randomToken("prd_", 12) if err != nil { return nil, err } code = token } product := &model.Product{ Code: code, Name: in.ProductName, Category: in.Category, Description: in.Description, Attributes: in.Attributes, Status: model.ProductStatusActive, } if err := tx.Create(product).Error; err != nil { return nil, err } return product, nil } func normalizePage(page, size int) (int, int) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } return page, size } func randomToken(prefix string, byteCount int) (string, error) { raw := make([]byte, byteCount) if _, err := rand.Read(raw); err != nil { return "", err } return prefix + base64.RawURLEncoding.EncodeToString(raw), nil } func scopeList(scopes string) []string { set := ParseScopes(scopes) items := make([]string, 0, len(set)) for scope := range set { items = append(items, scope) } return items } func fallbackName(value, fallback string) string { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } return fallback }