bug: 无订阅情况 出现下多笔订单 支付状态乱
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m18s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m18s
This commit is contained in:
parent
a9205cc3fc
commit
df7303738a
@ -2,39 +2,257 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/model/iap/apple"
|
||||||
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/internal/model/logmessage"
|
||||||
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/ticket"
|
||||||
|
"github.com/perfect-panel/server/internal/model/traffic"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
pkglogger "github.com/perfect-panel/server/pkg/logger"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeleteUserLogic struct {
|
type DeleteUserLogic struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
svcCtx *svc.ServiceContext
|
||||||
logger.Logger
|
pkglogger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeleteUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteUserLogic {
|
func NewDeleteUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteUserLogic {
|
||||||
return &DeleteUserLogic{
|
return &DeleteUserLogic{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svcCtx: svcCtx,
|
svcCtx: svcCtx,
|
||||||
Logger: logger.WithContext(ctx),
|
Logger: pkglogger.WithContext(ctx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *DeleteUserLogic) DeleteUser(req *types.GetDetailRequest) error {
|
func (l *DeleteUserLogic) DeleteUser(req *types.GetDetailRequest) error {
|
||||||
isDemo := strings.ToLower(os.Getenv("PPANEL_MODE")) == "demo"
|
isDemo := strings.ToLower(os.Getenv("PPANEL_MODE")) == "demo"
|
||||||
|
|
||||||
if req.Id == 2 && isDemo {
|
if req.Id == 2 && isDemo {
|
||||||
return errors.Wrapf(xerr.NewErrCodeMsg(503, "Demo mode does not allow deletion of the admin user"), "delete user failed: cannot delete admin user in demo mode")
|
return errors.Wrapf(xerr.NewErrCodeMsg(503, "Demo mode does not allow deletion of the admin user"), "delete user failed: cannot delete admin user in demo mode")
|
||||||
}
|
}
|
||||||
err := l.svcCtx.UserModel.Delete(l.ctx, req.Id)
|
|
||||||
|
return l.purgeUser(req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeUser 硬删除用户及其所有关联数据,无孤儿数据残留。
|
||||||
|
// 删除顺序:先删子表(引用 user_id),再删主表(user)。
|
||||||
|
func (l *DeleteUserLogic) purgeUser(userID int64) error {
|
||||||
|
// 1. 事务前:收集需要清缓存的数据
|
||||||
|
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "delete user error: %v", err.Error())
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authMethods, _ := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, userID)
|
||||||
|
subscribes, _ := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, userID)
|
||||||
|
|
||||||
|
// 2. 查 ticket id 列表(ticket_follow 通过 ticket_id 关联,需要先查再删)
|
||||||
|
var ticketIDs []int64
|
||||||
|
l.svcCtx.DB.WithContext(l.ctx).Model(&ticket.Ticket{}).
|
||||||
|
Where("user_id = ?", userID).Pluck("id", &ticketIDs)
|
||||||
|
|
||||||
|
// 3. 查家庭关系(需要处理家庭解散)
|
||||||
|
var familyMember usermodel.UserFamilyMember
|
||||||
|
isFamilyOwner := false
|
||||||
|
var familyID int64
|
||||||
|
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&usermodel.UserFamilyMember{}).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
First(&familyMember).Error; err == nil {
|
||||||
|
familyID = familyMember.FamilyId
|
||||||
|
var family usermodel.UserFamily
|
||||||
|
if err2 := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Where("id = ?", familyID).First(&family).Error; err2 == nil {
|
||||||
|
isFamilyOwner = (family.OwnerUserId == userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 事务内:按顺序删除所有关联表,最后删 user
|
||||||
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 4a. 家庭处理
|
||||||
|
if familyID > 0 {
|
||||||
|
if isFamilyOwner {
|
||||||
|
// 家主:解散整个家庭(删所有成员记录 + 删家庭)
|
||||||
|
if e := tx.Unscoped().Where("family_id = ?", familyID).
|
||||||
|
Delete(&usermodel.UserFamilyMember{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := tx.Unscoped().Where("id = ?", familyID).
|
||||||
|
Delete(&usermodel.UserFamily{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 成员:只删自己的成员记录
|
||||||
|
if e := tx.Unscoped().Where("user_id = ? AND family_id = ?", userID, familyID).
|
||||||
|
Delete(&usermodel.UserFamilyMember{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b. 用户认证方式
|
||||||
|
if e := tx.Unscoped().Where("user_id = ?", userID).
|
||||||
|
Delete(&usermodel.AuthMethods{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4c. 订阅
|
||||||
|
if e := tx.Unscoped().Where("user_id = ?", userID).
|
||||||
|
Delete(&usermodel.Subscribe{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4d. 设备 + 设备在线记录
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&usermodel.Device{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&usermodel.DeviceOnlineRecord{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4e. 提现记录
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&usermodel.Withdrawal{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4f. 订单
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&order.Order{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4g. 流量日志
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&traffic.TrafficLog{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4h. 系统日志(object_id = userID)
|
||||||
|
if e := tx.Where("object_id = ?", userID).
|
||||||
|
Delete(&log.SystemLog{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4i. 工单 follow + 工单
|
||||||
|
if len(ticketIDs) > 0 {
|
||||||
|
if e := tx.Where("ticket_id IN ?", ticketIDs).
|
||||||
|
Delete(&ticket.Follow{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&ticket.Ticket{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4j. Apple IAP 交易记录
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&apple.Transaction{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4k. 日志消息
|
||||||
|
if e := tx.Where("user_id = ?", userID).
|
||||||
|
Delete(&logmessage.LogMessage{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4l. 最后删除 user(Unscoped = 物理删除)
|
||||||
|
if e := tx.Unscoped().Where("id = ?", userID).
|
||||||
|
Delete(&usermodel.User{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "purge user %d failed: %v", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 事务后:清缓存 + 踢设备
|
||||||
|
l.cleanupAfterDelete(userID, userInfo, authMethods, subscribes)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *DeleteUserLogic) cleanupAfterDelete(
|
||||||
|
userID int64,
|
||||||
|
userInfo *usermodel.User,
|
||||||
|
authMethods []*usermodel.AuthMethods,
|
||||||
|
subscribes []*usermodel.SubscribeDetails,
|
||||||
|
) {
|
||||||
|
// 踢设备
|
||||||
|
var devices []usermodel.Device
|
||||||
|
l.svcCtx.DB.WithContext(l.ctx).Where("user_id = ?", userID).Find(&devices)
|
||||||
|
for _, d := range devices {
|
||||||
|
l.svcCtx.DeviceManager.KickDevice(d.UserId, d.Identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清 session
|
||||||
|
l.clearAllSessions(userID)
|
||||||
|
|
||||||
|
// 清 email 缓存
|
||||||
|
var emailKeys []string
|
||||||
|
for _, am := range authMethods {
|
||||||
|
if am.AuthType == "email" && am.AuthIdentifier != "" {
|
||||||
|
emailKeys = append(emailKeys, fmt.Sprintf("cache:user:email:%s", am.AuthIdentifier))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(emailKeys) > 0 {
|
||||||
|
if e := l.svcCtx.Redis.Del(l.ctx, emailKeys...).Err(); e != nil {
|
||||||
|
l.Errorw("clear email cache failed", pkglogger.Field("user_id", userID), pkglogger.Field("error", e.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清 user 缓存
|
||||||
|
if userInfo != nil {
|
||||||
|
if e := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); e != nil {
|
||||||
|
l.Errorw("clear user cache failed", pkglogger.Field("user_id", userID), pkglogger.Field("error", e.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清订阅缓存
|
||||||
|
subModels := make([]*usermodel.Subscribe, 0, len(subscribes)+1)
|
||||||
|
subModels = append(subModels, &usermodel.Subscribe{UserId: userID})
|
||||||
|
for _, s := range subscribes {
|
||||||
|
subModels = append(subModels, &usermodel.Subscribe{
|
||||||
|
Id: s.Id, UserId: s.UserId, SubscribeId: s.SubscribeId, Token: s.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if e := l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, subModels...); e != nil {
|
||||||
|
l.Errorw("clear subscribe cache failed", pkglogger.Field("user_id", userID), pkglogger.Field("error", e.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAllSessions 清理用户所有 session
|
||||||
|
func (l *DeleteUserLogic) clearAllSessions(userID int64) {
|
||||||
|
sessionsKey := fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userID)
|
||||||
|
sessions, _ := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result()
|
||||||
|
pipe := l.svcCtx.Redis.TxPipeline()
|
||||||
|
for _, sid := range sessions {
|
||||||
|
pipe.Del(l.ctx, fmt.Sprintf("%s:%s", config.SessionIdKey, sid))
|
||||||
|
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid))
|
||||||
|
pipe.ZRem(l.ctx, sessionsKey, sid)
|
||||||
|
}
|
||||||
|
pipe.Del(l.ctx, sessionsKey)
|
||||||
|
if _, e := pipe.Exec(l.ctx); e != nil {
|
||||||
|
l.Errorw("clear sessions failed", pkglogger.Field("user_id", userID), pkglogger.Field("error", e.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -111,7 +111,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单订阅模式下,若已有同套餐 pending 订单,直接返回,防止重复创建
|
// 单订阅模式下,若已有同套餐 pending 订单,关闭旧单后继续创建新单(确保新单参数生效)
|
||||||
if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
var existPending order.Order
|
var existPending order.Order
|
||||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
@ -119,15 +119,20 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
Where("user_id = ? AND subscribe_id = ? AND status = 1", u.Id, targetSubscribeID).
|
Where("user_id = ? AND subscribe_id = ? AND status = 1", u.Id, targetSubscribeID).
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
First(&existPending).Error; e == nil && existPending.Id > 0 {
|
First(&existPending).Error; e == nil && existPending.Id > 0 {
|
||||||
l.Infow("[Purchase] single mode pending order exists, returning existing",
|
l.Infow("[Purchase] single mode pending order exists, closing old order and creating new one",
|
||||||
logger.Field("user_id", u.Id),
|
logger.Field("user_id", u.Id),
|
||||||
logger.Field("order_no", existPending.OrderNo),
|
logger.Field("old_order_no", existPending.OrderNo),
|
||||||
logger.Field("subscribe_id", targetSubscribeID),
|
logger.Field("subscribe_id", targetSubscribeID),
|
||||||
)
|
)
|
||||||
return &types.PurchaseOrderResponse{
|
if closeErr := NewCloseOrderLogic(l.ctx, l.svcCtx).CloseOrder(&types.CloseOrderRequest{
|
||||||
OrderNo: existPending.OrderNo,
|
OrderNo: existPending.OrderNo,
|
||||||
AppAccountToken: existPending.AppAccountToken,
|
}); closeErr != nil {
|
||||||
}, nil
|
l.Errorw("[Purchase] close old pending order failed",
|
||||||
|
logger.Field("error", closeErr.Error()),
|
||||||
|
logger.Field("old_order_no", existPending.OrderNo),
|
||||||
|
)
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "close old pending order error: %v", closeErr.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
295
scripts/test_device_login.go
Normal file
295
scripts/test_device_login.go
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 设备登录测试脚本
|
||||||
|
// 用法: go run scripts/test_device_login.go
|
||||||
|
// 功能: 模拟客户端设备登录,自动加密请求体,解密响应,打印 token
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/forgoer/openssl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== 配置区域 ====================
|
||||||
|
|
||||||
|
const (
|
||||||
|
serverURL = "https://tapi.hifast.biz" // 服务地址
|
||||||
|
securitySecret = "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx" // device.security_secret
|
||||||
|
identifier = "test-device-script-001" // 设备唯一标识
|
||||||
|
userAgent = "TestScript/1.0" // UserAgent
|
||||||
|
|
||||||
|
appId = "android-client" // AppSignature.AppSecrets 中的 key
|
||||||
|
appSecret = "uB4G,XxL2{7b" // AppSignature.AppSecrets 中的 value
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== AES 工具(与服务端 pkg/aes/aes.go 一致)====================
|
||||||
|
|
||||||
|
func generateKey(key string) []byte {
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
return hash[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateIv(iv, key string) []byte {
|
||||||
|
h := md5.New()
|
||||||
|
h.Write([]byte(iv))
|
||||||
|
return generateKey(hex.EncodeToString(h.Sum(nil)) + key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesEncrypt(plainText []byte, keyStr string) (data string, nonce string, err error) {
|
||||||
|
nonce = fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
key := generateKey(keyStr)
|
||||||
|
iv := generateIv(nonce, keyStr)
|
||||||
|
dst, err := openssl.AesCBCEncrypt(plainText, key, iv, openssl.PKCS7_PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(dst), nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesDecrypt(cipherText string, keyStr string, ivStr string) (string, error) {
|
||||||
|
decode, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := generateKey(keyStr)
|
||||||
|
iv := generateIv(ivStr, keyStr)
|
||||||
|
dst, err := openssl.AesCBCDecrypt(decode, key, iv, openssl.PKCS7_PADDING)
|
||||||
|
return string(dst), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 签名工具(与服务端 pkg/signature 一致)====================
|
||||||
|
|
||||||
|
func buildStringToSign(method, path, rawQuery string, body []byte, xAppId, timestamp, nonce string) string {
|
||||||
|
canonical := canonicalQuery(rawQuery)
|
||||||
|
bodyHash := sha256Hex(body)
|
||||||
|
parts := []string{
|
||||||
|
strings.ToUpper(method),
|
||||||
|
path,
|
||||||
|
canonical,
|
||||||
|
bodyHash,
|
||||||
|
xAppId,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalQuery(rawQuery string) string {
|
||||||
|
if rawQuery == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pairs := strings.Split(rawQuery, "&")
|
||||||
|
sort.Strings(pairs)
|
||||||
|
return strings.Join(pairs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
return fmt.Sprintf("%x", h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSignature(stringToSign, secret string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(stringToSign))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedRequest(method, url, rawQuery string, body []byte, token string) (*http.Request, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
bodyReader = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", token) // 不带 Bearer 前缀,服务端直接 Parse token
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// 提取 path
|
||||||
|
path := req.URL.Path
|
||||||
|
|
||||||
|
sts := buildStringToSign(method, path, rawQuery, body, appId, timestamp, nonce)
|
||||||
|
sig := buildSignature(sts, appSecret)
|
||||||
|
|
||||||
|
req.Header.Set("X-App-Id", appId)
|
||||||
|
req.Header.Set("X-Timestamp", timestamp)
|
||||||
|
req.Header.Set("X-Nonce", nonce)
|
||||||
|
req.Header.Set("X-Signature", sig)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主逻辑 ====================
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("=== 设备登录测试 ===")
|
||||||
|
fmt.Printf("Server: %s\n", serverURL)
|
||||||
|
fmt.Printf("Identifier: %s\n", identifier)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 1. 构造原始请求体
|
||||||
|
payload := map[string]string{
|
||||||
|
"identifier": identifier,
|
||||||
|
"user_agent": userAgent,
|
||||||
|
}
|
||||||
|
plainBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] marshal payload: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("原始请求体: %s\n", string(plainBytes))
|
||||||
|
|
||||||
|
// 2. AES 加密请求体
|
||||||
|
encData, nonce, err := aesEncrypt(plainBytes, securitySecret)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] encrypt: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encBody := map[string]string{
|
||||||
|
"data": encData,
|
||||||
|
"time": nonce,
|
||||||
|
}
|
||||||
|
encBytes, _ := json.Marshal(encBody)
|
||||||
|
fmt.Printf("加密请求体: %s\n\n", string(encBytes))
|
||||||
|
|
||||||
|
// 3. 发送请求
|
||||||
|
req, err := signedRequest("POST", serverURL+"/v1/auth/login/device", "", encBytes, "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] new request: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Login-Type", "device")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] request failed: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("HTTP Status: %d\n", resp.StatusCode)
|
||||||
|
fmt.Printf("原始响应: %s\n\n", string(respBody))
|
||||||
|
|
||||||
|
// 4. 解密响应
|
||||||
|
// 响应格式: {"code":200,"data":{"data":"<encrypted>","time":"<nonce>"},"message":""}
|
||||||
|
var outer struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &outer); err != nil {
|
||||||
|
fmt.Printf("[ERROR] parse response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if outer.Code != 200 {
|
||||||
|
fmt.Printf("[FAIL] 登录失败: code=%d message=%s\n", outer.Code, outer.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// data 字段是加密对象
|
||||||
|
var encResp struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(outer.Data, &encResp); err != nil {
|
||||||
|
// 如果 Device.Enable=false,data 直接就是明文对象
|
||||||
|
fmt.Printf("响应 data 非加密格式,直接解析: %s\n", string(outer.Data))
|
||||||
|
var loginResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(outer.Data, &loginResp); err2 == nil && loginResp.Token != "" {
|
||||||
|
fmt.Printf("[OK] Token: %s\n", loginResp.Token)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := aesDecrypt(encResp.Data, securitySecret, encResp.Time)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] decrypt response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("解密后响应: %s\n\n", decrypted)
|
||||||
|
|
||||||
|
var loginResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(decrypted), &loginResp); err != nil {
|
||||||
|
fmt.Printf("[ERROR] parse decrypted: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[OK] Token: %s\n", loginResp.Token)
|
||||||
|
|
||||||
|
// 5. 用 token 请求订阅列表
|
||||||
|
fmt.Println("\n=== 请求订阅列表 ===")
|
||||||
|
subReq, err := signedRequest("GET", serverURL+"/v1/public/subscribe/list", "", nil, loginResp.Token)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] build subscribe request: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subReq.Header.Set("Login-Type", "device")
|
||||||
|
|
||||||
|
subResp, err := client.Do(subReq)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] subscribe list request: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer subResp.Body.Close()
|
||||||
|
|
||||||
|
subBody, _ := io.ReadAll(subResp.Body)
|
||||||
|
fmt.Printf("HTTP Status: %d\n", subResp.StatusCode)
|
||||||
|
fmt.Printf("原始响应: %s\n", string(subBody))
|
||||||
|
|
||||||
|
// 解密订阅列表响应
|
||||||
|
var subOuter struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(subBody, &subOuter); err != nil {
|
||||||
|
fmt.Printf("[ERROR] parse subscribe response: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subOuter.Code != 200 {
|
||||||
|
fmt.Printf("[FAIL] 订阅列表失败: code=%d message=%s\n", subOuter.Code, subOuter.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var subEnc struct {
|
||||||
|
Data string `json:"data"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(subOuter.Data, &subEnc); err != nil || subEnc.Data == "" {
|
||||||
|
// 无加密,直接打印
|
||||||
|
fmt.Printf("\n[OK] 订阅列表(明文): %s\n", string(subOuter.Data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subDecrypted, err := aesDecrypt(subEnc.Data, securitySecret, subEnc.Time)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[ERROR] decrypt subscribe list: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("\n[OK] 订阅列表(解密): %s\n", subDecrypted)
|
||||||
|
}
|
||||||
BIN
test_device_login
Executable file
BIN
test_device_login
Executable file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user