diff --git a/internal/logic/admin/user/deleteUserLogic.go b/internal/logic/admin/user/deleteUserLogic.go index 5253d09..4910e93 100644 --- a/internal/logic/admin/user/deleteUserLogic.go +++ b/internal/logic/admin/user/deleteUserLogic.go @@ -2,39 +2,257 @@ package user import ( "context" + "fmt" "os" "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/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/pkg/errors" + "gorm.io/gorm" ) type DeleteUserLogic struct { ctx context.Context svcCtx *svc.ServiceContext - logger.Logger + pkglogger.Logger } func NewDeleteUserLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteUserLogic { return &DeleteUserLogic{ ctx: ctx, svcCtx: svcCtx, - Logger: logger.WithContext(ctx), + Logger: pkglogger.WithContext(ctx), } } func (l *DeleteUserLogic) DeleteUser(req *types.GetDetailRequest) error { isDemo := strings.ToLower(os.Getenv("PPANEL_MODE")) == "demo" - 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") } - 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 { - 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 } + +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())) + } +} diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index 374ecf7..1b4878b 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -111,7 +111,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P } } - // 单订阅模式下,若已有同套餐 pending 订单,直接返回,防止重复创建 + // 单订阅模式下,若已有同套餐 pending 订单,关闭旧单后继续创建新单(确保新单参数生效) if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 { var existPending order.Order 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). Order("id DESC"). 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("order_no", existPending.OrderNo), + logger.Field("old_order_no", existPending.OrderNo), logger.Field("subscribe_id", targetSubscribeID), ) - return &types.PurchaseOrderResponse{ - OrderNo: existPending.OrderNo, - AppAccountToken: existPending.AppAccountToken, - }, nil + if closeErr := NewCloseOrderLogic(l.ctx, l.svcCtx).CloseOrder(&types.CloseOrderRequest{ + OrderNo: existPending.OrderNo, + }); closeErr != 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()) + } } } diff --git a/scripts/test_device_login.go b/scripts/test_device_login.go new file mode 100644 index 0000000..4f77aa6 --- /dev/null +++ b/scripts/test_device_login.go @@ -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":"","time":""},"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) +} diff --git a/test_device_login b/test_device_login new file mode 100755 index 0000000..d7d1a1a Binary files /dev/null and b/test_device_login differ