压缩历史至 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
+21
View File
@@ -0,0 +1,21 @@
/*
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 admin
const (
AdminRequired = "未经授权访问"
)
+50
View File
@@ -0,0 +1,50 @@
/*
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 admin
import (
"net/http"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/otel_trace"
"github.com/linux-do/credit/internal/util"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
)
func LoginAdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// init trace
ctx, span := otel_trace.Start(c.Request.Context(), "LoginAdminRequired")
defer span.End()
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if !user.IsAdmin {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error_msg": AdminRequired, "data": nil})
return
}
// log
logger.InfoF(ctx, "[LoginAdminRequired] %d %s", user.ID, user.Username)
// next
c.Next()
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
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 system_config
const (
SystemConfigNotFound = "系统配置不存在"
ConfigKeyRequired = "配置键不能为空"
ConfigValueRequired = "配置值不能为空"
ConfigKeyExists = "配置键已存在"
)
@@ -0,0 +1,217 @@
/*
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 system_config
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"gorm.io/gorm"
)
// CreateSystemConfigRequest 创建系统配置请求
type CreateSystemConfigRequest struct {
Key string `json:"key" binding:"required,max=64"`
Value string `json:"value" binding:"required,max=255"`
Description string `json:"description" binding:"max=255"`
}
// UpdateSystemConfigRequest 更新系统配置请求
type UpdateSystemConfigRequest struct {
Value string `json:"value" binding:"required,max=255"`
Description string `json:"description" binding:"max=255"`
}
// CreateSystemConfig 创建系统配置
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateSystemConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs [post]
func CreateSystemConfig(c *gin.Context) {
var req CreateSystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查配置键是否已存在
var existing model.SystemConfig
if err := db.DB(c.Request.Context()).Where("key = ?", req.Key).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, util.Err(ConfigKeyExists))
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
config := model.SystemConfig{
Key: req.Key,
Value: req.Value,
Description: req.Description,
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 创建配置
if err := tx.Create(&config).Error; err != nil {
return err
}
if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, req.Key, &config); err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// ListSystemConfigs 获取系统配置列表
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs [get]
func ListSystemConfigs(c *gin.Context) {
var configs []model.SystemConfig
if err := db.DB(c.Request.Context()).
Order("created_at DESC").
Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(configs))
}
// GetSystemConfig 获取单个系统配置
// @Tags admin
// @Produce json
// @Param key path string true "配置键"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs/{key} [get]
func GetSystemConfig(c *gin.Context) {
var config model.SystemConfig
if err := db.DB(c.Request.Context()).Where("key = ?", c.Param("key")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(SystemConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(config))
}
// UpdateSystemConfig 更新系统配置
// @Tags admin
// @Accept json
// @Produce json
// @Param key path string true "配置键"
// @Param request body UpdateSystemConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs/{key} [put]
func UpdateSystemConfig(c *gin.Context) {
var req UpdateSystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
key := c.Param("key")
// 检查配置是否存在
var config model.SystemConfig
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(SystemConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 更新配置
if err := tx.Model(&config).
Updates(map[string]interface{}{
"value": req.Value,
"description": req.Description,
}).Error; err != nil {
return err
}
if err := db.HSetJSON(c.Request.Context(), model.SystemConfigRedisHashKey, key, &config); err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteSystemConfig 删除系统配置
// @Tags admin
// @Produce json
// @Param key path string true "配置键"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/system-configs/{key} [delete]
func DeleteSystemConfig(c *gin.Context) {
key := c.Param("key")
// 检查配置是否存在
var config model.SystemConfig
if err := db.DB(c.Request.Context()).Where("key = ?", key).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(SystemConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 删除配置
if err := tx.Delete(&config).Error; err != nil {
return err
}
if err := db.Redis.HDel(c.Request.Context(), db.PrefixedKey(model.SystemConfigRedisHashKey), key).Err(); err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+24
View File
@@ -0,0 +1,24 @@
/*
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 task
const (
InvalidTaskType = "无效的任务类型"
InvalidTimeRange = "无效的时间范围"
TaskDispatchFailed = "任务下发失败"
UserIDRequired = "用户ID必填"
)
+120
View File
@@ -0,0 +1,120 @@
/*
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 task
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/linux-do/credit/internal/util"
)
// ListTaskTypes 获取支持的任务类型列表
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/tasks/types [get]
func ListTaskTypes(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(task.DispatchableTasks))
}
// DispatchTaskRequest 下发任务请求
type DispatchTaskRequest struct {
TaskType string `json:"task_type" binding:"required"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
UserID *uint64 `json:"user_id"`
}
// DispatchTask 下发任务
// @Tags admin
// @Accept json
// @Produce json
// @Param request body DispatchTaskRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/tasks/dispatch [post]
func DispatchTask(c *gin.Context) {
var req DispatchTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
meta := task.GetTaskMeta(req.TaskType)
if meta == nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidTaskType))
return
}
var taskInfo *asynq.Task
var taskID string
switch req.TaskType {
case task.TaskTypeOrderSync:
if req.StartTime != nil && req.EndTime != nil {
if req.EndTime.Before(*req.StartTime) {
c.JSON(http.StatusBadRequest, util.Err(InvalidTimeRange))
return
}
payload, _ := json.Marshal(map[string]interface{}{
"start_time": req.StartTime,
"end_time": req.EndTime,
})
taskInfo = asynq.NewTask(meta.AsynqTask, payload)
taskID = fmt.Sprintf("manual_%s_%s_%s", req.TaskType,
req.StartTime.Format("20060102150405"),
req.EndTime.Format("20060102150405"))
} else {
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
}
case task.TaskTypeUserGamification:
if req.UserID == nil || *req.UserID == 0 {
taskInfo = asynq.NewTask(task.UpdateUserGamificationScoresTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
} else {
payload, _ := json.Marshal(map[string]interface{}{
"user_id": *req.UserID,
})
taskInfo = asynq.NewTask(meta.AsynqTask, payload)
taskID = fmt.Sprintf("manual_%s_user_%d", req.TaskType, *req.UserID)
}
default:
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
taskID = fmt.Sprintf("manual_%s", req.TaskType)
}
_, err := scheduler.AsynqClient.Enqueue(
taskInfo,
asynq.TaskID(taskID),
asynq.MaxRetry(meta.MaxRetry),
asynq.Queue(meta.Queue),
)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", TaskDispatchFailed, err)))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+23
View File
@@ -0,0 +1,23 @@
/*
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 user
const (
userNotFound = "用户不存在"
cannotDisable = "不能禁用管理员用户"
updateUserFailed = "更新用户状态失败"
)
+171
View File
@@ -0,0 +1,171 @@
/*
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 user
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// listUsersRequest 用户列表查询请求
type listUsersRequest struct {
Page int `form:"page" binding:"min=1"`
PageSize int `form:"page_size" binding:"min=1,max=100"`
UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"`
Username string `form:"username"`
}
type user struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AvatarUrl string `json:"avatar_url"`
TrustLevel model.TrustLevel `json:"trust_level"`
PayScore int64 `json:"pay_score"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
LastLoginAt time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// listUsersResponse 用户列表响应
type listUsersResponse struct {
Users []user `json:"users"`
Total int64 `json:"total"`
}
// ListUsers 获取用户列表
// @Tags admin
// @Produce json
// @Param request query listUsersRequest true "查询参数"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/users [get]
func ListUsers(c *gin.Context) {
var req listUsersRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var users []user
var total int64
query := db.DB(c.Request.Context()).Table("users")
username := strings.TrimSpace(req.Username)
if req.UserID != nil {
query = query.Where("id = ?", *req.UserID)
}
if username != "" {
query = query.Where("username LIKE ?", username+"%")
}
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
offset := (req.Page - 1) * req.PageSize
if err := query.
Select("id, username, nickname, avatar_url, trust_level, pay_score, " +
"total_receive, total_payment, total_transfer, total_community, " +
"community_balance, available_balance, is_active, is_admin, " +
"last_login_at, created_at, updated_at").
Order("id DESC").
Offset(offset).
Limit(req.PageSize).
Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(listUsersResponse{
Users: users,
Total: total,
}))
}
// updateUserStatusRequest 更新用户状态请求
type updateUserStatusRequest struct {
IsActive bool `json:"is_active"`
}
// UpdateUserStatus 更新用户状态(启用/禁用)
// @Tags admin
// @Produce json
// @Param id path int true "用户ID"
// @Param request body updateUserStatusRequest true "状态"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/users/{id}/status [put]
func UpdateUserStatus(c *gin.Context) {
var req updateUserStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
id := c.Param("id")
var targetUser struct {
ID uint64 `gorm:"column:id"`
IsAdmin bool `gorm:"column:is_admin"`
}
if err := db.DB(c.Request.Context()).
Table("users").
Select("id, is_admin").
Where("id = ?", id).
First(&targetUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, util.Err(userNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if !req.IsActive && targetUser.IsAdmin {
c.JSON(http.StatusForbidden, util.Err(cannotDisable))
return
}
if err := db.DB(c.Request.Context()).
Table("users").
Where("id = ?", id).
Update("is_active", req.IsActive).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(updateUserFailed))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
@@ -0,0 +1,25 @@
/*
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 user_pay_config
const (
UserPayConfigNotFound = "支付配置不存在"
LevelRequired = "等级不能为空"
MinScoreRequired = "最小分数不能为空"
ScoreRangeInvalid = "分数范围无效:最大分数必须大于最小分数"
LevelExists = "等级已存在"
)
@@ -0,0 +1,211 @@
/*
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 user_pay_config
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// CreateUserPayConfigRequest 创建支付配置请求
type CreateUserPayConfigRequest struct {
Level model.PayLevel `json:"level"`
MinScore int64 `json:"min_score"`
MaxScore *int64 `json:"max_score" binding:"omitempty,gtfield=MinScore"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" binding:"required"`
ScoreRate decimal.Decimal `json:"score_rate" binding:"required"`
DistributeRate decimal.Decimal `json:"distribute_rate" binding:"required"`
}
// UpdateUserPayConfigRequest 更新支付配置请求
type UpdateUserPayConfigRequest struct {
MinScore int64 `json:"min_score"`
MaxScore *int64 `json:"max_score" binding:"omitempty,gtfield=MinScore"`
DailyLimit *int64 `json:"daily_limit"`
FeeRate decimal.Decimal `json:"fee_rate" binding:"required"`
ScoreRate decimal.Decimal `json:"score_rate" binding:"required"`
DistributeRate decimal.Decimal `json:"distribute_rate" binding:"required"`
}
// CreateUserPayConfig 创建支付配置
// @Tags admin
// @Accept json
// @Produce json
// @Param request body CreateUserPayConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs [post]
func CreateUserPayConfig(c *gin.Context) {
var req CreateUserPayConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证费率和积分倍率
if err := util.ValidateRates(req.FeeRate, req.ScoreRate, req.DistributeRate); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查等级是否已存在
var existing model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("level = ?", req.Level).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, util.Err(LevelExists))
return
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
config := model.UserPayConfig{
Level: req.Level,
MinScore: req.MinScore,
MaxScore: req.MaxScore,
DailyLimit: req.DailyLimit,
FeeRate: req.FeeRate,
ScoreRate: req.ScoreRate,
DistributeRate: req.DistributeRate,
}
if err := db.DB(c.Request.Context()).Create(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(config))
}
// ListUserPayConfigs 获取支付配置列表
// @Tags admin
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs [get]
func ListUserPayConfigs(c *gin.Context) {
var configs []model.UserPayConfig
if err := db.DB(c.Request.Context()).
Order("min_score ASC").
Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(configs))
}
// GetUserPayConfig 获取单个支付配置
// @Tags admin
// @Produce json
// @Param id path string true "配置ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [get]
func GetUserPayConfig(c *gin.Context) {
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(config))
}
// UpdateUserPayConfig 更新支付配置
// @Tags admin
// @Accept json
// @Produce json
// @Param id path string true "配置ID"
// @Param request body UpdateUserPayConfigRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [put]
func UpdateUserPayConfig(c *gin.Context) {
var req UpdateUserPayConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证费率和积分倍率
if err := util.ValidateRates(req.FeeRate, req.ScoreRate); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查配置是否存在
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
// 更新配置
if err := db.DB(c.Request.Context()).
Model(&config).
Updates(map[string]interface{}{
"min_score": req.MinScore,
"max_score": req.MaxScore,
"fee_rate": req.FeeRate,
"score_rate": req.ScoreRate,
"daily_limit": req.DailyLimit,
"distribute_rate": req.DistributeRate,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteUserPayConfig 删除支付配置
// @Tags admin
// @Produce json
// @Param id path string true "配置ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/admin/user-pay-configs/{id} [delete]
func DeleteUserPayConfig(c *gin.Context) {
// 检查配置是否存在
var config model.UserPayConfig
if err := db.DB(c.Request.Context()).Where("id = ?", c.Param("id")).First(&config).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(UserPayConfigNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
if err := db.DB(c.Request.Context()).Delete(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+110
View File
@@ -0,0 +1,110 @@
/*
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 config
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// PublicConfigResponse 公共配置响应
type PublicConfigResponse struct {
DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
RedEnvelopeEnabled bool `json:"red_envelope_enabled"` // 红包功能是否启用
RedEnvelopeMaxAmount decimal.Decimal `json:"red_envelope_max_amount"` // 单个红包的最大积分上限
RedEnvelopeDailyLimit int `json:"red_envelope_daily_limit"` // 每日发红包的个数限制
RedEnvelopeFeeRate decimal.Decimal `json:"red_envelope_fee_rate"` // 红包手续费率
RedEnvelopeMaxRecipients int `json:"red_envelope_max_recipients"` // 每个红包的最大可领取人数上限
SettlementDelayDaysMin int `json:"settlement_delay_days_min"` // 商户收款延迟到账最小天数
SettlementDelayDaysMax int `json:"settlement_delay_days_max"` // 商户收款延迟到账最大天数
}
// GetPublicConfig 获取公共配置
// @Tags config
// @Accept json
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/config/public [get]
func GetPublicConfig(c *gin.Context) {
// 获取争议时间窗口配置
disputeTimeHours, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyDisputeTimeWindowHours)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取红包功能启用状态
redEnvelopeEnabled, err := model.GetBoolByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取红包配置
redEnvelopeMaxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
redEnvelopeDailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
redEnvelopeFeeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
redEnvelopeMaxRecipients, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxRecipients)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
settlementDelayDaysMin, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeySettlementDelayDaysMin)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
settlementDelayDaysMax, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeySettlementDelayDaysMax)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := PublicConfigResponse{
DisputeTimeWindowHours: disputeTimeHours,
RedEnvelopeEnabled: redEnvelopeEnabled,
RedEnvelopeMaxAmount: redEnvelopeMaxAmount,
RedEnvelopeDailyLimit: redEnvelopeDailyLimit,
RedEnvelopeFeeRate: redEnvelopeFeeRate,
RedEnvelopeMaxRecipients: redEnvelopeMaxRecipients,
SettlementDelayDaysMin: settlementDelayDaysMin,
SettlementDelayDaysMax: settlementDelayDaysMax,
}
c.JSON(http.StatusOK, util.OK(response))
}
+21
View File
@@ -0,0 +1,21 @@
/*
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 dashboard
const (
dashboardCacheKeyPrefix = "dashboard:stats:user_balance"
)
+149
View File
@@ -0,0 +1,149 @@
/*
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 dashboard
import (
"context"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/shopspring/decimal"
)
// getDateRange 计算查询的时间范围
func getDateRange(days int) (startDate, endDate time.Time) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return todayStart.AddDate(0, 0, -(days - 1)), todayStart.AddDate(0, 0, 1)
}
// dailyAmountResult 每日金额查询结果
type dailyAmountResult struct {
Date time.Time
Amount decimal.Decimal
}
// queryDailyAmounts 查询每日金额
// isIncome: true=收入(payee), false=支出(payer)
func queryDailyAmounts(ctx context.Context, userID uint64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) {
var results []dailyAmountResult
var err error
if isIncome {
// 收入查询:payee_user_id = user
// 包括:普通收款、红包领取(red_envelope_receive)、红包退款(red_envelope_refund)
err = db.DB(ctx).Model(&model.Order{}).
Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
Where("payee_user_id = ?", userID).
Where("status = ?", model.OrderStatusSuccess).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Group("DATE_TRUNC('day', created_at)").
Scan(&results).Error
} else {
// 支出查询:payer_user_id = user,但排除 red_envelope_receive
// red_envelope_receive 的 payer_user_id 是红包创建者,但创建者的支出已在 red_envelope_send 时计算
err = db.DB(ctx).Model(&model.Order{}).
Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
Where("payer_user_id = ?", userID).
Where("status = ?", model.OrderStatusSuccess).
Where("type != ?", model.OrderTypeRedEnvelopeReceive).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Group("DATE_TRUNC('day', created_at)").
Scan(&results).Error
}
if err != nil {
return nil, err
}
// 转换为 map
statsMap := make(map[string]decimal.Decimal)
for _, r := range results {
dateStr := r.Date.Format("2006-01-02")
statsMap[dateStr] = r.Amount
}
return statsMap, nil
}
// mergeDailyStats 合并收入和支出统计,填充无数据的日期
func mergeDailyStats(startDate time.Time, days int, incomeMap, expenseMap map[string]decimal.Decimal) []DailyStatsItem {
result := make([]DailyStatsItem, days)
for i := range result {
dateStr := startDate.AddDate(0, 0, i).Format("2006-01-02")
result[i] = DailyStatsItem{
Date: dateStr,
Income: incomeMap[dateStr],
Expense: expenseMap[dateStr],
}
}
return result
}
// userBalanceStatsResult 用户余额统计查询结果
type userBalanceStatsResult struct {
TotalCount int64
TotalAmount decimal.Decimal
AvgAmount decimal.Decimal
MinAmount decimal.Decimal
MaxAmount decimal.Decimal
StdDev decimal.Decimal
}
// calculateUserBalanceStats 计算用户余额统计数据
func calculateUserBalanceStats(ctx context.Context) (*userBalanceStatsResult, error) {
var result userBalanceStatsResult
err := db.DB(ctx).Model(&model.User{}).
Select(`
COUNT(*) as total_count,
COALESCE(SUM(available_balance), 0) as total_amount,
COALESCE(AVG(available_balance), 0) as avg_amount,
COALESCE(MIN(available_balance), 0) as min_amount,
COALESCE(MAX(available_balance), 0) as max_amount,
COALESCE(STDDEV_SAMP(available_balance), 0) as std_dev
`).
Scan(&result).Error
if err != nil {
return nil, err
}
return &result, nil
}
// calculateMedian 计算用户余额中位数
func calculateMedian(ctx context.Context) (decimal.Decimal, error) {
var median decimal.Decimal
err := db.DB(ctx).Raw(`
SELECT COALESCE(
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY available_balance),
0
) as median
FROM users
`).Scan(&median).Error
if err != nil {
return decimal.Zero, err
}
return median, nil
}
+197
View File
@@ -0,0 +1,197 @@
/*
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 dashboard
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// DailyStatsRequest 每日统计请求参数
type DailyStatsRequest struct {
Days int `form:"days" binding:"required,min=1,max=7"`
}
// DailyStatsItem 每日统计项
type DailyStatsItem struct {
Date string `json:"date"`
Income decimal.Decimal `json:"income"`
Expense decimal.Decimal `json:"expense"`
}
// TopCustomersRequest Top客户请求参数
type TopCustomersRequest struct {
Days int `form:"days" binding:"required,min=1,max=7"`
Limit int `form:"limit" binding:"required,min=1,max=10"`
}
// TopCustomer Top客户项
type TopCustomer struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
TotalAmount decimal.Decimal `json:"total_amount"`
OrderCount int64 `json:"order_count"`
}
// GetDailyStats 获取每日收支统计
// @Summary 获取每日收支统计
// @Tags dashboard
// @Accept json
// @Produce json
// @Param days query int true "查询天数,最大7天"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/dashboard/stats/daily [get]
func GetDailyStats(c *gin.Context) {
var req DailyStatsRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
startDate, endDate := getDateRange(req.Days)
// 查询每日收入(用户作为收款方)
incomeStats, err := queryDailyAmounts(ctx, user.ID, true, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询每日支出(用户作为付款方)
expenseStats, err := queryDailyAmounts(ctx, user.ID, false, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
dailyStats := mergeDailyStats(startDate, req.Days, incomeStats, expenseStats)
c.JSON(http.StatusOK, util.OK(dailyStats))
}
// GetTopCustomers 获取Top客户(向当前用户付款最多的客户)
// @Summary 获取Top客户
// @Tags dashboard
// @Accept json
// @Produce json
// @Param days query int true "查询天数,最大7天"
// @Param limit query int true "返回数量,最大10"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/dashboard/stats/top-customers [get]
func GetTopCustomers(c *gin.Context) {
var req TopCustomersRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
startDate, endDate := getDateRange(req.Days)
// 查询Top客户
var customers []TopCustomer
err := db.DB(ctx).Model(&model.Order{}).
Select(`
orders.payer_user_id as user_id,
users.username,
SUM(orders.amount) as total_amount,
COUNT(*) as order_count
`).
Joins("LEFT JOIN users ON orders.payer_user_id = users.id").
Where("orders.payee_user_id = ?", user.ID).
Where("orders.status = ?", model.OrderStatusSuccess).
Where("orders.type in ?", []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
Where("orders.created_at >= ? AND orders.created_at < ?", startDate, endDate).
Group("orders.payer_user_id, users.username").
Order("total_amount DESC").
Limit(req.Limit).
Scan(&customers).Error
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(customers))
}
// UserBalanceStatsResponse 用户余额统计响应
type UserBalanceStatsResponse struct {
TotalCount int64 `json:"total_count"`
TotalAmount decimal.Decimal `json:"total_amount"`
AvgAmount decimal.Decimal `json:"avg_amount"`
MedianAmount decimal.Decimal `json:"median_amount"`
MinAmount decimal.Decimal `json:"min_amount"`
MaxAmount decimal.Decimal `json:"max_amount"`
StdDev decimal.Decimal `json:"std_dev"`
}
// GetUserBalanceStats 获取用户余额统计
// @Summary 获取用户余额统计
// @Description 统计所有用户的AvailableBalance字段
// @Tags dashboard
// @Accept json
// @Produce json
// @Success 200 {object} util.ResponseAny{data=UserBalanceStatsResponse}
// @Router /api/v1/dashboard/stats/user-balance [get]
func GetUserBalanceStats(c *gin.Context) {
ctx := c.Request.Context()
var cachedStats UserBalanceStatsResponse
if err := db.GetJSON(ctx, dashboardCacheKeyPrefix, &cachedStats); err == nil {
c.JSON(http.StatusOK, util.OK(cachedStats))
return
}
stats, err := calculateUserBalanceStats(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err("查询用户余额统计失败: "+err.Error()))
return
}
median, err := calculateMedian(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err("计算中位数失败: "+err.Error()))
return
}
response := UserBalanceStatsResponse{
TotalCount: stats.TotalCount,
TotalAmount: stats.TotalAmount,
AvgAmount: stats.AvgAmount,
MedianAmount: median,
MinAmount: stats.MinAmount,
MaxAmount: stats.MaxAmount,
StdDev: stats.StdDev,
}
if cacheTTL, errGet := model.GetIntByKey(ctx, model.ConfigKeyUserBalanceStatsCacheTTL); errGet == nil && cacheTTL > 0 {
_ = db.SetJSON(ctx, dashboardCacheKeyPrefix, response, time.Duration(cacheTTL)*time.Second)
}
c.JSON(http.StatusOK, util.OK(response))
}
+26
View File
@@ -0,0 +1,26 @@
/*
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 dispute
const (
OrderNotFoundForDispute = "订单不存在"
DisputeNotFound = "争议不存在"
NotOrderMerchant = "您不是该订单的商家"
ReasonRequiredForRefusal = "拒绝退款时必须提供理由"
DisputeTimeWindowExpired = "订单已交易完成,超过争议时间窗口,无法发起争议"
DuplicateDispute = "无法重复发起争议,如仍有疑问请联系商家或LINUX DO Credit 团队"
)
+449
View File
@@ -0,0 +1,449 @@
/*
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 dispute
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListDisputesRequest 查询争议列表请求
type ListDisputesRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
Status string `json:"status" form:"status" binding:"omitempty,oneof=disputing refund closed"`
DisputeID *uint64 `json:"dispute_id,string" form:"dispute_id" binding:"omitempty"`
}
// ListDisputesResponse 查询争议列表响应
type ListDisputesResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Disputes []struct {
model.Dispute
OrderName string `json:"order_name"`
PayeeUsername string `json:"payee_username"`
Amount decimal.Decimal `json:"amount"`
} `json:"disputes"`
}
// ListDisputes 查询当前用户作为发起者的争议订单
// @Tags order
// @Accept json
// @Produce json
// @Param request body ListDisputesRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/disputes [post]
func ListDisputes(c *gin.Context) {
var req ListDisputesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
Joins("JOIN orders ON disputes.order_id = orders.id").
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
Where("disputes.initiator_user_id = ?", user.ID)
if req.Status != "" {
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
}
if req.DisputeID != nil {
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &ListDisputesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// ListMerchantDisputes 查询当前用户作为商家的争议订单
// @Tags order
// @Accept json
// @Produce json
// @Param request body ListDisputesRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/disputes/merchant [post]
func ListMerchantDisputes(c *gin.Context) {
var req ListDisputesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Dispute{}).
Select("disputes.*, orders.order_name, payee_user.username as payee_username, orders.amount, initiator_user.username as initiator_username, handler_user.username as handler_username").
Joins("JOIN orders ON disputes.order_id = orders.id").
Joins("JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("JOIN users as initiator_user ON disputes.initiator_user_id = initiator_user.id").
Joins("LEFT JOIN users as handler_user ON disputes.handler_user_id = handler_user.id").
Where("orders.payee_user_id = ?", user.ID)
if req.Status != "" {
baseQuery = baseQuery.Where("disputes.status = ?", model.DisputeStatus(req.Status))
}
if req.DisputeID != nil {
baseQuery = baseQuery.Where("disputes.id = ?", req.DisputeID)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &ListDisputesResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("disputes.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Disputes).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// CreateDisputeRequest 发起争议请求
type CreateDisputeRequest struct {
OrderID uint64 `json:"order_id,string" binding:"required"`
Reason string `json:"reason" binding:"required,max=100"`
}
// CreateDispute 发起争议
// @Tags order
// @Accept json
// @Produce json
// @Param request body CreateDisputeRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/dispute [post]
func CreateDispute(c *gin.Context) {
var req CreateDisputeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 获取争议时间窗口配置(小时)
disputeTimeHours, errKey := model.GetIntByKey(c.Request.Context(), model.ConfigKeyDisputeTimeWindowHours)
if errKey != nil {
c.JSON(http.StatusInternalServerError, util.Err(errKey.Error()))
return
}
dispute := model.Dispute{
OrderID: req.OrderID,
InitiatorUserID: user.ID,
Reason: req.Reason,
Status: model.DisputeStatusDisputing,
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND payer_user_id = ? AND status = ? AND type IN ?", req.OrderID, user.ID, model.OrderStatusSuccess, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFoundForDispute)
}
return err
}
// 检查是否在争议时间窗口内
// 订单支付时间 + 争议时间窗口 <= 当前时间,则无法发起争议
disputeDeadline := order.TradeTime.Add(time.Duration(disputeTimeHours) * time.Hour)
if time.Now().After(disputeDeadline) {
return errors.New(DisputeTimeWindowExpired)
}
if err := tx.Create(&dispute).Error; err != nil {
return err
}
// 更新订单状态为争议中
if err := tx.Model(&order).Update("status", model.OrderStatusDisputing).Error; err != nil {
return err
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == OrderNotFoundForDispute {
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
} else if errMsg == DisputeTimeWindowExpired {
c.JSON(http.StatusBadRequest, util.Err(DisputeTimeWindowExpired))
} else if strings.Contains(errMsg, "SQLSTATE 23505") {
c.JSON(http.StatusBadRequest, util.Err(DuplicateDispute))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OK(dispute))
}
// RefundReviewRequest 退款审核请求
type RefundReviewRequest struct {
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
Status string `json:"status" binding:"required,oneof=refund closed"`
Reason string `json:"reason" binding:"omitempty,max=100"`
}
// RefundReview 退款审核(同意/拒绝)
// @Tags order
// @Accept json
// @Produce json
// @Param request body RefundReviewRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/refund-review [post]
func RefundReview(c *gin.Context) {
var req RefundReviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
status := model.DisputeStatus(req.Status)
if status == model.DisputeStatusClosed && req.Reason == "" {
c.JSON(http.StatusBadRequest, util.Err(ReasonRequiredForRefusal))
return
}
merchantUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", req.DisputeID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(DisputeNotFound)
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND payee_user_id = ? AND status = ? AND type IN ?", dispute.OrderID, merchantUser.ID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(NotOrderMerchant)
}
return err
}
if status == model.DisputeStatusRefund {
var payerUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return err
}
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := tx.Model(&model.User{}).
Where("id = ?", merchantUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusRefund,
"handler_user_id": merchantUser.ID,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return err
}
} else if status == model.DisputeStatusClosed {
updateData := map[string]interface{}{
"status": model.DisputeStatusClosed,
"handler_user_id": merchantUser.ID,
"reason": dispute.Reason + " [服务方拒绝理由: " + req.Reason + "]",
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(updateData).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefused).Error; err != nil {
return err
}
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == DisputeNotFound {
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// CloseDisputeRequest 关闭争议请求
type CloseDisputeRequest struct {
DisputeID uint64 `json:"dispute_id,string" binding:"required"`
}
// CloseDispute 用户主动关闭争议(只能由发起者关闭)
// @Tags order
// @Accept json
// @Produce json
// @Param request body CloseDisputeRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/dispute/close [post]
func CloseDispute(c *gin.Context) {
var req CloseDisputeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND initiator_user_id = ? AND status = ?", req.DisputeID, user.ID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(DisputeNotFound)
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ? AND type IN ?", dispute.OrderID, model.OrderStatusDisputing, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFoundForDispute)
}
return err
}
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusClosed,
"handler_user_id": user.ID,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusSuccess).Error; err != nil {
return err
}
return nil
},
); err != nil {
errMsg := err.Error()
if errMsg == DisputeNotFound {
c.JSON(http.StatusNotFound, util.Err(DisputeNotFound))
} else if errMsg == OrderNotFoundForDispute {
c.JSON(http.StatusNotFound, util.Err(OrderNotFoundForDispute))
} else {
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+199
View File
@@ -0,0 +1,199 @@
/*
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 dispute
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// HandleAutoRefundExpiredDisputes 处理所有过期争议的批量任务
func HandleAutoRefundExpiredDisputes(ctx context.Context, t *asynq.Task) error {
// 获取争议时间窗口配置(小时)
disputeTimeHours, errGet := model.GetIntByKey(ctx, model.ConfigKeyDisputeTimeWindowHours)
if errGet != nil {
logger.ErrorF(ctx, "获取争议时间窗口配置失败: %v", errGet)
return errGet
}
pageSize := 1000
lastID := uint64(0)
currentDelay := 0 * time.Second
// 计算过期时间阈值:created_at < deadline 的争议需要自动退款
deadline := time.Now().Add(-time.Duration(disputeTimeHours) * time.Hour)
for {
var disputes []model.Dispute
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?",
lastID, model.DisputeStatusDisputing, deadline).
Order("id ASC").
Limit(pageSize).
Find(&disputes).Error; err != nil {
logger.ErrorF(ctx, "查询过期争议失败: %v", err)
return err
}
// 没有更多争议,退出循环
if len(disputes) == 0 {
break
}
for _, dispute := range disputes {
currentDelay += time.Duration(config.Config.Scheduler.DisputeAutoRefundDispatchIntervalSeconds) * time.Second
payload, _ := json.Marshal(map[string]interface{}{
"dispute_id": dispute.ID,
})
if _, errTask := scheduler.AsynqClient.Enqueue(
asynq.NewTask(task.AutoRefundSingleDisputeTask, payload),
asynq.ProcessIn(currentDelay),
asynq.MaxRetry(5),
); errTask != nil {
logger.ErrorF(ctx, "下发争议[ID:%d]自动退款任务失败: %v", dispute.ID, errTask)
return errTask
} else {
logger.InfoF(ctx, "下发争议[ID:%d]自动退款任务成功", dispute.ID)
}
}
lastID = disputes[len(disputes)-1].ID
}
return nil
}
// HandleAutoRefundSingleDispute 处理单个争议的自动退款任务
func HandleAutoRefundSingleDispute(ctx context.Context, t *asynq.Task) error {
// 解析任务参数
var payload struct {
DisputeID uint64 `json:"dispute_id"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("解析任务参数失败: %w", err)
}
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var dispute model.Dispute
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", payload.DisputeID, model.DisputeStatusDisputing).
First(&dispute).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.InfoF(ctx, "争议[ID:%d]已被处理或不存在,跳过", payload.DisputeID)
return nil // 已处理,不算错误
}
return err
}
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where(
"id = ? AND status = ? AND type IN ?",
dispute.OrderID,
model.OrderStatusDisputing,
[]model.OrderType{model.OrderTypePayment, model.OrderTypeOnline},
).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "争议[ID:%d]关联订单[ID:%d]不存在或状态异常", payload.DisputeID, dispute.OrderID)
return nil // 订单状态异常,跳过
}
return err
}
// 获取付款方和收款方用户
var payerUser, payeeUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return fmt.Errorf("查询付款方用户失败: %w", err)
}
if err := payeeUser.GetByID(tx, order.PayeeUserID); err != nil {
return fmt.Errorf("查询收款方用户失败: %w", err)
}
// 获取商家的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, payeeUser.PayScore); err != nil {
return fmt.Errorf("查询商家支付配置失败: %w", err)
}
// 计算商家积分减少:订单金额 × 商家的 score_rate
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
// 商家(收款方)退款:扣除可用余额、总收款和积分
if err := tx.Model(&model.User{}).
Where("id = ?", payeeUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return fmt.Errorf("商家退款失败: %w", err)
}
// 付款方收到退款:增加可用余额,减少总支付和支付积分
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return fmt.Errorf("付款方退款失败: %w", err)
}
// 更新争议状态为已退款,handler_user_id 设为 0(系统自动处理)
if err := tx.Model(&model.Dispute{}).
Where("id = ?", dispute.ID).
Updates(map[string]interface{}{
"status": model.DisputeStatusRefund,
"handler_user_id": 0,
}).Error; err != nil {
return fmt.Errorf("更新争议状态失败: %w", err)
}
// 更新订单状态为已退款
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return fmt.Errorf("更新订单状态失败: %w", err)
}
logger.InfoF(ctx, "自动退款成功: 争议[ID:%d] 订单[ID:%d] 金额[%s] 付款方[%s] 商家[%s]",
dispute.ID, order.ID, order.Amount.String(), payerUser.Username, payeeUser.Username)
return nil
}); err != nil {
logger.ErrorF(ctx, "处理争议[ID:%d]自动退款失败: %v", payload.DisputeID, err)
return err
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
/*
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 health
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/util"
)
// Health godoc
// @Tags health
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/health [get]
func Health(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
+21
View File
@@ -0,0 +1,21 @@
/*
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 leaderboard
const (
cacheKeyPrefix = "user:leaderboard:"
)
+98
View File
@@ -0,0 +1,98 @@
/*
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 leaderboard
import (
"context"
"encoding/json"
"fmt"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
)
func getList(ctx context.Context, req *ListRequest) (*ListResponse, error) {
// 检查 Redis 缓存
cacheKey := fmt.Sprintf("%slist:p:%d:s:%d", cacheKeyPrefix, req.Page, req.PageSize)
if data, err := db.Redis.Get(ctx, db.PrefixedKey(cacheKey)).Bytes(); err == nil {
var cached ListResponse
if err := json.Unmarshal(data, &cached); err == nil {
return &cached, nil
}
}
// 2. 查询数据库
items, total, err := queryLeaderboard(ctx, req)
if err != nil {
return nil, err
}
response := &ListResponse{
SortBy: "available_balance",
Order: "desc",
Page: req.Page,
PageSize: req.PageSize,
Total: total,
Items: items,
}
if data, err := json.Marshal(response); err == nil {
_ = db.Redis.Set(ctx, db.PrefixedKey(cacheKey), data, getCacheTTL(ctx)).Err()
}
return response, nil
}
func getUserRank(ctx context.Context, userID uint64) (*UserRankResponse, error) {
// 检查缓存
cacheKey := fmt.Sprintf("%suser:%d", cacheKeyPrefix, userID)
if data, err := db.Redis.Get(ctx, db.PrefixedKey(cacheKey)).Bytes(); err == nil {
var cached UserRankResponse
if err := json.Unmarshal(data, &cached); err == nil {
return &cached, nil
}
}
// 查询用户余额
var user model.User
if err := db.DB(ctx).Select("id, available_balance").Where("id = ? AND is_active = ?", userID, true).First(&user).Error; err != nil {
return nil, err
}
// 计算排名:统计比当前用户排位更高的人数
// 排序规则:available_balance DESC, id ASC
// 即:余额更高,或余额相同但 id 更小
var rank int64
if err := db.DB(ctx).Model(&model.User{}).
Where("is_active = ? AND ((available_balance > ?) OR (available_balance = ? AND id < ?))",
true, user.AvailableBalance, user.AvailableBalance, userID).
Count(&rank).Error; err != nil {
return nil, err
}
response := &UserRankResponse{
User: UserRankInfo{
UserID: userID,
Rank: int(rank + 1),
AvailableBalance: user.AvailableBalance,
},
}
if data, err := json.Marshal(response); err == nil {
_ = db.Redis.Set(ctx, db.PrefixedKey(cacheKey), data, getCacheTTL(ctx)).Err()
}
return response, nil
}
+135
View File
@@ -0,0 +1,135 @@
/*
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 leaderboard
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// ListRequest 排行榜列表请求
type ListRequest struct {
Page int `form:"page" binding:"required,min=1"`
PageSize int `form:"page_size" binding:"required,min=1,max=50"`
}
// LeaderboardEntry 排行榜条目(rank 由前端根据 offset + index + 1 计算)
type LeaderboardEntry struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
AvailableBalance decimal.Decimal `json:"available_balance"`
}
// ListResponse 排行榜列表响应
type ListResponse struct {
SortBy string `json:"sort_by"`
Order string `json:"order"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items []LeaderboardEntry `json:"items"`
}
// UserRankResponse 用户排名响应
type UserRankResponse struct {
User UserRankInfo `json:"user"`
}
// UserRankInfo 用户排名信息
type UserRankInfo struct {
UserID uint64 `json:"user_id"`
Rank int `json:"rank"`
AvailableBalance decimal.Decimal `json:"available_balance"`
}
// List 获取排行榜列表
// @Summary 获取排行榜列表
// @Tags leaderboard
// @Accept json
// @Produce json
// @Param page query int true "页码"
// @Param page_size query int true "每页数量"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard [get]
func List(c *gin.Context) {
var req ListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
response, err := getList(ctx, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// GetMyRank 获取当前用户排名
// @Summary 获取当前用户排名
// @Tags leaderboard
// @Accept json
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard/me [get]
func GetMyRank(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
response, err := getUserRank(ctx, user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
// GetUserRankByID 获取指定用户排名
// @Summary 获取指定用户排名
// @Tags leaderboard
// @Accept json
// @Produce json
// @Param id path int true "用户ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/leaderboard/users/{id} [get]
func GetUserRankByID(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := strconv.ParseUint(userIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err("invalid user id"))
return
}
ctx := c.Request.Context()
response, err := getUserRank(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(response))
}
+56
View File
@@ -0,0 +1,56 @@
/*
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 leaderboard
import (
"context"
"time"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
)
func getCacheTTL(ctx context.Context) time.Duration {
ttl, err := model.GetIntByKey(ctx, model.ConfigKeyLeaderboardCacheTTLSeconds)
if err != nil || ttl <= 0 {
ttl = 30
}
return time.Duration(ttl) * time.Second
}
func queryLeaderboard(ctx context.Context, req *ListRequest) ([]LeaderboardEntry, int64, error) {
offset := (req.Page - 1) * req.PageSize
baseQuery := db.DB(ctx).Model(&model.User{}).Where("is_active = ?", true)
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []LeaderboardEntry
if err := baseQuery.
Select("id as user_id, username, avatar_url, available_balance").
Order("available_balance DESC, id ASC").
Offset(offset).
Limit(req.PageSize).
Scan(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
+22
View File
@@ -0,0 +1,22 @@
/*
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 api_key
const (
APIKeyNotFound = "API Key 不存在"
NoFieldsToUpdate = "没有需要更新的字段"
)
@@ -0,0 +1,46 @@
/*
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 api_key
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
func RequireAPIKey() gin.HandlerFunc {
return func(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("id = ? AND user_id = ?", c.Param("id"), user.ID).
First(&apiKey).Error; err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(APIKeyNotFound))
return
}
util.SetToContext(c, merchant.APIKeyObjKey, &apiKey)
c.Next()
}
}
+200
View File
@@ -0,0 +1,200 @@
/*
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 api_key
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
type CreateAPIKeyRequest struct {
AppName string `json:"app_name" binding:"required,max=20"`
AppHomepageURL string `json:"app_homepage_url" binding:"required,max=100,url"`
AppDescription string `json:"app_description" binding:"max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"required,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}
type UpdateAPIKeyRequest struct {
AppName string `json:"app_name" binding:"omitempty,max=20"`
AppHomepageURL string `json:"app_homepage_url" binding:"omitempty,max=100,url"`
AppDescription string `json:"app_description" binding:"omitempty,max=100"`
RedirectURI string `json:"redirect_uri" binding:"omitempty,max=100,url"`
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
PublicKey string `json:"public_key" binding:"omitempty,max=100"`
TestMode bool `json:"test_mode"`
}
type APIKeyListResponse struct {
Total int64 `json:"total"`
Data []model.MerchantAPIKey `json:"data"`
}
// CreateAPIKey 创建商户 API Key
// @Tags merchant
// @Accept json
// @Produce json
// @Param request body CreateAPIKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys [post]
func CreateAPIKey(c *gin.Context) {
var req CreateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
apiKey := model.MerchantAPIKey{
UserID: user.ID,
ClientID: util.GenerateUniqueIDSimple(),
ClientSecret: util.GenerateUniqueIDSimple(),
AppName: req.AppName,
AppHomepageURL: req.AppHomepageURL,
AppDescription: req.AppDescription,
RedirectURI: req.RedirectURI,
NotifyURL: req.NotifyURL,
TestMode: req.TestMode,
}
if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
apiKey.PublicKey = publicKeyBytes
}
if err := db.DB(c.Request.Context()).Create(&apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(apiKey))
}
// ListAPIKeys 获取商户 API Key 列表
// @Tags merchant
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys [get]
func ListAPIKeys(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var apiKeys []model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("user_id = ?", user.ID).
Order("created_at DESC").
Find(&apiKeys).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(apiKeys))
}
// GetAPIKey 获取单个商户 API Key
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [get]
func GetAPIKey(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
c.JSON(http.StatusOK, util.OK(apiKey))
}
// UpdateAPIKey 更新商户 API Key
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param request body UpdateAPIKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [put]
func UpdateAPIKey(c *gin.Context) {
var req UpdateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
updates := map[string]interface{}{
"app_name": req.AppName,
"app_homepage_url": req.AppHomepageURL,
"app_description": req.AppDescription,
"redirect_uri": req.RedirectURI,
"notify_url": req.NotifyURL,
"test_mode": req.TestMode,
}
if len(req.PublicKey) > 0 {
publicKeyBytes, err := util.Base64Decode(req.PublicKey)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyFormat))
return
}
if len(publicKeyBytes) != 32 {
c.JSON(http.StatusBadRequest, util.Err(payment.InvalidPublicKeyLength))
return
}
updates["public_key"] = publicKeyBytes
}
if err := db.DB(c.Request.Context()).
Model(&apiKey).
Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// DeleteAPIKey 删除商户 API Key
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id} [delete]
func DeleteAPIKey(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
if err := db.DB(c.Request.Context()).Delete(&apiKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+22
View File
@@ -0,0 +1,22 @@
/*
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 merchant
const (
APIKeyObjKey = "merchant_api_key_obj"
PaymentLinkObjKey = "payment_link_obj"
)
+23
View File
@@ -0,0 +1,23 @@
/*
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 link
const (
PaymentLinkNotFound = "支付链接不存在"
PaymentLinkTotalLimitExceeded = "该支付链接已达到付款次数上限"
PaymentLinkUserLimitExceeded = "您已达到该链接的付款次数限制"
)
@@ -0,0 +1,42 @@
/*
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 link
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
// RequirePaymentLink 根据 Token 查询支付链接并保存到上下文
func RequirePaymentLink() gin.HandlerFunc {
return func(c *gin.Context) {
var paymentLink model.MerchantPaymentLink
if err := paymentLink.GetByToken(db.DB(c.Request.Context()), c.Param("token")); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
util.SetToContext(c, merchant.PaymentLinkObjKey, &paymentLink)
c.Next()
}
}
+441
View File
@@ -0,0 +1,441 @@
/*
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 link
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/merchant"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// PayByLinkRequest 通过支付链接支付请求
type PayByLinkRequest struct {
Token string `json:"token" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
Remark string `json:"remark" binding:"max=100"`
}
// PaymentLinkRequest 创建支付链接请求
type PaymentLinkRequest struct {
Amount decimal.Decimal `json:"amount" binding:"required"`
ProductName string `json:"product_name" binding:"required,max=30"`
Remark string `json:"remark" binding:"max=100"`
TotalLimit *uint `json:"total_limit" binding:"omitempty,min=1"`
UserLimit *uint `json:"user_limit" binding:"omitempty,min=1"`
}
// CreatePaymentLink 创建支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param request body PaymentLinkRequest true "创建支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [post]
func CreatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
paymentLink := model.MerchantPaymentLink{
MerchantAPIKeyID: apiKey.ID,
Token: util.GenerateUniqueIDSimple(),
Amount: req.Amount,
ProductName: req.ProductName,
Remark: req.Remark,
TotalLimit: req.TotalLimit,
UserLimit: req.UserLimit,
}
if err := db.DB(c.Request.Context()).Create(&paymentLink).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// PaymentLinkDetail 支付链接详情
type PaymentLinkDetail struct {
ID uint64 `json:"id,string"`
Token string `json:"token"`
Amount decimal.Decimal `json:"amount"`
ProductName string `json:"product_name"`
Remark string `json:"remark"`
TotalLimit *uint `json:"total_limit"`
UserLimit *uint `json:"user_limit"`
CreatedAt time.Time `json:"created_at"`
AppName string `json:"app_name"`
RedirectURI string `json:"redirect_uri"`
}
// ListPaymentLinks 获取支付链接列表
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links [get]
func ListPaymentLinks(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
var paymentLinks []PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.total_limit, merchant_payment_links.user_limit, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.merchant_api_key_id = ? AND merchant_payment_links.deleted_at IS NULL", apiKey.ID).
Order("merchant_payment_links.created_at DESC").
Find(&paymentLinks).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(paymentLinks))
}
// GetPaymentLinkByToken 通过 Token 查询支付链接信息
// @Tags merchant
// @Produce json
// @Param token path string true "支付链接 Token"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/{token} [get]
func GetPaymentLinkByToken(c *gin.Context) {
var paymentLink PaymentLinkDetail
if err := db.DB(c.Request.Context()).
Table("merchant_payment_links").
Select("merchant_payment_links.id, merchant_payment_links.token, merchant_payment_links.amount, merchant_payment_links.product_name, merchant_payment_links.remark, merchant_payment_links.created_at, merchant_api_keys.app_name, merchant_api_keys.redirect_uri").
Joins("JOIN merchant_api_keys ON merchant_api_keys.id = merchant_payment_links.merchant_api_key_id").
Where("merchant_payment_links.token = ? AND merchant_payment_links.deleted_at IS NULL", c.Param("token")).
First(&paymentLink).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(paymentLink))
}
// DeletePaymentLink 删除支付链接
// @Tags merchant
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [delete]
func DeletePaymentLink(c *gin.Context) {
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Delete(&model.MerchantPaymentLink{})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// UpdatePaymentLink 更新支付链接
// @Tags merchant
// @Accept json
// @Produce json
// @Param id path uint64 true "API Key ID"
// @Param linkId path uint64 true "Payment Link ID"
// @Param request body PaymentLinkRequest true "更新支付链接请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/api-keys/{id}/payment-links/{linkId} [put]
func UpdatePaymentLink(c *gin.Context) {
var req PaymentLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, merchant.APIKeyObjKey)
linkID := c.Param("linkId")
result := db.DB(c.Request.Context()).
Model(&model.MerchantPaymentLink{}).
Where("id = ? AND merchant_api_key_id = ?", linkID, apiKey.ID).
Updates(map[string]interface{}{
"amount": req.Amount,
"product_name": req.ProductName,
"remark": req.Remark,
"total_limit": req.TotalLimit,
"user_limit": req.UserLimit,
})
if result.Error != nil {
c.JSON(http.StatusInternalServerError, util.Err(result.Error.Error()))
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// PayByLink 通过支付链接支付
// @Tags merchant
// @Accept json
// @Produce json
// @Param request body PayByLinkRequest true "支付请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment-links/pay [post]
func PayByLink(c *gin.Context) {
var req PayByLinkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
var paymentLink model.MerchantPaymentLink
if err := paymentLink.GetByToken(db.DB(c.Request.Context()), req.Token); err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, util.Err(PaymentLinkNotFound))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
// 检查余额是否足够
if currentUser.AvailableBalance.LessThan(paymentLink.Amount) {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
return
}
// 查询商户 API Key
var merchantAPIKey model.MerchantAPIKey
if err := merchantAPIKey.GetByID(db.DB(c.Request.Context()), paymentLink.MerchantAPIKeyID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询商户用户
var merchantUser model.User
if err := merchantUser.GetByID(db.DB(c.Request.Context()), merchantAPIKey.UserID); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 验证测试模式下的支付权限
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, merchantAPIKey.TestMode); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 获取商户的支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取付款方的支付配置
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
isTestMode := merchantAPIKey.TestMode
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 非测试模式
if !isTestMode {
if paymentLink.TotalLimit != nil || paymentLink.UserLimit != nil {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", paymentLink.ID).Error; err != nil {
return err
}
}
// 检查总付款次数限制
if paymentLink.TotalLimit != nil {
var totalCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ?", paymentLink.ID, model.OrderStatusSuccess).
Count(&totalCount).Error; err != nil {
return err
}
if totalCount >= int64(*paymentLink.TotalLimit) {
return errors.New(PaymentLinkTotalLimitExceeded)
}
}
// 检查单用户付款次数限制
if paymentLink.UserLimit != nil {
var userCount int64
if err := tx.Table("orders").
Where("payment_link_id = ? AND status = ? AND payer_user_id = ?",
paymentLink.ID, model.OrderStatusSuccess, currentUser.ID).
Count(&userCount).Error; err != nil {
return err
}
if userCount >= int64(*paymentLink.UserLimit) {
return errors.New(PaymentLinkUserLimitExceeded)
}
}
// 检查每日限额
if err := service.CheckDailyLimit(tx, currentUser.ID, paymentLink.Amount, payerPayConfig.DailyLimit); err != nil {
return err
}
}
// 计算手续费
_, merchantAmount, feePercent := service.CalculateFee(paymentLink.Amount, merchantPayConfig.FeeRate)
var remark string
var orderType model.OrderType
var paymentLinkID *uint64
if isTestMode {
remark = common.TestModeOrderRemark
orderType = model.OrderTypeTest
} else {
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
if req.Remark != "" {
remark = req.Remark + " " + feeRemark
} else {
remark = feeRemark
}
orderType = model.OrderTypeOnline
paymentLinkID = &paymentLink.ID
}
// 创建订单
order := model.Order{
OrderName: paymentLink.ProductName,
PayerUserID: currentUser.ID,
PayeeUserID: merchantUser.ID,
ClientID: merchantAPIKey.ClientID,
Amount: paymentLink.Amount,
Status: model.OrderStatusSuccess,
Type: orderType,
Remark: remark,
PaymentLinkID: paymentLinkID,
TradeTime: time.Now(),
ExpiresAt: time.Now(),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 非测试模式:扣减用户余额和增加商户余额
if !isTestMode {
// 扣用户
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: paymentLink.Amount,
Operation: service.BalanceDeduct,
ScoreChange: paymentLink.Amount.Round(0).IntPart(),
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 加商家
merchantScoreIncrease := paymentLink.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: merchantUser.ID,
Amount: merchantAmount,
Operation: service.BalanceAdd,
ScoreChange: merchantScoreIncrease,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 创建异步流转记录
orderTransfer := model.OrderTransfer{
OrderID: order.ID,
PayeeUserID: merchantUser.ID,
Amount: merchantAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
}
return service.EnqueueMerchantNotify(order.ID, merchantAPIKey.ClientID)
},
); err != nil {
errMsg := err.Error()
switch errMsg {
case common.InsufficientBalance, common.DailyLimitExceeded,
PaymentLinkTotalLimitExceeded, PaymentLinkUserLimitExceeded:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+46
View File
@@ -0,0 +1,46 @@
/*
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 oauth
import (
"context"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
)
func LogForAudit(ctx context.Context, user *model.User, c *gin.Context) {
auditLog := loginRequiredAuditLog{
UserID: user.ID,
Username: user.Username,
ClientIP: c.ClientIP(),
Method: c.Request.Method,
Path: c.Request.URL.Path,
RequestURI: c.Request.RequestURI,
UserAgent: c.Request.UserAgent(),
Referer: c.Request.Referer(),
}
auditJSON, err := json.Marshal(auditLog)
if err != nil {
logger.ErrorF(ctx, "[LoginRequiredAudit] marshal failed: %v", err)
logger.InfoF(ctx, "[LoginRequiredAudit] %s %d %s", c.ClientIP(), user.ID, user.Username)
} else {
logger.InfoF(ctx, "[LoginRequiredAudit] %s", auditJSON)
}
}
+67
View File
@@ -0,0 +1,67 @@
/*
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 oauth
import (
"context"
"log"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/linux-do/credit/internal/config"
"golang.org/x/oauth2"
)
var (
oauthConf *oauth2.Config
oidcVerifier *oidc.IDTokenVerifier
)
func init() {
cfg := config.Config.OAuth2
if cfg.Issuer != "" {
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
log.Printf("[OAuth] 初始化 OIDC Provider 失败: %v,将仅使用 OAuth2", err)
} else {
oidcVerifier = provider.Verifier(&oidc.Config{
ClientID: cfg.ClientID,
})
log.Printf("[OAuth] OIDC Provider 初始化成功: %s", cfg.Issuer)
}
}
// 初始化 OAuth2 配置
scopes := []string{"profile", "email"}
if oidcVerifier != nil {
// 启用 OIDC 时添加 openid scope
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
oauthConf = &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURI,
Scopes: scopes,
Endpoint: oauth2.Endpoint{
AuthURL: cfg.AuthorizationEndpoint,
TokenURL: cfg.TokenEndpoint,
AuthStyle: oauth2.AuthStyleAutoDetect,
},
}
}
+32
View File
@@ -0,0 +1,32 @@
/*
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 oauth
import (
"time"
)
const (
UserNameKey = "username"
UserIDKey = "user_id"
UserObjKey = "user_obj"
)
const (
OAuthStateCacheKeyFormat = "oauth:state:%s"
OAuthStateCacheKeyExpiration = 10 * time.Minute
)
+23
View File
@@ -0,0 +1,23 @@
/*
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 oauth
const (
InvalidState = "非法登录请求"
IDTokenVerifyFailed = "ID Token 验证失败"
NonceMismatch = "nonce 不匹配,可能存在重放攻击"
)
+152
View File
@@ -0,0 +1,152 @@
/*
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 oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/otel_trace"
"go.opentelemetry.io/otel/codes"
"gorm.io/gorm"
)
func GetUserIDFromSession(s sessions.Session) uint64 {
userID, ok := s.Get(UserIDKey).(uint64)
if !ok {
return 0
}
return userID
}
func GetUserIDFromContext(c *gin.Context) uint64 {
session := sessions.Default(c)
return GetUserIDFromSession(session)
}
// doOAuth 执行 OAuth2/OIDC 认证流程
func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error) {
ctx, span := otel_trace.Start(ctx, "OAuth")
defer span.End()
// 使用授权码换取 Token
token, err := oauthConf.Exchange(ctx, code)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var userInfo model.OAuthUserInfo
if oidcVerifier != nil {
if rawIDToken, ok := token.Extra("id_token").(string); ok {
idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if nonce != "" && idToken.Nonce != nonce {
span.SetStatus(codes.Error, NonceMismatch)
return nil, errors.New(NonceMismatch)
}
if claimsErr := idToken.Claims(&userInfo); claimsErr != nil {
span.SetStatus(codes.Error, claimsErr.Error())
return nil, claimsErr
}
}
}
if userInfo.GetID() == 0 {
client := oauthConf.Client(ctx, token)
resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint)
if httpErr != nil {
span.SetStatus(codes.Error, httpErr.Error())
return nil, httpErr
}
defer resp.Body.Close()
responseData, readErr := io.ReadAll(resp.Body)
if readErr != nil {
span.SetStatus(codes.Error, readErr.Error())
return nil, readErr
}
if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil {
span.SetStatus(codes.Error, unmarshalErr.Error())
return nil, unmarshalErr
}
}
if !userInfo.Active {
err = errors.New(common.BannedAccount)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var user model.User
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
var holder model.User
if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil {
// 存在冲突 -> 将占用者改名并注销
newParams := map[string]interface{}{
"username": fmt.Sprintf("%s已注销: %s", holder.Username, uuid.NewString()),
"is_active": false,
}
if updateErr := tx.Model(&holder).Updates(newParams).Error; updateErr != nil {
return updateErr
}
}
// 根据 ID 处理当前用户的 更新 或 创建
if queryErr := tx.Where("id = ?", userInfo.GetID()).First(&user).Error; queryErr == nil {
// 用户已存在 -> 更新信息
if activeErr := user.CheckActive(); activeErr != nil {
return activeErr
}
user.UpdateFromOAuthInfo(&userInfo)
if saveErr := tx.Save(&user).Error; saveErr != nil {
return saveErr
}
} else if errors.Is(queryErr, gorm.ErrRecordNotFound) {
// 用户不存在 -> 创建新用户
user = model.User{}
if createErr := user.CreateWithInitialCredit(tx, &userInfo); createErr != nil {
return createErr
}
} else {
return queryErr
}
return nil
})
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
return &user, nil
}
+77
View File
@@ -0,0 +1,77 @@
/*
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 oauth
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/otel_trace"
"github.com/linux-do/credit/internal/util"
)
type loginRequiredAuditLog struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
Path string `json:"path"`
RequestURI string `json:"request_uri"`
UserAgent string `json:"user_agent"`
Referer string `json:"referer"`
}
func LoginRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// init trace
ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired")
defer span.End()
// load user
userId := GetUserIDFromContext(c)
if userId <= 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
// load user from db to make sure is active
var user model.User
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user)
if tx.Error != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error_msg": tx.Error.Error(), "data": nil})
return
}
// log
LogForAudit(ctx, &user, c)
// set user info
util.SetToContext(c, UserObjKey, &user)
if risk, ok := checkOpenAPIUserRisk(ctx, user.ID); ok {
if blocked := applyOpenAPIUserRisk(c, risk); blocked {
return
}
}
// next
c.Next()
}
}
+251
View File
@@ -0,0 +1,251 @@
/*
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 oauth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/util"
"github.com/redis/go-redis/v9"
)
const (
openAPIRiskCacheKeyFormat = "openapi_risk:user:%d"
minOpenAPIRiskCacheTTL = time.Hour
riskLevelHeader = "X-Credit-Risk-Level"
riskLabelsHeader = "X-Credit-Risk-Labels"
riskItemsHeader = "X-Credit-Risks"
exposeHeader = "Access-Control-Expose-Headers"
riskBlockedCode = "RISK_BLOCKED"
riskBlockedMsg = "账号存在风险"
)
type openAPIUserRiskItem struct {
Label string `json:"label"`
Value string `json:"value"`
Desc string `json:"desc"`
}
type openAPIUserRiskResponse struct {
Risky bool `json:"risky"`
RiskLevel string `json:"risk_level"`
Risks []openAPIUserRiskItem `json:"risks"`
}
type riskBlockDetails struct {
RiskLevel string `json:"risk_level"`
RiskLabels []string `json:"risk_labels"`
Risks []openAPIUserRiskItem `json:"risks"`
}
func checkOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, bool) {
cfg := config.Config.OpenAPIRisk
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
return nil, false
}
if db.Redis == nil {
logger.ErrorF(ctx, "[OpenAPIRisk] redis is not initialized, skip risk check")
return nil, false
}
cacheKey := fmt.Sprintf(openAPIRiskCacheKeyFormat, userID)
var cached openAPIUserRiskResponse
if err := db.GetJSON(ctx, cacheKey, &cached); err == nil {
return &cached, true
} else if err != nil && !errors.Is(err, redis.Nil) {
logger.ErrorF(ctx, "[OpenAPIRisk] read cache failed, skip risk check: %v", err)
return nil, false
}
risk, err := fetchOpenAPIUserRisk(ctx, userID)
if err != nil {
logger.ErrorF(ctx, "[OpenAPIRisk] fetch user risk failed, skip risk check: %v", err)
return nil, false
}
if err := db.SetJSON(ctx, cacheKey, risk, openAPIRiskCacheTTL()); err != nil {
logger.ErrorF(ctx, "[OpenAPIRisk] write cache failed, skip risk check: %v", err)
return nil, false
}
return risk, true
}
func fetchOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, error) {
cfg := config.Config.OpenAPIRisk
endpoint := fmt.Sprintf(
"%s/api/open/v1/risk/users/%d",
strings.TrimRight(cfg.BaseURL, "/"),
userID,
)
headers := map[string]string{
"Accept": "application/json",
}
if cfg.Username != "" || cfg.Password != "" {
token := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.Password))
headers["Authorization"] = "Basic " + token
}
resp, err := util.Request(ctx, http.MethodGet, endpoint, nil, headers, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var risk openAPIUserRiskResponse
if err := json.NewDecoder(resp.Body).Decode(&risk); err != nil {
return nil, fmt.Errorf("decode response failed: %w", err)
}
return &risk, nil
}
func openAPIRiskCacheTTL() time.Duration {
ttl := time.Duration(config.Config.OpenAPIRisk.CacheTTLSeconds) * time.Second
if ttl < minOpenAPIRiskCacheTTL {
return minOpenAPIRiskCacheTTL
}
return ttl
}
func applyOpenAPIUserRisk(c *gin.Context, risk *openAPIUserRiskResponse) bool {
if risk == nil || !risk.Risky {
return false
}
labels := riskLabels(risk)
items := riskItems(risk)
cfg := config.Config.OpenAPIRisk
if containsString(cfg.BlockRiskLevels, risk.RiskLevel) {
setRiskHeaders(c, risk.RiskLevel, labels, items)
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error_code": riskBlockedCode,
"error_msg": riskBlockedMsg,
"details": riskBlockDetails{
RiskLevel: risk.RiskLevel,
RiskLabels: labels,
Risks: items,
},
})
return true
}
if containsString(cfg.PromptRiskLevels, risk.RiskLevel) {
setRiskHeaders(c, risk.RiskLevel, labels, items)
}
return false
}
func setRiskHeaders(c *gin.Context, riskLevel string, labels []string, items []openAPIUserRiskItem) {
labelsJSON, err := json.Marshal(labels)
if err != nil {
logger.ErrorF(c.Request.Context(), "[OpenAPIRisk] marshal risk labels failed: %v", err)
return
}
itemsJSON, err := json.Marshal(items)
if err != nil {
logger.ErrorF(c.Request.Context(), "[OpenAPIRisk] marshal risk items failed: %v", err)
return
}
c.Header(riskLevelHeader, riskLevel)
c.Header(riskLabelsHeader, base64.StdEncoding.EncodeToString(labelsJSON))
c.Header(riskItemsHeader, base64.StdEncoding.EncodeToString(itemsJSON))
appendExposeHeaders(c, riskLevelHeader, riskLabelsHeader, riskItemsHeader)
}
func appendExposeHeaders(c *gin.Context, names ...string) {
existing := c.Writer.Header().Get(exposeHeader)
exposed := make([]string, 0, len(names)+1)
if existing != "" {
exposed = append(exposed, strings.Split(existing, ",")...)
}
exposed = append(exposed, names...)
seen := make(map[string]struct{}, len(exposed))
normalized := make([]string, 0, len(exposed))
for _, header := range exposed {
header = strings.TrimSpace(header)
if header == "" {
continue
}
key := strings.ToLower(header)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, header)
}
c.Header(exposeHeader, strings.Join(normalized, ", "))
}
func riskLabels(risk *openAPIUserRiskResponse) []string {
labels := make([]string, 0, len(risk.Risks))
for _, item := range risk.Risks {
label := strings.TrimSpace(item.Label)
if label == "" {
continue
}
labels = append(labels, label)
}
return labels
}
func riskItems(risk *openAPIUserRiskResponse) []openAPIUserRiskItem {
items := make([]openAPIUserRiskItem, 0, len(risk.Risks))
for _, item := range risk.Risks {
item.Label = strings.TrimSpace(item.Label)
item.Value = strings.TrimSpace(item.Value)
item.Desc = strings.TrimSpace(item.Desc)
if item.Label == "" {
continue
}
items = append(items, item)
}
return items
}
func containsString(values []string, target string) bool {
target = strings.TrimSpace(target)
for _, value := range values {
if strings.TrimSpace(value) == target {
return true
}
}
return false
}
+196
View File
@@ -0,0 +1,196 @@
/*
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 oauth
import (
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// GetLoginURL godoc
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/login [get]
func GetLoginURL(c *gin.Context) {
ctx := c.Request.Context()
// 生成 state
state := uuid.NewString()
cmd := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), state, OAuthStateCacheKeyExpiration)
if cmd.Err() != nil {
c.JSON(http.StatusInternalServerError, util.Err(cmd.Err().Error()))
return
}
// 构造登录 URL
var authURL string
if oidcVerifier != nil {
// OIDC 模式:state 同时用作 nonce
authURL = oauthConf.AuthCodeURL(state, oidc.Nonce(state))
} else {
// 纯 OAuth2 模式
authURL = oauthConf.AuthCodeURL(state)
}
c.JSON(http.StatusOK, util.OK(authURL))
}
type CallbackRequest struct {
State string `json:"state"`
Code string `json:"code"`
}
// Callback godoc
// @Tags oauth
// @Param request body CallbackRequest true "request body"
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/callback [post]
func Callback(c *gin.Context) {
// 解析请求
var req CallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
ctx := c.Request.Context()
// 验证 state
cmd := db.Redis.Get(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)))
if cmd.Val() != req.State {
c.JSON(http.StatusBadRequest, util.Err(InvalidState))
return
}
db.Redis.Del(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)))
// 执行 OAuth/OIDC 认证
user, err := doOAuth(ctx, req.Code, req.State)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
session := sessions.Default(c)
session.Set(UserIDKey, user.ID)
session.Set(UserNameKey, user.Username)
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
LogForAudit(ctx, user, c)
c.JSON(http.StatusOK, util.OKNil())
}
type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
TrustLevel model.TrustLevel `json:"trust_level"`
AvatarUrl string `json:"avatar_url"`
TotalReceive decimal.Decimal `json:"total_receive"`
TotalPayment decimal.Decimal `json:"total_payment"`
TotalTransfer decimal.Decimal `json:"total_transfer"`
TotalCommunity decimal.Decimal `json:"total_community"`
CommunityBalance decimal.Decimal `json:"community_balance"`
AvailableBalance decimal.Decimal `json:"available_balance"`
PendingBalance decimal.Decimal `json:"pending_balance"`
PayScore int64 `json:"pay_score"`
IsPayKey bool `json:"is_pay_key"`
IsAdmin bool `json:"is_admin"`
RemainQuota decimal.Decimal `json:"remain_quota"`
PayLevel model.PayLevel `json:"pay_level"`
DailyLimit *int64 `json:"daily_limit"`
}
// UserInfo godoc
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/user-info [get]
func UserInfo(c *gin.Context) {
user, _ := util.GetFromContext[*model.User](c, UserObjKey)
var payConfig model.UserPayConfig
if err := payConfig.GetByPayScore(db.DB(c.Request.Context()), user.PayScore); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 计算剩余额度(-1 表示无限额)
remainQuota := decimal.NewFromInt(-1)
if payConfig.DailyLimit != nil && *payConfig.DailyLimit > 0 {
todayUsed, err := service.GetTodayUsedAmount(db.DB(c.Request.Context()), user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
remainQuota = decimal.NewFromInt(*payConfig.DailyLimit).Sub(todayUsed)
}
c.JSON(
http.StatusOK,
util.OK(BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
TrustLevel: user.TrustLevel,
AvatarUrl: user.AvatarUrl,
TotalReceive: user.TotalReceive,
TotalPayment: user.TotalPayment,
TotalTransfer: user.TotalTransfer,
TotalCommunity: user.TotalCommunity,
CommunityBalance: user.CommunityBalance,
AvailableBalance: user.AvailableBalance,
PendingBalance: user.PendingBalance,
PayScore: user.PayScore,
IsPayKey: user.PayKey != "",
IsAdmin: user.IsAdmin,
RemainQuota: remainQuota,
PayLevel: payConfig.Level,
DailyLimit: payConfig.DailyLimit,
}),
)
}
// Logout godoc
// @Tags oauth
// @Produce json
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/oauth/logout [get]
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Options(util.GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+219
View File
@@ -0,0 +1,219 @@
/*
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 order
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
type TransactionListRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
Types []string `json:"types" form:"types" binding:"omitempty,dive,oneof=receive payment transfer community online test distribute red_envelope_send red_envelope_receive red_envelope_refund"`
Statuses []string `json:"statuses" form:"statuses" binding:"omitempty,dive,oneof=success pending failed expired disputing refund refused"`
ClientID string `json:"client_id" form:"client_id" binding:"omitempty"`
StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"`
EndTime *time.Time `json:"endTime" form:"endTime" binding:"omitempty,gtfield=StartTime"`
ID *uint64 `json:"id,string" form:"id" binding:"omitempty"`
OrderName string `json:"order_name" form:"order_name" binding:"omitempty"`
PayerUsername string `json:"payer_username" form:"payer_username" binding:"omitempty"`
PayeeUsername string `json:"payee_username" form:"payee_username" binding:"omitempty"`
PayeeTransferStatus model.OrderTransferStatus `json:"payee_transfer_status" form:"payee_transfer_status" binding:"omitempty,oneof=pending completed"`
}
type TransactionListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Orders []struct {
model.Order
AppName string `json:"app_name"`
AppHomepageURL string `json:"app_homepage_url"`
AppDescription string `json:"app_description"`
RedirectURI string `json:"redirect_uri"`
DisputeID *uint64 `json:"dispute_id,string"`
PayerUsername string `json:"payer_username"`
PayeeUsername string `json:"payee_username"`
PayerAvatarURL string `json:"payer_avatar_url"`
PayeeAvatarURL string `json:"payee_avatar_url"`
PayeeTransferStatus string `json:"payee_transfer_status"`
PayeeTransferAt string `json:"payee_transfer_at"`
} `json:"orders"`
}
// ListTransactions 获取交易列表
// @Tags order
// @Accept json
// @Produce json
// @Param request body TransactionListRequest false "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/order/transactions [post]
func ListTransactions(c *gin.Context) {
var req TransactionListRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
baseQuery := db.DB(c.Request.Context()).Model(&model.Order{}).
Select("orders.*, merchant_api_keys.app_name, merchant_api_keys.app_homepage_url, merchant_api_keys.app_description, merchant_api_keys.redirect_uri, disputes.id as dispute_id, payer_user.username as payer_username, payee_user.username as payee_username, payer_user.avatar_url as payer_avatar_url, payee_user.avatar_url as payee_avatar_url, order_transfers.status as payee_transfer_status, order_transfers.transfer_at as payee_transfer_at").
Joins("LEFT JOIN merchant_api_keys ON orders.client_id = merchant_api_keys.client_id").
Joins("LEFT JOIN disputes ON orders.id = disputes.order_id").
Joins("LEFT JOIN users as payer_user ON orders.payer_user_id = payer_user.id").
Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Joins("LEFT JOIN order_transfers ON orders.id = order_transfers.order_id")
clientIDHandled := false
if len(req.Types) > 0 {
// 使用 IN 查询支持多个类型
var conditions []string
var args []interface{}
for _, t := range req.Types {
orderType := model.OrderType(t)
switch orderType {
case model.OrderTypeReceive:
// receive 类型:查询当前用户作为收款方的 payment 订单
conditions = append(conditions, "(orders.type = ? AND orders.payee_user_id = ?)")
args = append(args, model.OrderTypePayment, user.ID)
case model.OrderTypeCommunity, model.OrderTypeRedEnvelopeRefund, model.OrderTypeRedEnvelopeReceive:
// community、red_envelope_refund、red_envelope_receive 类型:查询当前用户作为收款方的订单
conditions = append(conditions, "(orders.type = ? AND orders.payee_user_id = ?)")
args = append(args, orderType, user.ID)
case model.OrderTypeOnline:
// online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
if req.ClientID != "" {
clientIDHandled = true
var count int64
if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}).
Where("client_id = ? AND user_id = ?", req.ClientID, user.ID).
Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if count > 0 {
conditions = append(conditions, "(orders.type = ? AND orders.client_id = ?)")
args = append(args, orderType, req.ClientID)
} else {
conditions = append(conditions, "(orders.type = ? AND orders.client_id = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?))")
args = append(args, orderType, req.ClientID, user.ID, user.ID)
}
} else {
conditions = append(conditions, "(orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?))")
args = append(args, orderType, user.ID, user.ID)
}
case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest, model.OrderTypeRedEnvelopeSend:
// payment、transfer、test、red_envelope_send 类型:查询当前用户作为付款方的订单
conditions = append(conditions, "(orders.type = ? AND orders.payer_user_id = ?)")
args = append(args, orderType, user.ID)
case model.OrderTypeDistribute:
// distribute 类型:查询当前用户作为付款方或者收款方的订单
conditions = append(conditions, "((orders.type = ? AND orders.payer_user_id = ?) OR (orders.type = ? AND orders.payee_user_id = ?))")
args = append(args, orderType, user.ID, orderType, user.ID)
}
}
if len(conditions) > 0 {
combinedCondition := conditions[0]
for i := 1; i < len(conditions); i++ {
combinedCondition += " OR " + conditions[i]
}
baseQuery = baseQuery.Where(combinedCondition, args...)
}
} else {
// 查询所有与当前用户相关的订单,但排除用户作为payer的red_envelope_receive订单
baseQuery = baseQuery.Where(
"orders.payee_user_id = ? OR (orders.payer_user_id = ? AND orders.type != ?)",
user.ID, user.ID, model.OrderTypeRedEnvelopeReceive,
)
}
if len(req.Statuses) > 0 {
baseQuery = baseQuery.Where("orders.status IN ?", req.Statuses)
}
if req.ClientID != "" && !clientIDHandled {
baseQuery = baseQuery.Where("orders.client_id = ?", req.ClientID)
}
if req.ID != nil {
baseQuery = baseQuery.Where("orders.id = ?", req.ID)
}
if req.OrderName != "" {
baseQuery = baseQuery.Where("orders.order_name LIKE ?", req.OrderName+"%")
}
if req.PayerUsername != "" {
baseQuery = baseQuery.Where("payer_user.username LIKE ?", req.PayerUsername+"%")
}
if req.PayeeUsername != "" {
baseQuery = baseQuery.Where("payee_user.username LIKE ?", req.PayeeUsername+"%")
}
if req.PayeeTransferStatus != "" {
switch req.PayeeTransferStatus {
case model.OrderTransferStatusPending:
baseQuery = baseQuery.Where("order_transfers.status = ?", model.OrderTransferStatusPending)
case model.OrderTransferStatusCompleted:
baseQuery = baseQuery.Where("order_transfers.status = ? OR order_transfers.status IS NULL", model.OrderTransferStatusCompleted)
}
}
if req.StartTime != nil {
baseQuery = baseQuery.Where("orders.created_at >= ?", req.StartTime)
}
if req.EndTime != nil {
baseQuery = baseQuery.Where("orders.created_at <= ?", req.EndTime)
}
var total int64
if err := baseQuery.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := &TransactionListResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
offset := (req.Page - 1) * req.PageSize
if err := baseQuery.Order("orders.created_at DESC").Offset(offset).Limit(req.PageSize).Find(&response.Orders).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 转换订单类型:从收款方视角看,payment 订单应该显示为 receive
// 并更新 Payee_transfer_status,兼容为空的场景
for i := range response.Orders {
if response.Orders[i].PayeeTransferStatus == "" {
response.Orders[i].PayeeTransferStatus = string(model.OrderTransferStatusCompleted)
}
if response.Orders[i].Type == model.OrderTypePayment && response.Orders[i].PayeeUserID == user.ID {
response.Orders[i].Type = model.OrderTypeReceive
}
}
c.JSON(http.StatusOK, util.OK(response))
}
+190
View File
@@ -0,0 +1,190 @@
/*
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 order
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// HandleSyncOrdersToClickHouse 同步订单数据
func HandleSyncOrdersToClickHouse(ctx context.Context, t *asynq.Task) error {
if !config.Config.ClickHouse.Enabled {
logger.InfoF(ctx, "ClickHouse 未启用,跳过订单同步")
return nil
}
var payload struct {
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
_ = json.Unmarshal(t.Payload(), &payload)
var startOfDay, endOfDay time.Time
if payload.StartTime != nil && payload.EndTime != nil {
startOfDay = *payload.StartTime
endOfDay = *payload.EndTime
} else {
now := time.Now()
yesterday := now.AddDate(0, 0, -1)
startOfDay = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
endOfDay = startOfDay.AddDate(0, 0, 1)
}
logger.InfoF(ctx, "开始同步订单到 ClickHouse: %s ~ %s", startOfDay.Format("2006-01-02 15:04:05"), endOfDay.Format("2006-01-02 15:04:05"))
pageSize := 10000
lastID := uint64(0)
totalSynced := 0
for {
var orders []model.Order
if err := db.DB(ctx).
Where("updated_at >= ? AND updated_at < ? AND id > ?", startOfDay, endOfDay, lastID).
Order("id ASC").
Limit(pageSize).
Find(&orders).Error; err != nil {
logger.ErrorF(ctx, "查询订单失败: %v", err)
return err
}
if len(orders) == 0 {
break
}
if err := batchInsertToClickHouse(ctx, orders); err != nil {
logger.ErrorF(ctx, "写入 ClickHouse 失败: %v", err)
return err
}
totalSynced += len(orders)
lastID = orders[len(orders)-1].ID
logger.InfoF(ctx, "已同步 %d 条订单,当前进度 ID: %d", len(orders), lastID)
}
logger.InfoF(ctx, "订单同步完成,共同步 %d 条", totalSynced)
return nil
}
// batchInsertToClickHouse 批量写入订单
func batchInsertToClickHouse(ctx context.Context, orders []model.Order) error {
batch, err := db.ChConn.PrepareBatch(ctx, `
INSERT INTO orders (
id, order_name, merchant_order_no, client_id,
payer_user_id, payee_user_id, amount,
status, type, remark, payment_type,
trade_time, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return err
}
for _, order := range orders {
if err := batch.Append(
order.ID,
order.OrderName,
util.DerefString(order.MerchantOrderNo),
order.ClientID,
order.PayerUserID,
order.PayeeUserID,
order.Amount,
string(order.Status),
string(order.Type),
order.Remark,
order.PaymentType,
order.TradeTime,
order.ExpiresAt,
order.CreatedAt,
order.UpdatedAt,
); err != nil {
return err
}
}
return batch.Send()
}
// HandleSettlePendingPayments 处理到期的延迟到账订单
func HandleSettlePendingPayments(ctx context.Context, _ *asynq.Task) error {
batchSize := 500
totalSettled := 0
for {
orders, err := model.GetDueTransferOrders(ctx, batchSize)
if err != nil {
logger.ErrorF(ctx, "查询到期待结算订单失败: %v", err)
return err
}
if len(orders) == 0 {
break
}
for _, order := range orders {
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 锁定行
var lockedOrderTransfer model.OrderTransfer
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", order.ID, model.OrderTransferStatusPending).
First(&lockedOrderTransfer).Error; err != nil {
return fmt.Errorf("lock order failed: %w", err)
}
// 移动到可用余额
if err := service.SettlePendingToAvailable(tx, lockedOrderTransfer.PayeeUserID, lockedOrderTransfer.Amount); err != nil {
return fmt.Errorf("settle balance failed: %w", err)
}
// 更新订单状态
if err := tx.Model(&lockedOrderTransfer).Updates(
map[string]interface{}{
"transfer_at": time.Now(),
"status": model.OrderTransferStatusCompleted,
},
).Error; err != nil {
return fmt.Errorf("update order status failed: %w", err)
}
logger.InfoF(ctx, "订单[ID:%d]延迟到账结算成功: 商户[ID:%d] 金额[%s]", lockedOrderTransfer.ID, lockedOrderTransfer.PayeeUserID, lockedOrderTransfer.Amount.String())
return nil
}); err != nil {
logger.ErrorF(ctx, "结算订单[ID:%d]失败: %v", order.ID, err)
continue
}
totalSettled++
}
}
if totalSettled > 0 {
logger.InfoF(ctx, "延迟到账结算完成,共结算 %d 笔", totalSettled)
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
/*
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 payment
const (
APIKeyObjKey = "payment_api_key_obj"
CreateOrderRequestKey = "payment_create_order_request"
)
const (
// OrderMerchantIDCacheKeyFormat Redis key 格式,用于存储订单号对应的商户ID
OrderMerchantIDCacheKeyFormat = "payment:order:%s"
// OrderExpireKeyFormat Redis key 格式,用于订单过期监听,key中包含订单ID
OrderExpireKeyFormat = "payment:order:expire:%d"
)
+30
View File
@@ -0,0 +1,30 @@
/*
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 payment
const (
OrderNotFound = "订单不存在或已完成"
OrderStatusInvalid = "订单状态不允许支付"
OrderExpired = "订单已过期"
MerchantInfoNotFound = "商户信息不存在"
RecipientNotFound = "收款人不存在"
OrderNoFormatError = "订单号格式错误"
CannotTransferToSelf = "不能转账给自己"
PayConfigNotFound = "支付配置不存在"
InvalidPublicKeyFormat = "公钥格式错误"
InvalidPublicKeyLength = "公钥长度必须为32字节"
)
+161
View File
@@ -0,0 +1,161 @@
/*
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 payment
import (
"encoding/base64"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
)
// CreateOrderRequest 商户创建订单统一请求
type CreateOrderRequest struct {
OrderName string `json:"order_name" binding:"required,max=64"`
MerchantOrderNo *string `json:"merchant_order_no" binding:"omitempty,min=1,max=64"`
Amount decimal.Decimal `json:"amount" binding:"required"`
Remark string `json:"remark" binding:"max=100"`
PaymentType string `json:"payment_type"`
NotifyURL string `json:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `json:"return_url" binding:"omitempty,max=100,url"`
}
// EPayRequest 易支付请求
type EPayRequest struct {
ClientID string `form:"pid" binding:"required"`
OrderName string `form:"name" binding:"required,max=64"`
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
Amount decimal.Decimal `form:"money" binding:"required"`
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
Device string `form:"device"`
Sign string `form:"sign" binding:"required"`
PayType string `form:"type" binding:"required"`
SignType string `form:"sign_type"`
}
// LDCPayRequest LDC支付请求
type LDCPayRequest struct {
ClientID string `form:"client_id" binding:"required"`
OrderName string `form:"order_name" binding:"required,max=64"`
MerchantOrderNo *string `form:"out_trade_no" binding:"required,min=1,max=64"`
Amount decimal.Decimal `form:"money" binding:"required"`
NotifyURL string `form:"notify_url" binding:"omitempty,max=100,url"`
ReturnURL string `form:"return_url" binding:"omitempty,max=100,url"`
PayType string `form:"type" binding:"required"`
Sign string `form:"sign" binding:"required"`
}
// NewCreateOrderRequest 从支付请求创建通用订单请求
func NewCreateOrderRequest(orderName string, merchantOrderNo *string, amount decimal.Decimal, payType string, notifyURL string, returnURL string) *CreateOrderRequest {
return &CreateOrderRequest{
OrderName: orderName,
MerchantOrderNo: merchantOrderNo,
Amount: amount,
PaymentType: payType,
NotifyURL: notifyURL,
ReturnURL: returnURL,
}
}
// RequireMerchantAuth 验证商户 ClientID/ClientSecret(Basic Auth)
func RequireMerchantAuth() gin.HandlerFunc {
return func(c *gin.Context) {
// Authorization: Basic base64(ClientID:ClientSecret)
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("缺少认证信息"))
return
}
// 解析 Basic Auth
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Basic" {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证格式错误"))
return
}
// 解码 base64
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息解码失败"))
return
}
// 解析 ClientID:ClientSecret
credentials := strings.SplitN(string(decoded), ":", 2)
if len(credentials) != 2 {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证信息格式错误"))
return
}
clientID := credentials[0]
clientSecret := credentials[1]
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_secret = ? AND client_id = ?", clientSecret, clientID).
First(&apiKey).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err("认证失败"))
return
}
util.SetToContext(c, APIKeyObjKey, &apiKey)
c.Next()
}
}
// RequireSignatureAuth 验证签名
func RequireSignatureAuth() gin.HandlerFunc {
return func(c *gin.Context) {
PayType := c.Request.FormValue("type")
var apiKey model.MerchantAPIKey
var createOrderReq *CreateOrderRequest
var err error
switch PayType {
case common.PayTypeLDCPay:
createOrderReq, err = VerifySignatureEd25519(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
}
case common.PayTypeEPay:
createOrderReq, err = VerifySignatureMD5(c, &apiKey)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, util.Err(err.Error()))
return
}
default:
c.AbortWithStatusJSON(http.StatusBadRequest, util.Err("不支持的请求类型"))
return
}
util.SetToContext(c, CreateOrderRequestKey, createOrderReq)
util.SetToContext(c, APIKeyObjKey, &apiKey)
c.Next()
}
}
+761
View File
@@ -0,0 +1,761 @@
/*
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 payment
import (
"cmp"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"time"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/service"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PayOrderRequest 用户支付订单请求
type PayOrderRequest struct {
OrderNo string `json:"order_no" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
}
// GetOrderRequest 查询订单请求
type GetOrderRequest struct {
OrderNo string `form:"order_no" json:"order_no" binding:"required"`
}
// MerchantInfo 商户信息
type MerchantInfo struct {
AppName string `json:"app_name"`
RedirectURI string `json:"redirect_uri"`
}
// GetOrderResponse 查询订单响应
type GetOrderResponse struct {
Order *model.Order `json:"order"`
FeeRate decimal.Decimal `json:"fee_rate"`
Merchant MerchantInfo `json:"merchant"`
}
// TransferRequest 转账请求
type TransferRequest struct {
RecipientID uint64 `json:"recipient_id,string" binding:"required"`
RecipientUsername string `json:"recipient_username" binding:"required"`
Amount decimal.Decimal `json:"amount" binding:"required"`
PayKey string `json:"pay_key" binding:"required,max=6"`
Remark string `json:"remark" binding:"max=100"`
}
// QueryOrderRequest 商户查询订单请求
type QueryOrderRequest struct {
Act string `form:"act" json:"act"`
ClientID string `form:"pid" json:"pid" binding:"required"`
ClientSecret string `form:"key" json:"key" binding:"required"`
MerchantOrderNo *string `form:"out_trade_no" json:"out_trade_no" binding:"required,min=1,max=64"`
}
// RefundOrderRequest 商户退款请求
type RefundOrderRequest struct {
ClientID string `form:"pid" json:"pid" binding:"required"`
ClientSecret string `form:"key" json:"key" binding:"required"`
MerchantOrderNo string `form:"out_trade_no" json:"out_trade_no"`
TradeNo uint64 `form:"trade_no" json:"trade_no" binding:"required"`
Amount decimal.Decimal `form:"money" json:"money" binding:"required"`
}
// CreateMerchantOrder 商户创建订单接口
// @Tags payment
// @Accept x-www-form-urlencoded
// @Produce json
// @Param request body CreateOrderRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /pay/submit.php [post]
// @Router /pay/submit.php [get]
func CreateMerchantOrder(c *gin.Context) {
req, _ := util.GetFromContext[*CreateOrderRequest](c, CreateOrderRequestKey)
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
// 获取商户用户信息
var merchantUser model.User
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(MerchantInfoNotFound))
return
}
// 获取商家订单过期时间(分钟)
expireMinutes, errGet := model.GetIntByKey(c.Request.Context(), model.ConfigKeyMerchantOrderExpireMinutes)
if errGet != nil {
c.JSON(http.StatusInternalServerError, util.Err(errGet.Error()))
return
}
var payURL string
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 创建订单
order := model.Order{
OrderName: req.OrderName,
ClientID: apiKey.ClientID,
MerchantOrderNo: req.MerchantOrderNo,
PayeeUserID: merchantUser.ID,
Amount: req.Amount,
Status: model.OrderStatusPending,
Type: model.OrderTypePayment,
Remark: req.Remark,
PaymentType: req.PaymentType,
RedirectURI: req.ReturnURL,
NotifyURL: req.NotifyURL,
ExpiresAt: time.Now().Add(time.Duration(expireMinutes) * time.Minute),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
encryptString, err := util.Encrypt(merchantUser.SignKey, strconv.FormatUint(order.ID, 10))
if err != nil {
return err
}
merchantIDStr := strconv.FormatUint(merchantUser.ID, 10)
if errSet := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, encryptString)), merchantIDStr, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
return fmt.Errorf("failed to set redis key: %w", errSet)
}
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
if errSet := db.Redis.Set(c.Request.Context(), expireKey, order.ID, time.Duration(expireMinutes)*time.Minute).Err(); errSet != nil {
return fmt.Errorf("failed to set order expire key: %w", errSet)
}
payURL = fmt.Sprintf("%s?order_no=%s", config.Config.App.FrontendPayURL, url.QueryEscape(encryptString))
return nil
},
); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.Redirect(http.StatusFound, payURL)
}
// QueryMerchantOrderResponse 查询订单响应
type QueryMerchantOrderResponse struct {
Code int `json:"code" example:"1"`
Msg string `json:"msg" example:"查询订单号成功!"`
TradeNo string `json:"trade_no" example:"123456"`
OutTradeNo string `json:"out_trade_no" example:"M202312080001"`
Type string `json:"type" example:"epay"`
Pid string `json:"pid" example:"1001"`
AddTime string `json:"addtime" example:"2023-12-08 12:00:00"`
EndTime string `json:"endtime" example:"2023-12-08 12:05:00"`
Name string `json:"name" example:"商品名称"`
Money string `json:"money" example:"10.00"`
Status int `json:"status" example:"1"`
}
// QueryMerchantOrder 商户主动查询订单状态接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request query QueryOrderRequest true "查询参数"
// @Success 200 {object} QueryMerchantOrderResponse
// @Router /api.php [get]
func QueryMerchantOrder(c *gin.Context) {
var req QueryOrderRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
return
}
var order model.Order
if err := db.DB(c.Request.Context()).Where("client_id = ? AND merchant_order_no = ?", req.ClientID, req.MerchantOrderNo).First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": -1, "msg": OrderNotFound})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": -1, "msg": err.Error()})
return
}
statusInt := 0
if order.Status == model.OrderStatusSuccess {
statusInt = 1
}
c.JSON(http.StatusOK, gin.H{
"code": 1,
"msg": "查询订单号成功!",
"trade_no": strconv.FormatUint(order.ID, 10),
"out_trade_no": order.MerchantOrderNo,
"type": order.PaymentType,
"pid": order.ClientID,
"addtime": order.CreatedAt.Format("2006-01-02 15:04:05"),
"endtime": order.TradeTime.Format("2006-01-02 15:04:05"),
"name": order.OrderName,
"money": order.Amount.Truncate(2).StringFixed(2),
"status": statusInt,
})
}
// RefundMerchantOrderResponse 退款响应
type RefundMerchantOrderResponse struct {
Code int `json:"code" example:"1"`
Msg string `json:"msg" example:"退款成功"`
}
// RefundMerchantOrder 商户退款接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body RefundOrderRequest true "退款请求"
// @Success 200 {object} RefundMerchantOrderResponse
// @Router /api.php [post]
func RefundMerchantOrder(c *gin.Context) {
var req RefundOrderRequest
if err := c.ShouldBind(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": err.Error()})
return
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).Where("client_id = ? AND client_secret = ?", req.ClientID, req.ClientSecret).First(&apiKey).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": -1, "msg": MerchantInfoNotFound})
return
}
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND client_id = ? AND status = ? AND amount = ? AND type IN ?", req.TradeNo, req.ClientID, model.OrderStatusSuccess, req.Amount, []model.OrderType{model.OrderTypePayment, model.OrderTypeOnline}).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
}
return err
}
var payerUser model.User
if err := payerUser.GetByID(tx, order.PayerUserID); err != nil {
return err
}
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).First(&merchantUser).Error; err != nil {
return err
}
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return err
}
merchantScoreDecrease := order.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
if err := tx.Model(&model.User{}).
Where("id = ?", merchantUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance - ?", order.Amount),
"total_receive": gorm.Expr("total_receive - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", merchantScoreDecrease),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", payerUser.ID).
UpdateColumns(map[string]interface{}{
"available_balance": gorm.Expr("available_balance + ?", order.Amount),
"total_payment": gorm.Expr("total_payment - ?", order.Amount),
"pay_score": gorm.Expr("pay_score - ?", order.Amount.Round(0).IntPart()),
}).Error; err != nil {
return err
}
if err := tx.Model(&model.Order{}).
Where("id = ?", order.ID).
Update("status", model.OrderStatusRefund).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusOK, gin.H{"code": -1, "msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 1,
"msg": "退款成功",
})
}
// MerchantDistributeRequest 商户分发请求
type MerchantDistributeRequest struct {
RecipientID uint64 `json:"user_id" binding:"required"`
RecipientUsername string `json:"username" binding:"required"`
Amount decimal.Decimal `json:"amount" binding:"required"`
MerchantOrderNo *string `json:"out_trade_no" binding:"omitempty,min=1,max=64"`
Remark string `json:"remark" binding:"max=100"`
}
// MerchantDistribute 商户分发接口(商户向用户分发)
// @Tags payment
// @Accept json
// @Produce json
// @Param Authorization header string true "Basic Auth (base64(client_id:client_secret))"
// @Param request body MerchantDistributeRequest true "分发请求"
// @Success 200 {object} util.ResponseAny
// @Router /pay/distribute [post]
func MerchantDistribute(c *gin.Context) {
var req MerchantDistributeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
apiKey, _ := util.GetFromContext[*model.MerchantAPIKey](c, APIKeyObjKey)
var orderID uint64
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 验证收款人是否存在且用户名匹配
var recipient model.User
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RecipientNotFound)
}
return err
}
// 获取商户用户信息
var merchantUser model.User
if err := tx.Where("id = ? AND is_active = ?", apiKey.UserID, true).
First(&merchantUser).Error; err != nil {
return errors.New(MerchantInfoNotFound)
}
// 不能分发给自己
if recipient.ID == merchantUser.ID {
return errors.New(CannotTransferToSelf)
}
// 获取商户支付配置
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(tx, merchantUser.PayScore); err != nil {
return errors.New(PayConfigNotFound)
}
if err := service.CheckDailyLimit(tx, merchantUser.ID, req.Amount, merchantPayConfig.DailyLimit); err != nil {
return err
}
_, recipientAmount, distributePercent := service.CalculateFee(req.Amount, merchantPayConfig.DistributeRate)
merchantScore := req.Amount.Mul(merchantPayConfig.ScoreRate).Round(0).IntPart()
order := model.Order{
OrderName: "商户分发",
ClientID: apiKey.ClientID,
MerchantOrderNo: req.MerchantOrderNo,
PayerUserID: merchantUser.ID,
PayeeUserID: recipient.ID,
Amount: req.Amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeDistribute,
Remark: req.Remark,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
distributeRemark := fmt.Sprintf("[系统]: 分发费率%d%%", distributePercent)
if order.Remark != "" {
order.Remark = order.Remark + " " + distributeRemark
} else {
order.Remark = distributeRemark
}
if err := tx.Create(&order).Error; err != nil {
return err
}
orderID = order.ID
// 扣减商户余额,同时增加平台分数
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: merchantUser.ID,
Amount: req.Amount,
Operation: service.BalanceDeduct,
ScoreChange: merchantScore,
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 增加收款人余额(按分发费率计算后的金额)
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: recipient.ID,
Amount: recipientAmount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 创建异步到账任务
orderTransfer := model.OrderTransfer{
OrderID: orderID,
PayeeUserID: recipient.ID,
Amount: recipientAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(gin.H{
"trade_no": strconv.FormatUint(orderID, 10),
"out_trade_no": req.MerchantOrderNo,
}))
}
// GetPaymentPageDetails 查询支付订单信息接口(用于收银台页面)
// @Tags payment
// @Accept json
// @Produce json
// @Param order_no query string true "订单号"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment/order [get]
func GetPaymentPageDetails(c *gin.Context) {
var req GetOrderRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
if HandleParseOrderNoError(c, errCtx) {
return
}
var order model.Order
if err := db.DB(c.Request.Context()).
Select("orders.*, payee_user.username as payee_username").
Joins("LEFT JOIN users as payee_user ON orders.payee_user_id = payee_user.id").
Where("orders.id = ? AND orders.status = ?", orderCtx.OrderID, model.OrderStatusPending).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(OrderNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
order.PayerUsername = orderCtx.CurrentUser.Username
var merchant model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_id = ?", order.ClientID).
First(&merchant).Error; err != nil {
c.JSON(http.StatusNotFound, util.Err(MerchantInfoNotFound))
return
}
redirectURI := cmp.Or(order.RedirectURI, merchant.RedirectURI)
c.JSON(http.StatusOK, util.OK(GetOrderResponse{
Order: &order,
FeeRate: orderCtx.MerchantPayConfig.FeeRate,
Merchant: MerchantInfo{
AppName: merchant.AppName,
RedirectURI: redirectURI,
},
}))
}
// PayMerchantOrder 用户支付订单接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body PayOrderRequest true "支付订单请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/merchant/payment [post]
func PayMerchantOrder(c *gin.Context) {
var req PayOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
orderCtx, errCtx := ParseOrderNo(c, req.OrderNo)
if HandleParseOrderNoError(c, errCtx) {
return
}
if !orderCtx.CurrentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
var order model.Order
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ? AND status = ?", orderCtx.OrderID, model.OrderStatusPending).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(OrderNotFound)
}
return err
}
// 检查订单是否过期
if order.ExpiresAt.Before(time.Now()) {
return errors.New(OrderExpired)
}
isTestMode := orderCtx.MerchantAPIKey.TestMode
// 非测试模式:检查每日限额
if !isTestMode {
if err := service.CheckDailyLimit(tx, orderCtx.CurrentUser.ID, order.Amount, orderCtx.PayerPayConfig.DailyLimit); err != nil {
return err
}
}
// 计算手续费
_, merchantAmount, feePercent := service.CalculateFee(order.Amount, orderCtx.MerchantPayConfig.FeeRate)
// 更新订单状态
order.Status = model.OrderStatusSuccess
order.PayerUserID = orderCtx.CurrentUser.ID
order.TradeTime = time.Now()
if isTestMode {
order.Type = model.OrderTypeTest
order.Remark = common.TestModeOrderRemark
} else {
feeRemark := fmt.Sprintf("[系统]: 收取商家%d%%手续费", feePercent)
if order.Remark != "" {
order.Remark = order.Remark + " " + feeRemark
} else {
order.Remark = feeRemark
}
}
if err := tx.Save(&order).Error; err != nil {
return err
}
// 非测试模式:扣减用户余额和增加商户余额
if !isTestMode {
// 扣用户
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: orderCtx.CurrentUser.ID,
Amount: order.Amount,
Operation: service.BalanceDeduct,
ScoreChange: order.Amount.Round(0).IntPart(),
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 加给商家
merchantScoreIncrease := order.Amount.Mul(orderCtx.MerchantPayConfig.ScoreRate).Round(0).IntPart()
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: orderCtx.MerchantUser.ID,
Amount: merchantAmount,
Operation: service.BalanceAdd,
ScoreChange: merchantScoreIncrease,
TotalField: "total_receive",
CheckBalance: false,
AsyncTransfer: true,
}); err != nil {
return err
}
// 异步到账任务
orderTransfer := model.OrderTransfer{
OrderID: order.ID,
PayeeUserID: order.PayeeUserID,
Amount: merchantAmount,
Status: model.OrderTransferStatusPending,
TransferAt: model.GetRandomSettleAt(c.Request.Context()),
}
if err := tx.Create(&orderTransfer).Error; err != nil {
return err
}
}
expireKey := db.PrefixedKey(fmt.Sprintf(OrderExpireKeyFormat, order.ID))
if err := db.Redis.Del(c.Request.Context(), expireKey).Err(); err != nil {
log.Printf("[Payment] 删除订单过期key失败: order_id=%d, error=%v", order.ID, err)
}
return service.EnqueueMerchantNotify(order.ID, order.ClientID)
},
); err != nil {
errMsg := err.Error()
switch errMsg {
case common.InsufficientBalance, OrderExpired, common.DailyLimitExceeded:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
case OrderNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
c.JSON(http.StatusOK, util.OKNil())
}
// Transfer 用户转账接口
// @Tags payment
// @Accept json
// @Produce json
// @Param request body TransferRequest true "转账请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/payment/transfer [post]
func Transfer(c *gin.Context) {
var req TransferRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.Amount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
if currentUser.ID == req.RecipientID && currentUser.Username == req.RecipientUsername {
c.JSON(http.StatusBadRequest, util.Err(CannotTransferToSelf))
return
}
if err := db.DB(c.Request.Context()).Transaction(
func(tx *gorm.DB) error {
// 验证收款人是否存在且用户名匹配
var recipient model.User
if err := tx.Where("id = ? AND username = ?", req.RecipientID, req.RecipientUsername).First(&recipient).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RecipientNotFound)
}
return err
}
// 获取转账人支付配置
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(tx, currentUser.PayScore); err != nil {
return err
}
if err := service.CheckDailyLimit(tx, currentUser.ID, req.Amount, payerPayConfig.DailyLimit); err != nil {
return err
}
// 创建转账订单
order := model.Order{
OrderName: "转账",
PayerUserID: currentUser.ID,
PayeeUserID: recipient.ID,
Amount: req.Amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeTransfer,
Remark: req.Remark,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
// 扣减付款人余额
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: req.Amount,
Operation: service.BalanceDeduct,
TotalField: "total_transfer",
CheckBalance: true,
}); err != nil {
return err
}
// 增加收款人余额
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: recipient.ID,
Amount: req.Amount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
CheckBalance: false,
}); err != nil {
return err
}
return nil
},
); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+142
View File
@@ -0,0 +1,142 @@
/*
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 payment
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"gorm.io/gorm"
)
// HandleMerchantPaymentNotify 处理商户支付回调任务
func HandleMerchantPaymentNotify(ctx context.Context, t *asynq.Task) error {
// 解析任务参数
var payload struct {
OrderID uint64 `json:"order_id"`
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
logger.ErrorF(ctx, "解析商户回调任务参数失败: %v", err)
return fmt.Errorf("解析任务参数失败: %w", err)
}
// 查询订单信息
var order model.Order
if err := db.DB(ctx).Where("id = ? AND status = ?", payload.OrderID, model.OrderStatusSuccess).First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.ErrorF(ctx, "订单[ID:%d]不存在,跳过回调", payload.OrderID)
return nil
}
return fmt.Errorf("查询订单失败: %w", err)
}
// 查询商户API Key信息
var apiKey model.MerchantAPIKey
if err := apiKey.GetByClientID(db.DB(ctx), payload.ClientID); err != nil {
logger.ErrorF(ctx, "查询商户[ClientID:%s]失败: %v", payload.ClientID, err)
return fmt.Errorf("查询商户信息失败: %w", err)
}
// 回调 URL
callbackURL := cmp.Or(order.NotifyURL, apiKey.NotifyURL)
// 判断是否需要回调
if config.Config.App.IsProduction() && util.IsLocalhost(callbackURL) {
return nil
}
// 构建回调参数
callbackParams := map[string]string{
"pid": payload.ClientID,
"trade_no": strconv.FormatUint(order.ID, 10),
"out_trade_no": util.DerefString(order.MerchantOrderNo),
"type": common.PayTypeEPay,
"name": order.OrderName,
"money": order.Amount.Truncate(2).StringFixed(2),
"trade_status": "TRADE_SUCCESS",
}
callbackParams["sign"] = GenerateSignature(callbackParams, apiKey.ClientSecret, true)
// 回调
if err := sendCallbackRequest(ctx, callbackURL, callbackParams); err != nil {
retried, _ := asynq.GetRetryCount(ctx)
logger.ErrorF(ctx, "商户回调失败: 订单[ID:%d] 重试次数[%d] 错误: %v",
payload.OrderID, retried+1, err)
return err
}
logger.InfoF(ctx, "商户回调成功: 订单[ID:%d] ClientID[%s]", payload.OrderID, payload.ClientID)
return nil
}
// sendCallbackRequest 发送HTTP回调请求
func sendCallbackRequest(ctx context.Context, callbackURL string, params map[string]string) error {
vals := url.Values{}
for k, v := range params {
vals.Add(k, v)
}
// 拼接URL
separator := "?"
if strings.Contains(callbackURL, "?") {
separator = "&"
}
targetURL := callbackURL + separator + vals.Encode()
headers := map[string]string{
"User-Agent": "LinuxDo-Credit/1.0",
}
resp, err := util.Request(ctx, http.MethodGet, targetURL, nil, headers, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("回调返回异常状态码: %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
responseText := strings.TrimSpace(strings.ToLower(string(respBody)))
if responseText != "success" {
return fmt.Errorf("回调返回非成功响应: %s", string(respBody))
}
logger.InfoF(ctx, "商户回调请求成功: URL[%s] 响应[%s]", callbackURL, string(respBody))
return nil
}
+278
View File
@@ -0,0 +1,278 @@
/*
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 payment
import (
"crypto/md5"
"crypto/subtle"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// HandleParseOrderNoError 处理 ParseOrderNo 返回的错误,返回对应的 HTTP 响应
func HandleParseOrderNoError(c *gin.Context, err error) bool {
if err == nil {
return false
}
errMsg := err.Error()
switch errMsg {
case OrderNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
case MerchantInfoNotFound, PayConfigNotFound:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
case common.CannotPaySelf, common.TestModeCannotProcessOrder, OrderNoFormatError:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
case common.UnAuthorized:
c.JSON(http.StatusUnauthorized, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return true
}
// OrderContext 订单上下文信息
type OrderContext struct {
OrderID uint64
MerchantUser *model.User
CurrentUser *model.User
PayerPayConfig *model.UserPayConfig
MerchantPayConfig *model.UserPayConfig
MerchantAPIKey *model.MerchantAPIKey
}
// ParseOrderNo 解析订单号,获取订单上下文信息
func ParseOrderNo(c *gin.Context, orderNo string) (*OrderContext, error) {
merchantIDStr, errGet := db.Redis.Get(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OrderMerchantIDCacheKeyFormat, orderNo))).Result()
if errGet != nil {
if errors.Is(errGet, redis.Nil) {
return nil, errors.New(OrderNotFound)
}
return nil, errGet
}
merchantID, errParse := strconv.ParseUint(merchantIDStr, 10, 64)
if errParse != nil {
return nil, errors.New(OrderNoFormatError)
}
// 获取商户用户信息
var merchantUser model.User
if err := db.DB(c.Request.Context()).Where("id = ? AND is_active = ?", merchantID, true).First(&merchantUser).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(MerchantInfoNotFound)
}
return nil, err
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
orderNoStr, errDecrypt := util.Decrypt(merchantUser.SignKey, orderNo)
if errDecrypt != nil {
return nil, errors.New(OrderNoFormatError)
}
orderID, errParse := strconv.ParseUint(orderNoStr, 10, 64)
if errParse != nil {
return nil, errors.New(OrderNoFormatError)
}
var apiKey model.MerchantAPIKey
if err := db.DB(c.Request.Context()).
Where("client_id = (SELECT client_id FROM orders WHERE id = ?)", orderID).
First(&apiKey).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(OrderNotFound)
}
return nil, err
}
// 验证测试模式下的支付权限
if err := service.ValidateTestModePayment(currentUser.ID, merchantUser.ID, apiKey.TestMode); err != nil {
return nil, err
}
ctx := &OrderContext{
OrderID: orderID,
MerchantUser: &merchantUser,
CurrentUser: currentUser,
MerchantAPIKey: &apiKey,
}
// 获取付款用户的支付配置(用于限额检查)
var payerPayConfig model.UserPayConfig
if err := payerPayConfig.GetByPayScore(db.DB(c.Request.Context()), currentUser.PayScore); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(PayConfigNotFound)
}
return nil, err
}
ctx.PayerPayConfig = &payerPayConfig
// 获取商家的支付配置(用于手续费倍率)
var merchantPayConfig model.UserPayConfig
if err := merchantPayConfig.GetByPayScore(db.DB(c.Request.Context()), merchantUser.PayScore); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New(PayConfigNotFound)
}
return nil, err
}
ctx.MerchantPayConfig = &merchantPayConfig
return ctx, nil
}
// GenerateSignature 生成签名
func GenerateSignature(params map[string]string, secret string, isMD5 bool) string {
// 按key排序
keys := make([]string, 0, len(params))
for k := range params {
if k == "sign" || k == "sign_type" {
continue
}
// 空值不参与签名
if params[k] == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
// 拼接签名字符串
var builder strings.Builder
builder.Grow(256)
for i, k := range keys {
if i > 0 {
builder.WriteByte('&')
}
builder.WriteString(k)
builder.WriteByte('=')
builder.WriteString(params[k])
}
builder.WriteString(secret)
if isMD5 {
// MD5加密
hash := md5.Sum([]byte(builder.String()))
return fmt.Sprintf("%x", hash)
}
return builder.String()
}
// VerifySignatureMD5 验证MD5签名
func VerifySignatureMD5(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req EPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
return nil, err
}
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
return nil, err
}
// 构建签名参数
params := map[string]string{
"pid": req.ClientID,
"type": req.PayType,
"out_trade_no": util.DerefString(req.MerchantOrderNo),
"notify_url": req.NotifyURL,
"return_url": req.ReturnURL,
"name": req.OrderName,
"device": req.Device,
}
params["money"] = req.Amount.Truncate(2).StringFixed(2)
expectedSignFixed := GenerateSignature(params, apiKey.ClientSecret, true)
params["money"] = req.Amount.Truncate(2).String()
expectedSignTrimmed := GenerateSignature(params, apiKey.ClientSecret, true)
matchFixed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignFixed)), []byte(strings.ToLower(req.Sign))) == 1
matchTrimmed := subtle.ConstantTimeCompare([]byte(strings.ToLower(expectedSignTrimmed)), []byte(strings.ToLower(req.Sign))) == 1
if !matchFixed && !matchTrimmed {
return nil, errors.New("签名验证失败")
}
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
}
// VerifySignatureEd25519 验证 Ed25519 签名
func VerifySignatureEd25519(c *gin.Context, apiKey *model.MerchantAPIKey) (*CreateOrderRequest, error) {
var req LDCPayRequest
if err := c.ShouldBind(&req); err != nil {
return nil, err
}
// 验证金额
if err := util.ValidateAmount(req.Amount); err != nil {
return nil, err
}
if err := apiKey.GetByClientID(db.DB(c.Request.Context()), req.ClientID); err != nil {
return nil, err
}
if len(apiKey.PublicKey) == 0 {
return nil, errors.New("商户未配置公钥")
}
signatureBytes, err := util.Base64Decode(req.Sign)
if err != nil {
return nil, errors.New("签名格式错误")
}
// 构建签名参数
params := map[string]string{
"client_id": req.ClientID,
"type": req.PayType,
"out_trade_no": util.DerefString(req.MerchantOrderNo),
"order_name": req.OrderName,
"notify_url": req.NotifyURL,
"return_url": req.ReturnURL,
"money": req.Amount.Truncate(2).StringFixed(2),
}
signatureParam := GenerateSignature(params, apiKey.ClientSecret, false)
validTrimmed := util.Ed25519Verify(apiKey.PublicKey, []byte(signatureParam), signatureBytes)
if !validTrimmed {
return nil, errors.New("签名验证失败")
}
return NewCreateOrderRequest(req.OrderName, req.MerchantOrderNo, req.Amount, req.PayType, req.NotifyURL, req.ReturnURL), nil
}
+33
View File
@@ -0,0 +1,33 @@
/*
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 redenvelope
const (
RedEnvelopeNotFound = "红包不存在"
RedEnvelopeExpired = "红包已过期"
RedEnvelopeFinished = "红包已领完"
RedEnvelopeAlreadyClaimed = "您已领取过该红包"
CannotClaimOwnRedEnvelope = "不能领取自己的红包"
InvalidRedEnvelopeType = "无效的红包类型"
InvalidRedEnvelopeCount = "红包个数必须大于0"
InvalidRedEnvelopeAmount = "红包金额必须大于0"
AmountTooSmall = "每个红包金额不能小于0.01"
RedEnvelopeTooPopular = "太火爆啦,稍后再试试吧~"
InvalidRedEnvelopeID = "红包ID格式错误"
InvalidCoverImage = "无效的封面图片"
InvalidHeterotypicImage = "无效的装饰图片"
)
+42
View File
@@ -0,0 +1,42 @@
/*
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 redenvelope
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
// CheckRedEnvelopeEnabled 检查红包功能是否启用的中间件
func CheckRedEnvelopeEnabled() gin.HandlerFunc {
return func(c *gin.Context) {
enabled, err := model.GetBoolByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if !enabled {
c.AbortWithStatusJSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
return
}
c.Next()
}
}
+543
View File
@@ -0,0 +1,543 @@
/*
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 redenvelope
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CreateRequest 创建红包请求
type CreateRequest struct {
Type model.RedEnvelopeType `json:"type" binding:"required,oneof=fixed random"`
TotalAmount decimal.Decimal `json:"total_amount" binding:"required"`
TotalCount int `json:"total_count" binding:"required,min=1"`
Greeting string `json:"greeting" binding:"max=100"`
PayKey string `json:"pay_key" binding:"required,max=10"`
CoverUploadID *uint64 `json:"cover_upload_id,string" binding:"omitempty"`
HeterotypicUploadID *uint64 `json:"heterotypic_upload_id,string" binding:"omitempty"`
}
// CreateResponse 创建红包响应
type CreateResponse struct {
ID uint64 `json:"id,string"`
}
// ClaimRequest 领取红包请求
type ClaimRequest struct {
ID uint64 `json:"id,string" binding:"required"`
}
// ClaimResponse 领取红包响应
type ClaimResponse struct {
Amount decimal.Decimal `json:"amount"`
RedEnvelope model.RedEnvelope `json:"red_envelope"`
}
// DetailResponse 红包详情响应
type DetailResponse struct {
RedEnvelope model.RedEnvelope `json:"red_envelope"`
Claims []model.RedEnvelopeClaim `json:"claims"`
UserClaimed *model.RedEnvelopeClaim `json:"user_claimed,omitempty"`
}
// ListRequest 红包列表请求
type ListRequest struct {
Page int `json:"page" binding:"required,min=1"`
PageSize int `json:"page_size" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"omitempty,oneof=sent received"`
}
// ListResponse 红包列表响应
type ListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
}
// Create 创建红包
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body CreateRequest true "创建红包请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/create [post]
func Create(c *gin.Context) {
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
if err := util.ValidateAmount(req.TotalAmount); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
// 检查红包最低金额限制(1 LDC)
if req.TotalAmount.LessThan(decimal.NewFromInt(1)) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeMinAmountRequired))
return
}
// 检查单个红包最大金额限制
maxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if req.TotalAmount.GreaterThan(maxAmount) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeAmountExceeded))
return
}
// 检查红包最大领取人数限制
maxRecipients, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxRecipients)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if req.TotalCount > maxRecipients {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeRecipientsExceeded))
return
}
// 检查每个红包平均金额不能小于0.01(避免前面领取者获得0 LDC)
perAmount := req.TotalAmount.Div(decimal.NewFromInt(int64(req.TotalCount)))
if perAmount.LessThan(decimal.NewFromFloat(0.01)) {
c.JSON(http.StatusBadRequest, util.Err(AmountTooSmall))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 检查每日红包发送数量限制
dailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 查询今日已发送的红包数量
var todayCount int64
today := time.Now().Truncate(24 * time.Hour)
if err := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
Where("creator_id = ? AND created_at >= ?", currentUser.ID, today).
Count(&todayCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if todayCount >= int64(dailyLimit) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeDailyLimitExceeded))
return
}
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
// 获取红包手续费率并计算手续费
feeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 计算手续费(红包金额 * 费率)
feeAmount := req.TotalAmount.Mul(feeRate).Round(2)
// 总扣款金额 = 红包金额 + 手续费
totalDeduction := req.TotalAmount.Add(feeAmount)
// 提前检查余额,避免不必要的事务
if currentUser.AvailableBalance.LessThan(totalDeduction) {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
return
}
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var coverUploadID *uint64
var heterotypicUploadID *uint64
if req.CoverUploadID != nil {
var coverUpload model.Upload
if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.CoverUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeCover).
First(&coverUpload).Error; err != nil {
return errors.New(InvalidCoverImage)
}
if coverUpload.Status == model.UploadStatusPending {
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", coverUpload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusUsed).Error; err != nil {
return err
}
}
coverUploadID = &coverUpload.ID
}
if req.HeterotypicUploadID != nil {
var heterotypicUpload model.Upload
if err := tx.Where("id = ? AND status IN (?, ?) AND user_id = ? AND type = ?", *req.HeterotypicUploadID, model.UploadStatusPending, model.UploadStatusUsed, currentUser.ID, model.UploadTypeHeterotypic).
First(&heterotypicUpload).Error; err != nil {
return errors.New(InvalidHeterotypicImage)
}
if heterotypicUpload.Status == model.UploadStatusPending {
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", heterotypicUpload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusUsed).Error; err != nil {
return err
}
}
heterotypicUploadID = &heterotypicUpload.ID
}
// 扣减发送者余额并更新total_payment
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: totalDeduction,
Operation: service.BalanceDeduct,
TotalField: "total_payment",
CheckBalance: true,
}); err != nil {
return err
}
// 创建红包
redEnvelope = model.RedEnvelope{
ID: idgen.NextUint64ID(),
CreatorID: currentUser.ID,
Type: req.Type,
TotalAmount: req.TotalAmount,
RemainingAmount: req.TotalAmount,
TotalCount: req.TotalCount,
RemainingCount: req.TotalCount,
Greeting: req.Greeting,
Status: model.RedEnvelopeStatusActive,
CoverUploadID: coverUploadID,
HeterotypicUploadID: heterotypicUploadID,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&redEnvelope).Error; err != nil {
return err
}
// 创建订单记录(红包支出)
remarkMsg := fmt.Sprintf("创建红包,共%d个", req.TotalCount)
if feeAmount.GreaterThan(decimal.Zero) {
remarkMsg = fmt.Sprintf("%s,手续费: %s", remarkMsg, feeAmount.String())
}
if req.Greeting != "" {
remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, req.Greeting)
}
order := model.Order{
OrderName: "红包支出",
PayerUserID: currentUser.ID,
PayeeUserID: 0,
Amount: totalDeduction,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeSend,
Remark: remarkMsg,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
}); err != nil {
if err.Error() == common.InsufficientBalance {
c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
} else if err.Error() == InvalidCoverImage || err.Error() == InvalidHeterotypicImage {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
} else {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
}
return
}
c.JSON(http.StatusOK, util.OK(CreateResponse{
ID: redEnvelope.ID,
}))
}
// Claim 领取红包
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body ClaimRequest true "领取红包请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/claim [post]
func Claim(c *gin.Context) {
var req ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var claimedAmount decimal.Decimal
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 使用 FOR UPDATE 锁定红包记录,防止并发领取
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ?", req.ID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RedEnvelopeNotFound)
}
// 捕获锁等待超时错误,返回友好提示
return errors.New(RedEnvelopeTooPopular)
}
// 检查红包状态
if redEnvelope.Status == model.RedEnvelopeStatusExpired || redEnvelope.ExpiresAt.Before(time.Now()) {
return errors.New(RedEnvelopeExpired)
}
if redEnvelope.Status == model.RedEnvelopeStatusFinished || redEnvelope.RemainingCount <= 0 {
return errors.New(RedEnvelopeFinished)
}
// 检查是否已领取
var existingClaim model.RedEnvelopeClaim
if err := tx.Where("red_envelope_id = ? AND user_id = ?", redEnvelope.ID, currentUser.ID).
First(&existingClaim).Error; err == nil {
return errors.New(RedEnvelopeAlreadyClaimed)
}
// 计算领取金额
if redEnvelope.Type == model.RedEnvelopeTypeFixed {
// 固定金额:如果是最后一个,给全部剩余金额(避免舍入误差)
if redEnvelope.RemainingCount == 1 {
claimedAmount = redEnvelope.RemainingAmount
} else {
claimedAmount = redEnvelope.TotalAmount.Div(decimal.NewFromInt(int64(redEnvelope.TotalCount))).Round(2)
}
} else {
// 拼手气红包:使用二倍均值算法
claimedAmount = calculateRandomAmount(redEnvelope.RemainingAmount, redEnvelope.RemainingCount)
}
// 创建领取记录
claim := model.RedEnvelopeClaim{
ID: idgen.NextUint64ID(),
RedEnvelopeID: redEnvelope.ID,
UserID: currentUser.ID,
Amount: claimedAmount,
}
if err := tx.Create(&claim).Error; err != nil {
return err
}
// 更新红包状态
newRemainingCount := redEnvelope.RemainingCount - 1
newRemainingAmount := redEnvelope.RemainingAmount.Sub(claimedAmount)
newStatus := redEnvelope.Status
if newRemainingCount <= 0 {
newStatus = model.RedEnvelopeStatusFinished
}
if err := tx.Model(&model.RedEnvelope{}).Where("id = ?", redEnvelope.ID).
Updates(map[string]interface{}{
"remaining_count": newRemainingCount,
"remaining_amount": newRemainingAmount,
"status": newStatus,
}).Error; err != nil {
return err
}
// 更新红包对象用于返回
redEnvelope.RemainingCount = newRemainingCount
redEnvelope.RemainingAmount = newRemainingAmount
redEnvelope.Status = newStatus
// 增加领取者余额并更新total_receive
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: currentUser.ID,
Amount: claimedAmount,
Operation: service.BalanceAdd,
TotalField: "total_receive",
}); err != nil {
return err
}
// 创建订单记录(红包收入)
order := model.Order{
OrderName: "红包收入",
PayerUserID: redEnvelope.CreatorID,
PayeeUserID: currentUser.ID,
Amount: claimedAmount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeReceive,
Remark: fmt.Sprintf("祝福语: %s", redEnvelope.Greeting),
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
}); err != nil {
errMsg := err.Error()
switch errMsg {
case RedEnvelopeNotFound:
c.JSON(http.StatusNotFound, util.Err(errMsg))
case RedEnvelopeExpired, RedEnvelopeFinished, RedEnvelopeAlreadyClaimed, CannotClaimOwnRedEnvelope:
c.JSON(http.StatusBadRequest, util.Err(errMsg))
default:
c.JSON(http.StatusInternalServerError, util.Err(errMsg))
}
return
}
var redEnvelopeView model.RedEnvelope
if err := db.DB(c.Request.Context()).
Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
Where("red_envelopes.id = ?", redEnvelope.ID).First(&redEnvelopeView).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(ClaimResponse{
Amount: claimedAmount,
RedEnvelope: redEnvelopeView,
}))
}
// GetDetail 获取红包详情
// @Tags redenvelope
// @Produce json
// @Param id path string true "红包ID"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/{id} [get]
func GetDetail(c *gin.Context) {
idStr := c.Param("id")
redEnvelopeID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeID))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).
Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
Where("red_envelopes.id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(RedEnvelopeNotFound))
return
}
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
var claims []model.RedEnvelopeClaim
db.DB(c.Request.Context()).
Select("red_envelope_claims.*, users.username, users.avatar_url").
Joins("LEFT JOIN users ON red_envelope_claims.user_id = users.id").
Where("red_envelope_claims.red_envelope_id = ?", redEnvelope.ID).
Order("red_envelope_claims.claimed_at DESC").
Find(&claims)
var userClaimed *model.RedEnvelopeClaim
if currentUser != nil {
for i := range claims {
if claims[i].UserID == currentUser.ID {
userClaimed = &claims[i]
break
}
}
}
c.JSON(http.StatusOK, util.OK(DetailResponse{
RedEnvelope: redEnvelope,
Claims: claims,
UserClaimed: userClaimed,
}))
}
// List 获取红包列表
// @Tags redenvelope
// @Accept json
// @Produce json
// @Param request body ListRequest true "列表请求"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/list [post]
func List(c *gin.Context) {
var req ListRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
query := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id")
switch req.Type {
case "sent":
query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
case "received":
query = query.Joins("INNER JOIN red_envelope_claims ON red_envelopes.id = red_envelope_claims.red_envelope_id").
Where("red_envelope_claims.user_id = ?", currentUser.ID)
default:
query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
}
var total int64
query.Count(&total)
var redEnvelopes []model.RedEnvelope
query.Order("red_envelopes.created_at DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Find(&redEnvelopes)
c.JSON(http.StatusOK, util.OK(ListResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
RedEnvelopes: redEnvelopes,
}))
}
+133
View File
@@ -0,0 +1,133 @@
/*
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 redenvelope
import (
"context"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/service"
"gorm.io/gorm"
)
// HandleRefundExpiredRedEnvelopes 处理过期红包退款的定时任务
func HandleRefundExpiredRedEnvelopes(ctx context.Context, t *asynq.Task) error {
logger.InfoF(ctx, "开始处理过期红包退款任务")
refundExpiredRedEnvelopes(ctx)
logger.InfoF(ctx, "过期红包退款任务完成")
return nil
}
// refundExpiredRedEnvelopes 退款过期红包
func refundExpiredRedEnvelopes(ctx context.Context) {
const batchSize = 100 // 每批处理100个红包
var lastID uint64 = 0
var totalProcessed int = 0
for {
// 使用游标分页查询过期红包
var expiredEnvelopes []model.RedEnvelope
if err := db.DB(ctx).
Where("id > ? AND status = ? AND expires_at < ? AND remaining_amount > 0", lastID, model.RedEnvelopeStatusActive, time.Now()).
Order("id ASC").
Limit(batchSize).
Find(&expiredEnvelopes).Error; err != nil {
logger.ErrorF(ctx, "查询过期红包失败: %v", err)
return
}
// 没有更多数据,退出循环
if len(expiredEnvelopes) == 0 {
break
}
logger.InfoF(ctx, "本批次找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
// 处理每个过期红包
for _, envelope := range expiredEnvelopes {
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新红包状态为已过期
if err := tx.Model(&model.RedEnvelope{}).
Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
Updates(map[string]interface{}{
"status": model.RedEnvelopeStatusExpired,
"remaining_amount": 0,
"remaining_count": 0,
}).Error; err != nil {
return err
}
// 退还剩余金额给创建者
if envelope.RemainingAmount.IsPositive() {
// 增加余额并减少total_payment
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: envelope.CreatorID,
Amount: envelope.RemainingAmount.Neg(),
Operation: service.BalanceDeduct,
TotalField: "total_payment",
}); err != nil {
return err
}
// 创建退款订单记录
remarkMsg := fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID)
if envelope.Greeting != "" {
remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, envelope.Greeting)
}
order := model.Order{
OrderName: "红包退款",
PayerUserID: 0,
PayeeUserID: envelope.CreatorID,
Amount: envelope.RemainingAmount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeRefund,
Remark: remarkMsg,
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&order).Error; err != nil {
return err
}
logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
}
return nil
}); err != nil {
logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
} else {
totalProcessed++
}
// 更新游标
lastID = envelope.ID
}
}
if totalProcessed > 0 {
logger.InfoF(ctx, "退款任务完成,共处理 %d 个过期红包", totalProcessed)
} else {
logger.InfoF(ctx, "没有需要退款的过期红包")
}
}
+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 redenvelope
import (
"math/rand"
"github.com/shopspring/decimal"
)
// calculateRandomAmount 二倍均值算法计算随机红包金额
func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
// 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
if count == 1 {
return remaining
}
minAmount := decimal.NewFromFloat(0.01)
// 确保剩余金额足够分配给所有人至少0.01
minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
if remaining.LessThanOrEqual(minRequired) {
// 如果剩余金额刚好或不足,每人分配0.01(确保不会出现0 LDC的情况)
return minAmount
}
// 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
avg := remaining.Div(decimal.NewFromInt(int64(count)))
maxAmount := avg.Mul(decimal.NewFromInt(2))
// 确保给其他人留下足够的金额(每人至少0.01)
maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
if maxAmount.GreaterThan(maxPossible) {
maxAmount = maxPossible
}
// 确保maxAmount不小于minAmount
if maxAmount.LessThan(minAmount) {
maxAmount = minAmount
}
// 生成随机金额 [minAmount, maxAmount]
diff := maxAmount.Sub(minAmount)
if diff.LessThanOrEqual(decimal.Zero) {
return minAmount
}
// 生成随机数:转换为分(cents)来处理,避免精度问题
diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
if diffCents <= 0 {
return minAmount
}
randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
amount := minAmount.Add(randAmount)
return amount.Round(2)
}
+28
View File
@@ -0,0 +1,28 @@
/*
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 upload
// 封面类型
const (
CoverTypeCover = "cover"
CoverTypeHeterotypic = "heterotypic"
)
// 最大文件大小
const (
MaxFileSize = 2 * 1024 * 1024 // 2MB
)
+32
View File
@@ -0,0 +1,32 @@
/*
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 upload
const (
ErrNoFileSelected = "请选择要上传的文件"
ErrInvalidCoverType = "无效的封面类型"
ErrFileTooLarge = "图片大小不能超过 2MB"
ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片"
ErrInvalidImage = "无效的图片文件"
ErrUploadExtensionsNotConfigured = "上传扩展名未配置"
ErrProcessFileFailed = "处理文件失败"
ErrSaveFileFailed = "保存文件失败"
ErrOpenFileFailed = "打开文件失败"
ErrInvalidFilePath = "非法文件路径"
ErrSaveUploadRecordFailed = "保存上传记录失败"
ErrQueryHistoryCoverFailed = "查询历史封面失败"
)
+75
View File
@@ -0,0 +1,75 @@
/*
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 upload
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/storage"
"gorm.io/gorm"
)
// ServeFileByID serves an uploaded file by its ID
// @Tags upload
// @Produce octet-stream
// @Param id path string true "Upload ID"
// @Success 200
// @Router /f/{id} [get]
func ServeFileByID(c *gin.Context) {
idStr := c.Param("id")
uploadID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID"})
return
}
var upload model.Upload
if err := db.DB(c.Request.Context()).
Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).
First(&upload).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// Retrieve file from S3 (via CDN if configured)
obj, err := storage.GetObjectViaCache(c.Request.Context(), upload.FilePath)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
// Cachefile
if obj.CachePath != "" {
c.File(obj.CachePath)
return
}
// Stream from CDN/S3
defer obj.Body.Close()
// Respond with the file content
c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil)
}
+233
View File
@@ -0,0 +1,233 @@
/*
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 upload
import (
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/storage"
"github.com/linux-do/credit/internal/util"
_ "golang.org/x/image/webp"
"gorm.io/gorm"
)
// UploadResponse 上传响应
type UploadResponse struct {
ID uint64 `json:"id,string"`
}
// UploadRedEnvelopeCover 上传红包封面
// @Tags upload
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "图片文件"
// @Param type formData string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/upload/redenvelope/cover [post]
func UploadRedEnvelopeCover(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrNoFileSelected))
return
}
// 获取封面类型
coverType := c.PostForm("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
// 验证文件大小
if file.Size > int64(MaxFileSize) {
c.JSON(http.StatusBadRequest, util.Err(ErrFileTooLarge))
return
}
// 打开上传的文件
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrOpenFileFailed))
return
}
defer src.Close()
// 验证文件确实是图片并获取尺寸
_, format, err := image.DecodeConfig(src)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidImage))
return
}
// 验证图片类型
norm := strings.ToLower(format)
if norm == "jpeg" {
norm = "jpg"
}
var sc model.SystemConfig
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyUploadAllowedExtensions); err != nil || strings.TrimSpace(sc.Value) == "" {
c.JSON(http.StatusInternalServerError, util.Err(ErrUploadExtensionsNotConfigured))
return
}
v := strings.ToLower(strings.ReplaceAll(sc.Value, " ", ""))
v = strings.ReplaceAll(v, "jpeg", "jpg")
if !strings.Contains(","+v+",", ","+norm+",") {
c.JSON(http.StatusBadRequest, util.Err(ErrUnsupportedFormat))
return
}
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 计算文件 MD5 以避免重复上传
hash := md5.New()
if _, err := io.Copy(hash, src); err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrProcessFileFailed))
return
}
md5Sum := hex.EncodeToString(hash.Sum(nil))
// 重置文件指针
src.Close()
src, _ = file.Open()
defer src.Close()
// 生成安全的文件名: 用户ID_类型_MD5.扩展名
// 使用完整 MD5 实现去重,同一用户上传相同图片会命中已有文件
safeExt := "." + format
if format == "jpeg" {
safeExt = ".jpg"
}
filename := fmt.Sprintf("%s%s", md5Sum, safeExt)
// 构建 S3 object key: {prefix}{type}/{date}/{userID}/{filename}
now := time.Now()
objectPath := fmt.Sprintf("%s/%s/%d/%s", coverType, now.Format("2006/01/02"), currentUser.ID, filename)
s3Key := storage.BuildKey(objectPath)
// validate S3 key
if err := ValidateS3Key(s3Key); err != nil {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidFilePath))
return
}
// Content type for S3
contentType := "image/" + format
var recordID uint64
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var existing model.Upload
if err := tx.Where("file_path = ?", s3Key).First(&existing).Error; err == nil {
recordID = existing.ID
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Upload to S3
if err := storage.PutObject(c.Request.Context(), s3Key, src, file.Size, contentType); err != nil {
return errors.New(ErrSaveFileFailed)
}
upload := model.Upload{
ID: idgen.NextUint64ID(),
UserID: currentUser.ID,
FilePath: s3Key,
FileSize: file.Size,
Type: coverType,
Status: model.UploadStatusPending,
}
if err := tx.Create(&upload).Error; err != nil {
// 如果数据库保存失败,尝试删除已上传的文件以避免垃圾数据
_ = storage.DeleteObject(c.Request.Context(), s3Key)
return errors.New(ErrSaveUploadRecordFailed)
}
recordID = upload.ID
return nil
}); err != nil {
if err.Error() == ErrSaveFileFailed {
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveFileFailed))
return
}
c.JSON(http.StatusInternalServerError, util.Err(ErrSaveUploadRecordFailed))
return
}
c.JSON(http.StatusOK, util.OK(UploadResponse{
ID: recordID,
}))
}
// ListRedEnvelopeCovers 获取用户历史红包封面
// @Tags redenvelope
// @Produce json
// @Param type query string true "封面类型 (cover/heterotypic)"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/covers [get]
func ListRedEnvelopeCovers(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
coverType := c.Query("type")
if coverType != CoverTypeCover && coverType != CoverTypeHeterotypic {
c.JSON(http.StatusBadRequest, util.Err(ErrInvalidCoverType))
return
}
var uploads []model.Upload
if err := db.DB(c.Request.Context()).
Where("user_id = ? AND status = ? AND type = ?",
currentUser.ID, model.UploadStatusUsed, coverType).
Order("created_at DESC").
Limit(20).
Find(&uploads).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(ErrQueryHistoryCoverFailed))
return
}
var results []UploadResponse
for _, u := range uploads {
results = append(results, UploadResponse{
ID: u.ID,
})
}
c.JSON(http.StatusOK, util.OK(results))
}
+105
View File
@@ -0,0 +1,105 @@
/*
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 upload
import (
"context"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/storage"
"gorm.io/gorm"
)
// HandleCleanupUnusedUploads 处理清理未使用上传文件的定时任务
func HandleCleanupUnusedUploads(ctx context.Context, t *asynq.Task) error {
logger.InfoF(ctx, "开始清理未使用的上传文件任务")
cleanupUnusedUploads(ctx)
logger.InfoF(ctx, "未使用上传文件清理任务完成")
return nil
}
// cleanupUnusedUploads 清理超过1小时未使用的上传文件
func cleanupUnusedUploads(ctx context.Context) {
const batchSize = 100 // 每批处理100个文件
var lastID uint64 = 0
var totalProcessed int = 0
var totalDeleted int = 0
// 计算1小时前的时间
oneHourAgo := time.Now().Add(-1 * time.Hour)
for {
// 使用游标分页查询未使用且超过1小时的上传记录
var unusedUploads []model.Upload
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&unusedUploads).Error; err != nil {
logger.ErrorF(ctx, "查询未使用的上传文件失败: %v", err)
return
}
// 没有更多数据,退出循环
if len(unusedUploads) == 0 {
break
}
logger.InfoF(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
// 处理每个未使用的上传文件
for _, upload := range unusedUploads {
totalProcessed++
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新上传记录状态
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", upload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusDeleted).Error; err != nil {
return err
}
// Delete from S3
if err := storage.DeleteObject(ctx, upload.FilePath); err != nil {
return err
}
return nil
}); err != nil {
logger.ErrorF(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
lastID = upload.ID
continue
}
totalDeleted++
logger.InfoF(ctx, "成功清理上传文件 [ID:%d, Path:%s, Size:%d bytes]", upload.ID, upload.FilePath, upload.FileSize)
// 更新游标
lastID = upload.ID
}
}
if totalDeleted > 0 {
logger.InfoF(ctx, "清理任务完成,共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
} else {
logger.InfoF(ctx, "没有需要清理的上传文件")
}
}
+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 upload
import (
"fmt"
"strings"
)
const maxS3KeyLength = 1024
// ValidateS3Key validates an S3 object key for safety.
func ValidateS3Key(key string) error {
if key == "" {
return fmt.Errorf("s3 key must not be empty")
}
if len(key) > maxS3KeyLength {
return fmt.Errorf("s3 key exceeds maximum length of %d", maxS3KeyLength)
}
if strings.HasPrefix(key, "/") {
return fmt.Errorf("s3 key must not start with /")
}
if strings.Contains(key, "\x00") {
return fmt.Errorf("s3 key must not contain null bytes")
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
/*
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 user
const (
// linuxDoAPIRateLimitKey Redis 限流 Key
linuxDoAPIRateLimitKey = "linux_do:api:rate_limit"
)
+21
View File
@@ -0,0 +1,21 @@
/*
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 user
const (
EncryptPayKeyFailed = "加密支付密码失败"
)
+65
View File
@@ -0,0 +1,65 @@
/*
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 user
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
)
// UpdatePayKeyRequest 更新支付密钥请求
type UpdatePayKeyRequest struct {
PayKey string `json:"pay_key" binding:"required,max=6"`
}
// UpdatePayKey 更新用户支付密钥
// @Tags user
// @Accept json
// @Produce json
// @Param request body UpdatePayKeyRequest true "request body"
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/user/pay-key [put]
func UpdatePayKey(c *gin.Context) {
var req UpdatePayKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
encryptedPayKey, err := util.Encrypt(user.SignKey, req.PayKey)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(EncryptPayKeyFailed))
return
}
if err := db.DB(c.Request.Context()).
Model(&model.User{}).
Where("id = ?", user.ID).
Update("pay_key", encryptedPayKey).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OKNil())
}
+279
View File
@@ -0,0 +1,279 @@
/*
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 user
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis_rate/v10"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
var (
linuxDoRateLimiter *redis_rate.Limiter
rateLimitKey string
)
func init() {
linuxDoRateLimiter = redis_rate.NewLimiter(db.Redis)
rateLimitKey = db.PrefixedKey(linuxDoAPIRateLimitKey)
}
// waitForRateLimit 等待获取限流令牌(阻塞直到获取到令牌)
func waitForRateLimit(ctx context.Context, key string, limit redis_rate.Limit) error {
for {
res, err := linuxDoRateLimiter.Allow(ctx, key, limit)
if err != nil {
return fmt.Errorf("redis 限流器错误: %w", err)
}
if res.Allowed > 0 {
return nil // 获取到令牌
}
// 未获取到令牌,等待后重试
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(res.RetryAfter):
// 继续重试
}
}
}
// HandleUpdateUserGamificationScores 处理所有用户积分更新任务
func HandleUpdateUserGamificationScores(ctx context.Context, t *asynq.Task) error {
rateLimit := config.Config.Worker.GamificationScoreRateLimit
limit := redis_rate.Limit{
Rate: rateLimit.Rate,
Burst: rateLimit.Rate,
Period: time.Duration(rateLimit.Period) * time.Second,
}
page := 0
totalProcessed := 0
for {
if err := waitForRateLimit(ctx, rateLimitKey, limit); err != nil {
logger.ErrorF(ctx, "速率限制等待失败: %v", err)
return err
}
leaderboard, err := model.GetLeaderboard(ctx, page)
if err != nil {
logger.ErrorF(ctx, "获取排行榜第 %d 页失败: %v", page, err)
return err
}
if len(leaderboard.Users) == 0 {
logger.InfoF(ctx, "[调度] 排行榜数据处理完成,共处理 %d 个用户", totalProcessed)
break
}
if err = enqueueBatchScoreTask(ctx, leaderboard.Users); err != nil {
logger.ErrorF(ctx, "下发第 %d 页批量任务失败: %v", page, err)
return err
}
totalProcessed += len(leaderboard.Users)
logger.InfoF(ctx, "[调度] 已处理排行榜第 %d 页,本页 %d 个用户,累计 %d 个用户",
page, len(leaderboard.Users), totalProcessed)
page++
}
return nil
}
// enqueueBatchScoreTask 下发批量积分更新任务
func enqueueBatchScoreTask(ctx context.Context, userScores []model.LeaderboardUser) error {
payload, _ := json.Marshal(map[string]interface{}{
"user_scores": userScores,
})
opts := []asynq.Option{
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
}
if _, err := scheduler.AsynqClient.Enqueue(asynq.NewTask(task.UpdateSingleUserGamificationScoreTask, payload), opts...); err != nil {
logger.ErrorF(ctx, "下发批量积分任务失败: %v", err)
return err
}
return nil
}
// batchUpdateUserScores 批量更新用户积分
func batchUpdateUserScores(ctx context.Context, userScores []model.LeaderboardUser) error {
if len(userScores) == 0 {
return nil
}
userIDs := make([]uint64, len(userScores))
scoreMap := make(map[uint64]int64, len(userScores))
for i, u := range userScores {
userIDs[i] = u.ID
scoreMap[u.ID] = u.TotalScore
}
users, err := model.GetByIDs(db.DB(ctx), userIDs)
if err != nil {
return fmt.Errorf("批量查询用户失败: %w", err)
}
protectionDays, err := model.GetIntByKey(ctx, model.ConfigKeyNewUserProtectionDays)
if err != nil {
return fmt.Errorf("%s: %w", common.GetProtectionDaysFailed, err)
}
now := time.Now()
return db.DB(ctx).Transaction(func(tx *gorm.DB) error {
for _, user := range users {
newScore, exists := scoreMap[user.ID]
if !exists {
continue
}
newCommunityBalance := decimal.NewFromInt(newScore)
oldCommunityBalance := user.CommunityBalance
diff := newCommunityBalance.Sub(oldCommunityBalance)
createOrder := func(amount decimal.Decimal, remark string) error {
order := model.Order{
OrderName: "社区积分更新",
PayerUserID: 0,
PayeeUserID: user.ID,
Amount: amount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeCommunity,
Remark: remark,
TradeTime: now,
ExpiresAt: now,
}
if err = tx.Create(&order).Error; err != nil {
return fmt.Errorf("创建用户[%s]订单失败: %w", user.Username, err)
}
return nil
}
if user.CommunityBalance.IsZero() && user.TotalCommunity.IsZero() {
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
}).Error; err != nil {
return fmt.Errorf("初始化用户[%s]社区积分失败: %w", user.Username, err)
}
logger.InfoF(ctx, "用户[%s]首次同步社区积分: %s", user.Username, newCommunityBalance.String())
continue
}
// 积分未变化
if diff.IsZero() {
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s", oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(decimal.Zero, remark); err != nil {
return err
}
continue
}
// 新用户保护期检查
if diff.IsNegative() && protectionDays > 0 {
registeredDays := int(time.Since(user.CreatedAt).Hours() / 24)
if registeredDays < protectionDays {
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
}).Error; err != nil {
return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err)
}
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s(保护期内,跳过扣分)",
oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(decimal.Zero, remark); err != nil {
return err
}
logger.InfoF(ctx, "用户[%s]在保护期内,积分下降%s,跳过扣分", user.Username, diff.Abs().String())
continue
}
}
// 更新用户积分
if err = tx.Model(&user).UpdateColumns(map[string]interface{}{
"community_balance": newCommunityBalance,
"total_community": gorm.Expr("total_community + ?", diff),
"total_receive": gorm.Expr("total_receive + ?", diff),
"available_balance": gorm.Expr("available_balance + ?", diff),
}).Error; err != nil {
return fmt.Errorf("更新用户[%s]积分失败: %w", user.Username, err)
}
remark := fmt.Sprintf("社区积分从 %s 更新到 %s,变化 %s",
oldCommunityBalance.String(), newCommunityBalance.String(), diff.String())
if err = createOrder(diff, remark); err != nil {
return err
}
}
return nil
})
}
// HandleUpdateSingleUserGamificationScore 处理用户积分更新任务
func HandleUpdateSingleUserGamificationScore(ctx context.Context, t *asynq.Task) error {
var payload struct {
UserID uint64 `json:"user_id"`
UserScores []model.LeaderboardUser `json:"user_scores"`
}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("解析任务参数失败: %w", err)
}
if len(payload.UserScores) > 0 {
return batchUpdateUserScores(ctx, payload.UserScores)
}
if payload.UserID > 0 {
var user model.User
if err := user.GetByID(db.DB(ctx), payload.UserID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.InfoF(ctx, "用户[%d]不存在,跳过积分更新", payload.UserID)
return nil
}
return fmt.Errorf("查询用户[%d]失败: %w", payload.UserID, err)
}
response, errGet := user.GetUserGamificationScore(ctx)
if errGet != nil {
logger.ErrorF(ctx, "处理用户[%s]失败: %v", user.Username, errGet)
return errGet
}
return batchUpdateUserScores(ctx, []model.LeaderboardUser{
{ID: payload.UserID, TotalScore: response.User.GamificationScore},
})
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
/*
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 cmd
import (
"github.com/linux-do/credit/internal/router"
"github.com/spf13/cobra"
)
var apiCmd = &cobra.Command{
Use: "api",
Short: "credit API",
Run: func(cmd *cobra.Command, args []string) {
router.Serve()
},
}
+57
View File
@@ -0,0 +1,57 @@
/*
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 cmd
import (
"log"
"github.com/linux-do/credit/internal/db/migrator"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "linux-do-credit",
PreRun: func(cmd *cobra.Command, args []string) {
migrator.Migrate()
},
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
log.Fatalf("[CMD] please provide a command\n")
}
appMode := args[0]
switch appMode {
case "api":
apiCmd.Run(apiCmd, args)
case "scheduler":
schedulerCmd.Run(schedulerCmd, args)
case "worker":
workerCmd.Run(workerCmd, args)
default:
log.Fatal("[CMD] unknown app mode\n")
}
},
}
func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatalf("[CMD] execute failed; %s\n", err)
}
}
+36
View File
@@ -0,0 +1,36 @@
/*
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 cmd
import (
"log"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/spf13/cobra"
)
var schedulerCmd = &cobra.Command{
Use: "scheduler",
Short: "credit Scheduler",
Run: func(cmd *cobra.Command, args []string) {
log.Println("[Scheduler] 启动定时任务调度服务")
if err := scheduler.StartScheduler(); err != nil {
log.Fatalf("[调度器] 启动失败: %v", err)
}
},
}
+36
View File
@@ -0,0 +1,36 @@
/*
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 cmd
import (
"log"
"github.com/linux-do/credit/internal/task/worker"
"github.com/spf13/cobra"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "credit Worker",
Run: func(cmd *cobra.Command, args []string) {
log.Println("[Worker] 启动任务处理服务")
if err := worker.StartWorker(); err != nil {
log.Fatalf("[工作器] 启动失败: %v", err)
}
},
}
+24
View File
@@ -0,0 +1,24 @@
/*
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 common
const (
// PayTypeLDCPay Linux Do Credit 支付类型标识
PayTypeLDCPay = "ldcpay"
// PayTypeEPay Epay 支付类型
PayTypeEPay = "epay"
)
+41
View File
@@ -0,0 +1,41 @@
/*
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 common
const (
BannedAccount = "账号已被封禁"
AmountMustBeGreaterThanZero = "金额必须大于0"
AmountDecimalPlacesExceeded = "金额小数位数不能超过2位"
RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间"
RateDecimalPlacesExceeded = "比率小数位数不能超过2位"
InsufficientBalance = "余额不足"
DailyLimitExceeded = "已超过每日限额"
PayKeyIncorrect = "支付密钥错误"
CannotPaySelf = "不能给自己付款"
TestModeCannotProcessOrder = "测试模式下无法处理订单"
TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款"
UnAuthorized = "未登录"
RedEnvelopeDisabled = "红包功能未启用"
RedEnvelopeAmountExceeded = "红包金额超过单个红包最大限额"
RedEnvelopeDailyLimitExceeded = "今日发红包数量已达上限"
RedEnvelopeRecipientsExceeded = "红包个数超过最大可领取人数上限"
RedEnvelopeMinAmountRequired = "红包总金额不能低于1LDC"
)
const (
GetProtectionDaysFailed = "获取新用户保护期配置失败"
)
+66
View File
@@ -0,0 +1,66 @@
/*
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 config
import (
"encoding/json"
"log"
"os"
"github.com/spf13/viper"
)
var Config *configModel
func init() {
// 加载配置文件路径
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "config.yaml"
}
// 设置配置文件
viper.SetConfigFile(configPath)
viper.AutomaticEnv()
// 读取配置文件
if err := viper.ReadInConfig(); err != nil {
log.Fatalf("[Config] read config failed: %v\n", err)
}
// 解析配置到结构体
var c configModel
if err := viper.Unmarshal(&c); err != nil {
log.Fatalf("[Config] parse config failed: %v\n", err)
}
// 设置全局配置
Config = &c
// 打印配置
printConfig(&c)
}
// printConfig 打印配置内容
func printConfig(c *configModel) {
configJSON, err := json.MarshalIndent(c, "", " ")
if err != nil {
log.Printf("[Config] failed to marshal config: %v\n", err)
return
}
log.Printf("[Config] loaded configuration:\n%s\n", string(configJSON))
}
+217
View File
@@ -0,0 +1,217 @@
/*
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 config
import "time"
type configModel struct {
App appConfig `mapstructure:"app"`
OAuth2 OAuth2Config `mapstructure:"oauth2"`
Database databaseConfig `mapstructure:"database"`
Redis redisConfig `mapstructure:"redis"`
Log logConfig `mapstructure:"log"`
Scheduler schedulerConfig `mapstructure:"scheduler"`
Worker workerConfig `mapstructure:"worker"`
ClickHouse clickHouseConfig `mapstructure:"clickhouse"`
LinuxDo linuxDoConfig `mapstructure:"linuxdo"`
OpenAPIRisk openAPIRiskConfig `mapstructure:"openapi_risk"`
Otel otelConfig `mapstructure:"otel"`
S3 s3Config `mapstructure:"s3"`
}
// appConfig 应用基本配置
type appConfig struct {
AppName string `mapstructure:"app_name"`
Env string `mapstructure:"env"`
Addr string `mapstructure:"addr"`
NodeID int64 `mapstructure:"node_id"`
APIPrefix string `mapstructure:"api_prefix"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
FrontendURL string `mapstructure:"frontend_url"`
FrontendPayURL string `mapstructure:"frontend_pay_url"`
SessionCookieName string `mapstructure:"session_cookie_name"`
SessionSecret string `mapstructure:"session_secret"`
SessionDomain string `mapstructure:"session_domain"`
SessionAge int `mapstructure:"session_age"`
SessionHttpOnly bool `mapstructure:"session_http_only"`
SessionSecure bool `mapstructure:"session_secure"`
}
// IsProduction 检查当前环境是否为生产环境
func (a *appConfig) IsProduction() bool {
return a.Env == "production"
}
// OAuth2Config OAuth2/OIDC认证配置
type OAuth2Config struct {
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
RedirectURI string `mapstructure:"redirect_uri"`
Issuer string `mapstructure:"issuer"`
AuthorizationEndpoint string `mapstructure:"authorization_endpoint"`
TokenEndpoint string `mapstructure:"token_endpoint"`
UserEndpoint string `mapstructure:"user_endpoint"`
}
// databaseConfig 数据库配置
type databaseConfig struct {
Enabled bool `mapstructure:"enabled"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxIdleConn int `mapstructure:"max_idle_conn"`
MaxOpenConn int `mapstructure:"max_open_conn"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
LogLevel string `mapstructure:"log_level"`
SSLMode string `mapstructure:"ssl_mode"`
TimeZone string `mapstructure:"time_zone"`
ApplicationName string `mapstructure:"application_name"`
SearchPath string `mapstructure:"search_path"`
PreferSimpleProtocol bool `mapstructure:"prefer_simple_protocol"`
StatementCacheCapacity int `mapstructure:"statement_cache_capacity"`
DefaultQueryExecMode string `mapstructure:"default_query_exec_mode"`
Replicas []databaseReplicaConfig `mapstructure:"replicas"`
SlowThreshold time.Duration `mapstructure:"slow_threshold"`
}
// databaseReplicaConfig 只读副本配置
type databaseReplicaConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// clickhouse 配置
type clickHouseConfig struct {
Enabled bool `mapstructure:"enabled"`
Hosts []string `mapstructure:"hosts"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
MaxIdleConn int `mapstructure:"max_idle_conn"`
MaxOpenConn int `mapstructure:"max_open_conn"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
DialTimeout int `mapstructure:"dial_timeout"`
BlockBufferSize uint8 `mapstructure:"block_buffer_size"`
}
// redisConfig Redis配置
type redisConfig struct {
Enabled bool `mapstructure:"enabled"`
Addrs []string `mapstructure:"addrs"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
ClusterMode bool `mapstructure:"cluster_mode"`
MasterName string `mapstructure:"master_name"`
KeyPrefix string `mapstructure:"key_prefix"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConn int `mapstructure:"min_idle_conn"`
DialTimeout int `mapstructure:"dial_timeout"`
ReadTimeout int `mapstructure:"read_timeout"`
WriteTimeout int `mapstructure:"write_timeout"`
MaxRetries int `mapstructure:"max_retries"`
PoolTimeout int `mapstructure:"pool_timeout"`
ConnMaxIdleTime int `mapstructure:"conn_max_idle_time"`
}
// logConfig 日志配置
type logConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
Output string `mapstructure:"output"`
FilePath string `mapstructure:"file_path"`
MaxSize int `mapstructure:"max_size"`
MaxAge int `mapstructure:"max_age"`
MaxBackups int `mapstructure:"max_backups"`
Compress bool `mapstructure:"compress"`
}
// schedulerConfig 定时任务配置
type schedulerConfig struct {
UpdateUserGamificationScoresTaskCron string `mapstructure:"update_user_gamification_scores_task_cron"`
DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"`
AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"`
SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"`
RefundExpiredRedEnvelopesTaskCron string `mapstructure:"refund_expired_red_envelopes_task_cron"`
CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
SettlePendingPaymentsTaskCron string `mapstructure:"settle_pending_payments_task_cron"`
}
// workerConfig 工作配置
type workerConfig struct {
Concurrency int `mapstructure:"concurrency"`
StrictPriority bool `mapstructure:"strict_priority"`
Queues []QueueConfig `mapstructure:"queues"`
GamificationScoreRateLimit RateLimitConfig `mapstructure:"gamification_score_rate_limit"`
}
// QueueConfig 队列配置
type QueueConfig struct {
Name string `mapstructure:"name"`
Priority int `mapstructure:"priority"`
}
// RateLimitConfig 速率限制配置(N次/M秒)
type RateLimitConfig struct {
Rate int `mapstructure:"rate"` // 允许的请求次数
Period int `mapstructure:"period"` // 时间周期(秒)
}
// linuxDoConfig
type linuxDoConfig struct {
ApiKey string `mapstructure:"api_key"`
}
// openAPIRiskConfig OpenAPI 用户风险配置
type openAPIRiskConfig struct {
Enabled bool `mapstructure:"enabled"`
BaseURL string `mapstructure:"base_url"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password" json:"-"`
CacheTTLSeconds int `mapstructure:"cache_ttl_seconds"`
PromptRiskLevels []string `mapstructure:"prompt_risk_levels"`
BlockRiskLevels []string `mapstructure:"block_risk_levels"`
}
// otelConfig OpenTelemetry 配置
type otelConfig struct {
SamplingRate float64 `mapstructure:"sampling_rate"`
}
// s3Config S3 compatible storage configuration
type s3Config struct {
Enabled bool `mapstructure:"enabled"`
Endpoint string `mapstructure:"endpoint"`
Region string `mapstructure:"region"`
Bucket string `mapstructure:"bucket"`
AccessKeyID string `mapstructure:"access_key_id" json:"-"`
SecretAccessKey string `mapstructure:"secret_access_key" json:"-"`
PathStyle bool `mapstructure:"path_style"`
KeyPrefix string `mapstructure:"key_prefix"`
CdnURL string `mapstructure:"cdn_url"`
LocalCache localCacheConfig `mapstructure:"local_cache"`
}
type localCacheConfig struct {
Enabled bool `mapstructure:"enabled"`
CacheDir string `mapstructure:"cache_dir"`
}
+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
}
+157
View File
@@ -0,0 +1,157 @@
/*
* MIT License
*
* Copyright (c) 2025 linux.do
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package listener
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
"github.com/redis/go-redis/v9"
)
// orderExpireKeyPrefix 订单过期 Key 前缀
const orderExpireKeyPrefix = "payment:order:expire:"
// StartExpireListener 启动过期监听器
func StartExpireListener(ctx context.Context) error {
if db.Redis == nil {
return fmt.Errorf("redis client is not initialized")
}
// 初始化时先处理已过期的订单
model.ExpirePendingOrders(ctx)
cfg := config.Config.Redis
// Cluster 模式:订阅所有分片节点
if cfg.ClusterMode {
clusterClient, ok := db.Redis.(*redis.ClusterClient)
if !ok {
return fmt.Errorf("redis client is not a ClusterClient")
}
return startClusterExpireListener(ctx, clusterClient)
}
// Standalone/Sentinel 模式
return startStandaloneExpireListener(ctx, cfg.DB)
}
// startStandaloneExpireListener Standalone/Sentinel 模式的过期监听
func startStandaloneExpireListener(ctx context.Context, dbIndex int) error {
// 确保 Redis 开启 keyspace notifications
configResult := db.Redis.ConfigSet(ctx, "notify-keyspace-events", "Ex")
if configResult.Err() != nil {
log.Printf("[Expire Listener] 警告: 设置 keyspace notifications 失败: %v", configResult.Err())
log.Printf("[Expire Listener] 请手动执行: CONFIG SET notify-keyspace-events Ex")
return configResult.Err()
}
// 订阅过期事件频道
expiredChannel := fmt.Sprintf("__keyevent@%d__:expired", dbIndex)
pubSub := db.Redis.Subscribe(ctx, expiredChannel)
go subscribeExpireEvents(ctx, pubSub, expiredChannel)
return nil
}
// startClusterExpireListener Cluster 模式的过期监听
// 需要订阅所有分片节点的过期事件
func startClusterExpireListener(ctx context.Context, clusterClient *redis.ClusterClient) error {
expiredChannel := "__keyevent@0__:expired"
err := clusterClient.ForEachShard(ctx, func(ctx context.Context, shard *redis.Client) error {
// 为每个分片设置 keyspace notifications
if err := shard.ConfigSet(ctx, "notify-keyspace-events", "Ex").Err(); err != nil {
log.Printf("[Expire Listener] 警告: 分片 %s 设置 keyspace notifications 失败: %v",
shard.Options().Addr, err)
// 继续处理其他分片
}
// 订阅该分片的过期事件
pubSub := shard.Subscribe(ctx, expiredChannel)
go subscribeExpireEvents(ctx, pubSub, fmt.Sprintf("%s (shard: %s)", expiredChannel, shard.Options().Addr))
return nil
})
return err
}
// subscribeExpireEvents 订阅并处理过期事件
func subscribeExpireEvents(ctx context.Context, pubSub *redis.PubSub, channelDesc string) {
defer pubSub.Close()
log.Printf("[Expire Listener] 过期监听器已启动,监听频道: %s", channelDesc)
for {
msg, err := pubSub.ReceiveMessage(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
log.Printf("[Expire Listener] 监听器已停止: %s", channelDesc)
return
}
logger.ErrorF(ctx, "接收 Redis 过期事件失败: %v", err)
continue
}
// 处理过期事件
handleExpiredKey(ctx, msg.Payload)
}
}
// handleExpiredKey 处理过期的 Redis key
func handleExpiredKey(ctx context.Context, expiredKey string) {
fullPrefix := db.PrefixedKey(orderExpireKeyPrefix)
// 只处理订单过期相关的 key
if !strings.HasPrefix(expiredKey, fullPrefix) {
return
}
orderIDStr := strings.TrimPrefix(expiredKey, fullPrefix)
orderID, err := strconv.ParseUint(orderIDStr, 10, 64)
if err != nil {
logger.ErrorF(ctx, "解析订单ID失败: key=%s, error=%v", expiredKey, err)
return
}
// 更新订单状态为过期
result := db.DB(ctx).Model(&model.Order{}).
Where("id = ? AND status = ?", orderID, model.OrderStatusPending).
Update("status", model.OrderStatusExpired)
if result.Error != nil {
logger.ErrorF(ctx, "更新订单状态为过期失败: order_id=%d, error=%v", orderID, result.Error)
} else if result.RowsAffected > 0 {
logger.InfoF(ctx, "订单已过期: order_id=%d", orderID)
}
}
+68
View File
@@ -0,0 +1,68 @@
/*
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 logger
import (
"context"
"fmt"
"log"
"github.com/uptrace/opentelemetry-go-extra/otelzap"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var logger *otelzap.Logger
func init() {
logWriter, err := GetLogWriter()
if err != nil {
log.Fatalf("[Logger] get log writer err: %v\n", err)
}
zapLogger := zap.New(
zapcore.NewCore(getEncoder(), logWriter, getLogLevel()),
zap.AddCaller(),
zap.AddCallerSkip(1),
)
logger = otelzap.New(
zapLogger,
otelzap.WithMinLevel(zapLogger.Level()),
)
fmt.Printf("[Logger] %s\n", logger.Level())
}
func DebugF(ctx context.Context, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
logger.Ctx(ctx).Debug(msg, getTraceIDFields(ctx)...)
}
func InfoF(ctx context.Context, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
logger.Ctx(ctx).Info(msg, getTraceIDFields(ctx)...)
}
func WarnF(ctx context.Context, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
logger.Ctx(ctx).Warn(msg, getTraceIDFields(ctx)...)
}
func ErrorF(ctx context.Context, format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
logger.Ctx(ctx).Error(msg, getTraceIDFields(ctx)...)
}
+124
View File
@@ -0,0 +1,124 @@
/*
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 logger
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"github.com/linux-do/credit/internal/config"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
logWriter zapcore.WriteSyncer
initLogWriterOnce sync.Once
initLogWriterErr error
)
// GetLogWriter 获取日志输出写入器
func GetLogWriter() (zapcore.WriteSyncer, error) {
initLogWriterOnce.Do(func() {
logWriter, initLogWriterErr = initWriter()
})
return logWriter, initLogWriterErr
}
func initWriter() (zapcore.WriteSyncer, error) {
logConfig := config.Config.Log
if logConfig.Output == "file" {
// 初始化日志目录
logPath := logConfig.FilePath
logDir := filepath.Dir(logPath)
if err := os.MkdirAll(logDir, 0750); err != nil {
return nil, fmt.Errorf("[Logger] create log file dir err: %w", err)
}
// 配置日志轮转
logOutput := &lumberjack.Logger{
Filename: logPath,
MaxSize: logConfig.MaxSize,
MaxBackups: logConfig.MaxBackups,
MaxAge: logConfig.MaxAge,
Compress: logConfig.Compress,
}
return zapcore.AddSync(logOutput), nil
}
return zapcore.AddSync(os.Stdout), nil
}
// getEncoder 获取日志编码器
func getEncoder() zapcore.Encoder {
// 编码器配置
encoderConfig := zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
if config.Config.Log.Format == "json" {
return zapcore.NewJSONEncoder(encoderConfig)
}
return zapcore.NewConsoleEncoder(encoderConfig)
}
// getLogLevel 获取日志级别
func getLogLevel() zapcore.Level {
level := config.Config.Log.Level
switch level {
case "debug":
return zapcore.DebugLevel
case "info":
return zapcore.InfoLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
log.Fatalf("[Logger] invalid log level: %s\n", level)
return zapcore.InfoLevel
}
}
func getTraceIDFields(ctx context.Context) []zap.Field {
span := trace.SpanFromContext(ctx)
spanContext := span.SpanContext()
return []zap.Field{
zap.String("traceID", spanContext.TraceID().String()),
zap.String("spanID", spanContext.SpanID().String()),
}
}
+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)
}
+26
View File
@@ -0,0 +1,26 @@
/*
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 otel_trace
import "go.opentelemetry.io/otel/propagation"
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
+31
View File
@@ -0,0 +1,31 @@
/*
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 otel_trace
import (
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// ParentBasedErrorAwareSampler 创建父级感知的概率采样器
// - 如果父 Span 已采样,则子 Span 也采样
// - 如果父 Span 未采样,则子 Span 也不采样
// - 如果是根 Span,按 samplingRate 概率采样
func ParentBasedErrorAwareSampler(samplingRate float64) sdktrace.Sampler {
return sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(samplingRate),
)
}
+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 otel_trace
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"log"
)
var Tracer trace.Tracer
var shutdownFuncs []func(context.Context) error
func init() {
// 初始化 Propagator
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// 初始化 Trace Provider
tracerProvider, err := newTracerProvider()
if err != nil {
log.Fatalf("[Trace] init trace provider failed: %v", err)
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// 初始化 Tracer
Tracer = tracerProvider.Tracer("github.com/linux-do/credit")
}
func Shutdown(ctx context.Context) {
for _, fn := range shutdownFuncs {
_ = fn(ctx)
}
shutdownFuncs = nil
}
func Start(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return Tracer.Start(ctx, name, opts...)
}
+66
View File
@@ -0,0 +1,66 @@
/*
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 otel_trace
import (
"context"
"os"
"github.com/linux-do/credit/internal/config"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func newTracerProvider() (*sdktrace.TracerProvider, error) {
// 获取主机名和容器信息
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
// 初始化 Resource
r, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(config.Config.App.AppName),
semconv.HostName(hostname),
semconv.K8SNamespaceName(os.Getenv("KUBERNETES_NAMESPACE")),
semconv.K8SPodName(os.Getenv("KUBERNETES_POD_NAME")),
semconv.K8SPodUID(os.Getenv("KUBERNETES_POD_UID")),
),
)
if err != nil {
return nil, err
}
// 初始化 Exporter
traceExporter, err := otlptracegrpc.New(context.Background())
if err != nil {
return nil, err
}
// 初始化 Trace
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExporter),
sdktrace.WithResource(r),
sdktrace.WithSampler(ParentBasedErrorAwareSampler(config.Config.Otel.SamplingRate)),
)
return tracerProvider, nil
}
+78
View File
@@ -0,0 +1,78 @@
/*
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 router
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/otel_trace"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func loggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 初始化 Trace
ctx, span := otel_trace.Start(c.Request.Context(), "LoggerMiddleware")
defer span.End()
// 开始计时
start := time.Now()
// 记录请求路径和 Query
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
if raw != "" {
path = path + "?" + raw
}
// 执行请求
c.Next()
// 停止计时
end := time.Now()
latency := end.Sub(start)
// 打印日志
// 排除健康检查接口
healthPath := config.Config.App.APIPrefix + "/v1/health"
if c.Request.URL.Path != healthPath {
logger.InfoF(
ctx,
"[LoggerMiddleware] %s %s\nStartTime: %s\nEndTime: %s\nLatency: %d\nClientIP: %s\nResponse: %d %d",
c.Request.Method,
path,
start.Format(time.RFC3339),
end.Format(time.RFC3339),
latency.Milliseconds(),
c.ClientIP(),
c.Writer.Status(),
c.Writer.Size(),
)
}
// 设置 Span 状态
if c.Writer.Status() >= 400 {
span := trace.SpanFromContext(ctx)
span.SetStatus(codes.Error, strconv.Itoa(c.Writer.Status()))
}
}
}
+311
View File
@@ -0,0 +1,311 @@
/*
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 router
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/linux-do/credit/internal/apps/admin"
admin_task "github.com/linux-do/credit/internal/apps/admin/task"
admin_user "github.com/linux-do/credit/internal/apps/admin/user"
publicconfig "github.com/linux-do/credit/internal/apps/config"
"github.com/linux-do/credit/internal/apps/dispute"
"github.com/linux-do/credit/internal/apps/health"
"github.com/linux-do/credit/internal/apps/merchant/api_key"
"github.com/linux-do/credit/internal/apps/merchant/link"
"github.com/linux-do/credit/internal/apps/redenvelope"
"github.com/linux-do/credit/internal/apps/upload"
"github.com/linux-do/credit/internal/listener"
"github.com/linux-do/credit/internal/util"
"github.com/linux-do/credit/internal/apps/payment"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
_ "github.com/linux-do/credit/docs"
"github.com/linux-do/credit/internal/apps/admin/system_config"
"github.com/linux-do/credit/internal/apps/admin/user_pay_config"
"github.com/linux-do/credit/internal/apps/dashboard"
"github.com/linux-do/credit/internal/apps/leaderboard"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/apps/order"
"github.com/linux-do/credit/internal/apps/user"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/otel_trace"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
func Serve() {
// 运行模式
if config.Config.App.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
// 初始化路由
r := gin.New()
r.Use(gin.Recovery())
cfg := config.Config.Redis
addrs := cfg.Addrs
sessionAddr := "localhost:6379"
if len(addrs) > 0 {
sessionAddr = addrs[0]
}
sessionStore, err := redis.NewStoreWithDB(
cfg.MinIdleConn,
"tcp",
sessionAddr,
cfg.Username,
cfg.Password,
strconv.Itoa(cfg.DB),
[]byte(config.Config.App.SessionSecret),
)
if err != nil {
log.Fatalf("[API] init session store failed: %v\n", err)
}
// 设置 Session Redis Key 前缀
if cfg.KeyPrefix != "" {
if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil {
log.Printf("[API] set session key prefix failed: %v\n", err)
}
}
sessionStore.Options(util.GetSessionOptions(config.Config.App.SessionAge))
r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore))
// 补充中间件
r.Use(otelgin.Middleware(config.Config.App.AppName), loggerMiddleware())
// 支付接口
r.Match([]string{"GET", "POST"}, "/pay/submit.php", payment.RequireSignatureAuth(), payment.CreateMerchantOrder)
// 查询订单
r.GET("/api.php", payment.QueryMerchantOrder)
// 退款接口
r.POST("/api.php", payment.RefundMerchantOrder)
// 商户分发接口
r.POST("/pay/distribute", payment.RequireMerchantAuth(), payment.MerchantDistribute)
// Serve files by ID
r.GET("/f/:id", upload.ServeFileByID)
apiGroup := r.Group(config.Config.App.APIPrefix)
{
if !config.Config.App.IsProduction() {
// Swagger
apiGroup.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// API V1
apiV1Router := apiGroup.Group("/v1")
{
// Health
apiV1Router.GET("/health", health.Health)
// OAuth
apiV1Router.GET("/oauth/login", oauth.GetLoginURL)
apiV1Router.GET("/oauth/logout", oauth.Logout)
apiV1Router.POST("/oauth/callback", oauth.Callback)
apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo)
// User
userRouter := apiV1Router.Group("/user")
userRouter.Use(oauth.LoginRequired())
{
userRouter.PUT("/pay-key", user.UpdatePayKey)
}
// Dashboard
dashboardRouter := apiV1Router.Group("/dashboard")
dashboardRouter.Use(oauth.LoginRequired())
{
dashboardRouter.GET("/stats/daily", dashboard.GetDailyStats)
dashboardRouter.GET("/stats/top-customers", dashboard.GetTopCustomers)
}
apiV1Router.GET("/dashboard/stats/user-balance", dashboard.GetUserBalanceStats)
// Leaderboard
leaderboardRouter := apiV1Router.Group("/leaderboard")
leaderboardRouter.Use(oauth.LoginRequired())
{
leaderboardRouter.GET("", leaderboard.List)
leaderboardRouter.GET("/me", leaderboard.GetMyRank)
leaderboardRouter.GET("/users/:id", leaderboard.GetUserRankByID)
}
// Order
orderRouter := apiV1Router.Group("/order")
orderRouter.Use(oauth.LoginRequired())
{
orderRouter.POST("/transactions", order.ListTransactions)
orderRouter.POST("/dispute", dispute.CreateDispute)
orderRouter.POST("/disputes/merchant", dispute.ListMerchantDisputes)
orderRouter.POST("/disputes", dispute.ListDisputes)
orderRouter.POST("/refund-review", dispute.RefundReview)
orderRouter.POST("/dispute/close", dispute.CloseDispute)
}
// Payment
paymentRouter := apiV1Router.Group("/payment")
paymentRouter.Use(oauth.LoginRequired())
{
paymentRouter.POST("/transfer", payment.Transfer)
}
// Red Envelope
redEnvelopeRouter := apiV1Router.Group("/redenvelope")
{
redEnvelopeRouter.GET("/covers", oauth.LoginRequired(), upload.ListRedEnvelopeCovers)
redEnvelopeRouter.GET("/:id", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.GetDetail)
redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Create)
redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Claim)
redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.List)
}
// Upload
uploadRouter := apiV1Router.Group("/upload")
uploadRouter.Use(oauth.LoginRequired())
{
uploadRouter.POST("/redenvelope/cover", upload.UploadRedEnvelopeCover)
}
// Config (public)
configRouter := apiV1Router.Group("/config")
{
configRouter.GET("/public", publicconfig.GetPublicConfig)
configRouter.GET("/user-pay", user_pay_config.ListUserPayConfigs)
}
// MerchantAPIKey
merchantRouter := apiV1Router.Group("/merchant")
{
merchantRouter.POST("/api-keys", oauth.LoginRequired(), api_key.CreateAPIKey)
merchantRouter.GET("/api-keys", oauth.LoginRequired(), api_key.ListAPIKeys)
apiKeyRouter := merchantRouter.Group("/api-keys/:id")
apiKeyRouter.Use(oauth.LoginRequired(), api_key.RequireAPIKey())
{
apiKeyRouter.GET("", api_key.GetAPIKey)
apiKeyRouter.PUT("", api_key.UpdateAPIKey)
apiKeyRouter.DELETE("", api_key.DeleteAPIKey)
// Payment Links
linkRouter := apiKeyRouter.Group("/payment-links")
{
linkRouter.GET("", link.ListPaymentLinks)
linkRouter.POST("", link.CreatePaymentLink)
linkRouter.PUT("/:linkId", link.UpdatePaymentLink)
linkRouter.DELETE("/:linkId", link.DeletePaymentLink)
}
}
merchantRouter.GET("/payment-links/:token", oauth.LoginRequired(), link.GetPaymentLinkByToken)
merchantRouter.POST("/payment-links/pay", oauth.LoginRequired(), link.PayByLink)
// MerchantAPIKey Payment
MerchantPaymentRouter := merchantRouter.Group("/payment")
{
MerchantPaymentRouter.GET("/order", oauth.LoginRequired(), payment.GetPaymentPageDetails)
MerchantPaymentRouter.POST("", oauth.LoginRequired(), payment.PayMerchantOrder)
}
}
// Admin
adminRouter := apiV1Router.Group("/admin")
adminRouter.Use(oauth.LoginRequired(), admin.LoginAdminRequired())
{
// Task dispatch
adminRouter.GET("/tasks/types", admin_task.ListTaskTypes)
adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask)
// Users
adminRouter.GET("/users", admin_user.ListUsers)
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
systemConfigRouter := adminRouter.Group("/system-configs/:key")
{
systemConfigRouter.GET("", system_config.GetSystemConfig)
systemConfigRouter.PUT("", system_config.UpdateSystemConfig)
systemConfigRouter.DELETE("", system_config.DeleteSystemConfig)
}
// User Credit Config
adminRouter.POST("/user-pay-configs", user_pay_config.CreateUserPayConfig)
adminRouter.GET("/user-pay-configs", user_pay_config.ListUserPayConfigs)
userPayConfigRouter := adminRouter.Group("/user-pay-configs/:id")
{
userPayConfigRouter.GET("", user_pay_config.GetUserPayConfig)
userPayConfigRouter.PUT("", user_pay_config.UpdateUserPayConfig)
userPayConfigRouter.DELETE("", user_pay_config.DeleteUserPayConfig)
}
}
}
}
expireListenerCtx, expireListenerCancel := context.WithCancel(context.Background())
if err := listener.StartExpireListener(expireListenerCtx); err != nil {
log.Fatalf("[API] 警告: 启动过期监听器失败: %v\n", err)
}
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
}
go func() {
log.Printf("[API] server starting on %s\n", config.Config.App.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
defer cancel()
defer expireListenerCancel()
otel_trace.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("[API] server forced to shutdown: %v\n", err)
}
log.Println("[API] server exited")
}
+192
View File
@@ -0,0 +1,192 @@
/*
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 service
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/task"
"github.com/linux-do/credit/internal/task/scheduler"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// BalanceOperation 余额操作类型
type BalanceOperation int
const (
BalanceAdd BalanceOperation = iota
BalanceDeduct
)
// BalanceUpdateOptions 余额更新选项
type BalanceUpdateOptions struct {
UserID uint64
Amount decimal.Decimal
Operation BalanceOperation
ScoreChange int64
TotalField string // 累计字段:total_payment / total_receive / total_transfer
CheckBalance bool
AsyncTransfer bool // 异步结算时使用 pending_balance 字段
}
// UpdateBalance 通用余额更新函数
func UpdateBalance(tx *gorm.DB, opts BalanceUpdateOptions) error {
updates := make(map[string]interface{})
balanceField := "available_balance"
if opts.AsyncTransfer {
balanceField = "pending_balance"
}
if opts.Operation == BalanceAdd {
updates[balanceField] = gorm.Expr(balanceField+" + ?", opts.Amount)
} else {
updates[balanceField] = gorm.Expr(balanceField+" - ?", opts.Amount)
}
if opts.TotalField != "" {
updates[opts.TotalField] = gorm.Expr(opts.TotalField+" + ?", opts.Amount)
}
if opts.ScoreChange != 0 {
updates["pay_score"] = gorm.Expr("pay_score + ?", opts.ScoreChange)
}
query := tx.Model(&model.User{}).Where("id = ?", opts.UserID)
if opts.CheckBalance {
query = query.Where(balanceField+" >= ?", opts.Amount)
}
result := query.UpdateColumns(updates)
if result.Error != nil {
return result.Error
}
if opts.CheckBalance && result.RowsAffected == 0 {
return errors.New(common.InsufficientBalance)
}
return nil
}
// SettlePendingToAvailable 将资金从 PendingBalance 转入 AvailableBalance
func SettlePendingToAvailable(tx *gorm.DB, userID uint64, amount decimal.Decimal) error {
result := tx.Model(&model.User{}).
Where("id = ? AND pending_balance >= ?", userID, amount).
UpdateColumns(map[string]interface{}{
"pending_balance": gorm.Expr("pending_balance - ?", amount),
"available_balance": gorm.Expr("available_balance + ?", amount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(common.InsufficientBalance)
}
return nil
}
// CheckDailyLimit 检查用户每日支付限额
// 返回 nil 表示未超限额,返回 error 表示超限或查询失败
func CheckDailyLimit(tx *gorm.DB, userID uint64, amount decimal.Decimal, dailyLimit *int64) error {
if dailyLimit == nil || *dailyLimit <= 0 {
return nil
}
now := time.Now()
datePart := int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
lockID := int64(userID)*100000000 + datePart
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", lockID).Error; err != nil {
return err
}
todayUsed, err := GetTodayUsedAmount(tx, userID)
if err != nil {
return err
}
if todayUsed.Add(amount).GreaterThan(decimal.NewFromInt(*dailyLimit)) {
return errors.New(common.DailyLimitExceeded)
}
return nil
}
// GetTodayUsedAmount 获取用户当日已使用的支付额度
func GetTodayUsedAmount(db *gorm.DB, userID uint64) (decimal.Decimal, error) {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayEnd := todayStart.Add(24 * time.Hour)
var total decimal.Decimal
err := db.Model(&model.Order{}).
Where("payer_user_id = ? AND status = ? AND type IN ? AND trade_time >= ? AND trade_time < ?",
userID,
model.OrderStatusSuccess,
[]model.OrderType{model.OrderTypePayment, model.OrderTypeOnline, model.OrderTypeDistribute, model.OrderTypeTransfer},
todayStart,
todayEnd).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
}
// CalculateFee 计算手续费和商户实收金额
// 返回:手续费、商户实收金额、手续费百分比
func CalculateFee(amount decimal.Decimal, feeRate decimal.Decimal) (fee decimal.Decimal, merchantAmount decimal.Decimal, feePercent int64) {
fee = amount.Mul(feeRate).Round(2)
merchantAmount = amount.Sub(fee)
feePercent = feeRate.Mul(decimal.NewFromInt(100)).IntPart()
return
}
// ValidateTestModePayment 验证测试模式下的支付权限
// 返回 error:nil 表示允许支付,非 nil 表示拒绝支付
func ValidateTestModePayment(currentUserID, merchantUserID uint64, isTestMode bool) error {
if currentUserID == merchantUserID {
if !isTestMode {
return errors.New(common.CannotPaySelf)
}
} else if isTestMode {
return errors.New(common.TestModeCannotProcessOrder)
}
return nil
}
// EnqueueMerchantNotify 下发商户回调任务
func EnqueueMerchantNotify(orderID uint64, clientID string) error {
notifyPayload, _ := json.Marshal(map[string]interface{}{
"order_id": orderID,
"client_id": clientID,
})
if _, err := scheduler.AsynqClient.Enqueue(
asynq.NewTask(task.MerchantPaymentNotifyTask, notifyPayload),
asynq.Queue(task.QueueWebhook),
asynq.MaxRetry(10),
asynq.Timeout(30*time.Second),
); err != nil {
return fmt.Errorf("下发商户回调任务失败: %w", err)
}
return nil
}
+211
View File
@@ -0,0 +1,211 @@
/*
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 storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/otel_trace"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/singleflight"
)
var localCacheEnabled = false
var localCacheDir = ""
var cacheFilePath = "%s/%s"
var cacheMetaFilePath = "%s/%s.meta"
var group singleflight.Group
type metaInfo struct {
ContentType string `json:"content_type"`
ContentLength int64 `json:"content_length"`
}
func init() {
cfg := config.Config.S3.LocalCache
localCacheEnabled = cfg.Enabled && cfg.CacheDir != ""
localCacheDir = strings.TrimSuffix(cfg.CacheDir, "/")
if localCacheEnabled {
if err := os.MkdirAll(cfg.CacheDir, 0755); err != nil {
log.Fatalf("[Storage] failed to create local cache directory: %v\n", err)
}
}
}
func GetObjectViaCache(ctx context.Context, key string) (*ObjectInfo, error) {
// 没有开启本地缓存
if !localCacheEnabled {
return GetObjectViaProxy(ctx, key)
}
// 初始化 Trace
ctx, span := otel_trace.Start(ctx, "S3.GetObjectViaCache", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
// 检查本地缓存
key = strings.TrimPrefix(key, "/")
localPath := fmt.Sprintf(cacheFilePath, localCacheDir, key)
metaPath := fmt.Sprintf(cacheMetaFilePath, localCacheDir, key)
objInfo, err := getLocalCacheFile(ctx, localPath, metaPath)
if err != nil {
return nil, err
}
if objInfo != nil {
return objInfo, nil
}
// 使用 singleflight 确保同一时间只有一个请求会触发 CDN 获取和本地缓存保存
_, err, _ = group.Do(key, func() (interface{}, error) {
ctx := context.WithoutCancel(ctx)
// 没有缓存,通过 CDN 获取
objInfo, err := GetObjectViaProxy(ctx, key)
if err != nil {
return nil, err
}
// 保存到本地
if err := saveToLocalCache(ctx, localPath, metaPath, objInfo); err != nil {
return nil, err
}
return nil, nil
})
if err != nil {
logger.ErrorF(ctx, "Failed to get object via singleflight for key %s: %v", key, err)
return nil, LocalCacheError{}
}
return GetObjectViaCache(ctx, key)
}
func getLocalCacheFile(ctx context.Context, localPath, metaPath string) (*ObjectInfo, error) {
ctx, span := otel_trace.Start(ctx, "S3.GetLocalCacheFile", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
// 尝试打开本地缓存文件
file, err := os.Open(localPath)
defer file.Close()
// 文件不存在
if err != nil && os.IsNotExist(err) {
return nil, nil
}
// 判断是否为其他异常
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// 读取元信息
metaData, err := os.ReadFile(metaPath)
// 文件不存在
if err != nil && os.IsNotExist(err) {
return nil, nil
}
// 判断是否为其他异常
if err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// 解析元信息
meta := &metaInfo{}
if err := json.Unmarshal(metaData, meta); err != nil {
span.SetStatus(codes.Error, err.Error())
return nil, err
}
return &ObjectInfo{CachePath: localPath, ContentLength: meta.ContentLength, ContentType: meta.ContentType}, nil
}
func saveToLocalCache(ctx context.Context, localPath, metaPath string, objInfo *ObjectInfo) error {
ctx, span := otel_trace.Start(ctx, "S3.SaveToLocalCache", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
// 创建目录
localDir := filepath.Dir(localPath)
if err := os.MkdirAll(localDir, 0755); err != nil {
span.SetStatus(codes.Error, err.Error())
return err
}
// 创建文件
if err := saveFile(localPath, objInfo.Body); err != nil {
span.SetStatus(codes.Error, err.Error())
return err
}
// 创建元信息文件
meta := &metaInfo{ContentType: objInfo.ContentType, ContentLength: objInfo.ContentLength}
metaData, err := json.Marshal(meta)
if err != nil {
span.SetStatus(codes.Error, err.Error())
return err
}
if err := saveFile(metaPath, bytes.NewReader(metaData)); err != nil {
span.SetStatus(codes.Error, err.Error())
return err
}
return nil
}
func saveFile(localPath string, data io.Reader) error {
// 创建临时文件
tempFile, err := os.CreateTemp(filepath.Dir(localPath), "cache_temp_*")
if err != nil {
return err
}
defer os.Remove(tempFile.Name())
// 将内容写入临时文件
if _, err := tempFile.ReadFrom(data); err != nil {
return err
}
// 确保数据写入磁盘
if err := tempFile.Sync(); err != nil {
return err
}
// 关闭临时文件
if err := tempFile.Close(); err != nil {
return err
}
// 将临时文件重命名为最终文件
if err := os.Rename(tempFile.Name(), localPath); err != nil {
return err
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
/*
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 storage
type ErrS3InitializationFailed struct{}
func (e ErrS3InitializationFailed) Error() string {
return "S3存储初始化失败"
}
type LocalCacheError struct{}
func (e LocalCacheError) Error() string {
return "本地缓存错误"
}
+227
View File
@@ -0,0 +1,227 @@
/*
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 storage
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/otel_trace"
"github.com/linux-do/credit/internal/util"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var (
client *s3.Client
bucket string
keyPrefix string
cdnURL string
)
func init() {
cfg := config.Config.S3
if !cfg.Enabled {
log.Println("[Storage] S3 storage disabled")
return
}
bucket = cfg.Bucket
keyPrefix = cfg.KeyPrefix
cdnURL = strings.TrimRight(cfg.CdnURL, "/")
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
),
)
if err != nil {
log.Fatalf("[Storage] failed to load AWS config: %v\n", err)
}
client = s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.PathStyle
})
log.Printf("[Storage] S3 storage initialized (bucket: %s, prefix: %s, cdn: %s)\n", bucket, keyPrefix, cdnURL)
}
func IsEnabled() bool {
return client != nil
}
// BuildKey constructs a full S3 object key with the configured prefix.
func BuildKey(path string) string {
return keyPrefix + path
}
// PutObject uploads a file to S3.
func PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
ctx, span := otel_trace.Start(ctx, "S3.PutObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(
attribute.String("s3.key", key),
attribute.Int64("s3.content_length", size),
attribute.String("s3.content_type", contentType),
)
if !IsEnabled() {
span.SetStatus(codes.Error, "S3 not initialized")
return ErrS3InitializationFailed{}
}
input := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: body,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
}
_, err := client.PutObject(ctx, input)
if err != nil {
span.SetStatus(codes.Error, fmt.Sprintf("S3 put object failed: %v", err))
return fmt.Errorf("s3 put object failed: %w", err)
}
return nil
}
// ObjectInfo holds metadata about a retrieved object.
type ObjectInfo struct {
CachePath string
Body io.ReadCloser
ContentLength int64
ContentType string
}
// GetObject retrieves a file directly from S3.
func GetObject(ctx context.Context, key string) (*ObjectInfo, error) {
ctx, span := otel_trace.Start(ctx, "S3.GetObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(attribute.String("s3.key", key))
if !IsEnabled() {
span.SetStatus(codes.Error, "S3 not initialized")
return nil, ErrS3InitializationFailed{}
}
output, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
span.SetStatus(codes.Error, fmt.Sprintf("S3 get object failed: %v", err))
return nil, fmt.Errorf("s3 get object failed: %w", err)
}
contentType := "application/octet-stream"
if output.ContentType != nil {
contentType = *output.ContentType
}
var contentLength int64
if output.ContentLength != nil {
contentLength = *output.ContentLength
}
return &ObjectInfo{
Body: output.Body,
ContentLength: contentLength,
ContentType: contentType,
}, nil
}
// GetObjectViaProxy retrieves a file via CDN if configured, otherwise falls back to S3.
func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) {
ctx, span := otel_trace.Start(ctx, "S3.GetObjectViaProxy", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(attribute.String("s3.key", key))
if !IsEnabled() {
span.SetStatus(codes.Error, "S3 not initialized")
return nil, ErrS3InitializationFailed{}
}
if cdnURL == "" {
return GetObject(ctx, key)
}
url := cdnURL + "/" + key
span.SetAttributes(attribute.Bool("s3.use_cdn", true))
resp, err := util.Request(ctx, http.MethodGet, url, nil, nil, nil)
if err != nil {
span.SetStatus(codes.Error, fmt.Sprintf("cdn request failed: %v", err))
return nil, fmt.Errorf("cdn request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
span.SetStatus(codes.Error, fmt.Sprintf("cdn returned status %d", resp.StatusCode))
return nil, fmt.Errorf("cdn returned status %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
return &ObjectInfo{
Body: resp.Body,
ContentLength: resp.ContentLength,
ContentType: contentType,
}, nil
}
// DeleteObject deletes a file from S3.
func DeleteObject(ctx context.Context, key string) error {
ctx, span := otel_trace.Start(ctx, "S3.DeleteObject", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(attribute.String("s3.key", key))
if !IsEnabled() {
return ErrS3InitializationFailed{}
}
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
span.SetStatus(codes.Error, fmt.Sprintf("S3 delete object failed: %v", err))
return fmt.Errorf("s3 delete object failed: %w", err)
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
/*
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 task
const (
UpdateUserGamificationScoresTask = "user:gamification:update_scores_task"
UpdateSingleUserGamificationScoreTask = "user:gamification:update_single_score_task"
AutoRefundExpiredDisputesTask = "dispute:auto_refund_expired"
AutoRefundSingleDisputeTask = "dispute:auto_refund_single"
MerchantPaymentNotifyTask = "payment:merchant_notify"
SyncOrdersToClickHouseTask = "order:sync_to_clickhouse"
RefundExpiredRedEnvelopesTask = "redenvelope:refund_expired"
CleanupUnusedUploadsTask = "upload:cleanup_unused"
SettlePendingPaymentsTask = "order:settle_pending_payments"
)
const (
QueueWhitelistOnly = "whitelist_only"
QueueWebhook = "webhook"
QueueDefault = "default"
)
// 管理员可下发的任务类型标识
const (
TaskTypeOrderSync = "order_sync"
TaskTypeUserGamification = "user_gamification"
TaskTypeDisputeRefund = "dispute_auto_refund"
TaskTypeRedEnvelopeRefund = "redenvelope_auto_refund"
TaskTypeCleanupUploads = "cleanup_unused_uploads"
TaskTypeSettlePending = "settle_pending_payments"
)
// TaskMeta 任务元数据
type TaskMeta struct {
Type string
AsynqTask string
Name string
Description string
SupportsTime bool
MaxRetry int
Queue string
}
// DispatchableTasks 可下发的任务列表
var DispatchableTasks = []TaskMeta{
{
Type: TaskTypeOrderSync,
AsynqTask: SyncOrdersToClickHouseTask,
Name: "订单同步",
Description: "同步订单数据到 ClickHouse",
SupportsTime: true,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeUserGamification,
AsynqTask: UpdateSingleUserGamificationScoreTask,
Name: "用户积分更新",
Description: "更新用户的点数积分",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueWhitelistOnly,
},
{
Type: TaskTypeDisputeRefund,
AsynqTask: AutoRefundExpiredDisputesTask,
Name: "争议自动退款",
Description: "处理过期争议的自动退款",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeRedEnvelopeRefund,
AsynqTask: RefundExpiredRedEnvelopesTask,
Name: "红包自动退款",
Description: "处理过期红包的自动退款",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
{
Type: TaskTypeCleanupUploads,
AsynqTask: CleanupUnusedUploadsTask,
Name: "清理未使用上传",
Description: "清理超过1小时未使用的上传文件",
SupportsTime: false,
MaxRetry: 3,
Queue: QueueDefault,
},
{
Type: TaskTypeSettlePending,
AsynqTask: SettlePendingPaymentsTask,
Name: "延迟到账结算",
Description: "结算到期的延迟到账订单,将在途资金转入可用余额",
SupportsTime: false,
MaxRetry: 5,
Queue: QueueDefault,
},
}
// GetTaskMeta 根据任务类型获取元数据
func GetTaskMeta(taskType string) *TaskMeta {
for _, t := range DispatchableTasks {
if t.Type == taskType {
return &t
}
}
return nil
}
+120
View File
@@ -0,0 +1,120 @@
/*
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 scheduler
import (
"fmt"
"sync"
"time"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/task"
"github.com/hibiken/asynq"
)
var (
AsynqClient *asynq.Client
scheduler *asynq.Scheduler
schedulerOnce sync.Once
)
func init() {
AsynqClient = asynq.NewClient(task.RedisOpt)
}
// StartScheduler 启动调度器
func StartScheduler() error {
var err error
schedulerOnce.Do(func() {
location, locErr := time.LoadLocation("Asia/Shanghai")
if locErr != nil {
err = fmt.Errorf("failed to load location: %w", locErr)
return
}
scheduler = asynq.NewScheduler(
task.RedisOpt,
&asynq.SchedulerOpts{
Location: location,
},
)
// 用户积分更新任务
if _, err = scheduler.Register(
config.Config.Scheduler.UpdateUserGamificationScoresTaskCron,
asynq.NewTask(task.UpdateUserGamificationScoresTask, nil),
asynq.Queue(task.QueueWhitelistOnly),
asynq.MaxRetry(5),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 争议自动退款任务
if _, err = scheduler.Register(
config.Config.Scheduler.AutoRefundExpiredDisputesTaskCron,
asynq.NewTask(task.AutoRefundExpiredDisputesTask, nil),
asynq.MaxRetry(5),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 订单同步任务
if _, err = scheduler.Register(
config.Config.Scheduler.SyncOrdersToClickHouseTaskCron,
asynq.NewTask(task.SyncOrdersToClickHouseTask, nil),
asynq.MaxRetry(10),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 红包过期退款任务
if _, err = scheduler.Register(
config.Config.Scheduler.RefundExpiredRedEnvelopesTaskCron,
asynq.NewTask(task.RefundExpiredRedEnvelopesTask, nil),
asynq.Unique(23*time.Hour),
); err != nil {
return
}
// 清理未使用的上传文件任务
if _, err = scheduler.Register(
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
asynq.Unique(23*time.Hour),
asynq.MaxRetry(3),
); err != nil {
return
}
// 延迟到账结算任务
if _, err = scheduler.Register(
config.Config.Scheduler.SettlePendingPaymentsTaskCron,
asynq.NewTask(task.SettlePendingPaymentsTask, nil),
asynq.Unique(55*time.Minute),
asynq.MaxRetry(3),
); err != nil {
return
}
// 启动调度器
err = scheduler.Run()
})
return err
}
+74
View File
@@ -0,0 +1,74 @@
/*
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 task
import (
"github.com/hibiken/asynq"
"github.com/linux-do/credit/internal/config"
)
// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster)
var RedisOpt asynq.RedisConnOpt
func init() {
RedisOpt = NewRedisConnOpt()
}
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
func NewRedisConnOpt() asynq.RedisConnOpt {
cfg := config.Config.Redis
addrs := cfg.Addrs
if cfg.ClusterMode {
return asynq.RedisClusterClientOpt{
Addrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
}
}
if cfg.MasterName != "" {
return asynq.RedisFailoverClientOpt{
MasterName: cfg.MasterName,
SentinelAddrs: addrs,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
}
}
addr := "localhost:6379"
if len(addrs) > 0 {
addr = addrs[0]
}
return asynq.RedisClientOpt{
Addr: addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
}
}
// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离
func PrefixedQueue(queue string) string {
prefix := config.Config.Redis.KeyPrefix
if prefix == "" {
return queue
}
return prefix + queue
}

Some files were not shown because too many files have changed in this diff Show More