admin_backend/util/util.go

986 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package util
import (
"backend/msg"
"bytes"
"crypto/aes"
"crypto/cipher"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/rand"
"net"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"text/template"
"time"
"unicode"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
)
var MergeData = make(map[string]interface{})
var MergeEmitData = make(map[string]interface{})
var FrameData = make(map[string]interface{})
var CardData = make(map[string]interface{})
var CardCollectData = make(map[string]interface{})
var NetAssetData = make(map[string]interface{})
var ItemData = make(map[string]interface{})
func init() {
data, err := os.ReadFile("config/MergeData.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal MergeData.json: %v", err)
} else {
MergeData = m
}
}
data, err = os.ReadFile("config/MergeDataEmit.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal MergeEmitData.json: %v", err)
} else {
MergeEmitData = m
}
}
data, err = os.ReadFile("config/Avatar.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal FrameData.json: %v", err)
} else {
FrameData = m
}
}
data, err = os.ReadFile("config/CardDetail.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal CardData.json: %v", err)
} else {
CardData = m
}
}
data, err = os.ReadFile("config/CardCollect.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal CardCollectData.json: %v", err)
} else {
CardCollectData = m
}
}
data, err = os.ReadFile("config/NetAssetData.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal NetAssetData.json: %v", err)
} else {
NetAssetData = m
}
}
data, err = os.ReadFile("config/Item.json")
if err == nil {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("failed to unmarshal ItemData.json: %v", err)
} else {
ItemData = m
}
}
}
func GetItemName(itemId int) string {
id := strconv.Itoa(itemId)
if v, ok := ItemData[id]; ok && v != nil {
entry, ok := v.(map[string]interface{})
if ok {
return String(entry["Name"])
}
}
return id
}
// 获取结构体名称
func GetStructName(v interface{}) string {
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() == reflect.Struct {
return t.Name()
}
return ""
}
func PackMsg(m proto.Message) []byte {
buf, _ := proto.Marshal(m)
Func := GetStructName(m)
req := &msg.AdminReq{
Func: Func,
Info: buf,
}
buf, _ = proto.Marshal(req)
return append([]byte{0, 2}, buf...)
}
func UnpackMsg(buf []byte, n int) (string, error) {
// 检查数据长度
if n < 2 {
return "", fmt.Errorf("message too short: got %d bytes, need at least 2", n)
}
res := &msg.AdminRes{}
err := proto.Unmarshal(buf[2:n], res)
if err != nil {
// 记录更详细的错误信息
log.Printf("UnpackMsg error: %v, data length: %d, hex: %x", err, n-2, buf[2:n])
return "", fmt.Errorf("proto unmarshal error: %v", err)
}
return res.Info, nil
}
func ParseUid(uid int) (int, int) {
AppId := uid / 100000000
ServerId := uid % 100000000 / 100000
return AppId, ServerId
}
func Int(a interface{}) int {
if a == nil {
return 0
}
switch v := a.(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float64:
return int(v)
case string:
r, err := strconv.Atoi(v)
if err != nil {
return 0
}
return r
}
return 0
}
func String(a interface{}) string {
if a == nil {
return ""
}
switch v := a.(type) {
case string:
return v
case int:
return strconv.Itoa(v)
case int32:
return strconv.Itoa(int(v))
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case []byte:
return string(v)
default:
rv := reflect.ValueOf(a)
if !rv.IsValid() {
return ""
}
// unwrap pointer
if rv.Kind() == reflect.Ptr && !rv.IsNil() {
rv = rv.Elem()
}
kind := rv.Kind()
// if it's a struct/map/slice/array convert to JSON
if kind == reflect.Struct || kind == reflect.Map || kind == reflect.Slice || kind == reflect.Array {
b, err := json.Marshal(a)
if err == nil {
return string(b)
}
}
return ""
}
}
func InArray(Id int, s []int) bool {
for _, v := range s {
if v == Id {
return true
}
}
return false
}
func Now() int64 {
return time.Now().Unix()
}
func Year() int {
return time.Now().Year()
}
func NowFormat() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func SendAdminMsg(ws *websocket.Conn, req proto.Message) (map[string]interface{}, error) {
reqBuf := PackMsg(req)
err := ws.WriteMessage(websocket.BinaryMessage, reqBuf)
if err != nil {
return nil, fmt.Errorf("failed to write to websocket: %v", err)
}
_, readbuf, err := ws.ReadMessage()
if err != nil {
return nil, fmt.Errorf("failed to read from websocket: %v", err)
}
n := len(readbuf)
resBuf, err := UnpackMsg(readbuf, n)
if err != nil {
return nil, fmt.Errorf("failed to unpack message: %v", err)
}
r := make(map[string]interface{})
err = json.Unmarshal([]byte(resBuf), &r)
if err != nil {
log.Printf("Failed to unmarshal response: %v, %s", err, resBuf)
return nil, fmt.Errorf("failed to unmarshal response: %v, %s", err, resBuf)
}
return r, nil
}
func FloatDecimals(f float64, n int) float64 {
format := fmt.Sprintf("%%.%df", n)
r, _ := strconv.ParseFloat(fmt.Sprintf(format, f), 64)
return r
}
// 将两个参数转换成浮点数如果无法转换则为0实现两个浮点数的除法除数为0的话返回0
func FloatDiv(a, b interface{}, Decimal int) float64 {
af := toFloat64(a)
bf := toFloat64(b)
if bf == 0 {
return 0
}
result := af / bf
format := fmt.Sprintf("%%.%df", Decimal)
r, _ := strconv.ParseFloat(fmt.Sprintf(format, result), 64)
return r
}
func Decimal(f float64, n int) float64 {
format := fmt.Sprintf("%%.%df", n)
r, _ := strconv.ParseFloat(fmt.Sprintf(format, f), 64)
return r
}
// 辅助函数将interface{}转换为float64
func toFloat64(v interface{}) float64 {
switch val := v.(type) {
case int:
return float64(val)
case int32:
return float64(val)
case int64:
return float64(val)
case float32:
return float64(val)
case float64:
return val
case string:
r, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return r
default:
return 0
}
}
const (
SECRET_KEY = ")VQbB(vpy=U(wcp)"
)
// 加密字符串
func Encrypt(plainText string) (string, error) {
block, err := aes.NewCipher([]byte(SECRET_KEY))
if err != nil {
return "", err
}
cipherText := make([]byte, aes.BlockSize+len(plainText))
iv := cipherText[:aes.BlockSize]
if _, err := io.ReadFull(crand.Reader, iv); err != nil {
return "", err
}
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(cipherText[aes.BlockSize:], []byte(plainText))
return base64.URLEncoding.EncodeToString(cipherText), nil
}
// 解密字符串
func Decrypt(cipherText string) (string, error) {
cipherTextBytes, err := base64.URLEncoding.DecodeString(cipherText)
if err != nil {
return "", err
}
block, err := aes.NewCipher([]byte(SECRET_KEY))
if err != nil {
return "", err
}
if len(cipherTextBytes) < aes.BlockSize {
return "", fmt.Errorf("cipherText too short")
}
iv := cipherTextBytes[:aes.BlockSize]
cipherTextBytes = cipherTextBytes[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(cipherTextBytes, cipherTextBytes)
return string(cipherTextBytes), nil
}
func ParseTmpl(file string, data interface{}) string {
t, err := template.ParseFiles(file)
if err != nil {
log.Fatalf("failed to parse template file: %v", err)
}
var buf bytes.Buffer
err = t.Execute(&buf, data)
if err != nil {
log.Fatalf("failed to execute template: %v", err)
}
s := buf.String()
return s
}
// 将字符串转换成一行的字符串格式,并再进行一次转义输出
func ToTmplStr(input string) string {
// 转义换行符
escapedStr := strings.ReplaceAll(input, "\r", "")
escapedStr = strings.ReplaceAll(escapedStr, "\n", "\\n")
escapedStr = strings.ReplaceAll(escapedStr, "\"", "\\\"")
// 转义输出
// return template.HTMLEscapeString(escapedStr)
return escapedStr
}
func Ternary(condition bool, trueVal, falseVal interface{}) interface{} {
if condition {
return trueVal
}
return falseVal
}
func TimestampToDate(timestamp int64, Tz string) string {
loc, err := time.LoadLocation(Tz)
if err != nil {
log.Printf("failed to load location %s: %v", Tz, err)
loc = time.UTC // 默认使用UTC
}
t := time.Unix(timestamp, 0).In(loc)
return t.Format("2006-01-02")
}
func TimestampToDateTime(timestamp int64, Tz string) string {
loc, err := time.LoadLocation(Tz)
if err != nil {
log.Printf("failed to load location %s: %v", Tz, err)
loc = time.UTC // 默认使用UTC
}
t := time.Unix(timestamp, 0).In(loc)
return t.Format("2006-01-02 15:04:05")
}
func GenerateToken() string {
// 生成一个安全的随机token适合网页登录
b := make([]byte, 32)
if _, err := crand.Read(b); err != nil {
return ""
}
return base64.URLEncoding.EncodeToString(b)
}
func GeneratedCode(Phone string) (string, error) {
Code := Rand6DigitNumber()
err := SmsCode(Phone, Code)
if err != nil {
return "", fmt.Errorf("failed to send SMS code: %v", err)
}
return Code, err
}
func Rand6DigitNumber() string {
n := rand.Intn(1000000)
return fmt.Sprintf("%06d", n)
}
func GetToken(c *gin.Context) string {
// 从请求头中获取Token
token := c.GetHeader("Authorization")
if len(token) > 7 && token[:7] == "Bearer " {
token = token[7:] // 去掉"Bearer "前缀
}
return token
}
const (
RoleSuper = "super"
RoleAdmin = "admin"
RoleUser = "user"
RoleWbTransfer = "wb_transfer"
RoleGuest = "guest"
)
func GetRole(code int) string {
switch code {
case 0:
return RoleSuper
case 1:
return RoleAdmin
case 2:
return RoleUser
case 99:
return RoleWbTransfer
default:
return RoleGuest
}
}
func ToJson(v interface{}) string {
data, err := json.Marshal(v)
if err != nil {
log.Printf("failed to marshal to JSON: %v", err)
return ""
}
return string(data)
}
func AddAdminLog(c *gin.Context, action string, params interface{}) {
admin := c.GetString("admin")
ip := c.ClientIP()
db := MPool.GetGameDB()
defer db.Close()
_, err := db.Exec("INSERT INTO admin_log (admin, action, params, ip, createTime) VALUES (?, ?, ?, ?, ?)",
admin, action, ToJson(params), ip, Now())
if err != nil {
fmt.Printf("failed to insert admin log: %v", err)
return
}
}
func ParseParam(s string) map[string]interface{} {
result := make(map[string]interface{})
if strings.TrimSpace(s) == "" {
return result
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
return map[string]interface{}{}
}
return result
}
func CheckContainChess(ChessList []string, Emit []string) bool {
if len(Emit) == 0 {
return true
}
for _, c := range ChessList {
d, ok := MergeData[c]
if !ok {
continue
}
if d == nil {
continue
}
m, ok := d.(map[string]interface{})
if !ok {
continue
}
Color := m["Color"].(string)
Serise := getSeriseByColor(Color)
if InArrayStr(Serise, Emit) {
return true
}
}
return false
}
func InArrayStr(s string, arr []string) bool {
return slices.Contains(arr, s)
}
func getSeriseByColor(Color string) string {
for k, v := range MergeEmitData {
d := v.(map[string]interface{})
colors, ok := d["Order_Type"].(string)
if !ok {
continue
}
colorList := strings.Split(colors, ",")
if InArrayStr(Color, colorList) {
return k
}
}
return Color
}
func Success(c *gin.Context, message string) {
c.JSON(200, gin.H{
"code": 0,
"message": message,
})
}
func LoginSuccess(c *gin.Context, message string, Port int, Host string, ServerId int) {
c.JSON(200, gin.H{
"code": 0,
"message": message,
"Port": Port,
"Host": Host,
"ServerId": ServerId,
})
}
func GetChessURL(ChessId string) string {
key := fmt.Sprintf("UI_MergeData_%s", ChessId)
return GetLanguageImageURL(key)
}
func GetLanguageImageURL(key string) string {
// 检查是否符合 UI_MergeData_<id> 格式
re := regexp.MustCompile(`^UI_MergeData_(\d+)$`)
m := re.FindStringSubmatch(key)
if len(m) == 2 {
id := m[1]
// 从 MergeData 中取出对应 id 的 Icon 字段
if v, ok := MergeData[id]; ok && v != nil {
if entry, ok2 := v.(map[string]interface{}); ok2 {
var iconStr string
if iv, ok3 := entry["Icon"]; ok3 && iv != nil {
switch t := iv.(type) {
case string:
iconStr = t
case float64:
iconStr = strconv.Itoa(int(t))
default:
iconStr = fmt.Sprintf("%v", t)
}
}
if iconStr != "" {
// baseDir := `D:\Github\AplusB_Pet_nation\Assets\GameMain\UI\UISprites\MergeObj`
baseDir := `/data/AplusB_Pet_nation/Assets/Art_SubModule/GameMain/UI/UISprites/MergeObj`
if fi, err := os.Stat(baseDir); err == nil && fi.IsDir() {
var found string
walkErr := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
// 忽略大小写匹配文件名包含 iconStr
if strings.Contains(strings.ToLower(info.Name()), strings.ToLower(iconStr)) {
found = path
return errors.New("found")
}
return nil
})
if found != "" {
// 截取从 "Assets" 开始的相对路径,统一为斜杠形式
lower := strings.ToLower(found)
idx := strings.Index(lower, "assets")
if idx >= 0 {
return filepath.ToSlash(found[idx:])
}
return filepath.ToSlash(found)
}
if walkErr != nil && walkErr.Error() == "found" && found != "" {
lower := strings.ToLower(found)
idx := strings.Index(lower, "assets")
if idx >= 0 {
return filepath.ToSlash(found[idx:])
}
return filepath.ToSlash(found)
}
}
}
}
}
}
// 头像
reHead := regexp.MustCompile(`^Data_HeadName_(\d+)$`)
if m := reHead.FindStringSubmatch(key); len(m) == 2 {
id := Int(m[1])
return GetFaceURL(id)
}
// 头像框
reFrame := regexp.MustCompile(`^Data_HeadFrameName_(\d+)$`)
if m := reFrame.FindStringSubmatch(key); len(m) == 2 {
id := Int(m[1])
return GetFrameURL(id)
}
// Emoji
reEmoji := regexp.MustCompile(`^Data_EmojiName_(\d+)$`)
if m := reEmoji.FindStringSubmatch(key); len(m) == 2 {
return GetEmojiURL(Int(m[1]))
}
// 卡牌
reCard := regexp.MustCompile(`^UI_MainCardPanel_cardName_`)
if reCard.MatchString(key) {
parts := strings.Split(key, "_")
name := strings.TrimSpace(parts[len(parts)-1])
for _, v := range CardData {
if v == nil {
continue
}
entry, ok := v.(map[string]interface{})
if !ok {
continue
}
if String(entry["Name"]) == name {
icon := String(entry["Icon"])
if icon != "" {
return fmt.Sprintf("UI/UISprites/%s.png", icon)
}
}
}
}
// 卡牌收集
reCardCollect := regexp.MustCompile(`^UI_MainCardPanel_groupName_`)
if reCardCollect.MatchString(key) {
parts := strings.Split(key, "_")
name := strings.TrimSpace(parts[len(parts)-1])
for _, v := range CardCollectData {
if v == nil {
continue
}
entry, ok := v.(map[string]interface{})
if !ok {
continue
}
if String(entry["Name"]) == name {
return String(entry["ResourcesPath"])
}
}
}
//猫咪毛皮
reCat := regexp.MustCompile(`^UI_PetFurName_(\d+)$`)
if m := reCat.FindStringSubmatch(key); len(m) == 2 {
id := Int(m[1])
return fmt.Sprintf("UI/UISprites/Shop/Packed/Skin_pic_cat%d.png", id)
}
// 场景预览图
reScene := regexp.MustCompile(`^CS_ScenePanel_Scene(\d+)$`)
m = reScene.FindStringSubmatch(key)
if len(m) == 2 {
return fmt.Sprintf("UI/UISprites/Area/merge_pic_s%s.png", m[1])
}
//发射器插图
reNetAsset := regexp.MustCompile(`^UI_MainLvPanel_chapterTip_lv_(\d+)$`)
if reNetAsset.MatchString(key) {
if v, ok := NetAssetData[key]; ok && v != nil {
entry, ok := v.(map[string]interface{})
if !ok {
return ""
}
icon := String(entry["Picture"])
if icon != "" {
return fmt.Sprintf("UI/UISprites/%s.png", icon)
}
}
}
return ""
}
func ParseNodeTags(tags string) []string {
if tags == "" {
return []string{}
}
return strings.Split(tags, ",")
}
func GetAppName(AppId int) string {
switch AppId {
case 0:
return "正式服"
case 1:
return "测试服"
case 2:
return "QA服"
default:
return "未知服"
}
}
func GetAppRegion(AppId int) string {
switch AppId {
case 0:
return "prod"
default:
return "test"
}
}
func GetEmojiURL(EmojiId int) string {
return _GetURL(EmojiId, 109)
}
func GetFaceURL(HeadId int) string {
return _GetURL(HeadId, 110)
}
func GetFrameURL(FrameId int) string {
return _GetURL(FrameId, 105)
}
func _GetURL(Id, Type int) string {
for _, v := range ItemData {
info, ok := v.(map[string]interface{})
if !ok {
continue
}
if Int(info["IType"]) == Type {
effect := String(info["Effect"])
effectList := strings.Split(effect, ",")
if Id == Int(effectList[0]) && len(effectList) > 0 {
return info["FullResourcePath"].(string)
}
}
}
return ""
}
func GetCardURL(CardId string) string {
info, ok := CardData[CardId]
if ok && info != nil {
entry, ok := info.(map[string]interface{})
if ok {
return String(entry["ResourcesPath"])
}
}
return ""
}
func GetCardCollectURL(name string) string {
for _, v := range CardCollectData {
info, ok := v.(map[string]interface{})
if !ok {
continue
}
if String(info["Name"]) == name {
return String(info["ResourcesPath"])
}
}
return ""
}
func GetNetAssetURL(Key string) string {
info, ok := NetAssetData[Key]
if ok && info != nil {
entry, ok := info.(map[string]interface{})
if ok {
return String(entry["Picture"])
}
}
return ""
}
func CountDisplayLen(s string) int {
total := 0
for _, r := range s {
if unicode.In(r, unicode.Han) {
total += 2
continue
}
total += 1
}
return total
}
func FormatJson(v string) string {
var data interface{}
err := json.Unmarshal([]byte(v), &data)
if err != nil {
log.Printf("failed to unmarshal JSON: %v", err)
return ""
}
jsonBytes, err := json.Marshal(data)
if err != nil {
log.Printf("failed to marshal JSON: %v", err)
return ""
}
return string(jsonBytes)
}
func GetAddressLatency(Host string, Port int) (int64, error) {
address := fmt.Sprintf("%s:%d", Host, Port)
timeout := 3 * time.Second
start := time.Now()
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return 0, err
}
conn.Close()
latency := time.Since(start).Milliseconds()
return latency, nil
}
func Float(a interface{}) float64 {
if a == nil {
return 0
}
return toFloat64(a)
}
func parseMemoryTextToMB(value string) float64 {
s := strings.TrimSpace(strings.ToUpper(value))
if s == "" {
return 0
}
multiplier := 1.0
switch {
case strings.HasSuffix(s, "TB"):
multiplier = 1024 * 1024
s = strings.TrimSuffix(s, "TB")
case strings.HasSuffix(s, "GB"):
multiplier = 1024
s = strings.TrimSuffix(s, "GB")
case strings.HasSuffix(s, "MB"):
s = strings.TrimSuffix(s, "MB")
case strings.HasSuffix(s, "KB"):
multiplier = 1.0 / 1024
s = strings.TrimSuffix(s, "KB")
case strings.HasSuffix(s, "B"):
multiplier = 1.0 / (1024 * 1024)
s = strings.TrimSuffix(s, "B")
}
numberText := strings.TrimSpace(s)
if numberText == "" {
return 0
}
parsed, err := strconv.ParseFloat(numberText, 64)
if err != nil {
return 0
}
if parsed < 0 {
return 0
}
return parsed * multiplier
}
func clampFloat(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
func GetServerWeight(resp *msg.ResServerInfo) int {
const (
memCapMB = 16384.0
playerSoftCap = 1500.0
goroutineSoftCap = 20000.0
gcSoftCap = 2000.0
warmupSeconds = 600.0
)
freeMemMB := float64(resp.FreeMem)
if freeMemMB < 0 {
freeMemMB = 0
}
usagePercent := clampFloat(float64(resp.UsageMem), 0, 100)
if usagePercent == 0 && resp.Sys > 0 {
allocMB := parseMemoryTextToMB(resp.Alloc)
sysMB := float64(resp.Sys) / 1024 / 1024
if sysMB > 0 && allocMB > 0 {
usagePercent = clampFloat(allocMB/sysMB*100, 0, 100)
}
}
memScore := clampFloat(freeMemMB/memCapMB*100, 0, 100)
memPressureScore := 100 - usagePercent
cpuScore := 100 - clampFloat(resp.CPU, 0, 100)
playerScore := 100 - clampFloat(float64(resp.PlayerNum)/playerSoftCap*100, 0, 100)
goroutineScore := 100 - clampFloat(float64(resp.NumGoroutine)/goroutineSoftCap*100, 0, 100)
gcScore := 100 - clampFloat(float64(resp.NumGC)/gcSoftCap*100, 0, 100)
warmupScore := 100.0
if resp.StartTime > 0 {
uptime := float64(Now() - int64(resp.StartTime))
if uptime < 0 {
uptime = 0
}
warmupScore = clampFloat(uptime/warmupSeconds*100, 0, 100)
}
allocRatioScore := 100.0
totalAllocMB := parseMemoryTextToMB(resp.TotalAlloc)
allocMB := parseMemoryTextToMB(resp.Alloc)
if totalAllocMB > 0 && allocMB >= 0 {
allocRatioScore = 100 - clampFloat(allocMB/totalAllocMB*100, 0, 100)
}
weight :=
memScore*0.22 +
memPressureScore*0.23 +
cpuScore*0.23 +
playerScore*0.18 +
goroutineScore*0.05 +
gcScore*0.03 +
warmupScore*0.03 +
allocRatioScore*0.02
return int(clampFloat(weight, 1, 100))
}
func LogStructured(level string, message string, fields map[string]any) {
payload := map[string]any{
"level": level,
"msg": message,
"time": time.Now().Format(time.RFC3339),
}
for key, value := range fields {
payload[key] = value
}
data, err := json.Marshal(payload)
if err != nil {
log.Printf("level=%s msg=%q marshal_error=%v", level, message, err)
return
}
log.Print(string(data))
}