mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user