package pointsmall import ( "context" "errors" "fmt" "regexp" "strings" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/rentergrowth" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } var mobilePhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } func (r *Repository) Summary(ctx context.Context, userID uint64) (*SummaryDTO, error) { var user model.User if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { return nil, err } cfg, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx)) if err != nil { return nil, err } level := rentergrowth.LevelForPoints(cfg, user.RenterGrowthPoints) levels := make([]GrowthLevelDTO, 0, len(cfg.Levels)) for _, item := range cfg.Levels { levels = append(levels, GrowthLevelDTO{ Code: item.Code, Name: item.Name, MinPoints: item.MinPoints, PointsMultiplierBps: item.PointsMultiplierBps, DiscountBps: item.DiscountBps, DepositFreeQuotaCent: item.DepositFreeQuotaCent, }) } return &SummaryDTO{ AvailablePoints: user.PointsAvailable, LifetimePoints: user.RenterGrowthPoints, LevelCode: level.Code, LevelName: level.Name, PointsPerYuan: cfg.PointsPerYuan, PointsMultiplierBps: level.PointsMultiplierBps, GrowthEnabled: cfg.Enabled, Levels: levels, }, nil } func (r *Repository) Ledger(ctx context.Context, userID uint64, limit int) ([]model.RenterGrowthLedger, error) { if limit <= 0 || limit > 200 { limit = 100 } var rows []model.RenterGrowthLedger err := r.db.WithContext(ctx). Where("user_id = ?", userID). Order("created_at DESC, id DESC"). Limit(limit). Find(&rows).Error return rows, err } func (r *Repository) ListProducts(ctx context.Context, includeOffline bool) ([]ProductDTO, error) { var rows []model.PointsMallProduct tx := r.db.WithContext(ctx).Order("sort_order DESC, id DESC") if !includeOffline { tx = tx.Where("status = ?", "online") } if err := tx.Find(&rows).Error; err != nil { return nil, err } items := make([]ProductDTO, 0, len(rows)) for _, row := range rows { items = append(items, toProductDTO(row)) } return items, nil } func (r *Repository) CreateProduct(ctx context.Context, req ProductRequest) (*ProductDTO, error) { item := productFromRequest(req) if err := r.db.WithContext(ctx).Create(&item).Error; err != nil { return nil, err } dto := toProductDTO(item) return &dto, nil } func (r *Repository) UpdateProduct(ctx context.Context, id uint64, req ProductRequest) (*ProductDTO, error) { var item model.PointsMallProduct if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil { return nil, err } next := productFromRequest(req) next.ID = item.ID next.RedeemedCount = item.RedeemedCount next.CreatedAt = item.CreatedAt if err := r.db.WithContext(ctx).Model(&item).Updates(map[string]any{ "title": next.Title, "subtitle": next.Subtitle, "description": next.Description, "cover_url": next.CoverURL, "redemption_instructions": next.RedemptionInstructions, "points_price": next.PointsPrice, "stock": next.Stock, "per_user_limit": next.PerUserLimit, "status": next.Status, "sort_order": next.SortOrder, }).Error; err != nil { return nil, err } return r.findProduct(ctx, id) } func (r *Repository) findProduct(ctx context.Context, id uint64) (*ProductDTO, error) { var item model.PointsMallProduct if err := r.db.WithContext(ctx).First(&item, id).Error; err != nil { return nil, err } dto := toProductDTO(item) return &dto, nil } func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, req RedeemRequest) (*RedemptionDTO, error) { contactName := strings.TrimSpace(req.ContactName) contactPhone := strings.TrimSpace(req.ContactPhone) customerRemark := strings.TrimSpace(req.CustomerRemark) if userID == 0 || productID == 0 || req.Quantity <= 0 || req.Quantity > 10 || contactName == "" || len([]rune(contactName)) > 32 || !mobilePhonePattern.MatchString(contactPhone) || len([]rune(customerRemark)) > 500 { return nil, ErrInvalidInput } var result model.PointsMallRedemption err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var product model.PointsMallProduct if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil { return err } if product.Status != "online" || product.Stock < int64(req.Quantity) { return ErrProductUnavailable } var redeemed int64 if err := tx.Model(&model.PointsMallRedemption{}). Where("product_id = ? AND user_id = ? AND status <> ?", productID, userID, "cancelled"). Select("COALESCE(SUM(quantity), 0)"). Scan(&redeemed).Error; err != nil { return err } if redeemed+int64(req.Quantity) > int64(product.PerUserLimit) { return ErrRedeemLimitExceeded } var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } totalPoints := product.PointsPrice * int64(req.Quantity) if totalPoints <= 0 || user.PointsAvailable < totalPoints { return ErrInsufficientPoints } now := time.Now() result = model.PointsMallRedemption{ RedemptionNo: fmt.Sprintf("PM%d%d", userID, now.UnixNano()), UserID: userID, ProductID: product.ID, ProductTitle: product.Title, ProductCoverURL: product.CoverURL, RedemptionInstructions: product.RedemptionInstructions, ContactName: contactName, ContactPhone: contactPhone, CustomerRemark: customerRemark, PointsPrice: product.PointsPrice, Quantity: req.Quantity, TotalPoints: totalPoints, Status: "pending", CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&result).Error; err != nil { return err } beforePoints := user.PointsAvailable user.PointsAvailable -= totalPoints if err := tx.Save(&user).Error; err != nil { return err } if err := tx.Model(&product).Updates(map[string]any{ "stock": gorm.Expr("stock - ?", req.Quantity), "redeemed_count": gorm.Expr("redeemed_count + ?", req.Quantity), }).Error; err != nil { return err } bizKey := fmt.Sprintf("mall:%d:redeem", result.ID) ledger := model.RenterGrowthLedger{ UserID: userID, Role: "mall", RedemptionID: result.ID, Points: -totalPoints, BeforePoints: user.RenterGrowthPoints, AfterPoints: user.RenterGrowthPoints, AvailableBefore: beforePoints, AvailableAfter: user.PointsAvailable, BeforeLevel: user.RenterGrowthLevel, AfterLevel: user.RenterGrowthLevel, Source: "mall_redeem", BizKey: &bizKey, Remark: "积分商城兑换: " + product.Title, CreatedAt: now, } return tx.Create(&ledger).Error }) if err != nil { return nil, err } dto := toRedemptionDTO(result) return &dto, nil } func (r *Repository) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) { type row struct { model.PointsMallRedemption UserPhone string UserNickname string } var rows []row tx := r.db.WithContext(ctx).Table("points_mall_redemptions AS r"). Select("r.*, u.phone AS user_phone, u.nickname AS user_nickname"). Joins("LEFT JOIN users AS u ON u.id = r.user_id"). Order("r.created_at DESC, r.id DESC") if !admin { tx = tx.Where("r.user_id = ?", userID) } if err := tx.Scan(&rows).Error; err != nil { return nil, err } items := make([]RedemptionDTO, 0, len(rows)) for _, item := range rows { dto := toRedemptionDTO(item.PointsMallRedemption) dto.UserPhone = item.UserPhone dto.UserNickname = item.UserNickname items = append(items, dto) } return items, nil } func (r *Repository) CompleteRedemption(ctx context.Context, adminID, id uint64, remark string) (*RedemptionDTO, error) { var result model.PointsMallRedemption err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&result, id).Error; err != nil { return err } if result.Status != "pending" { return ErrRedemptionNotPending } now := time.Now() result.Status = "completed" result.AdminRemark = strings.TrimSpace(remark) result.CompletedBy = &adminID result.CompletedAt = &now return tx.Save(&result).Error }) if err != nil { return nil, err } dto := toRedemptionDTO(result) return &dto, nil } func productFromRequest(req ProductRequest) model.PointsMallProduct { return model.PointsMallProduct{ Title: strings.TrimSpace(req.Title), Subtitle: strings.TrimSpace(req.Subtitle), Description: strings.TrimSpace(req.Description), CoverURL: strings.TrimSpace(req.CoverURL), RedemptionInstructions: strings.TrimSpace(req.RedemptionInstructions), PointsPrice: req.PointsPrice, Stock: req.Stock, PerUserLimit: req.PerUserLimit, Status: req.Status, SortOrder: req.SortOrder, } } func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }