同步历史版本代码

This commit is contained in:
2026-03-03 09:32:22 -08:00
parent 7d46b31866
commit 4d8516b2e1
140 changed files with 8399 additions and 489 deletions
@@ -0,0 +1,138 @@
package apple
import (
"context"
"encoding/json"
"strings"
"github.com/perfect-panel/server/internal/model/payment"
"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"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type AttachTransactionByIdLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAttachTransactionByIdLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionByIdLogic {
return &AttachTransactionByIdLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AttachTransactionByIdLogic) AttachById(req *types.AttachAppleTransactionByIdRequest) (*types.AttachAppleTransactionResponse, error) {
l.Infow("attach by transaction id start", logger.Field("orderNo", req.OrderNo), logger.Field("transactionId", req.TransactionId))
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
l.Errorw("attach by id invalid access")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
ord, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
if err != nil {
l.Errorw("attach by id order not exist", logger.Field("orderNo", req.OrderNo))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist")
}
pay, err := l.svcCtx.PaymentModel.FindOne(l.ctx, ord.PaymentId)
if err != nil {
l.Errorw("attach by id payment not found", logger.Field("paymentId", ord.PaymentId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.PaymentMethodNotFound), "payment not found")
}
hasKey := false
if pay.Config != "" && (strings.Contains(pay.Config, "-----BEGIN PRIVATE KEY-----") || strings.Contains(pay.Config, "BEGIN PRIVATE KEY")) {
hasKey = true
}
l.Infow("attach by id payment config meta", logger.Field("paymentId", pay.Id), logger.Field("platform", pay.Platform), logger.Field("config_len", len(pay.Config)), logger.Field("has_private_key", hasKey))
if pay.Config == "" {
l.Errorw("attach by id iap config empty", logger.Field("paymentId", pay.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "iap config is empty")
}
var cfg payment.AppleIAPConfig
if err := cfg.Unmarshal([]byte(pay.Config)); err != nil {
l.Errorw("attach by id iap config error", logger.Field("error", err.Error()), logger.Field("paymentId", pay.Id), logger.Field("platform", pay.Platform), logger.Field("config_len", len(pay.Config)), logger.Field("has_private_key", hasKey))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "iap config error")
}
apiCfg := iapapple.ServerAPIConfig{
KeyID: cfg.KeyID,
IssuerID: cfg.IssuerID,
PrivateKey: cfg.PrivateKey,
Sandbox: cfg.Sandbox,
}
// Try to extract BundleID from productIds (if available in config) or custom data
// For now, we leave it empty unless we find it in config, but we can try to parse from payment config if needed.
// However, ServerAPIConfig update allows optional BundleID.
if req.Sandbox != nil {
apiCfg.Sandbox = *req.Sandbox
}
// Try to fix PEM format if it is missing newlines (common issue)
if !strings.Contains(apiCfg.PrivateKey, "\n") && strings.Contains(apiCfg.PrivateKey, "BEGIN PRIVATE KEY") {
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, " ", "\n")
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
}
// Fallback to hardcoded key (For debugging/dev)
if apiCfg.PrivateKey == "" {
apiCfg.PrivateKey = `-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
/T/KG1tr
-----END PRIVATE KEY-----`
apiCfg.KeyID = "2C4X3HVPM8"
}
if apiCfg.KeyID == "" || apiCfg.IssuerID == "" || apiCfg.PrivateKey == "" {
l.Errorw("attach by id credential missing")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "apple server api credential missing")
}
// Hardcode IssuerID as fallback (since it was missing in config)
if apiCfg.IssuerID == "" || apiCfg.IssuerID == "some_issuer_id" {
apiCfg.IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9"
}
// Try to get BundleID from Site CustomData if not set
if apiCfg.BundleID == "" {
var customData struct {
IapBundleId string `json:"iapBundleId"`
}
if l.svcCtx.Config.Site.CustomData != "" {
_ = json.Unmarshal([]byte(l.svcCtx.Config.Site.CustomData), &customData)
apiCfg.BundleID = customData.IapBundleId
}
}
jws, err := iapapple.GetTransactionInfo(apiCfg, req.TransactionId)
if err != nil {
l.Errorw("fetch transaction info error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "fetch transaction info error")
}
// reuse existing attach logic with JWS
attach := NewAttachTransactionLogic(l.ctx, l.svcCtx)
resp, e := attach.Attach(&types.AttachAppleTransactionRequest{
SignedTransactionJWS: jws,
SubscribeId: 0,
DurationDays: 0,
Tier: "",
OrderNo: req.OrderNo,
})
if e != nil {
l.Errorw("attach by id commit error", logger.Field("error", e.Error()))
return nil, e
}
l.Infow("attach by transaction id ok", logger.Field("orderNo", req.OrderNo), logger.Field("transactionId", req.TransactionId), logger.Field("expiresAt", resp.ExpiresAt))
return resp, nil
}
@@ -0,0 +1,267 @@
package apple
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/hibiken/asynq"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/subscribe"
"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"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
queueType "github.com/perfect-panel/server/queue/types"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type AttachTransactionLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewAttachTransactionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AttachTransactionLogic {
return &AttachTransactionLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest) (*types.AttachAppleTransactionResponse, error) {
l.Infow("开始绑定 Apple IAP 交易", logger.Field("orderNo", req.OrderNo))
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
l.Errorw("无效访问,用户信息缺失")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
txPayload, err := iapapple.VerifyTransactionJWS(req.SignedTransactionJWS)
if err != nil {
l.Errorw("JWS 验签失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
}
l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate))
// idempotency: check existing transaction by original id
var existTx *iapmodel.Transaction
existTx, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
l.Infow("幂等等检查", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("exists", existTx != nil && existTx.Id > 0))
// 解析 Apple 商品ID中的单位与数量:支持 dayN / monthN / yearN
var parsedUnit string
var parsedQuantity int64
{
pid := strings.ToLower(txPayload.ProductId)
parts := strings.Split(pid, ".")
for i := len(parts) - 1; i >= 0; i-- {
p := parts[i]
if strings.HasPrefix(p, "day") || strings.HasPrefix(p, "month") || strings.HasPrefix(p, "year") {
switch {
case strings.HasPrefix(p, "day"):
parsedUnit = "Day"
p = p[len("day"):]
case strings.HasPrefix(p, "month"):
parsedUnit = "Month"
p = p[len("month"):]
case strings.HasPrefix(p, "year"):
parsedUnit = "Year"
p = p[len("year"):]
}
digits := p
for j := 0; j < len(digits); j++ {
if digits[j] < '0' || digits[j] > '9' {
digits = digits[:j]
break
}
}
if q, e := strconv.ParseInt(digits, 10, 64); e == nil && q > 0 {
parsedQuantity = q
break
}
}
}
}
l.Infow("商品映射解析", logger.Field("productId", txPayload.ProductId), logger.Field("解析单位", parsedUnit), logger.Field("解析数量", parsedQuantity))
// 基于订阅列表的折扣配置做匹配:UnitTime=Day 且 Discount.quantity == parsedQuantity
var duration int64
var tier string
var subscribeId int64
if parsedQuantity > 0 {
_, subs, e := l.svcCtx.SubscribeModel.FilterList(l.ctx, &subscribe.FilterParams{
Page: 1,
Size: 9999,
Show: true,
Sell: true,
DefaultLanguage: true,
})
if e == nil && len(subs) > 0 {
for _, item := range subs {
if parsedUnit != "" && !strings.EqualFold(item.UnitTime, parsedUnit) {
continue
}
var discounts []types.SubscribeDiscount
if item.Discount != "" {
_ = json.Unmarshal([]byte(item.Discount), &discounts)
}
for _, d := range discounts {
if int64(d.Quantity) == parsedQuantity {
switch parsedUnit {
case "Day":
duration = parsedQuantity
case "Month":
duration = parsedQuantity * 30
case "Year":
duration = parsedQuantity * 365
default:
duration = parsedQuantity
}
subscribeId = item.Id
tier = item.Name
l.Infow("订阅映射命中", logger.Field("subscribeId", subscribeId), logger.Field("name", tier), logger.Field("durationDays", duration))
break
}
}
if subscribeId > 0 {
break
}
}
} else {
l.Infow("订阅列表为空或查询失败", logger.Field("error", func() string {
if e != nil {
return e.Error()
}
return ""
}()))
}
}
if subscribeId == 0 {
// fallback from order_no if provided
if req.OrderNo != "" {
if ord, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo); e == nil && ord != nil && ord.Id != 0 {
duration = ord.Quantity
subscribeId = ord.SubscribeId
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
} else {
l.Infow("订单信息不可用,尝试请求参数回退", logger.Field("orderNo", req.OrderNo))
}
}
// final fallback: use request fields
if duration <= 0 {
duration = req.DurationDays
}
if tier == "" {
tier = req.Tier
}
if subscribeId <= 0 {
subscribeId = req.SubscribeId
}
l.Infow("使用请求参数回退", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
if duration <= 0 || subscribeId <= 0 {
l.Errorw("商品识别失败", logger.Field("durationDays", duration), logger.Field("tier", tier), logger.Field("subscribeId", subscribeId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "unknown product")
}
}
exp := iapapple.CalcExpire(txPayload.PurchaseDate, duration)
l.Infow("计算订阅到期时间", logger.Field("expireAt", exp), logger.Field("expireUnix", exp.Unix()))
if existTx != nil && existTx.Id > 0 {
token := fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId)
existSub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
if err == nil && existSub != nil && existSub.Id > 0 {
// Already processed, return success
l.Infow("事务已处理,直接返回", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
Tier: tier,
}, nil
}
}
sum := sha256.Sum256([]byte(req.SignedTransactionJWS))
jwsHash := hex.EncodeToString(sum[:])
l.Infow("准备写入事务记录", logger.Field("userId", u.Id), logger.Field("transactionId", txPayload.TransactionId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("productId", txPayload.ProductId), logger.Field("jwsHash", jwsHash))
iapTx := &iapmodel.Transaction{
UserId: u.Id,
OriginalTransactionId: txPayload.OriginalTransactionId,
TransactionId: txPayload.TransactionId,
ProductId: txPayload.ProductId,
PurchaseAt: txPayload.PurchaseDate,
RevocationAt: txPayload.RevocationDate,
JWSHash: jwsHash,
}
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
if existTx == nil || existTx.Id == 0 {
if e := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; e != nil {
l.Errorw("写入事务表失败", logger.Field("error", e.Error()))
return e
}
l.Infow("写入事务表成功", logger.Field("id", iapTx.Id))
}
// insert user_subscribe
userSub := user.Subscribe{
UserId: u.Id,
SubscribeId: subscribeId,
StartTime: time.Now(),
ExpireTime: exp,
Traffic: 0,
Download: 0,
Upload: 0,
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
UUID: uuid.New().String(),
Status: 1,
}
if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil {
l.Errorw("写入用户订阅失败", logger.Field("error", e.Error()))
return e
}
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
// optional: mark related order as paid and enqueue activation
if req.OrderNo != "" {
orderInfo, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
if e != nil {
// do not fail transaction if order not found; just continue
l.Infow("订单不存在或查询失败,跳过订单状态更新", logger.Field("orderNo", req.OrderNo))
return nil
}
if orderInfo.Status == 1 {
if e := l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OrderNo, 2, tx); e != nil {
l.Errorw("更新订单状态失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", e.Error()))
return e
}
l.Infow("更新订单状态成功", logger.Field("orderNo", req.OrderNo), logger.Field("status", 2))
}
// enqueue activation regardless (idempotent handler downstream)
payload := queueType.ForthwithActivateOrderPayload{OrderNo: req.OrderNo}
bytes, _ := json.Marshal(payload)
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
if _, e := l.svcCtx.Queue.EnqueueContext(l.ctx, task); e != nil {
// non-fatal
l.Errorw("enqueue activate task error", logger.Field("error", e.Error()))
} else {
l.Infow("已加入订单激活队列", logger.Field("orderNo", req.OrderNo))
}
}
return nil
})
if err != nil {
l.Errorw("绑定事务提交失败", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error())
}
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
Tier: tier,
}, nil
}
@@ -0,0 +1,62 @@
package apple
import (
"context"
"time"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/constant"
"github.com/pkg/errors"
)
type GetStatusLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetStatusLogic {
return &GetStatusLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetStatusLogic) GetStatus() (*types.GetAppleStatusResponse, error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*struct{ Id int64 })
if !ok || u == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
var latest *iapmodel.Transaction
var err error
for pid := range pm.Items {
item, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByUserAndProduct(l.ctx, u.Id, pid)
if e == nil && item != nil && item.Id != 0 {
if latest == nil || item.PurchaseAt.After(latest.PurchaseAt) {
latest = item
}
}
}
if latest == nil {
return &types.GetAppleStatusResponse{
Active: false,
ExpiresAt: 0,
Tier: "",
}, nil
}
m := pm.Items[latest.ProductId]
exp := iapapple.CalcExpire(latest.PurchaseAt, m.DurationDays).Unix()
active := latest.RevocationAt == nil && (exp == 0 || exp > time.Now().Unix())
return &types.GetAppleStatusResponse{
Active: active,
ExpiresAt: exp,
Tier: m.Tier,
}, err
}
@@ -0,0 +1,170 @@
package apple
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/payment"
"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"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type RestoreLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRestoreLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RestoreLogic {
return &RestoreLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RestoreLogic) Restore(req *types.RestoreAppleTransactionsRequest) error {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
// Try to load payment config to get API credentials
var apiCfg iapapple.ServerAPIConfig
// We need to find *any* apple payment config to get credentials.
// In most cases, there is only one apple payment method.
// We can try to find by platform "apple"
payMethods, err := l.svcCtx.PaymentModel.FindListByPlatform(l.ctx, "apple")
if err == nil && len(payMethods) > 0 {
// Use the first available config
pay := payMethods[0]
var cfg payment.AppleIAPConfig
if err := cfg.Unmarshal([]byte(pay.Config)); err == nil {
apiCfg = iapapple.ServerAPIConfig{
KeyID: cfg.KeyID,
IssuerID: cfg.IssuerID,
PrivateKey: cfg.PrivateKey,
Sandbox: cfg.Sandbox,
}
// Fix private key format if needed (same as in attachTransactionByIdLogic)
if !strings.Contains(apiCfg.PrivateKey, "\n") && strings.Contains(apiCfg.PrivateKey, "BEGIN PRIVATE KEY") {
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, " ", "\n")
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
apiCfg.PrivateKey = strings.ReplaceAll(apiCfg.PrivateKey, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
}
}
}
// Fallback credentials if missing (dev/debug)
if apiCfg.PrivateKey == "" {
apiCfg.PrivateKey = `-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
/T/KG1tr
-----END PRIVATE KEY-----`
apiCfg.KeyID = "2C4X3HVPM8"
}
if apiCfg.IssuerID == "" {
apiCfg.IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9"
}
// Try to get BundleID
if apiCfg.BundleID == "" && l.svcCtx.Config.Site.CustomData != "" {
var customData struct {
IapBundleId string `json:"iapBundleId"`
}
_ = json.Unmarshal([]byte(l.svcCtx.Config.Site.CustomData), &customData)
apiCfg.BundleID = customData.IapBundleId
}
return l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
for _, txID := range req.Transactions {
// 1. Try to verify as JWS first (if client sends JWS)
var txp *iapapple.TransactionPayload
var err error
// Try to parse as JWS
if len(txID) > 50 && (strings.Contains(txID, ".") || strings.HasPrefix(txID, "ey")) {
txp, err = iapapple.VerifyTransactionJWS(txID)
} else {
// 2. If not JWS, treat as TransactionID and fetch from Apple
var jws string
jws, err = iapapple.GetTransactionInfo(apiCfg, txID)
if err == nil {
txp, err = iapapple.VerifyTransactionJWS(jws)
}
}
if err != nil || txp == nil {
l.Errorw("restore: invalid transaction", logger.Field("id", txID), logger.Field("error", err))
continue
}
m, ok := pm.Items[txp.ProductId]
if !ok {
continue
}
// Check if already processed
_, e := iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txp.OriginalTransactionId)
if e == nil {
continue // Already processed, skip
}
iapTx := &iapmodel.Transaction{
UserId: u.Id,
OriginalTransactionId: txp.OriginalTransactionId,
TransactionId: txp.TransactionId,
ProductId: txp.ProductId,
PurchaseAt: txp.PurchaseDate,
RevocationAt: txp.RevocationDate,
JWSHash: "",
}
if err := tx.Model(&iapmodel.Transaction{}).Create(iapTx).Error; err != nil {
return err
}
// Try to link with existing order if possible (Best Effort)
// Strategy 1: appAccountToken (from JWS) -> OrderNo (UUID)
if txp.AppAccountToken != "" {
// appAccountToken is usually a UUID string
// Try to find order by parsing UUID or matching direct orderNo (if we stored it as uuid)
// Since our orderNo is string, we can try to search it.
// However, AppAccountToken is strictly UUID format. If our orderNo is not UUID, we might need a mapping.
// Assuming orderNo -> UUID conversion was consistent on client side.
// Here we just try to update if we find an unpaid order with this ID (if orderNo was used as appAccountToken)
_ = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, txp.AppAccountToken, 2, tx)
}
// Strategy 2: If we had a way to pass orderNo in restore request (optional field in future), we could use it here.
// But for now, we only rely on appAccountToken or just skip order linking.
exp := iapapple.CalcExpire(txp.PurchaseDate, m.DurationDays)
userSub := user.Subscribe{
UserId: u.Id,
SubscribeId: m.SubscribeId,
StartTime: time.Now(),
ExpireTime: exp,
Traffic: 0,
Download: 0,
Upload: 0,
Token: txp.OriginalTransactionId,
UUID: uuid.New().String(),
Status: 1,
}
if err := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); err != nil {
return err
}
}
return nil
})
}
@@ -3,6 +3,7 @@ package order
import (
"context"
"encoding/json"
"math"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/pkg/tool"
@@ -82,7 +83,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}
price := sub.UnitPrice * req.Quantity
amount := int64(float64(price) * discount)
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
var couponAmount int64
if req.Coupon != "" {
+14 -2
View File
@@ -3,6 +3,7 @@ package order
import (
"context"
"encoding/json"
"math"
"time"
"github.com/perfect-panel/server/internal/model/log"
@@ -72,7 +73,18 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
}
if l.svcCtx.Config.Subscribe.SingleModel {
if len(userSub) > 0 {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
// Only block if user has a paid subscription (OrderId > 0)
// Allow purchase if user only has gift subscriptions
hasPaidSubscription := false
for _, s := range userSub {
if s.OrderId > 0 {
hasPaidSubscription = true
break
}
}
if hasPaidSubscription {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserSubscribeExist), "user has subscription")
}
}
}
@@ -101,7 +113,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
}
price := sub.UnitPrice * req.Quantity
// discount amount
amount := int64(float64(price) * discount)
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
// Validate amount to prevent overflow
@@ -35,7 +35,7 @@ func (l *QueryOrderListLogic) QueryOrderList(req *types.QueryOrderListRequest) (
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
total, data, err := l.svcCtx.OrderModel.QueryOrderListByPage(l.ctx, req.Page, req.Size, 0, u.Id, 0, "")
total, data, err := l.svcCtx.OrderModel.QueryOrderListByPage(l.ctx, req.Page, req.Size, uint8(req.Status), u.Id, 0, req.Search)
if err != nil {
l.Errorw("[QueryOrderListLogic] Query order list failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Query order list failed")
+2 -1
View File
@@ -3,6 +3,7 @@ package order
import (
"context"
"encoding/json"
"math"
"time"
"github.com/perfect-panel/server/internal/model/log"
@@ -79,7 +80,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
discount = getDiscount(dis, req.Quantity)
}
price := sub.UnitPrice * req.Quantity
amount := int64(float64(price) * discount)
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
// Validate amount to prevent overflow
@@ -3,6 +3,7 @@ package portal
import (
"context"
"encoding/json"
"strings"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/svc"
@@ -52,8 +53,28 @@ func (l *GetSubscriptionLogic) GetSubscription(req *types.GetSubscriptionRequest
var discount []types.SubscribeDiscount
_ = json.Unmarshal([]byte(item.Discount), &discount)
sub.Discount = discount
list[i] = sub
}
// Calculate node count
nodeIds := tool.StringToInt64Slice(item.Nodes)
var nodeTags []string
if item.NodeTags != "" {
tags := strings.Split(item.NodeTags, ",")
for _, tag := range tags {
if strings.TrimSpace(tag) != "" {
nodeTags = append(nodeTags, strings.TrimSpace(tag))
}
}
}
nodeCount, err := l.svcCtx.NodeModel.CountNodesByIdsAndTags(l.ctx, nodeIds, nodeTags)
if err != nil {
l.Logger.Error("[GetSubscription] count nodes failed: ", logger.Field("error", err.Error()))
sub.NodeCount = 0
} else {
sub.NodeCount = nodeCount
}
list[i] = sub
}
resp.List = list
@@ -3,6 +3,7 @@ package portal
import (
"context"
"encoding/json"
"math"
"github.com/perfect-panel/server/pkg/tool"
@@ -43,7 +44,7 @@ func (l *PrePurchaseOrderLogic) PrePurchaseOrder(req *types.PrePurchaseOrderRequ
discount = getDiscount(dis, req.Quantity)
}
price := sub.UnitPrice * req.Quantity
amount := int64(float64(price) * discount)
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
var coupon int64
if req.Coupon != "" {
@@ -3,7 +3,10 @@ package portal
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/perfect-panel/server/internal/model/log"
@@ -26,6 +29,7 @@ import (
"github.com/perfect-panel/server/pkg/payment/alipay"
"github.com/perfect-panel/server/pkg/payment/epay"
"github.com/perfect-panel/server/pkg/payment/stripe"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
@@ -73,6 +77,13 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
}
// Route to appropriate payment handler based on payment platform
switch paymentPlatform.ParsePlatform(orderInfo.Method) {
case paymentPlatform.AppleIAP:
productId := fmt.Sprintf("merchant.hifastvpn.day%d", orderInfo.Quantity)
resp = &types.CheckoutOrderResponse{
Type: "apple_iap",
ProductIds: []string{productId},
}
return resp, nil
case paymentPlatform.EPay:
// Process EPay payment - generates payment URL for redirect
url, err := l.epayPayment(paymentConfig, orderInfo, req.ReturnUrl)
@@ -186,13 +197,20 @@ func (l *PurchaseCheckoutLogic) alipayF2fPayment(pay *payment.Payment, info *ord
l.Errorw("[PurchaseCheckout] queryExchangeRate error", logger.Field("error", err.Error()))
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "queryExchangeRate error: %s", err.Error())
}
convertAmount := int64(amount * 100) // Convert to cents for API
convertAmount := int64(math.Round(amount * 100))
l.Infow("alipay amount",
logger.Field("src_cents", info.Amount),
logger.Field("decimal", amount),
logger.Field("cents", convertAmount),
)
// Create pre-payment trade and generate QR code
QRCode, err := client.PreCreateTrade(l.ctx, alipay.Order{
o := alipay.Order{
OrderNo: info.OrderNo,
Amount: convertAmount,
})
}
l.Infow("alipay request", logger.Field("order", o))
QRCode, err := client.PreCreateTrade(l.ctx, o)
if err != nil {
l.Errorw("[PurchaseCheckout] PreCreateTrade error", logger.Field("error", err.Error()))
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "PreCreateTrade error: %s", err.Error())
@@ -218,25 +236,50 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
WebhookSecret: stripeConfig.WebhookSecret,
})
// Convert order amount to CNY using current exchange rate
amount, err := l.queryExchangeRate("CNY", info.Amount)
currency := "USD"
sysCurrency, _ := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
if sysCurrency != nil {
configs := struct {
CurrencyUnit string
CurrencySymbol string
AccessKey string
}{}
tool.SystemConfigSliceReflectToStruct(sysCurrency, &configs)
if configs.CurrencyUnit != "" {
currency = configs.CurrencyUnit
}
}
// Convert order amount to configured currency using current exchange rate
amount, err := l.queryExchangeRate(strings.ToUpper(currency), info.Amount)
if err != nil {
l.Errorw("[PurchaseCheckout] queryExchangeRate error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "queryExchangeRate error: %s", err.Error())
}
convertAmount := int64(amount * 100) // Convert to cents for Stripe API
convertAmount := int64(math.Round(amount * 100))
l.Infow("stripe amount",
logger.Field("src_cents", info.Amount),
logger.Field("decimal", amount),
logger.Field("cents", convertAmount),
logger.Field("currency", currency),
)
// Create Stripe payment sheet for client-side processing
result, err := client.CreatePaymentSheet(&stripe.Order{
// Map apple_pay to card for Stripe API, but keep apple_pay in config/response
paymentMethod := stripeConfig.Payment
if paymentMethod == "apple_pay" {
paymentMethod = "card"
}
ord := &stripe.Order{
OrderNo: info.OrderNo,
Subscribe: strconv.FormatInt(info.SubscribeId, 10),
Amount: convertAmount,
Currency: "cny",
Payment: stripeConfig.Payment,
},
&stripe.User{
Email: identifier,
})
Currency: strings.ToLower(currency),
Payment: paymentMethod,
}
usr := &stripe.User{Email: identifier}
l.Infow("stripe request", logger.Field("order", ord), logger.Field("user", usr))
result, err := client.CreatePaymentSheet(ord, usr)
if err != nil {
l.Errorw("[PurchaseCheckout] CreatePaymentSheet error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "CreatePaymentSheet error: %s", err.Error())
@@ -282,6 +325,11 @@ func (l *PurchaseCheckoutLogic) epayPayment(config *payment.Payment, info *order
} else {
amount = float64(info.Amount) / float64(100)
}
amount = math.Round(amount*100) / 100
l.Infow("epay amount",
logger.Field("src_cents", info.Amount),
logger.Field("decimal", amount),
)
// gateway mod
isGatewayMod := report.IsGatewayMode()
@@ -342,6 +390,11 @@ func (l *PurchaseCheckoutLogic) CryptoSaaSPayment(config *payment.Payment, info
} else {
amount = float64(info.Amount) / float64(100)
}
amount = math.Round(amount*100) / 100
l.Infow("crypto amount",
logger.Field("src_cents", info.Amount),
logger.Field("decimal", amount),
)
// gateway mod
isGatewayMod := report.IsGatewayMode()
@@ -395,18 +448,53 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
return amount, nil
}
// Retrieve system currency configuration from DB for FixedRate fallback
currency, dbErr := l.svcCtx.SystemModel.GetCurrencyConfig(l.ctx)
var fixedRate string
if dbErr == nil && currency != nil {
configs := struct {
CurrencyUnit string
CurrencySymbol string
AccessKey string
FixedRate string
}{}
tool.SystemConfigSliceReflectToStruct(currency, &configs)
fixedRate = strings.TrimSpace(configs.FixedRate)
}
// Skip conversion if no exchange rate API key configured
if l.svcCtx.Config.Currency.AccessKey == "" {
if to == "CNY" && strings.TrimSpace(l.svcCtx.Config.Currency.Unit) == "USD" && fixedRate != "" {
r := tool.FormatStringToFloat(fixedRate)
if r > 0 {
l.Infow("exchangeRate.fixed", logger.Field("rate", r))
return amount * r, nil
}
}
l.Infof("[PurchaseCheckout] AccessKey is empty, skip conversion")
return amount, nil
}
// Convert currency if system currency differs from target currency
result, err := exchangeRate.GetExchangeRete(l.svcCtx.Config.Currency.Unit, to, l.svcCtx.Config.Currency.AccessKey, 1)
if err != nil {
l.Logger.Error("[PurchaseCheckout] QueryExchangeRate error", logger.Field("error", err.Error()))
return 0, err
if to == "CNY" && strings.TrimSpace(l.svcCtx.Config.Currency.Unit) == "USD" && fixedRate != "" {
r := tool.FormatStringToFloat(fixedRate)
if r > 0 {
l.Infow("exchangeRate.fixed.fallback", logger.Field("rate", r), logger.Field("error", err.Error()))
return amount * r, nil
}
}
// fallback: try without access key
result2, err2 := exchangeRate.GetExchangeRete(l.svcCtx.Config.Currency.Unit, to, "", 1)
if err2 != nil {
l.Logger.Error("[PurchaseCheckout] QueryExchangeRate error", logger.Field("error", err.Error()))
return 0, err
}
result = result2
}
l.svcCtx.ExchangeRate = result
l.Infow("exchangeRate", logger.Field("from", l.svcCtx.Config.Currency.Unit), logger.Field("to", to), logger.Field("rate", result))
return result * amount, nil
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"github.com/perfect-panel/server/internal/model/order"
@@ -73,7 +74,7 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
}
price := sub.UnitPrice * req.Quantity
// discount amount
amount := int64(float64(price) * discount)
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
var couponAmount int64 = 0
@@ -150,7 +151,7 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
}
content, _ := tempOrder.Marshal()
if _, err = l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), 24*time.Hour).Result(); err != nil {
if _, err = l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), CloseOrderTimeMinutes*time.Minute).Result(); err != nil {
l.Errorw("[Purchase] Redis set error", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo))
return err
}
@@ -0,0 +1,172 @@
package user
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
"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/jwt"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type BindEmailWithVerificationLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewBindEmailWithVerificationLogic Bind Email With Verification
func NewBindEmailWithVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindEmailWithVerificationLogic {
return &BindEmailWithVerificationLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.BindEmailWithVerificationRequest) (*types.BindEmailWithVerificationResponse, error) {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
type payload struct {
Code string `json:"code"`
LastAt int64 `json:"lastAt"`
}
var verified bool
scenes := []string{constant.Security.String(), constant.Register.String()}
for _, scene := range scenes {
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
value, getErr := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if getErr != nil || value == "" {
continue
}
var p payload
if err := json.Unmarshal([]byte(value), &p); err != nil {
continue
}
if p.Code == req.Code && time.Now().Unix()-p.LastAt <= l.svcCtx.Config.VerifyCode.ExpireTime {
_ = l.svcCtx.Redis.Del(l.ctx, cacheKey).Err()
verified = true
break
}
}
if !verified {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
}
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id)
if err != nil {
return nil, err
}
if currentEmailMethod != nil {
if currentEmailMethod.AuthIdentifier == req.Email {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account")
}
existingMethod, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query email bind status failed")
}
authInfo := &user.AuthMethods{
UserId: u.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: true,
}
if err = l.svcCtx.DB.Create(authInfo).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "bind email failed: %v", err)
}
token, err := l.refreshBindSessionToken(u.Id)
if err != nil {
return nil, err
}
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "email bound successfully",
Token: token,
UserId: u.Id,
}, nil
}
if existingMethod.UserId == u.Id {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
}
if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil {
return nil, err
}
joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification")
if err != nil {
return nil, err
}
token, err := l.refreshBindSessionToken(u.Id)
if err != nil {
return nil, err
}
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "joined family successfully",
Token: token,
UserId: u.Id,
FamilyJoined: true,
FamilyId: joinResult.FamilyId,
OwnerUserId: joinResult.OwnerUserId,
}, nil
}
func (l *BindEmailWithVerificationLogic) refreshBindSessionToken(userId int64) (string, error) {
sessionId, _ := l.ctx.Value(constant.CtxKeySessionID).(string)
if sessionId == "" {
sessionId = uuidx.NewUUID().String()
}
opts := []jwt.Option{
jwt.WithOption("UserId", userId),
jwt.WithOption("SessionId", sessionId),
}
if loginType, ok := l.ctx.Value(constant.CtxLoginType).(string); ok && loginType != "" {
opts = append(opts, jwt.WithOption("CtxLoginType", loginType))
}
if identifier, ok := l.ctx.Value(constant.CtxKeyIdentifier).(string); ok && identifier != "" {
opts = append(opts, jwt.WithOption("identifier", identifier))
}
token, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
time.Now().Unix(),
l.svcCtx.Config.JwtAuth.AccessExpire,
opts...,
)
if err != nil {
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
}
expire := time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire) * time.Second
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userId, expire).Err(); err != nil {
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
}
return token, nil
}
@@ -0,0 +1,68 @@
package user
import (
"context"
"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"
"gorm.io/gorm"
)
type BindInviteCodeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Bind Invite Code
func NewBindInviteCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindInviteCodeLogic {
return &BindInviteCodeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) error {
// 获取当前用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 检查用户是否已经绑定过邀请码
if currentUser.RefererId != 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserBindInviteCodeExist), "用户已绑定邀请人")
}
// 查找邀请人
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "无邀请码"), "invite code not found")
}
logger.WithContext(l.ctx).Error(err)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
}
// 检查是否是自己的邀请码
if referrer.Id == currentUser.Id {
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "不允许绑定自己"), "cannot bind your own invite code")
}
// 更新用户的RefererId
currentUser.RefererId = referrer.Id
err = l.svcCtx.UserModel.Update(l.ctx, currentUser)
if err != nil {
logger.WithContext(l.ctx).Error(err)
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update referrer id failed: %v", err.Error())
}
return nil
}
@@ -0,0 +1,374 @@
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/perfect-panel/server/internal/config"
"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/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type DeleteAccountLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewDeleteAccountLogic 创建注销账号逻辑实例
func NewDeleteAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteAccountLogic {
return &DeleteAccountLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// DeleteAccount 注销当前设备账号逻辑 (改为精准解绑)
func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, err error) {
// 获取当前用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 获取当前调用设备 ID
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
resp = &types.DeleteAccountResponse{}
var newUserId int64
// 如果没有识别到设备 ID (可能是旧版 Token),则执行安全注销:仅清除 Session
if currentDeviceId == 0 {
l.Infow("未识别到设备 ID,仅清理当前会话", logger.Field("user_id", currentUser.Id))
l.clearCurrentSession(currentUser.Id)
resp.Success = true
resp.Message = "会话已清除"
return resp, nil
}
// 开始数据库事务
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 1. 查找当前设备
var currentDevice user.Device
if err := tx.Where("id = ? AND user_id = ?", currentDeviceId, currentUser.Id).First(&currentDevice).Error; err != nil {
l.Infow("当前请求设备记录不存在或归属不匹配", logger.Field("device_id", currentDeviceId), logger.Field("error", err.Error()))
return nil // 不抛错,直接走清理 Session 流程
}
// 2. 检查用户是否有其他认证方式 (如邮箱) 或 其他设备
var authMethodsCount int64
tx.Model(&user.AuthMethods{}).Where("user_id = ?", currentUser.Id).Count(&authMethodsCount)
var devicesCount int64
tx.Model(&user.Device{}).Where("user_id = ?", currentUser.Id).Count(&devicesCount)
// 判定是否是主账号解绑:如果除了当前设备外,还有邮箱或其他设备,则只解绑当前设备
isMainAccount := authMethodsCount > 1 || devicesCount > 1
if isMainAccount {
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
// 【重要】先删除旧的认证记录,再创建新用户,避免唯一键冲突
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
return errors.Wrap(err, "删除原设备认证失败")
}
// 从原用户删除当前设备记录
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
return errors.Wrap(err, "删除原设备记录失败")
}
// 为当前设备创建新用户并迁移
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
if err != nil {
return err
}
newUserId = newUser.Id
} else {
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil {
return err
}
// 完全删除原用户相关资产
tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{})
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{})
tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{})
tx.Delete(&user.User{}, currentUser.Id)
// 重新注册一个新用户
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
if err != nil {
return err
}
newUserId = newUser.Id
}
return nil
})
if err != nil {
return nil, err
}
// 最终清理当前 Session
l.clearCurrentSession(currentUser.Id)
resp.Success = true
resp.Message = "注销成功"
resp.UserId = newUserId
resp.Code = 200
return resp, nil
}
// DeleteAccountAll 注销账号逻辑 (全部解绑默认创建账号)
func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountResponse, err error) {
// 获取当前用户
currentUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 获取当前调用设备 ID
currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64)
resp = &types.DeleteAccountResponse{}
var newUserId int64
// 开始数据库事务
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
// 1. 预先查找该用户下的所有设备记录 (因为稍后要迁移)
var userDevices []user.Device
if err := tx.Where("user_id = ?", currentUser.Id).Find(&userDevices).Error; err != nil {
l.Errorw("查询用户设备列表失败", logger.Field("user_id", currentUser.Id), logger.Field("error", err.Error()))
return err
}
// 如果没有识别到调用设备 ID,记录日志但继续执行 (全量注销不应受限)
if currentDeviceId == 0 {
l.Infow("未识别到当前设备 ID,将执行全量注销并尝试迁移所有已知设备", logger.Field("user_id", currentUser.Id), logger.Field("found_devices", len(userDevices)))
}
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
if err := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); err != nil {
return err
}
l.Infow("执行账号全量注销-迁移设备并删除旧数据", logger.Field("user_id", currentUser.Id), logger.Field("device_count", len(userDevices)))
// 2. 循环为每个设备创建新用户并迁移记录 (保留设备ID)
for _, dev := range userDevices {
// A. 创建新匿名用户
newUser, err := l.createAnonymousUser(tx)
if err != nil {
l.Errorw("为设备分配新用户主体失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
return err
}
// B. 迁移设备记录 (Update user_id)
if err := tx.Model(&user.Device{}).Where("id = ?", dev.Id).Update("user_id", newUser.Id).Error; err != nil {
l.Errorw("迁移设备记录失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
return errors.Wrap(err, "迁移设备记录失败")
}
// C. 迁移设备认证方式 (Update user_id)
if err := tx.Model(&user.AuthMethods{}).
Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", dev.Identifier).
Update("user_id", newUser.Id).Error; err != nil {
l.Errorw("迁移设备认证失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error()))
return errors.Wrap(err, "迁移设备认证失败")
}
// 如果是当前请求的设备,记录其新 UserID 返回给前端
if dev.Id == currentDeviceId || dev.Identifier == l.getIdentifierByDeviceID(userDevices, currentDeviceId) {
newUserId = newUser.Id
}
l.Infow("旧设备已迁移至新匿名账号",
logger.Field("old_user_id", currentUser.Id),
logger.Field("new_user_id", newUser.Id),
logger.Field("device_id", dev.Id),
logger.Field("identifier", dev.Identifier))
}
// 3. 删除旧账号的剩余数据
// 删除剩余的认证方式 (排除已迁移的device类型,剩下的如email/mobile等)
// 注意:刚才已经把由currentUser拥有的device类型auth都迁移走了,所以这里直接删剩下的即可
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil {
return errors.Wrap(err, "删除剩余认证方式失败")
}
// 设备记录已经全部迁移,理论上 user_id = currentUser.Id 的 device 应该没了,但为了保险可以删一下(或者是0)
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Device{}).Error; err != nil {
return errors.Wrap(err, "删除残留设备记录失败")
}
// 删除所有订阅
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil {
return errors.Wrap(err, "删除订阅失败")
}
// 删除用户主体
if err := tx.Delete(&user.User{}, currentUser.Id).Error; err != nil {
return errors.Wrap(err, "删除用户失败")
}
return nil
})
if err != nil {
return nil, err
}
// 最终清理所有 Session (踢掉所有设备)
l.clearAllSessions(currentUser.Id)
resp.Success = true
resp.Message = "注销成功"
resp.UserId = newUserId
resp.Code = 200
return resp, nil
}
// 辅助方法:通过 ID 查找 Identifier (以防 currentDeviceId 只是 ID)
func (l *DeleteAccountLogic) getIdentifierByDeviceID(devices []user.Device, id int64) string {
for _, d := range devices {
if d.Id == id {
return d.Identifier
}
}
return ""
}
// clearCurrentSession 清理当前请求的会话
func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
if sessionId, ok := l.ctx.Value(constant.CtxKeySessionID).(string); ok && sessionId != "" {
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
// 从用户会话集合中移除当前session
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
l.Infow("[SessionMonitor] 注销账号清除 Session",
logger.Field("user_id", userId),
logger.Field("session_id", sessionId))
}
}
// clearAllSessions 清理指定用户的所有会话
func (l *DeleteAccountLogic) clearAllSessions(userId int64) {
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
// 获取所有 session id
sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result()
if err != nil {
l.Errorw("获取用户会话列表失败", logger.Field("user_id", userId), logger.Field("error", err.Error()))
return
}
// 删除每个 session 的详情 key
for _, sid := range sessions {
sessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sid)
_ = l.svcCtx.Redis.Del(l.ctx, sessionKey).Err()
// 同时尝试删除 detail key (如果存在)
detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid)
_ = l.svcCtx.Redis.Del(l.ctx, detailKey).Err()
}
// 删除用户的 session 集合 key
_ = l.svcCtx.Redis.Del(l.ctx, sessionsKey).Err()
l.Infow("[SessionMonitor] 注销账号-清除所有Session", logger.Field("user_id", userId), logger.Field("count", len(sessions)))
}
// generateReferCode 生成推荐码
func generateReferCode() string {
bytes := make([]byte, 4)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
func (l *DeleteAccountLogic) registerUserAndDevice(tx *gorm.DB, identifier, ip, userAgent string) (*user.User, error) {
// 1. 创建新用户
userInfo := &user.User{
Salt: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
if err := tx.Create(userInfo).Error; err != nil {
l.Errorw("failed to create user", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
}
// 2. 更新推荐码
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
l.Errorw("failed to update refer code", logger.Field("user_id", userInfo.Id), logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
}
// 3. 创建设备认证方式
authMethod := &user.AuthMethods{
UserId: userInfo.Id,
AuthType: "device",
AuthIdentifier: identifier,
Verified: true,
}
if err := tx.Create(authMethod).Error; err != nil {
l.Errorw("failed to create device auth method", logger.Field("user_id", userInfo.Id), logger.Field("identifier", identifier), logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create device auth method failed: %v", err)
}
// 4. 插入设备记录
deviceInfo := &user.Device{
Ip: ip,
UserId: userInfo.Id,
UserAgent: userAgent,
Identifier: identifier,
Enabled: true,
Online: false,
}
if err := tx.Create(deviceInfo).Error; err != nil {
l.Errorw("failed to insert device", logger.Field("user_id", userInfo.Id), logger.Field("identifier", identifier), logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert device failed: %v", err)
}
return userInfo, nil
}
// createAnonymousUser 创建一个新的匿名用户主体 (仅User表)
func (l *DeleteAccountLogic) createAnonymousUser(tx *gorm.DB) (*user.User, error) {
// 1. 创建新用户
userInfo := &user.User{
Salt: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
if err := tx.Create(userInfo).Error; err != nil {
l.Errorw("failed to create user", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create user failed: %v", err)
}
// 2. 更新推荐码
userInfo.ReferCode = uuidx.UserInviteCode(userInfo.Id)
if err := tx.Model(&user.User{}).Where("id = ?", userInfo.Id).Update("refer_code", userInfo.ReferCode).Error; err != nil {
l.Errorw("failed to update refer code", logger.Field("user_id", userInfo.Id), logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
}
return userInfo, nil
}
@@ -0,0 +1,233 @@
package user
import (
"context"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type familyJoinResult struct {
FamilyId int64
OwnerUserId int64
}
type familyBindingHelper struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func newFamilyBindingHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyBindingHelper {
return &familyBindingHelper{
ctx: ctx,
svcCtx: svcCtx,
}
}
func (h *familyBindingHelper) getUserEmailMethod(userId int64) (*user.AuthMethods, error) {
method, err := h.svcCtx.UserModel.FindUserAuthMethodByUserId(h.ctx, "email", userId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user email auth method failed")
}
return method, nil
}
func (h *familyBindingHelper) validateJoinFamily(ownerUserId, memberUserId int64) error {
if ownerUserId == memberUserId {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already bound to this family")
}
var ownerFamily user.UserFamily
err := h.svcCtx.DB.WithContext(h.ctx).
Unscoped().
Model(&user.UserFamily{}).
Where("owner_user_id = ? AND status = ?", ownerUserId, user.FamilyStatusActive).
First(&ownerFamily).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family failed")
}
var memberRecord user.UserFamilyMember
err = h.svcCtx.DB.WithContext(h.ctx).
Unscoped().
Model(&user.UserFamilyMember{}).
Where("user_id = ?", memberUserId).
First(&memberRecord).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query member family relation failed")
}
if err == nil {
if ownerFamily.Id != 0 && memberRecord.FamilyId == ownerFamily.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
}
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
}
if ownerFamily.Id == 0 {
return nil
}
var activeCount int64
if err = h.svcCtx.DB.WithContext(h.ctx).
Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ?", ownerFamily.Id, user.FamilyMemberActive).
Count(&activeCount).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count family members failed")
}
if activeCount >= ownerFamily.MaxMembers {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyMemberLimitExceeded), "family member limit exceeded")
}
return nil
}
func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source string) (*familyJoinResult, error) {
if ownerUserId == memberUserId {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already bound to this family")
}
result := &familyJoinResult{
OwnerUserId: ownerUserId,
}
err := h.svcCtx.DB.WithContext(h.ctx).Transaction(func(tx *gorm.DB) error {
ownerFamily, err := h.getOrCreateOwnerFamily(tx, ownerUserId)
if err != nil {
return err
}
result.FamilyId = ownerFamily.Id
if err = h.ensureOwnerMember(tx, ownerFamily.Id, ownerUserId); err != nil {
return err
}
var memberRecord user.UserFamilyMember
err = tx.Unscoped().Model(&user.UserFamilyMember{}).Where("user_id = ?", memberUserId).First(&memberRecord).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query member family relation failed")
}
memberExists := err == nil
if memberExists {
if memberRecord.FamilyId == ownerFamily.Id {
if memberRecord.Status == user.FamilyMemberActive {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
}
} else {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
}
}
var activeCount int64
if err = tx.Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ?", ownerFamily.Id, user.FamilyMemberActive).
Count(&activeCount).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count family members failed")
}
if activeCount >= ownerFamily.MaxMembers {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyMemberLimitExceeded), "family member limit exceeded")
}
now := time.Now()
if !memberExists {
memberRecord = user.UserFamilyMember{
FamilyId: ownerFamily.Id,
UserId: memberUserId,
Role: user.FamilyRoleMember,
Status: user.FamilyMemberActive,
JoinSource: source,
JoinedAt: now,
}
if err = tx.Create(&memberRecord).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create family member failed")
}
return nil
}
memberRecord.Status = user.FamilyMemberActive
memberRecord.Role = user.FamilyRoleMember
memberRecord.JoinSource = source
memberRecord.JoinedAt = now
memberRecord.LeftAt = nil
if err = tx.Save(&memberRecord).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update family member failed")
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (h *familyBindingHelper) getOrCreateOwnerFamily(tx *gorm.DB, ownerUserId int64) (*user.UserFamily, error) {
var ownerFamily user.UserFamily
err := tx.Unscoped().Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&user.UserFamily{}).
Where("owner_user_id = ?", ownerUserId).
First(&ownerFamily).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family failed")
}
ownerFamily = user.UserFamily{
OwnerUserId: ownerUserId,
MaxMembers: user.DefaultFamilyMaxSize,
Status: user.FamilyStatusActive,
}
if err = tx.Create(&ownerFamily).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create owner family failed")
}
} else if ownerFamily.Status != user.FamilyStatusActive || ownerFamily.DeletedAt.Valid {
ownerFamily.Status = user.FamilyStatusActive
ownerFamily.DeletedAt = gorm.DeletedAt{}
if err = tx.Unscoped().Save(&ownerFamily).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "activate owner family failed")
}
}
return &ownerFamily, nil
}
func (h *familyBindingHelper) ensureOwnerMember(tx *gorm.DB, familyId, ownerUserId int64) error {
var ownerMember user.UserFamilyMember
err := tx.Unscoped().Model(&user.UserFamilyMember{}).Where("user_id = ?", ownerUserId).First(&ownerMember).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family member failed")
}
ownerMember = user.UserFamilyMember{
FamilyId: familyId,
UserId: ownerUserId,
Role: user.FamilyRoleOwner,
Status: user.FamilyMemberActive,
JoinSource: "owner_init",
JoinedAt: time.Now(),
}
if err = tx.Create(&ownerMember).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create owner family member failed")
}
return nil
}
if ownerMember.FamilyId != familyId {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "owner already belongs to another family")
}
if ownerMember.Status != user.FamilyMemberActive || ownerMember.Role != user.FamilyRoleOwner || ownerMember.DeletedAt.Valid {
ownerMember.Status = user.FamilyMemberActive
ownerMember.Role = user.FamilyRoleOwner
ownerMember.LeftAt = nil
ownerMember.DeletedAt = gorm.DeletedAt{}
if err = tx.Unscoped().Save(&ownerMember).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update owner family member failed")
}
}
return nil
}
@@ -0,0 +1,73 @@
package user
import (
"context"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
const familyStatusDisabled uint8 = 0
type familyExitHelper struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func newFamilyExitHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyExitHelper {
return &familyExitHelper{
ctx: ctx,
svcCtx: svcCtx,
}
}
func (h *familyExitHelper) removeUserFromActiveFamily(tx *gorm.DB, userID int64, dissolveWhenOwner bool) error {
var relation user.UserFamilyMember
err := tx.Unscoped().
Model(&user.UserFamilyMember{}).
Where("user_id = ? AND status = ?", userID, user.FamilyMemberActive).
First(&relation).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member relation failed")
}
now := time.Now()
if relation.Role == user.FamilyRoleOwner && dissolveWhenOwner {
if err = tx.Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ?", relation.FamilyId, user.FamilyMemberActive).
Updates(map[string]interface{}{
"status": user.FamilyMemberRemoved,
"left_at": now,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove owner family members failed")
}
if err = tx.Model(&user.UserFamily{}).
Where("id = ?", relation.FamilyId).
Updates(map[string]interface{}{
"status": familyStatusDisabled,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable owner family failed")
}
return nil
}
if err = tx.Model(&user.UserFamilyMember{}).
Where("id = ?", relation.Id).
Updates(map[string]interface{}{
"status": user.FamilyMemberRemoved,
"left_at": now,
}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove family member failed")
}
return nil
}
@@ -0,0 +1,68 @@
package user
import (
"context"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type familyScopeHelper struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func newFamilyScopeHelper(ctx context.Context, svcCtx *svc.ServiceContext) *familyScopeHelper {
return &familyScopeHelper{
ctx: ctx,
svcCtx: svcCtx,
}
}
func (h *familyScopeHelper) resolveScopedUserIds(currentUserId int64) ([]int64, error) {
familyId, err := h.getCurrentFamilyId(currentUserId)
if err != nil {
return nil, err
}
if familyId == 0 {
return []int64{currentUserId}, nil
}
var userIds []int64
err = h.svcCtx.DB.WithContext(h.ctx).
Model(&user.UserFamilyMember{}).
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive).
Where("user_family_member.family_id = ? AND user_family_member.status = ?", familyId, user.FamilyMemberActive).
Pluck("user_family_member.user_id", &userIds).Error
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family members failed")
}
if len(userIds) == 0 {
return []int64{currentUserId}, nil
}
if !tool.Contains(userIds, currentUserId) {
userIds = append(userIds, currentUserId)
}
return tool.RemoveDuplicateElements(userIds...), nil
}
func (h *familyScopeHelper) getCurrentFamilyId(currentUserId int64) (int64, error) {
var relation user.UserFamilyMember
err := h.svcCtx.DB.WithContext(h.ctx).
Model(&user.UserFamilyMember{}).
Select("user_family_member.family_id").
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL AND user_family.status = ?", user.FamilyStatusActive).
Where("user_family_member.user_id = ? AND user_family_member.status = ?", currentUserId, user.FamilyMemberActive).
First(&relation).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query current family failed")
}
return relation.FamilyId, nil
}
@@ -0,0 +1,94 @@
package user
import (
"context"
"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 GetAgentDownloadsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// NewGetAgentDownloadsLogic 创建 GetAgentDownloadsLogic 实例
func NewGetAgentDownloadsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentDownloadsLogic {
return &GetAgentDownloadsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// PlatformStats 各平台设备统计结果
type PlatformStats struct {
Android int64 `gorm:"column:android"`
IOS int64 `gorm:"column:ios"`
Mac int64 `gorm:"column:mac"`
Windows int64 `gorm:"column:windows"`
Total int64 `gorm:"column:total"`
}
// GetAgentDownloads 获取用户代理下载统计数据
// 基于用户邀请码查询被邀请用户的设备UA来统计各平台安装量
func (l *GetAgentDownloadsLogic) GetAgentDownloads(req *types.GetAgentDownloadsRequest) (resp *types.GetAgentDownloadsResponse, err error) {
// 1. 从 context 获取用户信息
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetAgentDownloads] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 2. 通过数据库查询各平台设备安装量
// 基于 user_device 表的 user_agent 字段判断平台
// UA格式: HiVPN/1.0.0 (平台; 设备; 版本) Flutter
var stats PlatformStats
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user u").
Select(`
SUM(CASE WHEN d.user_agent LIKE '%(Android;%' THEN 1 ELSE 0 END) AS android,
SUM(CASE WHEN d.user_agent LIKE '%(iOS;%' THEN 1 ELSE 0 END) AS ios,
SUM(CASE WHEN d.user_agent LIKE '%(macOS;%' THEN 1 ELSE 0 END) AS mac,
SUM(CASE WHEN d.user_agent LIKE '%(Windows;%' THEN 1 ELSE 0 END) AS windows,
COUNT(*) AS total
`).
Joins("JOIN user_device d ON u.id = d.user_id").
Where("u.referer_id = ?", u.Id).
Scan(&stats).Error
if err != nil {
l.Errorw("[GetAgentDownloads] query platform stats failed",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"query platform stats failed: %v", err.Error())
}
l.Infow("[GetAgentDownloads] platform stats fetched successfully",
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode),
logger.Field("android", stats.Android),
logger.Field("ios", stats.IOS),
logger.Field("mac", stats.Mac),
logger.Field("windows", stats.Windows),
logger.Field("total", stats.Total))
// 3. 构造响应
return &types.GetAgentDownloadsResponse{
Total: stats.Total,
Platforms: &types.PlatformDownloads{
IOS: stats.IOS,
Android: stats.Android,
Windows: stats.Windows,
Mac: stats.Mac,
},
ComparisonRate: nil, // 不再计算环比
}, nil
}
@@ -0,0 +1,182 @@
package user
import (
"context"
"fmt"
"time"
"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/loki"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetAgentRealtimeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetAgentRealtimeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAgentRealtimeLogic {
return &GetAgentRealtimeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequest) (resp *types.GetAgentRealtimeResponse, err error) {
// 1. Get current user
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetAgentRealtime] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
var views, lastMonthViews int64
var installs int64
var paidCount int64
// 2. 从 Loki 获取 viewsnginx 访问日志)
lokiCfg := l.svcCtx.Config.Loki
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
lokiClient := loki.NewClient(lokiCfg.URL)
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
if err != nil {
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode))
// 不返回错误,继续使用已有数据
} else {
views = lokiStats.MacClicks + lokiStats.WindowsClicks
lastMonthViews = lokiStats.LastMonthMac + lokiStats.LastMonthWindows
l.Infow("[GetAgentRealtime] Fetched Loki stats successfully",
logger.Field("user_id", u.Id),
logger.Field("refer_code", u.ReferCode),
logger.Field("views", views),
logger.Field("last_month_views", lastMonthViews))
}
}
// 3. 从数据库获取安装量(被邀请注册用户数)
err = l.svcCtx.DB.WithContext(l.ctx).
Model(&user.User{}).
Where("referer_id = ?", u.Id).
Count(&installs).Error
if err != nil {
l.Errorw("[GetAgentRealtime] Failed to count installs",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
installs = 0
}
// 4. 获取付费用户数
err = l.svcCtx.DB.WithContext(l.ctx).
Table("`order`").
Joins("LEFT JOIN user ON user.id = `order`.user_id").
Where("user.referer_id = ? AND `order`.status IN ?", u.Id, []int{2, 5}).
Distinct("`order`.user_id").
Count(&paidCount).Error
if err != nil {
l.Errorw("[GetAgentRealtime] Failed to count paid users",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id))
paidCount = 0
}
// 5. 计算环比增长率
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
// 6. 计算付费用户环比增长率
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
return &types.GetAgentRealtimeResponse{
Total: views,
Clicks: views,
Views: views,
Installs: installs,
PaidCount: paidCount,
GrowthRate: growthRate,
PaidGrowthRate: paidGrowthRate,
}, nil
}
// calculatePaidGrowthRate 计算付费用户的环比增长率
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
db := l.svcCtx.DB
// 获取本月第一天和上月第一天
now := time.Now()
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
// 查询本月付费用户数(本月有新订单的)
var currentMonthCount int64
err := db.Table("`order` o").
Joins("JOIN user u ON o.user_id = u.id").
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ?",
userId, 2, 5, currentMonthStart).
Distinct("o.user_id").
Count(&currentMonthCount).Error
if err != nil {
l.Errorw("Failed to count current month paid users",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return "N/A"
}
// 查询上月付费用户数
var lastMonthCount int64
err = db.Table("`order` o").
Joins("JOIN user u ON o.user_id = u.id").
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ? AND o.created_at < ?",
userId, 2, 5, lastMonthStart, currentMonthStart).
Distinct("o.user_id").
Count(&lastMonthCount).Error
if err != nil {
l.Errorw("Failed to count last month paid users",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return "N/A"
}
// 计算增长率
return calculateGrowthRate([]int{int(lastMonthCount), int(currentMonthCount)})
}
// calculateGrowthRate 计算环比增长率
// views: 月份数据数组,最后一个是本月,倒数第二个是上月
func calculateGrowthRate(views []int) string {
if len(views) < 2 {
return "N/A"
}
currentMonth := views[len(views)-1]
lastMonth := views[len(views)-2]
// 如果上月是0,无法计算百分比
if lastMonth == 0 {
if currentMonth == 0 {
return "0%"
}
return "+100%"
}
// 计算增长率
growth := float64(currentMonth-lastMonth) / float64(lastMonth) * 100
// 格式化输出
if growth > 0 {
return fmt.Sprintf("+%.1f%%", growth)
} else if growth < 0 {
return fmt.Sprintf("%.1f%%", growth)
}
return "0%"
}
@@ -28,7 +28,12 @@ func NewGetDeviceListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Get
func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse, err error) {
userInfo := l.ctx.Value(constant.CtxKeyUser).(*user.User)
list, count, err := l.svcCtx.UserModel.QueryDeviceList(l.ctx, userInfo.Id)
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
if err != nil {
return nil, err
}
list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
userRespList := make([]types.UserDevice, 0)
tool.DeepCopy(&userRespList, list)
resp = &types.GetDeviceListResponse{
@@ -0,0 +1,142 @@
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 = ?", userId, 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 = ?", userId, 5) // status 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 as "{{ quantity }}天VPN服务"
productName := fmt.Sprintf("%d天VPN服务", order.Quantity)
if order.Quantity <= 0 {
productName = "1天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
}
@@ -0,0 +1,63 @@
package user
import (
"context"
"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 GetSubscribeStatusLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetSubscribeStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSubscribeStatusLogic {
return &GetSubscribeStatusLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetSubscribeStatusLogic) GetSubscribeStatus(req *types.GetSubscribeStatusRequest) (*types.GetSubscribeStatusResponse, error) {
// 取当前用户
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok || u == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user token")
}
// 1. 查 device 认证方式有没有套餐
deviceStatus := false
if len(u.UserDevices) > 0 {
if dev, err := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, u.UserDevices[0].Identifier); err == nil && dev.Id > 0 {
subscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, dev.UserId)
if err == nil {
deviceStatus = len(subscribes) > 0
}
}
}
// 2.根据 req.email 查询 有没有套餐
emailStatus := false
if req.Email != "" {
if auth, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email); err == nil && auth.Id > 0 {
subscribes, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, auth.UserId)
if err == nil {
emailStatus = len(subscribes) > 0
}
}
}
l.Infow("get subscribe status", logger.Field("userId", u.Id), logger.Field("device_status", deviceStatus), logger.Field("email_status", emailStatus))
return &types.GetSubscribeStatusResponse{
DeviceStatus: deviceStatus,
EmailStatus: emailStatus,
}, nil
}
@@ -0,0 +1,78 @@
package user
import (
"context"
"database/sql"
"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 GetUserInviteStatsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get user invite statistics
func NewGetUserInviteStatsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserInviteStatsLogic {
return &GetUserInviteStatsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetUserInviteStatsLogic) GetUserInviteStats(req *types.GetUserInviteStatsRequest) (resp *types.GetUserInviteStatsResponse, err error) {
// 1. 从 context 中获取当前登录用户
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
l.Errorw("[GetUserInviteStats] user not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
userId := u.Id
// 2. 获取历史邀请佣金 (FriendlyCount): 所有被邀请用户产生订单的佣金总和
// 注意:这里复用了 friendly_count 字段名,实际含义是佣金总额
var totalCommission sql.NullInt64
err = l.svcCtx.DB.WithContext(l.ctx).
Table("`order` o").
Select("COALESCE(SUM(o.commission), 0) as total").
Joins("JOIN user u ON o.user_id = u.id").
Where("u.referer_id = ? AND o.status IN (?, ?)", userId, 2, 5). // 只统计已支付和已完成的订单
Scan(&totalCommission).Error
if err != nil {
l.Errorw("[GetUserInviteStats] sum commission failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"sum commission failed: %v", err.Error())
}
friendlyCount := totalCommission.Int64
// 3. 获取历史邀请总数 (HistoryCount)
var historyCount int64
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user").
Where("referer_id = ?", userId).
Count(&historyCount).Error
if err != nil {
l.Errorw("[GetUserInviteStats] count history users failed",
logger.Field("error", err.Error()),
logger.Field("user_id", userId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError),
"count history users failed: %v", err.Error())
}
return &types.GetUserInviteStatsResponse{
FriendlyCount: friendlyCount,
HistoryCount: historyCount,
}, nil
}
@@ -2,9 +2,11 @@ package user
import (
"context"
"encoding/json"
"sort"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/kutt"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
@@ -61,9 +63,124 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
})
resp.AuthMethods = userMethods
// 生成邀请短链接
if l.svcCtx.Config.Kutt.Enable && resp.ReferCode != "" {
shortLink := l.generateInviteShortLink(resp.ReferCode)
if shortLink != "" {
resp.ShareLink = shortLink
}
}
return resp, nil
}
// customData 用于解析 SiteConfig.CustomData JSON 字段
// 包含从自定义数据中提取所需的配置项
type customData struct {
ShareUrl string `json:"shareUrl"` // 分享链接前缀 URL(目标落地页)
Domain string `json:"domain"` // 短链接域名
}
// getShareUrl 从 SiteConfig.CustomData 中获取 shareUrl
//
// 返回:
// - string: 分享链接前缀 URL,如果获取失败则返回 Kutt.TargetURL 作为 fallback
func (l *QueryUserInfoLogic) getShareUrl() string {
siteConfig := l.svcCtx.Config.Site
if siteConfig.CustomData != "" {
var data customData
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
if data.ShareUrl != "" {
return data.ShareUrl
}
}
}
// fallback 到 Kutt.TargetURL
return l.svcCtx.Config.Kutt.TargetURL
}
// getDomain 从 SiteConfig.CustomData 中获取短链接域名
//
// 返回:
// - string: 短链接域名,如果获取失败则返回 Kutt.Domain 作为 fallback
func (l *QueryUserInfoLogic) getDomain() string {
siteConfig := l.svcCtx.Config.Site
if siteConfig.CustomData != "" {
var data customData
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
if data.Domain != "" {
return data.Domain
}
}
}
// fallback 到 Kutt.Domain
return l.svcCtx.Config.Kutt.Domain
}
// generateInviteShortLink 生成邀请短链接(带 Redis 缓存)
//
// 参数:
// - inviteCode: 邀请码
//
// 返回:
// - string: 短链接 URL,失败时返回空字符串
func (l *QueryUserInfoLogic) generateInviteShortLink(inviteCode string) string {
cfg := l.svcCtx.Config.Kutt
shareUrl := l.getShareUrl()
domain := l.getDomain()
// 检查必要配置
if cfg.ApiURL == "" || cfg.ApiKey == "" {
l.Sloww("Kutt config incomplete",
logger.Field("api_url", cfg.ApiURL != ""),
logger.Field("api_key", cfg.ApiKey != ""))
return ""
}
if shareUrl == "" {
l.Sloww("ShareUrl not configured in CustomData or Kutt.TargetURL")
return ""
}
// Redis 缓存 key
cacheKey := "cache:invite:short_link:" + inviteCode
// 1. 尝试从 Redis 缓存读取
cachedLink, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if err == nil && cachedLink != "" {
l.Debugw("Hit cache for invite short link",
logger.Field("invite_code", inviteCode),
logger.Field("short_link", cachedLink))
return cachedLink
}
// 2. 缓存未命中,调用 Kutt API 创建短链接
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
shortLink, err := client.CreateInviteShortLink(l.ctx, shareUrl, inviteCode, domain)
if err != nil {
l.Errorw("Failed to create short link",
logger.Field("error", err.Error()),
logger.Field("invite_code", inviteCode),
logger.Field("share_url", shareUrl))
return ""
}
// 3. 写入 Redis 缓存(永不过期,因为邀请码不变短链接也不会变)
if err := l.svcCtx.Redis.Set(l.ctx, cacheKey, shortLink, 0).Err(); err != nil {
l.Errorw("Failed to cache short link",
logger.Field("error", err.Error()),
logger.Field("invite_code", inviteCode))
// 缓存失败不影响返回
}
l.Infow("Created and cached invite short link",
logger.Field("invite_code", inviteCode),
logger.Field("short_link", shortLink),
logger.Field("share_url", shareUrl))
return shortLink
}
// getAuthTypePriority 获取认证类型的排序优先级
// email: 1 (第一位)
// mobile: 2 (第二位)
@@ -62,6 +62,15 @@ func (l *QueryUserSubscribeLogic) QueryUserSubscribe() (resp *types.QueryUserSub
short, _ := tool.FixedUniqueString(item.Token, 8, "")
sub.Short = short
// 查询订单金额,判断是否为赠送订单(amount=0)
if item.OrderId > 0 {
orderInfo, err := l.svcCtx.OrderModel.FindOne(l.ctx, item.OrderId)
if err == nil && orderInfo != nil {
sub.IsGift = orderInfo.Amount == 0
}
}
sub.ResetTime = calculateNextResetTime(&sub)
resp.List = append(resp.List, sub)
}
@@ -10,6 +10,7 @@ import (
"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/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
@@ -37,7 +38,13 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.DeviceNotExist), "find device")
}
if device.UserId != userInfo.Id {
scopeHelper := newFamilyScopeHelper(l.ctx, l.svcCtx)
scopeUserIds, err := scopeHelper.resolveScopedUserIds(userInfo.Id)
if err != nil {
return err
}
if !tool.Contains(scopeUserIds, device.UserId) {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user")
}
@@ -64,15 +71,21 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete device online record err: %v", err)
}
var count int64
err = tx.Model(user.AuthMethods{}).Where("user_id = ?", deleteDevice.UserId).Count(&count).Error
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count user auth methods err: %v", err)
if deleteDevice.UserId == userInfo.Id {
shouldLeaveFamily, leaveCheckErr := l.shouldLeaveFamilyAfterUnbind(tx, userInfo.Id)
if leaveCheckErr != nil {
return leaveCheckErr
}
if shouldLeaveFamily {
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
if leaveErr := exitHelper.removeUserFromActiveFamily(tx, userInfo.Id, true); leaveErr != nil {
return leaveErr
}
}
}
if count < 1 {
_ = tx.Where("id = ?", deleteDevice.UserId).Delete(&user.User{}).Error
}
l.svcCtx.DeviceManager.KickDevice(deleteDevice.UserId, deleteDevice.Identifier)
//remove device cache
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, deleteDevice.Identifier)
@@ -84,3 +97,34 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
return nil
})
}
func (l *UnbindDeviceLogic) shouldLeaveFamilyAfterUnbind(tx *gorm.DB, userID int64) (bool, error) {
var nonDeviceAuthCount int64
if err := tx.Model(&user.AuthMethods{}).
Where("user_id = ? AND auth_type <> ?", userID, "device").
Count(&nonDeviceAuthCount).Error; err != nil {
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count non-device auth methods failed")
}
if nonDeviceAuthCount > 0 {
return false, nil
}
var deviceAuthCount int64
if err := tx.Model(&user.AuthMethods{}).
Where("user_id = ? AND auth_type = ?", userID, "device").
Count(&deviceAuthCount).Error; err != nil {
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count device auth methods failed")
}
if deviceAuthCount > 0 {
return false, nil
}
var deviceCount int64
if err := tx.Model(&user.Device{}).
Where("user_id = ?", userID).
Count(&deviceCount).Error; err != nil {
return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count devices failed")
}
return deviceCount == 0, nil
}
@@ -2,6 +2,7 @@ package user
import (
"context"
"strings"
"github.com/perfect-panel/server/pkg/constant"
@@ -31,24 +32,42 @@ func NewUpdateBindEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *U
}
func (l *UpdateBindEmailLogic) UpdateBindEmail(req *types.UpdateBindEmailRequest) error {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
method, err := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", u.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
method, err := familyHelper.getUserEmailMethod(u.Id)
if err != nil {
return err
}
if method != nil {
if method.AuthIdentifier == req.Email {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
}
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "email already bound to another account")
}
m, err := l.svcCtx.UserModel.FindUserAuthMethodByOpenID(l.ctx, "email", req.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "FindUserAuthMethodByOpenID error")
}
// email already bind
if m.Id > 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "email already bind")
if m != nil && m.Id > 0 {
if m.UserId == u.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "email already bound to current user")
}
if err = familyHelper.validateJoinFamily(m.UserId, u.Id); err != nil {
return err
}
return errors.Wrapf(xerr.NewErrCode(xerr.EmailBindError), "email already bound to another user")
}
if method.Id == 0 {
if method == nil || method.Id == 0 {
method = &user.AuthMethods{
UserId: u.Id,
AuthType: "email",
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
@@ -36,6 +38,7 @@ type CacheKeyPayload struct {
}
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
if err != nil {
@@ -52,6 +55,9 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
if payload.Code != req.Code {
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error")
}
if time.Now().Unix()-payload.LastAt > l.svcCtx.Config.VerifyCode.ExpireTime {
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
}
l.svcCtx.Redis.Del(l.ctx, cacheKey)
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)