package user import ( "context" "fmt" "time" "bindbox-game/configs" "bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/pkg/wechat" "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" "gorm.io/gorm" ) type shareClaims struct { OwnerUserID int64 `json:"owner_user_id"` InventoryID int64 `json:"inventory_id"` jwt.RegisteredClaims } func signShareToken(ownerUserID int64, inventoryID int64, expiresAt time.Time) (string, error) { claims := shareClaims{ OwnerUserID: ownerUserID, InventoryID: inventoryID, RegisteredClaims: jwt.RegisteredClaims{ NotBefore: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(expiresAt), }, } return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(configs.Get().Random.CommitMasterKey)) } func parseShareToken(tokenString string) (*shareClaims, error) { tokenClaims, err := jwt.ParseWithClaims(tokenString, &shareClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(configs.Get().Random.CommitMasterKey), nil }) if tokenClaims != nil { if claims, ok := tokenClaims.Claims.(*shareClaims); ok && tokenClaims.Valid { return claims, nil } } return nil, err } func (s *service) CreateAddressShare(ctx context.Context, userID int64, inventoryID int64, expiresAt time.Time) (string, string, time.Time, error) { inv, err := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.UserID.Eq(userID), s.readDB.UserInventory.ID.Eq(inventoryID), s.readDB.UserInventory.Status.Eq(1)).First() if err != nil { return "", "", time.Time{}, err } token, err := signShareToken(userID, inventoryID, expiresAt) if err != nil { return "", "", time.Time{}, err } // 尝试生成微信小程序 ShortLink shortLink := "" c := configs.Get() if c.Wechat.AppID != "" && c.Wechat.AppSecret != "" { wcfg := &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret} at, errat := wechat.GetAccessTokenWithContext(ctx, wcfg) if errat == nil { pagePath := fmt.Sprintf("pages/address/submit?token=%s", token) pageTitle := "送你一个好礼,快来填写地址领走吧!" if inv.Remark != "" { pageTitle = fmt.Sprintf("送你一个%s,快来领走吧!", inv.Remark) } sl, errsl := wechat.GetShortLink(at, pagePath, pageTitle) if errsl == nil { shortLink = sl s.logger.Info("成功生成微信短链", zap.String("short_link", shortLink)) } else { // 降级尝试生成 Scheme s.logger.Warn("生成微信短链失败,尝试降级为Scheme", zap.Error(errsl), zap.String("page_path", pagePath)) // 修正 pagePath 格式,URL Scheme 需要 path 和 query 分离 // 假设 pagePath 格式为 "pages/address/submit?token=xxx" schemePath := "pages/address/submit" schemeQuery := fmt.Sprintf("token=%s", token) scheme, errScheme := wechat.GenerateScheme(at, schemePath, schemeQuery, "release") if errScheme == nil { shortLink = scheme s.logger.Info("成功生成微信Scheme", zap.String("scheme", scheme)) } else { s.logger.Error("生成微信Scheme也失败", zap.Error(errScheme)) } } } else { s.logger.Error("获取微信AccessToken失败", zap.Error(errat)) } } else { s.logger.Warn("微信配置缺失,跳过短链生成", zap.String("appid", c.Wechat.AppID)) } return token, shortLink, expiresAt, nil } func (s *service) RevokeAddressShare(ctx context.Context, userID int64, inventoryID int64) error { return nil } func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error) { claims, err := parseShareToken(shareToken) if err != nil { return 0, fmt.Errorf("invalid_or_expired_token") } // 1. 基本安全校验 cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where( s.readDB.ShippingRecords.InventoryID.Eq(claims.InventoryID), s.readDB.ShippingRecords.Status.Neq(5), // 排除已取消 ).Count() if err == nil && cnt > 0 { return 0, fmt.Errorf("already_processed") } inv, err := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.ID.Eq(claims.InventoryID)).First() if err != nil { return 0, err } if inv.Status != 1 { return 0, fmt.Errorf("inventory_unavailable") } // 2. 确定资产最终归属地 (实名转赠逻辑) targetUserID := claims.OwnerUserID isTransfer := false if submittedByUserID != nil && *submittedByUserID > 0 && *submittedByUserID != claims.OwnerUserID { targetUserID = *submittedByUserID isTransfer = true } var addrID int64 err = s.repo.GetDbW().Transaction(func(tx *gorm.DB) error { // a. 创建收货地址 (归属于 targetUserID) arow := &model.UserAddresses{ UserID: targetUserID, Name: name, Mobile: mobile, Province: province, City: city, District: district, Address: address, IsDefault: 0, } // Check if user has a default address cnt, _ := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(targetUserID), s.readDB.UserAddresses.IsDefault.Eq(1)).Count() if cnt == 0 { arow.IsDefault = 1 } if err := tx.Omit("DefaultUserUnique").Create(arow).Error; err != nil { return err } addrID = arow.ID // b. 资产状态更新及所有权转移 if isTransfer { // 记录转赠流水 transferLog := &model.UserInventoryTransfers{ InventoryID: claims.InventoryID, FromUserID: claims.OwnerUserID, ToUserID: targetUserID, Remark: "address_share_transfer", } if err := tx.Create(transferLog).Error; err != nil { return err } // 更新资产所属人 if err := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID). Updates(map[string]interface{}{ "user_id": targetUserID, "status": 3, "updated_at": time.Now(), "remark": fmt.Sprintf("transferred_from_%d|shipping_requested", claims.OwnerUserID), }).Error; err != nil { return err } } else { // 仅更新状态 (原主发货) if err := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID). Updates(map[string]interface{}{ "status": 3, "updated_at": time.Now(), "remark": "shipping_requested_via_share", }).Error; err != nil { return err } } // c. 创建发货记录 (归属于 targetUserID) var price int64 if inv.ProductID > 0 { var p model.Products if err := tx.Table("products").Where("id = ?", inv.ProductID).First(&p).Error; err == nil { price = p.Price } } // 生成转赠发货的批次号 (使用 T 前缀区分普通批发货的 B 前缀) transferBatchNo := fmt.Sprintf("T%d%d", targetUserID, time.Now().UnixNano()/1000000) shipRecord := &model.ShippingRecords{ UserID: targetUserID, OrderID: inv.OrderID, OrderItemID: 0, InventoryID: claims.InventoryID, ProductID: inv.ProductID, Quantity: 1, Price: price, AddressID: addrID, Status: 1, BatchNo: transferBatchNo, Remark: fmt.Sprintf("shared_address_submit|ip=%s|transfer_from=%d", *submittedIP, claims.OwnerUserID), } if err := tx.Omit("ShippedAt", "ReceivedAt").Create(shipRecord).Error; err != nil { return err } return nil }) if err != nil { return 0, err } return addrID, nil } func (s *service) RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error) { return s.RequestShippingWithBatch(ctx, userID, inventoryID, "", 0) } // RequestShippingWithBatch 申请发货(支持批次号和指定地址) func (s *service) RequestShippingWithBatch(ctx context.Context, userID int64, inventoryID int64, batchNo string, addrID int64) (int64, error) { cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where( s.readDB.ShippingRecords.InventoryID.Eq(inventoryID), s.readDB.ShippingRecords.Status.Neq(5), // Ignore cancelled ).Count() if err == nil && cnt > 0 { return 0, fmt.Errorf("already_processed") } inv, err := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.UserID.Eq(userID), s.readDB.UserInventory.ID.Eq(inventoryID), s.readDB.UserInventory.Status.Eq(1)).First() if err != nil { return 0, err } // 如果没有传入地址ID,使用默认地址 if addrID <= 0 { addr, err := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID), s.readDB.UserAddresses.IsDefault.Eq(1)).First() if err != nil { return 0, err } addrID = addr.ID } var price int64 if inv.ProductID > 0 { if p, e := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First(); e == nil && p != nil { price = p.Price } } if db := s.repo.GetDbW().Exec("INSERT INTO shipping_records (user_id, order_id, order_item_id, inventory_id, product_id, quantity, price, address_id, status, batch_no, remark) VALUES (?,?,?,?,?,?,?,?,?,?,?)", userID, inv.OrderID, 0, inventoryID, inv.ProductID, 1, price, addrID, 1, batchNo, "user_request_shipping"); db.Error != nil { err = db.Error return 0, err } if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|shipping_requested') WHERE id=? AND user_id=? AND status=1", inventoryID, userID); db.Error != nil { err = db.Error return 0, err } return addrID, nil } // generateBatchNo 生成唯一批次号 func generateBatchNo(userID int64) string { return fmt.Sprintf("B%d%d", userID, time.Now().UnixNano()/1000000) } func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct { ID int64 Reason string }, failed []struct { ID int64 Reason string }, err error) { if len(inventoryIDs) == 0 { return 0, "", nil, nil, []struct { ID int64 Reason string }{{ID: 0, Reason: "invalid_params"}}, nil } // 1. 去重 dedup := make(map[int64]struct{}, len(inventoryIDs)) uniq := make([]int64, 0, len(inventoryIDs)) for _, id := range inventoryIDs { if id > 0 { if _, ok := dedup[id]; !ok { dedup[id] = struct{}{} uniq = append(uniq, id) } } } if len(uniq) == 0 { return 0, "", nil, nil, []struct { ID int64 Reason string }{{ID: 0, Reason: "invalid_params"}}, nil } // 2. 获取收货地址 if addressID != nil && *addressID > 0 { ua, _ := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.ID.Eq(*addressID), s.readDB.UserAddresses.UserID.Eq(userID)).First() if ua == nil { return 0, "", nil, nil, []struct { ID int64 Reason string }{{ID: 0, Reason: "address_not_found"}}, nil } addrID = ua.ID } else { da, e := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID), s.readDB.UserAddresses.IsDefault.Eq(1)).First() if e != nil || da == nil { return 0, "", nil, nil, []struct { ID int64 Reason string }{{ID: 0, Reason: "no_default_address"}}, nil } addrID = da.ID } // 3. 生成批次号 batchNo = generateBatchNo(userID) // 4. 批量查询所有inventory(一次查询替代N次) invList, _ := s.readDB.UserInventory.WithContext(ctx). Where(s.readDB.UserInventory.ID.In(uniq...)). Find() // 构建inventory映射 invMap := make(map[int64]*model.UserInventory, len(invList)) for _, inv := range invList { invMap[inv.ID] = inv } // 5. 批量查询已有发货记录(检查哪些已处理) var existingShipInvIDs []int64 _ = s.repo.GetDbR().Raw("SELECT DISTINCT inventory_id FROM shipping_records WHERE inventory_id IN ? AND status != 5", uniq).Scan(&existingShipInvIDs).Error existingShipMap := make(map[int64]struct{}, len(existingShipInvIDs)) for _, id := range existingShipInvIDs { existingShipMap[id] = struct{}{} } // 6. 分类处理 success = make([]int64, 0, len(uniq)) skipped = make([]struct { ID int64 Reason string }, 0) failed = make([]struct { ID int64 Reason string }, 0) validInvs := make([]*model.UserInventory, 0, len(uniq)) productIDs := make([]int64, 0, len(uniq)) productIDSet := make(map[int64]struct{}) for _, id := range uniq { inv := invMap[id] if inv == nil { skipped = append(skipped, struct { ID int64 Reason string }{ID: id, Reason: "not_found"}) continue } if inv.UserID != userID { skipped = append(skipped, struct { ID int64 Reason string }{ID: id, Reason: "not_owned"}) continue } if _, exists := existingShipMap[id]; exists { skipped = append(skipped, struct { ID int64 Reason string }{ID: id, Reason: "already_processed"}) continue } if inv.Status == 3 { skipped = append(skipped, struct { ID int64 Reason string }{ID: id, Reason: "already_requested"}) continue } if inv.Status != 1 { skipped = append(skipped, struct { ID int64 Reason string }{ID: id, Reason: "invalid_status"}) continue } validInvs = append(validInvs, inv) if _, ok := productIDSet[inv.ProductID]; !ok && inv.ProductID > 0 { productIDSet[inv.ProductID] = struct{}{} productIDs = append(productIDs, inv.ProductID) } } if len(validInvs) == 0 { return addrID, batchNo, success, skipped, failed, nil } // 7. 批量查询products获取价格(一次查询替代N次) productMap := make(map[int64]int64) // productID -> price if len(productIDs) > 0 { prods, _ := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.In(productIDs...)).Find() for _, p := range prods { productMap[p.ID] = p.Price } } // 8. 单事务批量处理 validIDs := make([]int64, 0, len(validInvs)) err = s.repo.GetDbW().Transaction(func(tx *gorm.DB) error { // 批量插入shipping_records for _, inv := range validInvs { price := productMap[inv.ProductID] if errExec := tx.Exec( "INSERT INTO shipping_records (user_id, order_id, order_item_id, inventory_id, product_id, quantity, price, address_id, status, batch_no, remark) VALUES (?,?,?,?,?,?,?,?,?,?,?)", userID, inv.OrderID, 0, inv.ID, inv.ProductID, 1, price, addrID, 1, batchNo, "batch_request_shipping", ).Error; errExec != nil { return errExec } validIDs = append(validIDs, inv.ID) } // 批量更新inventory状态(一次UPDATE替代N次) if len(validIDs) > 0 { if errExec := tx.Exec( "UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_shipping_requested') WHERE id IN ? AND user_id=? AND status=1", validIDs, userID, ).Error; errExec != nil { return errExec } } return nil }) if err != nil { // 事务失败,所有都标记为failed for _, inv := range validInvs { failed = append(failed, struct { ID int64 Reason string }{ID: inv.ID, Reason: err.Error()}) } return addrID, batchNo, success, skipped, failed, nil } success = validIDs return addrID, batchNo, success, skipped, failed, nil } func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inventoryID int64) (int64, error) { inv, err := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.UserID.Eq(userID), s.readDB.UserInventory.ID.Eq(inventoryID), s.readDB.UserInventory.Status.Eq(1)).First() if err != nil { return 0, err } p, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(inv.ProductID)).First() if err != nil { return 0, err } cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First() rate := int64(1) if cfg != nil { var r int64 _, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r) if r > 0 { rate = r } } points := p.Price * rate if err = s.AddPoints(ctx, userID, points, "redeem_reward", fmt.Sprintf("inventory:%d product:%d", inventoryID, inv.ProductID), nil, nil); err != nil { return 0, err } if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET status=3, remark=CONCAT(IFNULL(remark,''),'|redeemed_points=',?) WHERE id=? AND user_id=? AND status=1", points, inventoryID, userID); db.Error != nil { err = db.Error return 0, err } return points, nil } func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, inventoryIDs []int64) (int64, error) { if len(inventoryIDs) == 0 { return 0, fmt.Errorf("invalid_params") } // 1. 去重 dedup := make(map[int64]struct{}) uniq := make([]int64, 0, len(inventoryIDs)) for _, id := range inventoryIDs { if id <= 0 { continue } if _, ok := dedup[id]; !ok { dedup[id] = struct{}{} uniq = append(uniq, id) } } if len(uniq) == 0 { return 0, fmt.Errorf("invalid_params") } // 2. 获取兑换比率(只查询一次) cfg, _ := s.readDB.SystemConfigs.WithContext(ctx).Where(s.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First() rate := int64(1) if cfg != nil { var r int64 _, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &r) if r > 0 { rate = r } } // 3. 批量查询所有inventory(一次查询替代N次) invList, err := s.readDB.UserInventory.WithContext(ctx). Where(s.readDB.UserInventory.ID.In(uniq...)). Where(s.readDB.UserInventory.UserID.Eq(userID)). Where(s.readDB.UserInventory.Status.Eq(1)). Find() if err != nil { return 0, err } if len(invList) == 0 { return 0, fmt.Errorf("no_valid_inventory") } // 构建inventory映射和收集productID invMap := make(map[int64]*model.UserInventory, len(invList)) productIDs := make([]int64, 0, len(invList)) productIDSet := make(map[int64]struct{}) for _, inv := range invList { invMap[inv.ID] = inv if _, ok := productIDSet[inv.ProductID]; !ok { productIDSet[inv.ProductID] = struct{}{} productIDs = append(productIDs, inv.ProductID) } } // 4. 批量查询所有products(一次查询替代N次) products, err := s.readDB.Products.WithContext(ctx). Where(s.readDB.Products.ID.In(productIDs...)). Find() if err != nil { return 0, err } productMap := make(map[int64]*model.Products, len(products)) for _, p := range products { productMap[p.ID] = p } // 5. 计算总积分和准备批量更新数据 var totalPoints int64 validIDs := make([]int64, 0, len(invList)) for _, inv := range invList { p := productMap[inv.ProductID] if p == nil { continue } points := p.Price * rate totalPoints += points validIDs = append(validIDs, inv.ID) } if len(validIDs) == 0 { return 0, fmt.Errorf("no_valid_products") } // 6. 单事务处理:添加积分 + 批量更新inventory状态 err = s.repo.GetDbW().Transaction(func(tx *gorm.DB) error { // 添加积分(一次性添加总积分) now := time.Now() ledger := &model.UserPointsLedger{ UserID: userID, Action: "redeem_reward", Points: totalPoints, RefTable: "user_inventory", RefID: fmt.Sprintf("batch:%d", len(validIDs)), Remark: fmt.Sprintf("batch_redeem_%d_items", len(validIDs)), } if err := tx.Create(ledger).Error; err != nil { return err } // 更新积分余额 pointRecord := &model.UserPoints{ UserID: userID, Kind: "redeem_reward", Points: totalPoints, ValidStart: now, ValidEnd: now.AddDate(100, 0, 0), // 100年有效期 } if err := tx.Create(pointRecord).Error; err != nil { return err } // 批量更新inventory状态(一次UPDATE替代N次) if err := tx.Exec( "UPDATE user_inventory SET status=3, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|batch_redeemed') WHERE id IN ? AND user_id=? AND status=1", validIDs, userID, ).Error; err != nil { return err } return nil }) if err != nil { return 0, err } return totalPoints, nil } func (s *service) VoidUserInventory(ctx context.Context, adminID int64, userID int64, inventoryID int64) error { if userID <= 0 || inventoryID <= 0 { return fmt.Errorf("invalid_params") } inv, err := s.readDB.UserInventory.WithContext(ctx). Where(s.readDB.UserInventory.ID.Eq(inventoryID)). Where(s.readDB.UserInventory.UserID.Eq(userID)). First() if err != nil { return err } if inv.Status != 1 { return fmt.Errorf("invalid_status") } if db := s.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|void_by_admin') WHERE id=? AND user_id=? AND status=1", inventoryID, userID); db.Error != nil { return db.Error } _ = adminID return nil }