新功能(#102): 新增邀请记录接口并删除旧邀请销售接口
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+13
-13
@@ -201,21 +201,22 @@ type (
|
|||||||
GrowthRate string `json:"growth_rate"`
|
GrowthRate string `json:"growth_rate"`
|
||||||
PaidGrowthRate string `json:"paid_growth_rate"`
|
PaidGrowthRate string `json:"paid_growth_rate"`
|
||||||
}
|
}
|
||||||
GetInviteSalesRequest {
|
GetInviteRecordsRequest {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
StartTime int64 `form:"start_time"`
|
StartTime int64 `form:"start_time"`
|
||||||
EndTime int64 `form:"end_time"`
|
EndTime int64 `form:"end_time"`
|
||||||
}
|
}
|
||||||
InvitedUserSale {
|
InviteRecord {
|
||||||
Amount float64 `json:"amount"`
|
Role string `json:"role"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
PeerHash string `json:"peer_hash"`
|
||||||
UserHash string `json:"user_hash"`
|
GiftDays int64 `json:"gift_days"`
|
||||||
ProductName string `json:"product_name"`
|
OrderNo string `json:"order_no"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
GetInviteSalesResponse {
|
GetInviteRecordsResponse {
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
List []InvitedUserSale `json:"list"`
|
List []InviteRecord `json:"list"`
|
||||||
}
|
}
|
||||||
GetSubscribeStatusRequest {
|
GetSubscribeStatusRequest {
|
||||||
Email string `form:"email" json:"email" validate:"omitempty,email"`
|
Email string `form:"email" json:"email" validate:"omitempty,email"`
|
||||||
@@ -397,9 +398,9 @@ service ppanel {
|
|||||||
@handler GetAgentRealtime
|
@handler GetAgentRealtime
|
||||||
get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
|
get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
|
||||||
|
|
||||||
@doc "Get Invite Sales"
|
@doc "Get Invite Records"
|
||||||
@handler GetInviteSales
|
@handler GetInviteRecords
|
||||||
get /invite_sales (GetInviteSalesRequest) returns (GetInviteSalesResponse)
|
get /invite_records (GetInviteRecordsRequest) returns (GetInviteRecordsResponse)
|
||||||
|
|
||||||
@doc "Get Subscribe Status"
|
@doc "Get Subscribe Status"
|
||||||
@handler GetSubscribeStatus
|
@handler GetSubscribeStatus
|
||||||
@@ -424,4 +425,3 @@ service ppanel {
|
|||||||
@handler DeviceWsConnect
|
@handler DeviceWsConnect
|
||||||
get /device_ws_connect
|
get /device_ws_connect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/Masterminds/sprig/v3 v3.3.0
|
github.com/Masterminds/sprig/v3 v3.3.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0=
|
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0=
|
||||||
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44=
|
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44=
|
||||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||||
@@ -264,6 +266,7 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
|
|||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||||
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||||
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
|||||||
+5
-5
@@ -8,10 +8,10 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get invite sales data
|
// Get invite gift records
|
||||||
func GetInviteSalesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
func GetInviteRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req types.GetInviteSalesRequest
|
var req types.GetInviteRecordsRequest
|
||||||
if err := c.ShouldBind(&req); err != nil {
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
result.ParamErrorResult(c, err)
|
result.ParamErrorResult(c, err)
|
||||||
return
|
return
|
||||||
@@ -23,8 +23,8 @@ func GetInviteSalesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
l := user.NewGetInviteSalesLogic(c.Request.Context(), svcCtx)
|
l := user.NewGetInviteRecordsLogic(c.Request.Context(), svcCtx)
|
||||||
resp, err := l.GetInviteSales(&req)
|
resp, err := l.GetInviteRecords(&req)
|
||||||
result.HttpResult(c, resp, err)
|
result.HttpResult(c, resp, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1118,9 +1118,8 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Query User Info
|
// Query User Info
|
||||||
publicUserGroupRouter.GET("/info", publicUser.QueryUserInfoHandler(serverCtx))
|
publicUserGroupRouter.GET("/info", publicUser.QueryUserInfoHandler(serverCtx))
|
||||||
|
|
||||||
// Get Invite Sales
|
// Get Invite Records
|
||||||
publicUserGroupRouter.GET("/invite_sales", publicUser.GetInviteSalesHandler(serverCtx))
|
publicUserGroupRouter.GET("/invite_records", publicUser.GetInviteRecordsHandler(serverCtx))
|
||||||
publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx)) // alias: backward-compat
|
|
||||||
|
|
||||||
// Get User Invite Stats
|
// Get User Invite Stats
|
||||||
publicUserGroupRouter.GET("/invite_stats", publicUser.GetUserInviteStatsHandler(serverCtx))
|
publicUserGroupRouter.GET("/invite_stats", publicUser.GetUserInviteStatsHandler(serverCtx))
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/hash"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
inviteRecordRoleInviter = "inviter"
|
||||||
|
inviteRecordRoleInvitee = "invitee"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetInviteRecordsLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteRecordLog struct {
|
||||||
|
Id int64 `gorm:"column:id"`
|
||||||
|
ObjectId int64 `gorm:"column:object_id"`
|
||||||
|
Content string `gorm:"column:content"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteGiftContent struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type parsedInviteRecordLog struct {
|
||||||
|
log inviteRecordLog
|
||||||
|
content inviteGiftContent
|
||||||
|
}
|
||||||
|
|
||||||
|
type inviteOrderUser struct {
|
||||||
|
OrderNo string `gorm:"column:order_no"`
|
||||||
|
UserId int64 `gorm:"column:user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invite gift records
|
||||||
|
func NewGetInviteRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteRecordsLogic {
|
||||||
|
return &GetInviteRecordsLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetInviteRecordsLogic) GetInviteRecords(req *types.GetInviteRecordsRequest) (resp *types.GetInviteRecordsResponse, err error) {
|
||||||
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
|
if !ok {
|
||||||
|
l.Errorw("[GetInviteRecords] user not found in context")
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeInviteRecordsPagination(req)
|
||||||
|
|
||||||
|
query := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Table("system_logs").
|
||||||
|
Where("type = ? AND object_id = ?", logmodel.TypeGift.Uint8(), u.Id).
|
||||||
|
Where("JSON_VALID(content) = 1").
|
||||||
|
Where("JSON_UNQUOTE(JSON_EXTRACT(content, '$.remark')) = ?", "邀请赠送")
|
||||||
|
if req.StartTime > 0 {
|
||||||
|
query = query.Where("created_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||||
|
}
|
||||||
|
if req.EndTime > 0 {
|
||||||
|
query = query.Where("created_at <= FROM_UNIXTIME(?)", req.EndTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err = query.Count(&total).Error; err != nil {
|
||||||
|
l.Errorw("[GetInviteRecords] count logs failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count logs failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs []inviteRecordLog
|
||||||
|
if err = query.
|
||||||
|
Select("id, object_id, content, CAST(UNIX_TIMESTAMP(created_at) * 1000 AS SIGNED) AS created_at").
|
||||||
|
Order("created_at DESC, id DESC").
|
||||||
|
Limit(req.Size).
|
||||||
|
Offset((req.Page - 1) * req.Size).
|
||||||
|
Scan(&logs).Error; err != nil {
|
||||||
|
l.Errorw("[GetInviteRecords] query logs failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query logs failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedLogs, orderNos := l.parseInviteRecordContents(logs)
|
||||||
|
if len(logs) == 0 || len(parsedLogs) == 0 {
|
||||||
|
return &types.GetInviteRecordsResponse{Total: total, List: []types.InviteRecord{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, err := l.queryInviteRecordOrders(orderNos)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[GetInviteRecords] query orders failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query orders failed: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]types.InviteRecord, 0, len(parsedLogs))
|
||||||
|
for _, parsed := range parsedLogs {
|
||||||
|
content := parsed.content
|
||||||
|
logItem := parsed.log
|
||||||
|
record := types.InviteRecord{
|
||||||
|
Role: inviteRecordRoleInviter,
|
||||||
|
GiftDays: content.Amount,
|
||||||
|
OrderNo: content.OrderNo,
|
||||||
|
CreatedAt: logItem.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if orderInfo, ok := orders[content.OrderNo]; ok {
|
||||||
|
peerId := orderInfo.UserId
|
||||||
|
if orderInfo.UserId == u.Id {
|
||||||
|
record.Role = inviteRecordRoleInvitee
|
||||||
|
peerId = u.RefererId
|
||||||
|
}
|
||||||
|
if peerId > 0 {
|
||||||
|
record.PeerHash = hash.InvitePeerHash(peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.GetInviteRecordsResponse{
|
||||||
|
Total: total,
|
||||||
|
List: list,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInviteRecordsPagination(req *types.GetInviteRecordsRequest) {
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.Size < 1 {
|
||||||
|
req.Size = 10
|
||||||
|
}
|
||||||
|
if req.Size > 100 {
|
||||||
|
req.Size = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetInviteRecordsLogic) parseInviteRecordContents(logs []inviteRecordLog) ([]parsedInviteRecordLog, []string) {
|
||||||
|
parsedLogs := make([]parsedInviteRecordLog, 0, len(logs))
|
||||||
|
orderNos := make([]string, 0, len(logs))
|
||||||
|
for _, logItem := range logs {
|
||||||
|
var content inviteGiftContent
|
||||||
|
if err := json.Unmarshal([]byte(logItem.Content), &content); err != nil {
|
||||||
|
l.Infow("[GetInviteRecords] parse content failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("log_id", logItem.Id))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedLogs = append(parsedLogs, parsedInviteRecordLog{log: logItem, content: content})
|
||||||
|
if content.OrderNo != "" {
|
||||||
|
orderNos = append(orderNos, content.OrderNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsedLogs, orderNos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *GetInviteRecordsLogic) queryInviteRecordOrders(orderNos []string) (map[string]inviteOrderUser, error) {
|
||||||
|
orders := make(map[string]inviteOrderUser, len(orderNos))
|
||||||
|
if len(orderNos) == 0 {
|
||||||
|
return orders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderData []inviteOrderUser
|
||||||
|
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&ordermodel.Order{}).
|
||||||
|
Select("order_no, user_id").
|
||||||
|
Where("order_no IN ?", orderNos).
|
||||||
|
Scan(&orderData).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range orderData {
|
||||||
|
orders[item.OrderNo] = item
|
||||||
|
}
|
||||||
|
return orders, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/hash"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetInviteRecordsInviter(t *testing.T) {
|
||||||
|
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("count(*)").
|
||||||
|
WithArgs(34, int64(100), "邀请赠送").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery("SELECT id, object_id, content").
|
||||||
|
WithArgs(34, int64(100), "邀请赠送", 10).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}).
|
||||||
|
AddRow(1, 100, `{"order_no":"order-1","amount":7,"remark":"邀请赠送"}`, 1779934580000))
|
||||||
|
mock.ExpectQuery("SELECT order_no, user_id FROM `order`").
|
||||||
|
WithArgs("order-1").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id"}).AddRow("order-1", 200))
|
||||||
|
|
||||||
|
resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(100, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInviteRecords returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertInviteRecordResponse(t, resp, types.InviteRecord{
|
||||||
|
Role: inviteRecordRoleInviter,
|
||||||
|
PeerHash: hash.InvitePeerHash(200),
|
||||||
|
GiftDays: 7,
|
||||||
|
OrderNo: "order-1",
|
||||||
|
CreatedAt: 1779934580000,
|
||||||
|
})
|
||||||
|
assertInviteRecordsExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInviteRecordsInvitee(t *testing.T) {
|
||||||
|
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("count(*)").
|
||||||
|
WithArgs(34, int64(200), "邀请赠送").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery("SELECT id, object_id, content").
|
||||||
|
WithArgs(34, int64(200), "邀请赠送", 10).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}).
|
||||||
|
AddRow(2, 200, `{"order_no":"order-2","amount":7,"remark":"邀请赠送"}`, 1779934590000))
|
||||||
|
mock.ExpectQuery("SELECT order_no, user_id FROM `order`").
|
||||||
|
WithArgs("order-2").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id"}).AddRow("order-2", 200))
|
||||||
|
|
||||||
|
resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(200, 100), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInviteRecords returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertInviteRecordResponse(t, resp, types.InviteRecord{
|
||||||
|
Role: inviteRecordRoleInvitee,
|
||||||
|
PeerHash: hash.InvitePeerHash(100),
|
||||||
|
GiftDays: 7,
|
||||||
|
OrderNo: "order-2",
|
||||||
|
CreatedAt: 1779934590000,
|
||||||
|
})
|
||||||
|
assertInviteRecordsExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInviteRecordsMissingOrderReturnsDirtyRecord(t *testing.T) {
|
||||||
|
svcCtx, mock, cleanup := newInviteRecordsTestSvc(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
mock.ExpectQuery("count(*)").
|
||||||
|
WithArgs(34, int64(100), "邀请赠送").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||||
|
mock.ExpectQuery("SELECT id, object_id, content").
|
||||||
|
WithArgs(34, int64(100), "邀请赠送", 10).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "object_id", "content", "created_at"}).
|
||||||
|
AddRow(3, 100, `{"order_no":"missing-order","amount":7,"remark":"邀请赠送"}`, 1779934600000))
|
||||||
|
mock.ExpectQuery("SELECT order_no, user_id FROM `order`").
|
||||||
|
WithArgs("missing-order").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"order_no", "user_id"}))
|
||||||
|
|
||||||
|
resp, err := NewGetInviteRecordsLogic(inviteRecordsContext(100, 0), svcCtx).GetInviteRecords(&types.GetInviteRecordsRequest{Page: 1, Size: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetInviteRecords returned error: %v", err)
|
||||||
|
}
|
||||||
|
assertInviteRecordResponse(t, resp, types.InviteRecord{
|
||||||
|
Role: inviteRecordRoleInviter,
|
||||||
|
GiftDays: 7,
|
||||||
|
OrderNo: "missing-order",
|
||||||
|
CreatedAt: 1779934600000,
|
||||||
|
})
|
||||||
|
assertInviteRecordsExpectations(t, mock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInviteRecordsTestSvc(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
|
||||||
|
if strings.Contains(actualSQL, expectedSQL) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
|
||||||
|
})))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create sqlmock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
t.Fatalf("open gorm db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &svc.ServiceContext{DB: db}, mock, func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inviteRecordsContext(userId, refererId int64) context.Context {
|
||||||
|
return context.WithValue(context.Background(), constant.CtxKeyUser, &modeluser.User{
|
||||||
|
Id: userId,
|
||||||
|
RefererId: refererId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertInviteRecordResponse(t *testing.T, resp *types.GetInviteRecordsResponse, want types.InviteRecord) {
|
||||||
|
t.Helper()
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("response is nil")
|
||||||
|
}
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Fatalf("Total = %d, want 1", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.List) != 1 {
|
||||||
|
t.Fatalf("len(List) = %d, want 1", len(resp.List))
|
||||||
|
}
|
||||||
|
if got := resp.List[0]; got != want {
|
||||||
|
t.Fatalf("record = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertInviteRecordsExpectations(t *testing.T, mock sqlmock.Sqlmock) {
|
||||||
|
t.Helper()
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatalf("unmet sql expectations: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"hash/fnv"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
|
||||||
"github.com/perfect-panel/server/internal/types"
|
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GetInviteSalesLogic struct {
|
|
||||||
logger.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGetInviteSalesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetInviteSalesLogic {
|
|
||||||
return &GetInviteSalesLogic{
|
|
||||||
Logger: logger.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (resp *types.GetInviteSalesResponse, err error) {
|
|
||||||
// 1. Get current user
|
|
||||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
|
||||||
if !ok {
|
|
||||||
l.Errorw("[GetInviteSales] user not found in context")
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
|
||||||
}
|
|
||||||
userId := u.Id
|
|
||||||
|
|
||||||
// 2. Count total sales
|
|
||||||
var totalSales int64
|
|
||||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
|
||||||
Table("`order` o").
|
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
|
||||||
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5})
|
|
||||||
|
|
||||||
if req.StartTime > 0 {
|
|
||||||
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
|
||||||
}
|
|
||||||
if req.EndTime > 0 {
|
|
||||||
db = db.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Count(&totalSales).Error
|
|
||||||
if err != nil {
|
|
||||||
l.Errorw("[GetInviteSales] count sales failed",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("user_id", userId))
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
|
||||||
"count sales failed: %v", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Pagination
|
|
||||||
if req.Page < 1 {
|
|
||||||
req.Page = 1
|
|
||||||
}
|
|
||||||
if req.Size < 1 {
|
|
||||||
req.Size = 10
|
|
||||||
}
|
|
||||||
if req.Size > 100 {
|
|
||||||
req.Size = 100
|
|
||||||
}
|
|
||||||
offset := (req.Page - 1) * req.Size
|
|
||||||
|
|
||||||
// 4. Get sales data
|
|
||||||
type OrderWithUser struct {
|
|
||||||
Amount int64 `gorm:"column:amount"`
|
|
||||||
UpdatedAt int64 `gorm:"column:updated_at"`
|
|
||||||
UserId int64 `gorm:"column:user_id"`
|
|
||||||
ProductName string `gorm:"column:product_name"`
|
|
||||||
Quantity int64 `gorm:"column:quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var orderData []OrderWithUser
|
|
||||||
query := l.svcCtx.DB.WithContext(l.ctx).
|
|
||||||
Table("`order` o").
|
|
||||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
|
||||||
Joins("JOIN user u ON o.user_id = u.id").
|
|
||||||
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
|
||||||
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5}) // status 2: Active, 5: Finished
|
|
||||||
|
|
||||||
if req.StartTime > 0 {
|
|
||||||
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
|
||||||
}
|
|
||||||
if req.EndTime > 0 {
|
|
||||||
query = query.Where("o.updated_at <= FROM_UNIXTIME(?)", req.EndTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = query.Order("o.updated_at DESC").
|
|
||||||
Limit(req.Size).
|
|
||||||
Offset(offset).
|
|
||||||
Scan(&orderData).Error
|
|
||||||
if err != nil {
|
|
||||||
l.Errorw("[GetInviteSales] query sales failed",
|
|
||||||
logger.Field("error", err.Error()),
|
|
||||||
logger.Field("user_id", userId))
|
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
|
|
||||||
"query sales failed: %v", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Get sales list
|
|
||||||
const HashSalt = "ppanel_invite_sales_v1" // Fixed Key
|
|
||||||
var list []types.InvitedUserSale
|
|
||||||
for _, order := range orderData {
|
|
||||||
// Calculate unique numeric hash (FNV-64a)
|
|
||||||
h := fnv.New64a()
|
|
||||||
h.Write([]byte(HashSalt))
|
|
||||||
h.Write([]byte(strconv.FormatInt(order.UserId, 10)))
|
|
||||||
// Truncate to 10 digits using modulo 10^10
|
|
||||||
hashVal := h.Sum64() % 10000000000
|
|
||||||
userHashStr := fmt.Sprintf("%010d", hashVal)
|
|
||||||
|
|
||||||
// Format product name: prefer subscribe name, fallback to quantity-based label
|
|
||||||
productName := order.ProductName
|
|
||||||
if productName == "" {
|
|
||||||
productName = fmt.Sprintf("%d天VPN服务", order.Quantity)
|
|
||||||
if order.Quantity <= 0 {
|
|
||||||
productName = "VPN服务"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
list = append(list, types.InvitedUserSale{
|
|
||||||
Amount: float64(order.Amount) / 100.0, // Convert cents to dollars
|
|
||||||
UpdatedAt: order.UpdatedAt,
|
|
||||||
UserHash: userHashStr,
|
|
||||||
ProductName: productName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &types.GetInviteSalesResponse{
|
|
||||||
Total: totalSales,
|
|
||||||
List: list,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
+10
-9
@@ -1327,16 +1327,16 @@ type GetGroupHistoryResponse struct {
|
|||||||
List []GroupHistory `json:"list"`
|
List []GroupHistory `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetInviteSalesRequest struct {
|
type GetInviteRecordsRequest struct {
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
Size int `form:"size"`
|
Size int `form:"size"`
|
||||||
StartTime int64 `form:"start_time"`
|
StartTime int64 `form:"start_time"`
|
||||||
EndTime int64 `form:"end_time"`
|
EndTime int64 `form:"end_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetInviteSalesResponse struct {
|
type GetInviteRecordsResponse struct {
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
List []InvitedUserSale `json:"list"`
|
List []InviteRecord `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetLoginLogRequest struct {
|
type GetLoginLogRequest struct {
|
||||||
@@ -1768,11 +1768,12 @@ type InviteConfig struct {
|
|||||||
GiftDays int64 `json:"gift_days"`
|
GiftDays int64 `json:"gift_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvitedUserSale struct {
|
type InviteRecord struct {
|
||||||
Amount float64 `json:"amount"`
|
Role string `json:"role"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
PeerHash string `json:"peer_hash"`
|
||||||
UserHash string `json:"user_hash"`
|
GiftDays int64 `json:"gift_days"`
|
||||||
ProductName string `json:"product_name"`
|
OrderNo string `json:"order_no"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KickOfflineRequest struct {
|
type KickOfflineRequest struct {
|
||||||
|
|||||||
@@ -3,15 +3,26 @@ package hash
|
|||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/spaolacci/murmur3"
|
"github.com/spaolacci/murmur3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const invitePeerHashSalt = "ppanel_invite_" + "sales_v1"
|
||||||
|
|
||||||
// Hash returns the hash value of data.
|
// Hash returns the hash value of data.
|
||||||
func Hash(data []byte) uint64 {
|
func Hash(data []byte) uint64 {
|
||||||
return murmur3.Sum64(data)
|
return murmur3.Sum64(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InvitePeerHash(userId int64) string {
|
||||||
|
h := fnv.New64a()
|
||||||
|
_, _ = h.Write([]byte(invitePeerHashSalt))
|
||||||
|
_, _ = h.Write([]byte(strconv.FormatInt(userId, 10)))
|
||||||
|
return fmt.Sprintf("%010d", h.Sum64()%10000000000)
|
||||||
|
}
|
||||||
|
|
||||||
// Md5 returns the md5 bytes of data.
|
// Md5 returns the md5 bytes of data.
|
||||||
func Md5(data []byte) []byte {
|
func Md5(data []byte) []byte {
|
||||||
digest := md5.New()
|
digest := md5.New()
|
||||||
|
|||||||
@@ -1183,15 +1183,15 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/public/user/invite_sales": {
|
"/v1/public/user/invite_records": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "Get Invite Sales",
|
"summary": "Get Invite Records",
|
||||||
"operationId": "GetInviteSales",
|
"operationId": "GetInviteRecords",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "A successful response.",
|
"description": "A successful response.",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/GetInviteSalesResponse"
|
"$ref": "#/definitions/GetInviteRecordsResponse"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3166,7 +3166,7 @@
|
|||||||
"connection_records"
|
"connection_records"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GetInviteSalesRequest": {
|
"GetInviteRecordsRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"page": {
|
"page": {
|
||||||
@@ -3186,7 +3186,7 @@
|
|||||||
"format": "int64"
|
"format": "int64"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "GetInviteSalesRequest",
|
"title": "GetInviteRecordsRequest",
|
||||||
"required": [
|
"required": [
|
||||||
"page",
|
"page",
|
||||||
"size",
|
"size",
|
||||||
@@ -3194,7 +3194,7 @@
|
|||||||
"end_time"
|
"end_time"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GetInviteSalesResponse": {
|
"GetInviteRecordsResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"total": {
|
"total": {
|
||||||
@@ -3204,11 +3204,11 @@
|
|||||||
"list": {
|
"list": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/InvitedUserSale"
|
"$ref": "#/definitions/InviteRecord"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "GetInviteSalesResponse",
|
"title": "GetInviteRecordsResponse",
|
||||||
"required": [
|
"required": [
|
||||||
"total",
|
"total",
|
||||||
"list"
|
"list"
|
||||||
@@ -3558,30 +3558,34 @@
|
|||||||
"gift_days"
|
"gift_days"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"InvitedUserSale": {
|
"InviteRecord": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"amount": {
|
"role": {
|
||||||
"type": "number",
|
"type": "string"
|
||||||
"format": "double"
|
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"peer_hash": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gift_days": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
"user_hash": {
|
"order_no": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"product_name": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": "InvitedUserSale",
|
"title": "InviteRecord",
|
||||||
"required": [
|
"required": [
|
||||||
"amount",
|
"role",
|
||||||
"updated_at",
|
"peer_hash",
|
||||||
"user_hash",
|
"gift_days",
|
||||||
"product_name"
|
"order_no",
|
||||||
|
"created_at"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MessageLog": {
|
"MessageLog": {
|
||||||
|
|||||||
Reference in New Issue
Block a user