576 lines
18 KiB
Go
576 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"juhe-factory/api/internal/security"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AdminData struct {
|
|
DB *gorm.DB
|
|
Passwords security.PasswordHasher
|
|
Encryptor *security.Encryptor
|
|
HTTPClient *http.Client
|
|
EncryptionKeyVersion string
|
|
balanceSyncing atomic.Bool
|
|
}
|
|
|
|
type Page struct {
|
|
Items []map[string]any `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
|
|
type ResourceSpec struct {
|
|
Table string
|
|
Select string
|
|
SearchColumns []string
|
|
Fields map[string]string
|
|
Filters map[string]string
|
|
Order string
|
|
SoftDelete bool
|
|
}
|
|
|
|
var resourceSpecs = map[string]ResourceSpec{
|
|
"styles": {Table: "project_styles", Select: "id,name,image_key,image_url,image_mime,image_size,image_width,image_height,sort_order", Fields: map[string]string{"name": "name", "image_key": "image_key", "image_url": "image_url", "image_mime": "image_mime", "image_size": "image_size", "image_width": "image_width", "image_height": "image_height"}, Order: "sort_order,name", SoftDelete: true},
|
|
"channels": {Table: "channels", Select: "c.id,c.name,c.channel_type,c.base_url,c.api_key_ciphertext,c.api_key_last4,c.warning_threshold,c.channel_points_per_cny,c.max_concurrency,c.max_user_concurrency,c.enabled,c.created_at,c.updated_at,b.balance,b.currency,b.synced_at", SearchColumns: []string{"c.name", "c.base_url"}, Fields: map[string]string{"name": "name", "channel_type": "channel_type", "base_url": "base_url", "warning_threshold": "warning_threshold", "channel_points_per_cny": "channel_points_per_cny", "max_concurrency": "max_concurrency", "max_user_concurrency": "max_user_concurrency", "enabled": "enabled"}, Filters: map[string]string{"channel_type": "channel_type", "enabled": "enabled"}, Order: "c.created_at DESC", SoftDelete: true},
|
|
"models": {Table: "models", Select: "id", Fields: map[string]string{}, Order: "created_at DESC", SoftDelete: true},
|
|
"prompts": {Table: "prompts", Select: "id", Fields: map[string]string{}, Order: "updated_at DESC", SoftDelete: true},
|
|
}
|
|
|
|
func pageArgs(pageRaw, sizeRaw string) (int, int) {
|
|
page, _ := strconv.Atoi(pageRaw)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size, _ := strconv.Atoi(sizeRaw)
|
|
if size < 1 {
|
|
size = 20
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
return page, size
|
|
}
|
|
|
|
func (s *AdminData) ListResource(ctx context.Context, resource, keyword string, filters map[string]string, pageRaw, sizeRaw string) (Page, error) {
|
|
spec, ok := resourceSpecs[resource]
|
|
if !ok {
|
|
return Page{}, errors.New("不支持的资源类型")
|
|
}
|
|
page, size := pageArgs(pageRaw, sizeRaw)
|
|
if resource == "channels" {
|
|
s.triggerChannelBalanceSync()
|
|
}
|
|
query := s.DB.Table(spec.Table)
|
|
if resource == "channels" {
|
|
query = s.DB.Table("channels c").Joins("LEFT JOIN LATERAL (SELECT balance,currency,synced_at FROM channel_balance_snapshots WHERE channel_id=c.id ORDER BY synced_at DESC LIMIT 1) b ON true")
|
|
}
|
|
if spec.SoftDelete {
|
|
prefix := strings.Split(spec.Table, " ")[0]
|
|
if resource == "channels" {
|
|
prefix = "c"
|
|
}
|
|
query = query.Where(prefix + ".deleted_at IS NULL")
|
|
}
|
|
if keyword = strings.TrimSpace(keyword); keyword != "" && len(spec.SearchColumns) > 0 {
|
|
parts := make([]string, len(spec.SearchColumns))
|
|
args := make([]any, len(spec.SearchColumns))
|
|
for i, column := range spec.SearchColumns {
|
|
parts[i] = column + "::text ILIKE ?"
|
|
args[i] = "%" + keyword + "%"
|
|
}
|
|
query = query.Where("("+strings.Join(parts, " OR ")+")", args...)
|
|
}
|
|
for key, value := range filters {
|
|
if column, exists := spec.Filters[key]; exists && value != "" {
|
|
query = query.Where(column+" = ?", value)
|
|
}
|
|
}
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return Page{}, err
|
|
}
|
|
items := make([]map[string]any, 0)
|
|
if err := query.Select(spec.Select).Order(spec.Order).Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
|
|
return Page{}, err
|
|
}
|
|
if resource == "channels" {
|
|
s.maskChannelAPIKeys(items)
|
|
}
|
|
return Page{Items: items, Total: total, Page: page, PageSize: size}, nil
|
|
}
|
|
|
|
func (s *AdminData) maskChannelAPIKeys(items []map[string]any) {
|
|
for _, item := range items {
|
|
ciphertext := strings.TrimSpace(fmt.Sprint(item["api_key_ciphertext"]))
|
|
last4 := strings.TrimSpace(fmt.Sprint(item["api_key_last4"]))
|
|
delete(item, "api_key_ciphertext")
|
|
delete(item, "api_key_last4")
|
|
item["api_key_masked"] = ""
|
|
if ciphertext != "" && ciphertext != "<nil>" && s.Encryptor != nil {
|
|
if plain, err := s.Encryptor.Decrypt(ciphertext); err == nil {
|
|
item["api_key_masked"] = maskAPIKey(plain)
|
|
continue
|
|
}
|
|
}
|
|
if last4 != "" && last4 != "<nil>" {
|
|
item["api_key_masked"] = "********" + last4
|
|
}
|
|
}
|
|
}
|
|
|
|
func maskAPIKey(value string) string {
|
|
characters := []rune(value)
|
|
if len(characters) <= 10 {
|
|
return strings.Repeat("*", max(len(characters), 8))
|
|
}
|
|
return string(characters[:5]) + "********" + string(characters[len(characters)-5:])
|
|
}
|
|
|
|
func (s *AdminData) SaveResource(resource, id string, values map[string]any) (string, error) {
|
|
spec, ok := resourceSpecs[resource]
|
|
if !ok {
|
|
return "", errors.New("不支持的资源类型")
|
|
}
|
|
data := map[string]any{}
|
|
for input, column := range spec.Fields {
|
|
if value, exists := values[input]; exists {
|
|
data[column] = value
|
|
}
|
|
}
|
|
if resource == "channels" {
|
|
if err := s.prepareChannel(values, data); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if resource == "channels" && id == "" {
|
|
apiKey, _ := values["api_key"].(string)
|
|
if strings.TrimSpace(apiKey) == "" {
|
|
return "", errors.New("API Key 不能为空")
|
|
}
|
|
data["channel_type"] = "relay"
|
|
}
|
|
if id == "" {
|
|
newID := uuid.NewString()
|
|
data["id"] = newID
|
|
if resource == "styles" {
|
|
var next int
|
|
if err := s.DB.Table("project_styles").Select("coalesce(max(sort_order),-1)+1").Scan(&next).Error; err != nil {
|
|
return "", err
|
|
}
|
|
data["sort_order"] = next
|
|
}
|
|
if err := s.DB.Table(spec.Table).Create(data).Error; err != nil {
|
|
return "", err
|
|
}
|
|
return newID, nil
|
|
}
|
|
if len(data) == 0 {
|
|
return id, nil
|
|
}
|
|
result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Updates(data)
|
|
if result.Error != nil {
|
|
return "", result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return "", gorm.ErrRecordNotFound
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *AdminData) ReorderStyles(ids []string) error {
|
|
if len(ids) == 0 {
|
|
return errors.New("风格排序不能为空")
|
|
}
|
|
seen := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if _, err := uuid.Parse(id); err != nil {
|
|
return errors.New("风格 ID 无效")
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
return errors.New("风格排序包含重复项")
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var count int64
|
|
if err := tx.Table("project_styles").Where("deleted_at IS NULL").Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if int64(len(ids)) != count {
|
|
return errors.New("风格列表已变化,请刷新后重试")
|
|
}
|
|
for position, id := range ids {
|
|
result := tx.Table("project_styles").Where("id=? AND deleted_at IS NULL", id).Update("sort_order", position)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return errors.New("风格列表已变化,请刷新后重试")
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *AdminData) prepareChannel(values, data map[string]any) error {
|
|
rateText := strings.TrimSpace(fmt.Sprint(values["channel_points_per_cny"]))
|
|
rate, err := strconv.ParseFloat(rateText, 64)
|
|
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 || rate > 1000000000000 {
|
|
return errors.New("渠道汇率必须大于 0")
|
|
}
|
|
data["channel_points_per_cny"] = rateText
|
|
maxConcurrency, err := positiveInt(values["max_concurrency"], 500)
|
|
if err != nil || maxConcurrency > 5000 {
|
|
return errors.New("最大并发数必须为 1 至 5000")
|
|
}
|
|
maxUserConcurrency, err := positiveInt(values["max_user_concurrency"], 10)
|
|
if err != nil || maxUserConcurrency > 500 || maxUserConcurrency > maxConcurrency {
|
|
return errors.New("单用户最高并发数必须为 1 至 500,且不能超过最大并发数")
|
|
}
|
|
data["max_concurrency"] = maxConcurrency
|
|
data["max_user_concurrency"] = maxUserConcurrency
|
|
plain, _ := values["api_key"].(string)
|
|
plain = strings.TrimSpace(plain)
|
|
if plain == "" {
|
|
return nil
|
|
}
|
|
if s.Encryptor == nil {
|
|
return errors.New("敏感配置加密未配置")
|
|
}
|
|
ciphertext, err := s.Encryptor.Encrypt(plain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
last4 := plain
|
|
if len(last4) > 4 {
|
|
last4 = last4[len(last4)-4:]
|
|
}
|
|
data["api_key_ciphertext"] = ciphertext
|
|
data["api_key_last4"] = last4
|
|
data["encryption_key_version"] = s.EncryptionKeyVersion
|
|
return nil
|
|
}
|
|
|
|
func positiveInt(value any, fallback int) (int, error) {
|
|
if value == nil || fmt.Sprint(value) == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(fmt.Sprint(value))
|
|
if err != nil || parsed < 1 {
|
|
return 0, errors.New("必须为正整数")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func (s *AdminData) ToggleResource(resource, id string, enabled bool) error {
|
|
spec, ok := resourceSpecs[resource]
|
|
if !ok {
|
|
return errors.New("不支持的资源类型")
|
|
}
|
|
if resource == "models" && enabled {
|
|
var channelEnabled bool
|
|
if err := s.DB.Raw(`SELECT c.enabled FROM models m JOIN channels c ON c.id=m.channel_id
|
|
WHERE m.id=? AND m.deleted_at IS NULL AND c.deleted_at IS NULL`, id).Scan(&channelEnabled).Error; err != nil {
|
|
return err
|
|
}
|
|
if !channelEnabled {
|
|
return errors.New("所属渠道已禁用,无法启用该模型")
|
|
}
|
|
}
|
|
if resource == "channels" {
|
|
return s.DB.Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Table("channels").Where("id = ? AND deleted_at IS NULL", id).Update("enabled", enabled)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return tx.Table("models").Where("channel_id = ? AND deleted_at IS NULL", id).Update("enabled", enabled).Error
|
|
})
|
|
}
|
|
result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Update("enabled", enabled)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminData) DeleteResource(resource, id string) error {
|
|
spec, ok := resourceSpecs[resource]
|
|
if !ok || !spec.SoftDelete {
|
|
return errors.New("不支持删除该资源")
|
|
}
|
|
result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", time.Now())
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminData) ListUsers(keyword, enabled, pageRaw, sizeRaw string) (Page, error) {
|
|
page, size := pageArgs(pageRaw, sizeRaw)
|
|
query := s.DB.Table("web_users u").Where("u.deleted_at IS NULL")
|
|
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
|
query = query.Where("u.account::text ILIKE ? OR u.username::text ILIKE ? OR u.uid = ?", "%"+keyword+"%", "%"+keyword+"%", keyword)
|
|
}
|
|
if enabled != "" {
|
|
query = query.Where("u.enabled = ?", enabled)
|
|
}
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return Page{}, err
|
|
}
|
|
items := make([]map[string]any, 0)
|
|
err := query.Select(`u.id,u.account,u.username,u.uid,u.point_balance,u.daily_limit,u.enabled,u.last_online_at,u.created_at,
|
|
coalesce((SELECT sum(-l.change_amount) FROM point_ledger l WHERE l.user_id=u.id AND l.change_amount<0 AND l.created_at>=CURRENT_DATE AND l.created_at<CURRENT_DATE+INTERVAL '1 day'),0) AS today_consumption`).
|
|
Order("u.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
|
return Page{Items: items, Total: total, Page: page, PageSize: size}, err
|
|
}
|
|
|
|
func (s *AdminData) CreateUser(account, password string, dailyLimit any) (map[string]any, error) {
|
|
account = strings.TrimSpace(account)
|
|
if account == "" {
|
|
return nil, errors.New("账号不能为空")
|
|
}
|
|
items, err := s.createUsers([]string{account}, password, dailyLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return items[0], nil
|
|
}
|
|
|
|
func (s *AdminData) BatchCreateUsers(prefix, startSequence, endSequence, password string, dailyLimit any) ([]map[string]any, error) {
|
|
accounts, err := buildBatchAccounts(prefix, startSequence, endSequence)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.createUsers(accounts, password, dailyLimit)
|
|
}
|
|
|
|
func (s *AdminData) createUsers(accounts []string, password string, dailyLimit any) ([]map[string]any, error) {
|
|
hash, err := s.Passwords.Hash(password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created := make([]map[string]any, 0, len(accounts))
|
|
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
|
var existing string
|
|
if err := tx.Table("web_users").Select("account").Where("account IN ?", accounts).Limit(1).Scan(&existing).Error; err != nil {
|
|
return err
|
|
}
|
|
if existing != "" {
|
|
return fmt.Errorf("账号 %s 已存在", existing)
|
|
}
|
|
for _, account := range accounts {
|
|
user, err := createUserWithAccount(tx, account, hash, dailyLimit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
created = append(created, user)
|
|
}
|
|
return nil
|
|
})
|
|
return created, err
|
|
}
|
|
|
|
const usernameAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
var sequencePattern = regexp.MustCompile(`^[0-9]+$`)
|
|
|
|
func buildBatchAccounts(prefix, startSequence, endSequence string) ([]string, error) {
|
|
prefix = strings.TrimSpace(prefix)
|
|
startSequence = strings.TrimSpace(startSequence)
|
|
endSequence = strings.TrimSpace(endSequence)
|
|
if prefix == "" {
|
|
return nil, errors.New("账号前缀不能为空")
|
|
}
|
|
if !sequencePattern.MatchString(startSequence) || !sequencePattern.MatchString(endSequence) {
|
|
return nil, errors.New("起始序号和终止序号必须为数字")
|
|
}
|
|
start, err := strconv.Atoi(startSequence)
|
|
if err != nil {
|
|
return nil, errors.New("起始序号无效")
|
|
}
|
|
end, err := strconv.Atoi(endSequence)
|
|
if err != nil {
|
|
return nil, errors.New("终止序号无效")
|
|
}
|
|
if start > end {
|
|
return nil, errors.New("起始序号不能大于终止序号")
|
|
}
|
|
count := end - start + 1
|
|
if count > 50 {
|
|
return nil, errors.New("每次最多批量创建 50 个用户")
|
|
}
|
|
width := max(len(startSequence), len(endSequence))
|
|
accounts := make([]string, 0, count)
|
|
for sequence := start; sequence <= end; sequence++ {
|
|
accounts = append(accounts, fmt.Sprintf("%s-%0*d", prefix, width, sequence))
|
|
}
|
|
return accounts, nil
|
|
}
|
|
|
|
func createUserWithAccount(tx *gorm.DB, account, passwordHash string, dailyLimit any) (map[string]any, error) {
|
|
for range 20 {
|
|
username, err := randomUsername()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uid, err := newUID(tx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id := uuid.NewString()
|
|
result := tx.Exec(`INSERT INTO web_users(id,uid,username,account,password_hash,daily_limit,enabled)
|
|
VALUES(?,?,?,?,?,?,true) ON CONFLICT DO NOTHING`, id, uid, username, account, passwordHash, dailyLimit)
|
|
if result.Error != nil {
|
|
return nil, result.Error
|
|
}
|
|
if result.RowsAffected == 1 {
|
|
return map[string]any{"id": id, "uid": uid, "account": account, "username": username}, nil
|
|
}
|
|
var accountExists int64
|
|
if err := tx.Table("web_users").Where("account = ?", account).Count(&accountExists).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if accountExists > 0 {
|
|
return nil, fmt.Errorf("账号 %s 已存在", account)
|
|
}
|
|
}
|
|
return nil, errors.New("用户名生成失败,请重试")
|
|
}
|
|
|
|
func randomUsername() (string, error) {
|
|
lengthOffset, err := rand.Int(rand.Reader, big.NewInt(8))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
username := make([]byte, 5+lengthOffset.Int64())
|
|
for i := range username {
|
|
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameAlphabet))))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
username[i] = usernameAlphabet[index.Int64()]
|
|
}
|
|
return string(username), nil
|
|
}
|
|
|
|
func newUID(tx *gorm.DB) (string, error) {
|
|
for i := 0; i < 20; i++ {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(90000000))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
uid := fmt.Sprintf("%08d", n.Int64()+10000000)
|
|
var count int64
|
|
if err := tx.Table("web_users").Where("uid=?", uid).Count(&count).Error; err != nil {
|
|
return "", err
|
|
}
|
|
if count == 0 {
|
|
return uid, nil
|
|
}
|
|
}
|
|
return "", errors.New("UID 生成失败,请重试")
|
|
}
|
|
|
|
func (s *AdminData) UpdateUsers(ids []string, updates map[string]any) error {
|
|
if len(ids) == 0 {
|
|
return errors.New("请选择用户")
|
|
}
|
|
allowed := map[string]any{}
|
|
if value, ok := updates["daily_limit"]; ok {
|
|
allowed["daily_limit"] = value
|
|
}
|
|
if value, ok := updates["enabled"]; ok {
|
|
allowed["enabled"] = value
|
|
allowed["session_version"] = gorm.Expr("session_version + 1")
|
|
}
|
|
if len(allowed) == 0 {
|
|
return errors.New("没有可更新的字段")
|
|
}
|
|
return s.DB.Table("web_users").Where("id IN ? AND deleted_at IS NULL", ids).Updates(allowed).Error
|
|
}
|
|
|
|
func (s *AdminData) DeleteUsers(ids []string) error {
|
|
if len(ids) == 0 {
|
|
return errors.New("请选择用户")
|
|
}
|
|
return s.DB.Table("web_users").Where("id IN ? AND deleted_at IS NULL", ids).Updates(map[string]any{"deleted_at": time.Now(), "enabled": false, "session_version": gorm.Expr("session_version + 1")}).Error
|
|
}
|
|
|
|
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
|
|
|
func ValidatePromptVariables(content string, variables []string) error {
|
|
allowed := map[string]bool{}
|
|
for _, v := range variables {
|
|
allowed[v] = true
|
|
}
|
|
missing := []string{}
|
|
for _, m := range placeholderPattern.FindAllStringSubmatch(content, -1) {
|
|
if !allowed[m[1]] {
|
|
missing = append(missing, m[1])
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
if len(missing) > 0 {
|
|
return fmt.Errorf("正文使用了未声明变量:%s", strings.Join(missing, "、"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func GenerateRedemptionCode() (plain, hash, mask string, err error) {
|
|
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
const firstAlphabet = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
raw := make([]byte, 30)
|
|
for i := range raw {
|
|
chars := alphabet
|
|
if i == 0 {
|
|
chars = firstAlphabet
|
|
}
|
|
index, randomErr := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
|
if randomErr != nil {
|
|
err = randomErr
|
|
return
|
|
}
|
|
raw[i] = chars[index.Int64()]
|
|
}
|
|
normalized := string(raw)
|
|
groups := make([]string, 0, 5)
|
|
for start := 0; start < len(raw); start += 6 {
|
|
groups = append(groups, string(raw[start:start+6]))
|
|
}
|
|
plain = strings.Join(groups, "-")
|
|
sum := sha256.Sum256([]byte(normalized))
|
|
hash = hex.EncodeToString(sum[:])
|
|
mask = normalized[:3] + "***-******-******-******-" + normalized[len(normalized)-3:]
|
|
return
|
|
}
|