压缩历史至 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
+52
View File
@@ -0,0 +1,52 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"gorm.io/gorm"
)
type DisputeStatus string
const (
DisputeStatusDisputing DisputeStatus = "disputing"
DisputeStatusRefund DisputeStatus = "refund"
DisputeStatusClosed DisputeStatus = "closed"
)
type Dispute struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderID uint64 `json:"order_id,string" gorm:"uniqueIndex:idx_dispute_order;index:idx_dispute_order_status,priority:1;not null"`
InitiatorUserID uint64 `json:"initiator_user_id" gorm:"not null;index:idx_initiator_status_created,priority:1"`
Reason string `json:"reason" gorm:"size:500;not null"`
Status DisputeStatus `json:"status" gorm:"type:varchar(20);index;index:idx_dispute_order_status,priority:2;index:idx_initiator_status_created,priority:2;not null;default:'disputing'"`
HandlerUserID *uint64 `json:"handler_user_id" gorm:"index"`
InitiatorUsername string `json:"initiator_username" gorm:"-:migration;->"`
HandlerUsername string `json:"handler_username" gorm:"-:migration;->"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_initiator_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (d *Dispute) BeforeCreate(*gorm.DB) error {
if d.ID == 0 {
d.ID = idgen.NextUint64ID()
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"gorm.io/gorm"
)
type MerchantAPIKey struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
UserID uint64 `json:"user_id" gorm:"not null;index:idx_merchant_api_keys_user_created,priority:1"`
ClientID string `json:"client_id" gorm:"size:64;uniqueIndex;index:idx_client_credentials,priority:2;not null"`
ClientSecret string `json:"client_secret" gorm:"size:64;index:idx_client_credentials,priority:1;not null"`
AppName string `json:"app_name" gorm:"size:20;not null"`
AppHomepageURL string `json:"app_homepage_url" gorm:"size:100;not null"`
AppDescription string `json:"app_description" gorm:"size:100"`
RedirectURI string `json:"redirect_uri" gorm:"size:100"`
NotifyURL string `json:"notify_url" gorm:"size:100;not null"`
PublicKey []byte `json:"public_key" gorm:"type:bytea"`
TestMode bool `json:"test_mode" gorm:"default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_merchant_api_keys_user_created,priority:2"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// GetByID 通过 ID 查询商户 API Key
func (m *MerchantAPIKey) GetByID(tx *gorm.DB, id uint64) error {
return tx.Where("id = ?", id).First(m).Error
}
// GetByClientID 通过 ClientID 查询商户 API Key
func (m *MerchantAPIKey) GetByClientID(tx *gorm.DB, clientID string) error {
return tx.Where("client_id = ?", clientID).First(m).Error
}
func (m *MerchantAPIKey) BeforeCreate(*gorm.DB) error {
if m.ID == 0 {
m.ID = idgen.NextUint64ID()
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
/*
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 model
import (
"time"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type MerchantPaymentLink struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
MerchantAPIKeyID uint64 `json:"merchant_api_key_id,string" gorm:"not null;index"`
Token string `json:"token" gorm:"size:64;uniqueIndex;not null"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ProductName string `json:"product_name" gorm:"size:30;not null"`
Remark string `json:"remark" gorm:"size:100"`
TotalLimit *uint `json:"total_limit" gorm:"default:null"`
UserLimit *uint `json:"user_limit" gorm:"default:null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
// GetByToken 通过 Token 查询支付链接
func (m *MerchantPaymentLink) GetByToken(tx *gorm.DB, token string) error {
return tx.Where("token = ?", token).First(m).Error
}
func (m *MerchantPaymentLink) BeforeCreate(*gorm.DB) error {
if m.ID == 0 {
m.ID = idgen.NextUint64ID()
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
/*
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 model
import (
"context"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/shopspring/decimal"
)
type OrderTransferStatus string
const (
OrderTransferStatusPending OrderTransferStatus = "pending"
OrderTransferStatusCompleted OrderTransferStatus = "completed"
)
type OrderTransfer struct {
ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"`
OrderID uint64 `json:"order_id,string" gorm:"index;uniqueIndex:uk_order_id;index:idx_order_status,priority:1"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
Status OrderTransferStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_status_transfer_at,priority:1;index:idx_order_status,priority:2"`
TransferAt time.Time `json:"transfer_at" gorm:"not null;index:idx_status_transfer_at,priority:2"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func GetDueTransferOrders(ctx context.Context, limit int) ([]OrderTransfer, error) {
var transfers []OrderTransfer
if err := db.DB(ctx).
Where("status = ? AND transfer_at <= ?", OrderTransferStatusPending, time.Now()).
Order("status, transfer_at ASC").
Limit(limit).
Find(&transfers).Error; err != nil {
return nil, err
}
return transfers, nil
}
+107
View File
@@ -0,0 +1,107 @@
/*
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 model
import (
"context"
"fmt"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/logger"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type OrderType string
const (
OrderTypeReceive OrderType = "receive"
OrderTypePayment OrderType = "payment"
OrderTypeTransfer OrderType = "transfer"
OrderTypeCommunity OrderType = "community"
OrderTypeOnline OrderType = "online"
OrderTypeTest OrderType = "test"
OrderTypeDistribute OrderType = "distribute"
OrderTypeRedEnvelopeSend OrderType = "red_envelope_send"
OrderTypeRedEnvelopeReceive OrderType = "red_envelope_receive"
OrderTypeRedEnvelopeRefund OrderType = "red_envelope_refund"
)
type OrderStatus string
const (
OrderStatusSuccess OrderStatus = "success"
OrderStatusFailed OrderStatus = "failed"
OrderStatusPending OrderStatus = "pending"
OrderStatusExpired OrderStatus = "expired"
OrderStatusDisputing OrderStatus = "disputing"
OrderStatusRefund OrderStatus = "refund"
OrderStatusRefused OrderStatus = "refused"
)
type Order struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
OrderNo string `json:"order_no" gorm:"-"`
OrderName string `json:"order_name" gorm:"size:64;not null;index"`
MerchantOrderNo *string `json:"merchant_order_no" gorm:"size:64;uniqueIndex:idx_orders_client_merchant_order,priority:2"`
ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1;uniqueIndex:idx_orders_client_merchant_order,priority:1"`
PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"`
PayerUsername string `json:"payer_username" gorm:"-:migration;->"`
PayeeUsername string `json:"payee_username" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null;index"`
Status OrderStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:2;index:idx_orders_payer_status_type_created,priority:2;index:idx_orders_client_status_created,priority:2;index:idx_orders_payer_status_type_trade,priority:2;index:idx_orders_payment_link_status,priority:2;index:idx_orders_status_expires,priority:1"`
Type OrderType `json:"type" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:3;index:idx_orders_payer_status_type_created,priority:3;index:idx_orders_payer_status_type_trade,priority:3"`
Remark string `json:"remark" gorm:"size:255"`
PaymentType string `json:"payment_type" gorm:"size:20"`
RedirectURI string `json:"redirect_uri" gorm:"size:100"`
NotifyURL string `json:"notify_url" gorm:"size:100"`
PaymentLinkID *uint64 `json:"payment_link_id,string" gorm:"index:idx_orders_payment_link_status,priority:1"`
TradeTime time.Time `json:"trade_time" gorm:"index:idx_orders_payer_status_type_trade,priority:4"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_orders_status_expires,priority:2"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_orders_payee_status_type_created,priority:4;index:idx_orders_payer_status_type_created,priority:4;index:idx_orders_client_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (o *Order) BeforeCreate(*gorm.DB) error {
if o.ID == 0 {
o.ID = idgen.NextUint64ID()
}
return nil
}
// AfterFind 格式化 OrderNo
func (o *Order) AfterFind(*gorm.DB) error {
o.OrderNo = fmt.Sprintf("%018d", o.ID)
return nil
}
// ExpirePendingOrders 将已过期且 pending 状态的订单设置为 expired
func ExpirePendingOrders(ctx context.Context) {
result := db.DB(ctx).Model(&Order{}).
Where("status = ? AND expires_at <= ?", OrderStatusPending, time.Now()).
Update("status", OrderStatusExpired)
if result.Error != nil {
logger.ErrorF(ctx, "过期 pending 订单失败: %v", result.Error)
} else {
logger.InfoF(ctx, "已将 %d 个已过期的 pending 订单设置为 expired", result.RowsAffected)
}
}
+69
View File
@@ -0,0 +1,69 @@
/*
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 model
import (
"time"
"github.com/shopspring/decimal"
)
type RedEnvelopeType string
const (
RedEnvelopeTypeFixed RedEnvelopeType = "fixed"
RedEnvelopeTypeRandom RedEnvelopeType = "random"
)
type RedEnvelopeStatus string
const (
RedEnvelopeStatusActive RedEnvelopeStatus = "active"
RedEnvelopeStatusFinished RedEnvelopeStatus = "finished"
RedEnvelopeStatusExpired RedEnvelopeStatus = "expired"
)
// RedEnvelope 红包
type RedEnvelope struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
CreatorID uint64 `json:"creator_id,string" gorm:"index;not null"`
CreatorUsername string `json:"creator_username" gorm:"-:migration;->"`
CreatorAvatarURL string `json:"creator_avatar_url" gorm:"-:migration;->"`
Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"`
TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
TotalCount int `json:"total_count" gorm:"not null"`
RemainingCount int `json:"remaining_count" gorm:"not null"`
Greeting string `json:"greeting" gorm:"size:100"`
Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null"`
CoverUploadID *uint64 `json:"cover_upload_id,string,omitempty" gorm:"index"`
HeterotypicUploadID *uint64 `json:"heterotypic_upload_id,string,omitempty" gorm:"index"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// RedEnvelopeClaim 红包领取记录
type RedEnvelopeClaim struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"`
UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"`
Username string `json:"username" gorm:"-:migration;->"`
AvatarURL string `json:"avatar_url" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"`
}
+157
View File
@@ -0,0 +1,157 @@
/*
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 model
import (
"context"
"errors"
"fmt"
"math/rand"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"github.com/shopspring/decimal"
"github.com/linux-do/credit/internal/db"
)
// 配置键常量 - 所有系统配置的 key 定义
const (
ConfigKeyMerchantOrderExpireMinutes = "merchant_order_expire_minutes" // 商家订单过期时间(分钟)
ConfigKeyWebsiteOrderExpireMinutes = "website_order_expire_minutes" // 网站订单过期时间(分钟)
ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时)
ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分
ConfigKeyNewUserProtectionDays = "new_user_protection_days" // 新用户保护期天数(期内不扣分)
ConfigKeyLeaderboardCacheTTLSeconds = "leaderboard_cache_ttl_seconds" // 排行榜缓存过期时间(秒)
ConfigKeyRedEnvelopeEnabled = "red_envelope_enabled" // 红包功能是否启用(1启用,0禁用)
ConfigKeyRedEnvelopeMaxAmount = "red_envelope_max_amount" // 单个红包的最大积分上限
ConfigKeyRedEnvelopeDailyLimit = "red_envelope_daily_limit" // 每日发红包的个数限制
ConfigKeyRedEnvelopeFeeRate = "red_envelope_fee_rate" // 红包手续费率(0-1之间的小数,0表示不收费)
ConfigKeyRedEnvelopeMaxRecipients = "red_envelope_max_recipients" // 每个红包的最大可领取人数上限
ConfigKeyUserBalanceStatsCacheTTL = "user_balance_stats_cache_ttl" // 用户余额统计缓存过期时间(秒)
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
ConfigKeySettlementDelayDaysMin = "settlement_delay_days_min" // 商户收款延迟到账最小天数(0表示即时到账)
ConfigKeySettlementDelayDaysMax = "settlement_delay_days_max" // 商户收款延迟到账最大天数(实际天数在min~max随机)
)
const (
// SystemConfigRedisHashKey Redis Hash key,存储所有系统配置
SystemConfigRedisHashKey = "system:system_configs"
)
type SystemConfig struct {
Key string `json:"key" gorm:"primaryKey;size:64;not null"`
Value string `json:"value" gorm:"size:255;not null"`
Description string `json:"description" gorm:"size:255"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// GetByKey 通过 key 查询配置(带 Redis 缓存)
func (sc *SystemConfig) GetByKey(ctx context.Context, key string) error {
if err := db.HGetJSON(ctx, SystemConfigRedisHashKey, key, sc); err == nil {
return nil
} else if !errors.Is(err, redis.Nil) {
// Redis 服务错误,返回错误
return err
}
// 查数据库
if err := db.DB(ctx).Where("key = ?", key).First(sc).Error; err != nil {
return err
}
// 更新 Redis Hash 缓存
_ = db.HSetJSON(ctx, SystemConfigRedisHashKey, key, sc)
return nil
}
// GetIntByKey 通过 key 查询配置并转换为 int 类型
func GetIntByKey(ctx context.Context, key string) (int, error) {
var sc SystemConfig
if err := sc.GetByKey(ctx, key); err != nil {
return 0, err
}
value, err := strconv.Atoi(sc.Value)
if err != nil {
return 0, fmt.Errorf("配置 %s 的值 '%s' 无法转换为整数: %w", key, sc.Value, err)
}
return value, nil
}
// GetDecimalByKey 通过 key 查询配置并转换为 decimal.Decimal 类型
// precision 指定保留的小数位数,多余的小数会被裁剪
func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) {
var sc SystemConfig
if err := sc.GetByKey(ctx, key); err != nil {
return decimal.Zero, err
}
value, err := decimal.NewFromString(sc.Value)
if err != nil {
return decimal.Zero, fmt.Errorf("配置 %s 的值 '%s' 无法转换为decimal: %w", key, sc.Value, err)
}
// 裁剪到指定小数位数
return value.Truncate(precision), nil
}
// GetBoolByKey 通过 key 查询配置并转换为 bool 类型
func GetBoolByKey(ctx context.Context, key string) (bool, error) {
var sc SystemConfig
if err := sc.GetByKey(ctx, key); err != nil {
return false, err
}
value, err := strconv.ParseBool(sc.Value)
if err != nil {
return false, fmt.Errorf("配置 %s 的值 '%s' 无法转换为布尔值: %w", key, sc.Value, err)
}
return value, nil
}
const defaultHoldDays = 7
func GetRandomHoldDays(ctx context.Context) int {
// get config
holdDays := defaultHoldDays
holdDaysMin, errMin := GetIntByKey(ctx, ConfigKeySettlementDelayDaysMin)
if errMin != nil || holdDaysMin <= 0 {
holdDaysMin = defaultHoldDays
}
holdDaysMax, errMax := GetIntByKey(ctx, ConfigKeySettlementDelayDaysMax)
if errMax != nil || holdDaysMax <= 0 {
holdDaysMax = defaultHoldDays
}
// check config
if holdDaysMin == holdDaysMax {
return holdDaysMin
}
if holdDaysMin > holdDaysMax {
return holdDays
}
return holdDaysMin + rand.Intn(holdDaysMax-holdDaysMin+1)
}
func GetRandomSettleAt(ctx context.Context) time.Time {
return time.Now().AddDate(0, 0, GetRandomHoldDays(ctx))
}
+48
View File
@@ -0,0 +1,48 @@
/*
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 model
import (
"time"
)
// UploadStatus 上传状态
type UploadStatus string
const (
UploadStatusPending UploadStatus = "pending" // 待使用
UploadStatusUsed UploadStatus = "used" // 已使用
UploadStatusDeleted UploadStatus = "deleted" // 已删除
)
// UploadType 上传类型常量
const (
UploadTypeCover = "cover" // 红包背景封面
UploadTypeHeterotypic = "heterotypic" // 红包异形装饰
)
// Upload 上传文件记录
type Upload struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
UserID uint64 `json:"user_id,string" gorm:"index;not null"`
FilePath string `json:"file_path" gorm:"size:500;not null;uniqueIndex"` // 文件路径
FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节)
Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 类型 (cover, heterotypic)
Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
+58
View File
@@ -0,0 +1,58 @@
/*
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 model
import (
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type PayLevel uint8
const (
PayLevelFree PayLevel = iota
PayLevelBasic
PayLevelStandard
PayLevelPremium
)
type UserPayConfig struct {
ID uint64 `json:"id,string" gorm:"primaryKey;autoIncrement"`
Level PayLevel `json:"level" gorm:"uniqueIndex;not null"`
MinScore int64 `json:"min_score" gorm:"not null;index:idx_score_range,priority:1"`
MaxScore *int64 `json:"max_score" gorm:"index:idx_score_range,priority:2"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" gorm:"type:numeric(3,2);default:0;check:fee_rate >= 0 AND fee_rate <= 1"`
ScoreRate decimal.Decimal `json:"score_rate" gorm:"type:numeric(3,2);default:0;check:score_rate >= 0 AND score_rate <= 1"`
DistributeRate decimal.Decimal `json:"distribute_rate" gorm:"type:numeric(3,2);default:0;check:distribute_rate >= 0 AND distribute_rate <= 1"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// GetByPayScore 通过 pay_score 查询对应的支付配置
func (upc *UserPayConfig) GetByPayScore(tx *gorm.DB, payScore int64) error {
return tx.Where("min_score <= ?", payScore).
Where("max_score IS NULL OR max_score > ?", payScore).
First(upc).Error
}
// GetByID 通过 ID 查询支付配置
func (upc *UserPayConfig) GetByID(tx *gorm.DB, id uint64) error {
return tx.Where("id = ?", id).First(upc).Error
}
+268
View File
@@ -0,0 +1,268 @@
/*
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 model
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type TrustLevel uint8
const (
TrustLevelNewUser TrustLevel = iota
TrustLevelBasicUser
TrustLevelUser
TrustLevelActiveUser
TrustLevelLeader
)
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
type OAuthUserInfo struct {
Id uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
Name string `json:"name"`
Active bool `json:"active"`
AvatarUrl string `json:"avatar_url"`
TrustLevel TrustLevel `json:"trust_level"`
}
// GetID 获取用户 ID
func (u *OAuthUserInfo) GetID() uint64 {
if u.Id != 0 {
return u.Id
}
// 从 sub 解析(OIDC 格式)
if u.Sub != "" {
if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil {
return id
}
}
return 0
}
// UserGamificationScoreResponse API响应
type UserGamificationScoreResponse struct {
User struct {
GamificationScore int64 `json:"gamification_score"`
} `json:"user"`
}
// LeaderboardResponse 排行榜 API 响应
type LeaderboardResponse struct {
Users []LeaderboardUser `json:"users"`
}
// LeaderboardUser 排行榜用户信息
type LeaderboardUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
TotalScore int64 `json:"total_score"`
}
type User struct {
ID uint64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Nickname string `json:"nickname" gorm:"size:255"`
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
TrustLevel TrustLevel `json:"trust_level" gorm:"index"`
PayScore int64 `json:"pay_score" gorm:"default:0;index"`
PayKey string `json:"pay_key" gorm:"size:128"`
SignKey string `json:"sign_key" gorm:"size:64;uniqueIndex;not null"`
TotalReceive decimal.Decimal `json:"total_receive" gorm:"type:numeric(20,2);default:0"`
TotalPayment decimal.Decimal `json:"total_payment" gorm:"type:numeric(20,2);default:0"`
TotalTransfer decimal.Decimal `json:"total_transfer" gorm:"type:numeric(20,2);default:0"`
TotalCommunity decimal.Decimal `json:"total_community" gorm:"type:numeric(20,2);default:0"`
CommunityBalance decimal.Decimal `json:"community_balance" gorm:"type:numeric(20,2);default:0"`
AvailableBalance decimal.Decimal `json:"available_balance" gorm:"type:numeric(20,2);default:0;index:idx_users_active_bal_id,priority:2"`
PendingBalance decimal.Decimal `json:"pending_balance" gorm:"type:numeric(20,2);default:0"`
IsActive bool `json:"is_active" gorm:"default:true;index:idx_users_active_bal_id,priority:1"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
return err
}
return nil
}
// GetByIDs 批量查询用户
func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) {
var users []User
if err := tx.Where("id IN ?", ids).Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// VerifyPayKey 验证用户支付密码
// 使用用户的 SignKey 解密存储的加密密码,然后与输入的明文密码比较
func (u *User) VerifyPayKey(inputPayKey string) bool {
if u.PayKey == "" {
return false
}
decrypted, err := util.Decrypt(u.SignKey, u.PayKey)
if err != nil {
return false
}
return subtle.ConstantTimeCompare([]byte(decrypted), []byte(inputPayKey)) == 1
}
func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) {
url := fmt.Sprintf("https://linux.do/u/%s.json", u.Username)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取用户积分失败,状态码: %d", resp.StatusCode)
}
var response UserGamificationScoreResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析用户积分响应失败: %w", err)
}
return &response, nil
}
// GetLeaderboard 获取排行榜数据
func GetLeaderboard(ctx context.Context, page int) (*LeaderboardResponse, error) {
url := fmt.Sprintf("https://linux.do/leaderboard/1.json?period=all_time&page=%d", page)
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取排行榜失败,状态码: %d", resp.StatusCode)
}
var response LeaderboardResponse
if err = json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("解析排行榜响应失败: %w", err)
}
return &response, nil
}
// UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据
func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
u.Username = oauthInfo.Username
u.Nickname = oauthInfo.Name
u.AvatarUrl = oauthInfo.AvatarUrl
u.IsActive = oauthInfo.Active
u.TrustLevel = oauthInfo.TrustLevel
u.LastLoginAt = time.Now()
}
// CheckActive 检查用户账户是否激活,未激活则返回错误
func (u *User) CheckActive() error {
if !u.IsActive {
return errors.New(common.BannedAccount)
}
return nil
}
// EnqueueBadgeScoreTask 为用户下发积分计算任务
func (u *User) EnqueueBadgeScoreTask(ctx context.Context, delay time.Duration) error {
payload, _ := json.Marshal(map[string]interface{}{
"user_id": u.ID,
})
opts := []asynq.Option{
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
asynq.TaskID(fmt.Sprintf("user_gamification_score_%d", u.ID)),
}
if delay > 0 {
opts = append(opts, asynq.ProcessIn(delay))
}
if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil {
logger.ErrorF(ctx, "下发用户[%s]积分计算任务失败: %v", u.Username, err)
return err
}
return nil
}
// CreateWithInitialCredit 创建新用户并初始化积分、订单
func (u *User) CreateWithInitialCredit(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
ctx := tx.Statement.Context
newUserInitialCredit, err := GetDecimalByKey(ctx, ConfigKeyNewUserInitialCredit, 2)
if err != nil {
return err
}
now := time.Now()
newUser := User{
ID: oauthInfo.GetID(),
Username: oauthInfo.Username,
Nickname: oauthInfo.Name,
AvatarUrl: oauthInfo.AvatarUrl,
IsActive: oauthInfo.Active,
TrustLevel: oauthInfo.TrustLevel,
SignKey: util.GenerateUniqueIDSimple(),
TotalReceive: newUserInitialCredit,
AvailableBalance: newUserInitialCredit,
LastLoginAt: now,
}
if err = tx.Create(&newUser).Error; err != nil {
return err
}
order := Order{
OrderName: "新用户注册奖励",
PayerUserID: 0,
PayeeUserID: newUser.ID,
Amount: newUserInitialCredit,
Status: OrderStatusSuccess,
Type: OrderTypeCommunity,
Remark: fmt.Sprintf("新用户 %s 注册赠送初始积分 %s", newUser.Username, newUserInitialCredit.String()),
TradeTime: now,
ExpiresAt: now,
}
if err = tx.Create(&order).Error; err != nil {
return err
}
*u = newUser
return u.EnqueueBadgeScoreTask(ctx, 0)
}