feat(distribution): 分站品牌装修可配置首页入口并上传图片
分站可单独配置回收、合作反馈和客服二维码,未配置时继承主站;Logo、图标和轮播支持直接上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,27 +4,29 @@ import "time"
|
||||
|
||||
// DistributionSite 表示一个独立分销网站主体。
|
||||
type DistributionSite struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
|
||||
SiteName string `gorm:"size:128;not null" json:"site_name"`
|
||||
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
|
||||
LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"`
|
||||
FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"`
|
||||
BannersJSON string `gorm:"type:text" json:"banners_json"`
|
||||
AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"`
|
||||
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
|
||||
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
||||
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
|
||||
SMSConfigJSON string `gorm:"type:text" json:"-"`
|
||||
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
|
||||
ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"`
|
||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
|
||||
SiteName string `gorm:"size:128;not null" json:"site_name"`
|
||||
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
|
||||
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
|
||||
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
|
||||
LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"`
|
||||
FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"`
|
||||
BannersJSON string `gorm:"type:text" json:"banners_json"`
|
||||
AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"`
|
||||
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
|
||||
AWRecycleJSON string `gorm:"type:text" json:"aw_recycle_json"`
|
||||
CooperationFeedbackJSON string `gorm:"type:text" json:"cooperation_feedback_json"`
|
||||
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
|
||||
ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"`
|
||||
SMSConfigJSON string `gorm:"type:text" json:"-"`
|
||||
ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"`
|
||||
ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"`
|
||||
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
|
||||
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DistributionSite) TableName() string { return "distribution_sites" }
|
||||
|
||||
@@ -356,7 +356,12 @@ func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string
|
||||
if qrcode != nil {
|
||||
return qrcode, qrcode.ImageURL, nil
|
||||
}
|
||||
return nil, "", nil
|
||||
// 分站没有固定码、也没有自己的码池时,沿用主站二维码池。
|
||||
mainQR, err := fetchUnusedQrCode(tx)
|
||||
if err != nil || mainQR == nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return mainQR, mainQR.ImageURL, nil
|
||||
}
|
||||
qrcode, err := fetchUnusedQrCodeForSite(tx, nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,30 +3,32 @@ package distribution
|
||||
import "time"
|
||||
|
||||
type SiteDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
SiteCode string `json:"site_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
DisplayAccountEnabled bool `json:"display_account_enabled"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
BannersJSON string `json:"banners_json"`
|
||||
AnnouncementsJSON string `json:"announcements_json"`
|
||||
FooterInfoJSON string `json:"footer_info_json"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
||||
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||
ReceiveCostDelta float64 `json:"receive_cost_delta"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
AdminNickname string `json:"admin_nickname,omitempty"`
|
||||
MemberType string `json:"member_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
SiteCode string `json:"site_code"`
|
||||
SiteName string `json:"site_name"`
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
DisplayAccountEnabled bool `json:"display_account_enabled"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
FaviconURL string `json:"favicon_url"`
|
||||
BannersJSON string `json:"banners_json"`
|
||||
AnnouncementsJSON string `json:"announcements_json"`
|
||||
FooterInfoJSON string `json:"footer_info_json"`
|
||||
AWRecycleJSON string `json:"aw_recycle_json"`
|
||||
CooperationFeedbackJSON string `json:"cooperation_feedback_json"`
|
||||
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
|
||||
ListingQrcodeURL string `json:"listing_qrcode_url"`
|
||||
ReceiveRatioDelta float64 `json:"receive_ratio_delta"`
|
||||
ReceiveCostDelta float64 `json:"receive_cost_delta"`
|
||||
TaxEnabled bool `json:"tax_enabled"`
|
||||
TaxRateBPS int64 `json:"tax_rate_bps"`
|
||||
SettlementHoldDays int `json:"settlement_hold_days"`
|
||||
AdminUserID *uint64 `json:"admin_user_id,omitempty"`
|
||||
AdminUsername string `json:"admin_username,omitempty"`
|
||||
AdminNickname string `json:"admin_nickname,omitempty"`
|
||||
MemberType string `json:"member_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ResetSiteAdminPasswordRequest struct {
|
||||
@@ -418,11 +420,14 @@ type UpdateReceivedListingStatusRequest struct {
|
||||
|
||||
// 站点品牌与装修 DTO。指针字段未传则保持原值,避免空字符串把装修清掉。
|
||||
type UpdateSiteBrandingRequest struct {
|
||||
LogoURL *string `json:"logo_url"`
|
||||
FaviconURL *string `json:"favicon_url"`
|
||||
BannersJSON *string `json:"banners_json"`
|
||||
AnnouncementsJSON *string `json:"announcements_json"`
|
||||
FooterInfoJSON *string `json:"footer_info_json"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
FaviconURL *string `json:"favicon_url"`
|
||||
BannersJSON *string `json:"banners_json"`
|
||||
AnnouncementsJSON *string `json:"announcements_json"`
|
||||
FooterInfoJSON *string `json:"footer_info_json"`
|
||||
AWRecycleJSON *string `json:"aw_recycle_json"`
|
||||
CooperationFeedbackJSON *string `json:"cooperation_feedback_json"`
|
||||
ListingQrcodeURL *string `json:"listing_qrcode_url"`
|
||||
}
|
||||
|
||||
// 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。
|
||||
|
||||
@@ -855,26 +855,28 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, s
|
||||
|
||||
func siteDTO(row model.DistributionSite) SiteDTO {
|
||||
return SiteDTO{
|
||||
ID: row.ID,
|
||||
SiteCode: row.SiteCode,
|
||||
SiteName: row.SiteName,
|
||||
Domain: row.Domain,
|
||||
Status: row.Status,
|
||||
DisplayAccountEnabled: row.DisplayAccountEnabled,
|
||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||
ListingQrcodeURL: row.ListingQrcodeURL,
|
||||
ReceiveRatioDelta: row.ReceiveRatioDelta,
|
||||
ReceiveCostDelta: row.ReceiveCostDelta,
|
||||
TaxEnabled: row.TaxEnabled,
|
||||
TaxRateBPS: row.TaxRateBPS,
|
||||
SettlementHoldDays: row.SettlementHoldDays,
|
||||
LogoURL: row.LogoURL,
|
||||
FaviconURL: row.FaviconURL,
|
||||
BannersJSON: row.BannersJSON,
|
||||
AnnouncementsJSON: row.AnnouncementsJSON,
|
||||
FooterInfoJSON: row.FooterInfoJSON,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
SiteCode: row.SiteCode,
|
||||
SiteName: row.SiteName,
|
||||
Domain: row.Domain,
|
||||
Status: row.Status,
|
||||
DisplayAccountEnabled: row.DisplayAccountEnabled,
|
||||
DefaultSupportGroupID: row.DefaultSupportGroupID,
|
||||
ListingQrcodeURL: row.ListingQrcodeURL,
|
||||
ReceiveRatioDelta: row.ReceiveRatioDelta,
|
||||
ReceiveCostDelta: row.ReceiveCostDelta,
|
||||
TaxEnabled: row.TaxEnabled,
|
||||
TaxRateBPS: row.TaxRateBPS,
|
||||
SettlementHoldDays: row.SettlementHoldDays,
|
||||
LogoURL: row.LogoURL,
|
||||
FaviconURL: row.FaviconURL,
|
||||
BannersJSON: row.BannersJSON,
|
||||
AnnouncementsJSON: row.AnnouncementsJSON,
|
||||
FooterInfoJSON: row.FooterInfoJSON,
|
||||
AWRecycleJSON: row.AWRecycleJSON,
|
||||
CooperationFeedbackJSON: row.CooperationFeedbackJSON,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2700,6 +2702,18 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin
|
||||
site.FooterInfoJSON = strings.TrimSpace(*req.FooterInfoJSON)
|
||||
updates["footer_info_json"] = site.FooterInfoJSON
|
||||
}
|
||||
if req.AWRecycleJSON != nil {
|
||||
site.AWRecycleJSON = strings.TrimSpace(*req.AWRecycleJSON)
|
||||
updates["aw_recycle_json"] = site.AWRecycleJSON
|
||||
}
|
||||
if req.CooperationFeedbackJSON != nil {
|
||||
site.CooperationFeedbackJSON = strings.TrimSpace(*req.CooperationFeedbackJSON)
|
||||
updates["cooperation_feedback_json"] = site.CooperationFeedbackJSON
|
||||
}
|
||||
if req.ListingQrcodeURL != nil {
|
||||
site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL)
|
||||
updates["listing_qrcode_url"] = site.ListingQrcodeURL
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := r.db.WithContext(ctx).Model(&site).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -101,7 +101,8 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
||||
!strings.HasPrefix(key, "mohong/") &&
|
||||
!strings.HasPrefix(key, "crash/") &&
|
||||
!strings.HasPrefix(key, "aw-recycle/") &&
|
||||
!strings.HasPrefix(key, "cooperation-feedback/") {
|
||||
!strings.HasPrefix(key, "cooperation-feedback/") &&
|
||||
!strings.HasPrefix(key, "support-qrcode/") {
|
||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
||||
|
||||
func fileURLForScene(scene string, key string) string {
|
||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" {
|
||||
if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" || scene == "support-qrcode" {
|
||||
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||
}
|
||||
return fileURL
|
||||
@@ -118,7 +118,7 @@ func fileURLForScene(scene string, key string) string {
|
||||
func normalizeScene(scene string) string {
|
||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||
switch scene {
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
|
||||
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "support-qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense":
|
||||
return scene
|
||||
case "listing_screenshot", "listing-screenshot":
|
||||
return "listing"
|
||||
|
||||
@@ -77,6 +77,16 @@ func TestCooperationFeedbackSceneUsesPublicFileURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportQrcodeSceneUsesPublicFileURL(t *testing.T) {
|
||||
if got := normalizeScene("support-qrcode"); got != "support-qrcode" {
|
||||
t.Fatalf("normalize support-qrcode scene = %q", got)
|
||||
}
|
||||
got := fileURLForScene("support-qrcode", "support-qrcode/2026/09/25/example.png")
|
||||
if !strings.HasPrefix(got, "/api/public/files/object?") {
|
||||
t.Fatalf("support-qrcode file url = %q, want public file endpoint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) {
|
||||
if got := normalizeScene("listing_screenshot"); got != "listing" {
|
||||
t.Fatalf("normalize listing_screenshot scene = %q, want listing", got)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/sitecode"
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -82,7 +83,7 @@ func (h *Handler) HomeAnnouncements(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Handler) HomeConfig(c *gin.Context) {
|
||||
config, err := h.service.HomeConfig(c.Request.Context())
|
||||
config, err := h.service.HomeConfig(c.Request.Context(), sitecode.FromRequest(c))
|
||||
if err != nil {
|
||||
writeConfigError(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package systemconfig
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseHomeEntryOverrideInheritsWhenEmpty(t *testing.T) {
|
||||
if _, ok := parseAWRecycleOverride(" "); ok {
|
||||
t.Fatal("空配置应继承主站")
|
||||
}
|
||||
if _, ok := parseCooperationFeedbackOverride(""); ok {
|
||||
t.Fatal("空配置应继承主站")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHomeEntryOverrideUsesSiteJSON(t *testing.T) {
|
||||
aw, ok := parseAWRecycleOverride(`{"title":"分站回收","subtitle":"看图","image_url":"/aw.png","enabled":true}`)
|
||||
if !ok || aw.Title != "分站回收" || aw.ImageURL != "/aw.png" {
|
||||
t.Fatalf("回收覆盖 = %+v ok=%v", aw, ok)
|
||||
}
|
||||
coop, ok := parseCooperationFeedbackOverride(`{"title":"分站合作","subtitle":"联系","image_url":"/coop.png","enabled":false}`)
|
||||
if !ok || coop.Title != "分站合作" || coop.Enabled {
|
||||
t.Fatalf("合作覆盖 = %+v ok=%v", coop, ok)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package systemconfig
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) {
|
||||
@@ -16,7 +19,7 @@ func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO,
|
||||
return &HomeAnnouncementsDTO{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
|
||||
func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
@@ -40,13 +43,61 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &HomeConfigDTO{
|
||||
config := &HomeConfigDTO{
|
||||
Announcements: announcements,
|
||||
Banners: banners,
|
||||
PublishOptions: publishOptions,
|
||||
AWRecycle: awRecycle,
|
||||
CooperationFeedback: cooperationFeedback,
|
||||
}, nil
|
||||
}
|
||||
s.applySiteHomeOverrides(ctx, siteCode, config)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// applySiteHomeOverrides 用分站自己的首页入口覆盖主站配置。字段为空表示继续继承主站。
|
||||
func (s *Service) applySiteHomeOverrides(ctx context.Context, siteCode string, config *HomeConfigDTO) {
|
||||
siteCode = strings.TrimSpace(siteCode)
|
||||
if s == nil || s.repo == nil || s.repo.db == nil || config == nil || siteCode == "" {
|
||||
return
|
||||
}
|
||||
var site model.DistributionSite
|
||||
err := s.repo.db.WithContext(ctx).
|
||||
Select("aw_recycle_json", "cooperation_feedback_json").
|
||||
Where("site_code = ? AND status = ?", siteCode, "active").
|
||||
First(&site).Error
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if item, ok := parseAWRecycleOverride(site.AWRecycleJSON); ok {
|
||||
config.AWRecycle = item
|
||||
}
|
||||
if item, ok := parseCooperationFeedbackOverride(site.CooperationFeedbackJSON); ok {
|
||||
config.CooperationFeedback = item
|
||||
}
|
||||
}
|
||||
|
||||
func parseAWRecycleOverride(raw string) (AWRecycleConfig, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return AWRecycleConfig{}, false
|
||||
}
|
||||
var item AWRecycleConfig
|
||||
if err := json.Unmarshal([]byte(raw), &item); err != nil {
|
||||
return AWRecycleConfig{}, false
|
||||
}
|
||||
return normalizeAWRecycle(item), true
|
||||
}
|
||||
|
||||
func parseCooperationFeedbackOverride(raw string) (CooperationFeedbackConfig, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return CooperationFeedbackConfig{}, false
|
||||
}
|
||||
var item CooperationFeedbackConfig
|
||||
if err := json.Unmarshal([]byte(raw), &item); err != nil {
|
||||
return CooperationFeedbackConfig{}, false
|
||||
}
|
||||
return normalizeCooperationFeedback(item), true
|
||||
}
|
||||
|
||||
func (s *Service) homeAnnouncements(ctx context.Context) ([]string, error) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
|
||||
-- 分站首页入口。空字符串表示继承主站 mobile.aw_recycle / mobile.cooperation_feedback。
|
||||
ALTER TABLE distribution_sites
|
||||
ADD COLUMN aw_recycle_json TEXT NULL COMMENT '分站 AW炫彩回收 JSON,空则继承主站' AFTER footer_info_json,
|
||||
ADD COLUMN cooperation_feedback_json TEXT NULL COMMENT '分站合作与反馈 JSON,空则继承主站' AFTER aw_recycle_json;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE distribution_sites
|
||||
DROP COLUMN cooperation_feedback_json,
|
||||
DROP COLUMN aw_recycle_json;
|
||||
Reference in New Issue
Block a user