压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package db
import (
"context"
"log"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/linux-do/credit/internal/config"
)
var (
ChConn driver.Conn
)
func init() {
if !config.Config.ClickHouse.Enabled {
return
}
cfg := config.Config.ClickHouse
var err error
// 配置 ClickHouse 连接
ChConn, err = clickhouse.Open(&clickhouse.Options{
Addr: cfg.Hosts,
Auth: clickhouse.Auth{
Database: cfg.Database,
Username: cfg.Username,
Password: cfg.Password,
},
Settings: clickhouse.Settings{
"max_execution_time": 60,
},
Compression: &clickhouse.Compression{
Method: clickhouse.CompressionLZ4,
},
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
MaxOpenConns: cfg.MaxOpenConn,
MaxIdleConns: cfg.MaxIdleConn,
ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second,
ReadTimeout: time.Duration(cfg.DialTimeout*2) * time.Second,
BlockBufferSize: cfg.BlockBufferSize,
})
if err != nil {
log.Fatalf("[ClickHouse] init connection failed: %v\n", err)
}
// 测试连接
if err = ChConn.Ping(context.Background()); err != nil {
log.Fatalf("[ClickHouse] ping failed: %v\n", err)
}
log.Println("[ClickHouse] connection established successfully")
}
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package idgen
import (
"log"
"github.com/bwmarrin/snowflake"
"github.com/linux-do/credit/internal/config"
)
// 2025-12-01 00:00:00 UTC 的毫秒时间戳
const epoch int64 = 1764547200000
var node *snowflake.Node
func init() {
snowflake.Epoch = epoch
nodeID := config.Config.App.NodeID
var err error
node, err = snowflake.NewNode(nodeID)
if err != nil {
log.Fatalf("[Snowflake] init failed: %v\n", err)
}
log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID)
}
func NextUint64ID() uint64 {
return uint64(node.Generate().Int64())
}
+221
View File
@@ -0,0 +1,221 @@
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package migrator
import (
"context"
"log"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/shopspring/decimal"
)
func Migrate() {
if !config.Config.Database.Enabled {
return
}
if err := db.DB(context.Background()).AutoMigrate(
&model.User{},
&model.UserPayConfig{},
&model.MerchantAPIKey{},
&model.MerchantPaymentLink{},
&model.Order{},
&model.OrderTransfer{},
&model.SystemConfig{},
&model.Dispute{},
&model.RedEnvelope{},
&model.RedEnvelopeClaim{},
&model.Upload{},
); err != nil {
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
}
log.Printf("[PostgreSQL] auto migrate success\n")
// 初始化系统配置数据
initSystemConfigs()
// 初始化用户支付配置数据
initUserPayConfigs()
}
// initSystemConfigs 初始化系统配置数据
func initSystemConfigs() {
tx := db.DB(context.Background())
var count int64
if err := tx.Model(&model.SystemConfig{}).Count(&count).Error; err != nil {
log.Printf("[PostgreSQL] failed to check system_config table: %v\n", err)
return
}
if count > 0 {
return
}
defaultConfigs := []model.SystemConfig{
{
Key: model.ConfigKeyMerchantOrderExpireMinutes,
Value: "5",
Description: "商家订单过期时间(分钟)",
},
{
Key: model.ConfigKeyWebsiteOrderExpireMinutes,
Value: "10",
Description: "网站订单过期时间(分钟)",
},
{
Key: model.ConfigKeyDisputeTimeWindowHours,
Value: "168",
Description: "商家争议时间窗口(小时)",
},
{
Key: model.ConfigKeyNewUserInitialCredit,
Value: "0",
Description: "新用户注册初始积分",
},
{
Key: model.ConfigKeyNewUserProtectionDays,
Value: "30",
Description: "新用户保护期天数,期内积分下降不扣分",
},
{
Key: model.ConfigKeyLeaderboardCacheTTLSeconds,
Value: "600",
Description: "排行榜缓存过期时间(秒)",
},
{
Key: model.ConfigKeyRedEnvelopeEnabled,
Value: "0",
Description: "红包功能是否启用(1启用,0禁用)",
},
{
Key: model.ConfigKeyRedEnvelopeMaxAmount,
Value: "1000",
Description: "单个红包的最大积分上限",
},
{
Key: model.ConfigKeyRedEnvelopeDailyLimit,
Value: "10",
Description: "每日发红包的个数限制",
},
{
Key: model.ConfigKeyRedEnvelopeFeeRate,
Value: "0",
Description: "红包手续费率(0-1之间的小数,0表示不收费)",
},
{
Key: model.ConfigKeyRedEnvelopeMaxRecipients,
Value: "10000",
Description: "每个红包的最大可领取人数上限",
},
{
Key: model.ConfigKeyUserBalanceStatsCacheTTL,
Value: "600",
Description: "用户余额统计缓存过期时间(秒)",
},
{
Key: model.ConfigKeyUploadAllowedExtensions,
Value: "jpg,png,webp",
Description: "允许上传的图片扩展名(逗号分隔)",
},
{
Key: model.ConfigKeySettlementDelayDaysMin,
Value: "7",
Description: "商户收款延迟到账最小天数(大于等于1)",
},
{
Key: model.ConfigKeySettlementDelayDaysMax,
Value: "14",
Description: "商户收款延迟到账最大天数(实际天数在min~max随机)",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
log.Printf("[PostgreSQL] failed to create default system configs: %v\n", err)
} else {
log.Printf("[PostgreSQL] initialized %d default system configs\n", len(defaultConfigs))
}
}
// int64Ptr 返回 int64 指针
func int64Ptr(v int64) *int64 {
return &v
}
// initUserPayConfigs 初始化用户支付配置数据
func initUserPayConfigs() {
tx := db.DB(context.Background())
var count int64
if err := tx.Model(&model.UserPayConfig{}).Count(&count).Error; err != nil {
log.Printf("[PostgreSQL] failed to check user_pay_configs table: %v\n", err)
return
}
if count > 0 {
return
}
defaultConfigs := []model.UserPayConfig{
{
Level: model.PayLevelFree,
MinScore: 0,
MaxScore: int64Ptr(2000),
DailyLimit: int64Ptr(1000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelBasic,
MinScore: 2000,
MaxScore: int64Ptr(10000),
DailyLimit: int64Ptr(6000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelStandard,
MinScore: 10000,
MaxScore: int64Ptr(50000),
DailyLimit: int64Ptr(25000),
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
{
Level: model.PayLevelPremium,
MinScore: 50000,
MaxScore: nil,
DailyLimit: nil,
FeeRate: decimal.Zero,
ScoreRate: decimal.Zero,
DistributeRate: decimal.Zero,
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
log.Printf("[PostgreSQL] failed to create default user pay configs: %v\n", err)
} else {
log.Printf("[PostgreSQL] initialized %d default user pay configs\n", len(defaultConfigs))
}
}
+177
View File
@@ -0,0 +1,177 @@
/*
Copyright 2025-2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package db
import (
"context"
"log"
"net"
"net/url"
"strconv"
"time"
"github.com/linux-do/credit/internal/config"
"go.opentelemetry.io/otel/attribute"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/plugin/dbresolver"
"gorm.io/plugin/opentelemetry/tracing"
)
var (
db *gorm.DB
)
func init() {
if !config.Config.Database.Enabled {
log.Println("[PostgreSQL] is disabled, skipping initialization")
return
}
var err error
dbConfig := config.Config.Database
// 构建主库 DSN 并连接
primaryDSN := buildDSN(dbConfig.Host, dbConfig.Port, dbConfig.Username, dbConfig.Password)
pgConfig := postgres.Config{
DSN: primaryDSN,
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
}
db, err = gorm.Open(postgres.New(pgConfig), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: &gormZapLogger{
logLevel: parseLogLevel(config.Config.Database.LogLevel),
slowThreshold: config.Config.Database.SlowThreshold,
ignoreRecordNotFoundError: config.Config.App.IsProduction(),
},
})
if err != nil {
log.Fatalf("[PostgreSQL] init connection failed: %v\n", err)
}
// Trace 注入
if err = db.Use(
tracing.NewPlugin(
tracing.WithoutMetrics(),
tracing.WithAttributes(
attribute.String("db.instance", dbConfig.Database),
attribute.String("db.ip", dbConfig.Host),
attribute.String("server.address", net.JoinHostPort(dbConfig.Host, strconv.Itoa(dbConfig.Port))),
attribute.String("db.system", "PostgreSQL"),
),
),
); err != nil {
log.Fatalf("[PostgreSQL] init trace failed: %v\n", err)
}
if len(dbConfig.Replicas) > 0 {
var replicaDialectors []gorm.Dialector
for _, replica := range dbConfig.Replicas {
username := replica.Username
if username == "" {
username = dbConfig.Username
}
password := replica.Password
if password == "" {
password = dbConfig.Password
}
replicaDSN := buildDSN(replica.Host, replica.Port, username, password)
replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{
DSN: replicaDSN,
PreferSimpleProtocol: dbConfig.PreferSimpleProtocol,
}))
}
resolver := dbresolver.Register(dbresolver.Config{
Replicas: replicaDialectors,
Policy: dbresolver.RandomPolicy{},
})
resolver.SetMaxIdleConns(dbConfig.MaxIdleConn).
SetMaxOpenConns(dbConfig.MaxOpenConn).
SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second).
SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
if err = db.Use(resolver); err != nil {
log.Fatalf("[PostgreSQL] init dbresolver failed: %v\n", err)
}
log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(dbConfig.Replicas))
} else {
log.Println("[PostgreSQL] initialized in Standalone mode")
}
// 获取通用数据库对象设置连接池
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("[PostgreSQL] load sql db failed: %v\n", err)
}
sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConn)
sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConn)
sqlDB.SetConnMaxLifetime(time.Duration(dbConfig.ConnMaxLifetime) * time.Second)
sqlDB.SetConnMaxIdleTime(time.Duration(dbConfig.ConnMaxIdleTime) * time.Second)
}
// buildDSN 构建 PostgreSQL DSN
func buildDSN(host string, port int, username, password string) string {
cfg := config.Config.Database
pqURL := &url.URL{
Scheme: "postgres",
Host: net.JoinHostPort(host, strconv.Itoa(port)),
Path: cfg.Database,
}
if username != "" {
pqURL.User = url.UserPassword(username, password)
}
query := pqURL.Query()
sslMode := cfg.SSLMode
if sslMode == "" {
sslMode = "disable"
}
query.Set("sslmode", sslMode)
if cfg.ApplicationName != "" {
query.Set("application_name", cfg.ApplicationName)
}
if cfg.SearchPath != "" {
query.Set("search_path", cfg.SearchPath)
}
if cfg.DefaultQueryExecMode != "" {
query.Set("default_query_exec_mode", cfg.DefaultQueryExecMode)
}
if cfg.StatementCacheCapacity > 0 {
query.Set("statement_cache_capacity", strconv.Itoa(cfg.StatementCacheCapacity))
}
rawQuery := query.Encode()
if cfg.TimeZone != "" {
if rawQuery != "" {
rawQuery += "&"
}
rawQuery += "TimeZone=" + cfg.TimeZone
}
pqURL.RawQuery = rawQuery
return pqURL.String()
}
func DB(ctx context.Context) *gorm.DB {
return db.WithContext(ctx)
}
+103
View File
@@ -0,0 +1,103 @@
/*
Copyright 2025-2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package db
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/linux-do/credit/internal/logger"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type gormZapLogger struct {
logLevel gormLogger.LogLevel
ignoreRecordNotFoundError bool
slowThreshold time.Duration
}
func (l *gormZapLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface {
clone := *l
clone.logLevel = level
return &clone
}
func (l *gormZapLogger) Info(ctx context.Context, fmt string, args ...interface{}) {
if l.logLevel >= gormLogger.Info {
logger.InfoF(ctx, fmt, args...)
}
}
func (l *gormZapLogger) Warn(ctx context.Context, fmt string, args ...interface{}) {
if l.logLevel >= gormLogger.Warn {
logger.WarnF(ctx, fmt, args...)
}
}
func (l *gormZapLogger) Error(ctx context.Context, fmt string, args ...interface{}) {
if l.logLevel >= gormLogger.Error {
logger.ErrorF(ctx, fmt, args...)
}
}
func (l *gormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
elapsed := time.Since(begin)
switch {
case err != nil && l.logLevel >= gormLogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.ignoreRecordNotFoundError):
sql, rows := fc()
if rows == -1 {
logger.ErrorF(ctx, "%s\n[%.3fms] [rows:%v] %s", err, float64(elapsed.Nanoseconds())/1e6, "-", sql)
} else {
logger.ErrorF(ctx, "%s\n[%.3fms] [rows:%v] %s", err, float64(elapsed.Nanoseconds())/1e6, rows, sql)
}
case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.logLevel >= gormLogger.Warn:
sql, rows := fc()
slowLog := fmt.Sprintf("SLOW SQL >= %v", l.slowThreshold)
if rows == -1 {
logger.WarnF(ctx, "%s\n[%.3fms] [rows:%v] %s", slowLog, float64(elapsed.Nanoseconds())/1e6, "-", sql)
} else {
logger.WarnF(ctx, "%s\n[%.3fms] [rows:%v] %s", slowLog, float64(elapsed.Nanoseconds())/1e6, rows, sql)
}
case l.logLevel == gormLogger.Info:
sql, rows := fc()
if rows == -1 {
logger.InfoF(ctx, "[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, "-", sql)
} else {
logger.InfoF(ctx, "[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/1e6, rows, sql)
}
}
}
func parseLogLevel(level string) gormLogger.LogLevel {
level = strings.ToLower(level)
switch level {
case "silent":
return gormLogger.Silent
case "error":
return gormLogger.Error
case "warn":
return gormLogger.Warn
case "info":
return gormLogger.Info
default:
return gormLogger.Info
}
}
+182
View File
@@ -0,0 +1,182 @@
/*
Copyright 2025-2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package db
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
"github.com/linux-do/credit/internal/config"
"github.com/redis/go-redis/extra/redisotel/v9"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/attribute"
)
var (
Redis redis.UniversalClient
)
func init() {
cfg := config.Config.Redis
if !cfg.Enabled {
log.Println("[Redis] is disabled, skipping Redis initialization")
return
}
if cfg.ClusterMode {
// Cluster 模式
Redis = redis.NewClusterClient(&redis.ClusterOptions{
Addrs: cfg.Addrs,
Username: cfg.Username,
Password: cfg.Password,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
})
log.Println("[Redis] initialized in Cluster mode")
} else {
// Standalone 或 Sentinel 模式
Redis = redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: cfg.Addrs,
MasterName: cfg.MasterName, // 非空时启用 Sentinel
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConn,
DialTimeout: time.Duration(cfg.DialTimeout) * time.Second,
ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
MaxRetries: cfg.MaxRetries,
PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second,
ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second,
})
if cfg.MasterName != "" {
log.Println("[Redis] initialized in Sentinel mode")
} else {
log.Println("[Redis] initialized in Standalone mode")
}
}
// OpenTelemetry 追踪(UniversalClient 兼容)
if err := redisotel.InstrumentTracing(
Redis,
redisotel.WithAttributes(
attribute.String("db.instance", fmt.Sprintf("%v", cfg.DB)),
attribute.String("db.ip", strings.Join(cfg.Addrs, ",")),
attribute.String("db.system", "Redis"),
),
); err != nil {
log.Fatalf("[Redis] failed to init trace: %v\n", err)
}
// 测试连接
_, err := Redis.Ping(context.Background()).Result()
if err != nil {
log.Fatalf("[Redis] failed to connect to redis: %v\n", err)
}
}
// PrefixedKey 返回带前缀的 Key
func PrefixedKey(key string) string {
prefix := config.Config.Redis.KeyPrefix
if prefix == "" {
return key
}
return prefix + key
}
// HSetJSON 将泛型数据序列化为 JSON 并设置到 Redis Hash
// ctx: 上下文
// hashKey: Redis Hash key
// fieldKey: Hash field key
// data: 要存储的数据(泛型)
func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) error {
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil {
return fmt.Errorf("failed to set redis hash: %w", err)
}
return nil
}
// HGetJSON 从 Redis Hash 获取数据并反序列化为泛型类型
// ctx: 上下文
// hashKey: Redis Hash key
// fieldKey: Hash field key
// data: 用于接收数据的指针(泛型)
func HGetJSON[T any](ctx context.Context, hashKey, fieldKey string, data *T) error {
val, err := Redis.HGet(ctx, PrefixedKey(hashKey), fieldKey).Result()
if err != nil {
return err
}
if err := json.Unmarshal([]byte(val), data); err != nil {
return fmt.Errorf("failed to unmarshal data: %w", err)
}
return nil
}
// GetJSON 从Redis获取数据并反序列化为泛型类型
// ctx: 上下文
// key: Redis key
// data: 用于接收数据的指针(泛型)
func GetJSON[T any](ctx context.Context, key string, data *T) error {
val, err := Redis.Get(ctx, PrefixedKey(key)).Bytes()
if err != nil {
return err
}
if err := json.Unmarshal(val, data); err != nil {
return fmt.Errorf("failed to unmarshal data: %w", err)
}
return nil
}
// SetJSON 将泛型数据序列化为JSON并设置到Redis
// ctx: 上下文
// key: Redis key
// data: 要存储的数据(泛型)
// expiration: 过期时间
func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil {
return fmt.Errorf("failed to set redis key: %w", err)
}
return nil
}