feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验
统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -16,15 +17,19 @@ import (
|
||||
// ---- TypeMeta 结构 ----
|
||||
|
||||
type QuestionMeta struct {
|
||||
Solved bool `json:"solved"`
|
||||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||||
Solved bool `json:"solved"`
|
||||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||||
SolvedAt string `json:"solved_at,omitempty"` // 解决时刻 RFC3339
|
||||
}
|
||||
|
||||
type PollMeta struct {
|
||||
Options []string `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
Closed bool `json:"closed"`
|
||||
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
|
||||
Options []string `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
Closed bool `json:"closed"`
|
||||
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
ClosedAt string `json:"closed_at,omitempty"` // 实际结束时刻;提前结束时与 ends_at 可能不同
|
||||
ExpireDays int `json:"expire_days,omitempty"`
|
||||
}
|
||||
|
||||
type BountyMeta struct {
|
||||
@@ -37,13 +42,19 @@ type BountyMeta struct {
|
||||
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
|
||||
}
|
||||
|
||||
type LotteryPrize struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type LotteryMeta struct {
|
||||
Slots int `json:"slots"`
|
||||
Drawn bool `json:"drawn"`
|
||||
WinnerIDs []uint `json:"winner_ids"`
|
||||
Closed bool `json:"closed"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
ExpireDays int `json:"expire_days,omitempty"`
|
||||
Prizes []LotteryPrize `json:"prizes"`
|
||||
Slots int `json:"slots"` // 由 prizes 数量合计推导,兼容旧数据
|
||||
Drawn bool `json:"drawn"`
|
||||
WinnerIDs []uint `json:"winner_ids"`
|
||||
Closed bool `json:"closed"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
ExpireDays int `json:"expire_days,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -53,10 +64,9 @@ var (
|
||||
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
|
||||
ErrBountySettled = errors.New("悬赏已结算")
|
||||
ErrBountyNotEscrow = errors.New("悬赏未托管")
|
||||
ErrBountyExpired = errors.New("悬赏已过期")
|
||||
ErrLotteryDrawn = errors.New("已开奖")
|
||||
ErrLotteryClosed = errors.New("抽奖已截止")
|
||||
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
||||
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
|
||||
ErrQuestionNotType = errors.New("本文不是问答帖")
|
||||
)
|
||||
|
||||
@@ -73,10 +83,13 @@ func parseQuestionMeta(raw string) (*QuestionMeta, error) {
|
||||
|
||||
func parsePollMeta(raw string) (*PollMeta, error) {
|
||||
var wire struct {
|
||||
Options []string `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
Closed bool `json:"closed"`
|
||||
Anonymous *bool `json:"anonymous"`
|
||||
Options []string `json:"options"`
|
||||
Multi bool `json:"multi"`
|
||||
Closed bool `json:"closed"`
|
||||
Anonymous *bool `json:"anonymous"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
ClosedAt string `json:"closed_at"`
|
||||
ExpireDays int `json:"expire_days"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
||||
return nil, errors.New("投票配置无效")
|
||||
@@ -86,10 +99,13 @@ func parsePollMeta(raw string) (*PollMeta, error) {
|
||||
anon = *wire.Anonymous
|
||||
}
|
||||
return &PollMeta{
|
||||
Options: wire.Options,
|
||||
Multi: wire.Multi,
|
||||
Closed: wire.Closed,
|
||||
Anonymous: anon,
|
||||
Options: wire.Options,
|
||||
Multi: wire.Multi,
|
||||
Closed: wire.Closed,
|
||||
Anonymous: anon,
|
||||
EndsAt: strings.TrimSpace(wire.EndsAt),
|
||||
ClosedAt: strings.TrimSpace(wire.ClosedAt),
|
||||
ExpireDays: wire.ExpireDays,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -106,9 +122,49 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) {
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil, errors.New("抽奖配置无效")
|
||||
}
|
||||
// 旧数据只有 slots:合成单一奖品项
|
||||
if len(m.Prizes) == 0 && m.Slots > 0 {
|
||||
m.Prizes = []LotteryPrize{{Name: "奖品", Count: m.Slots}}
|
||||
}
|
||||
if len(m.Prizes) > 0 {
|
||||
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func lotterySlotsFromPrizes(prizes []LotteryPrize) int {
|
||||
n := 0
|
||||
for _, p := range prizes {
|
||||
if p.Count > 0 {
|
||||
n += p.Count
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func lotteryPrizesEqual(a, b []LotteryPrize) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Name != b[i].Name || a[i].Count != b[i].Count {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// expandLotteryPrizeNames 按奖项顺序展开为与名额等长的名称列表(开奖结果按序对应)
|
||||
func expandLotteryPrizeNames(prizes []LotteryPrize) []string {
|
||||
out := make([]string, 0, lotterySlotsFromPrizes(prizes))
|
||||
for _, p := range prizes {
|
||||
for i := 0; i < p.Count; i++ {
|
||||
out = append(out, p.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeMeta(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -145,6 +201,35 @@ func parseEndsAt(s string) (time.Time, bool) {
|
||||
return t, true
|
||||
}
|
||||
|
||||
// normalizeOptionalEndsAt 校验自定义截止时刻:至少晚于现在 1 小时,最远 90 天。空串表示未自定义。
|
||||
func normalizeOptionalEndsAt(endsAt string) (string, error) {
|
||||
endsAt = strings.TrimSpace(endsAt)
|
||||
if endsAt == "" {
|
||||
return "", nil
|
||||
}
|
||||
t, ok := parseEndsAt(endsAt)
|
||||
if !ok {
|
||||
return "", errors.New("截止时间格式无效")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !t.After(now.Add(time.Hour)) {
|
||||
return "", errors.New("截止时间至少晚于当前 1 小时")
|
||||
}
|
||||
if t.After(now.Add(90 * 24 * time.Hour)) {
|
||||
return "", errors.New("截止时间最远 90 天")
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// resolveEndsAtOnPublish 发布时确定 ends_at:已有自定义时刻则保留,否则按天数推算。
|
||||
func resolveEndsAtOnPublish(existing string, expireDays, def int, allowed []int, from time.Time) string {
|
||||
if t, ok := parseEndsAt(existing); ok {
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
days := normalizeExpireDays(expireDays, def, allowed)
|
||||
return endsAtFromDays(days, from)
|
||||
}
|
||||
|
||||
// ComputeTypeStatus 列表徽章状态
|
||||
func ComputeTypeStatus(postType, typeMeta string) string {
|
||||
pt := model.NormalizePostType(postType)
|
||||
@@ -175,7 +260,10 @@ func ComputeTypeStatus(postType, typeMeta string) string {
|
||||
if m.AcceptedCommentID > 0 {
|
||||
return "solved"
|
||||
}
|
||||
if m.Expired || m.Refunded {
|
||||
if m.Expired {
|
||||
return "expired"
|
||||
}
|
||||
if m.Refunded {
|
||||
return "expired"
|
||||
}
|
||||
return "open"
|
||||
@@ -240,6 +328,14 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
|
||||
return "", errors.New("最多 10 个投票选项")
|
||||
}
|
||||
m.Options = opts
|
||||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||
return "", err
|
||||
} else if ends != "" {
|
||||
m.EndsAt = ends
|
||||
m.ExpireDays = 0
|
||||
} else {
|
||||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
}
|
||||
return encodeMeta(m)
|
||||
case model.PostTypeBounty:
|
||||
if meta == "" {
|
||||
@@ -255,50 +351,102 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
|
||||
if m.Points > 100000 {
|
||||
return "", errors.New("悬赏积分过高")
|
||||
}
|
||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||
m.ExpireDays = days
|
||||
// ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值
|
||||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||
return "", err
|
||||
} else if ends != "" {
|
||||
m.EndsAt = ends
|
||||
m.ExpireDays = 0
|
||||
} else {
|
||||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||
}
|
||||
// ends_at 若仍空,在 Create/Approve 时按发布时间写入
|
||||
return encodeMeta(m)
|
||||
case model.PostTypeLottery:
|
||||
if meta == "" {
|
||||
return "", errors.New("请设置中奖名额")
|
||||
return "", errors.New("请设置奖品")
|
||||
}
|
||||
m, err := parseLotteryMeta(meta)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if m.Slots < 1 {
|
||||
return "", errors.New("中奖名额至少为 1")
|
||||
prizes := make([]LotteryPrize, 0, len(m.Prizes))
|
||||
for _, p := range m.Prizes {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
if name == "" {
|
||||
return "", errors.New("请填写奖品名称")
|
||||
}
|
||||
if utf8.RuneCountInString(name) > 32 {
|
||||
return "", errors.New("奖品名称最多 32 字")
|
||||
}
|
||||
if p.Count < 1 {
|
||||
return "", errors.New("请填写每个奖品的数量")
|
||||
}
|
||||
if p.Count > 100 {
|
||||
return "", errors.New("单个奖品数量过多")
|
||||
}
|
||||
prizes = append(prizes, LotteryPrize{Name: name, Count: p.Count})
|
||||
}
|
||||
if m.Slots > 100 {
|
||||
return "", errors.New("中奖名额过多")
|
||||
if len(prizes) < 1 {
|
||||
return "", errors.New("请至少添加一种奖品")
|
||||
}
|
||||
if len(prizes) > 10 {
|
||||
return "", errors.New("最多 10 种奖品")
|
||||
}
|
||||
total := lotterySlotsFromPrizes(prizes)
|
||||
if total < 1 {
|
||||
return "", errors.New("奖品总名额至少为 1")
|
||||
}
|
||||
if total > 100 {
|
||||
return "", errors.New("奖品总名额过多")
|
||||
}
|
||||
m.Prizes = prizes
|
||||
m.Slots = total
|
||||
if m.WinnerIDs == nil {
|
||||
m.WinnerIDs = []uint{}
|
||||
}
|
||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
m.ExpireDays = days
|
||||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||
return "", err
|
||||
} else if ends != "" {
|
||||
m.EndsAt = ends
|
||||
m.ExpireDays = 0
|
||||
} else {
|
||||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
}
|
||||
return encodeMeta(m)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖)
|
||||
// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(投票 / 悬赏 / 抽奖)
|
||||
func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) {
|
||||
pt := model.NormalizePostType(postType)
|
||||
switch pt {
|
||||
case model.PostTypePoll:
|
||||
m, err := parsePollMeta(typeMeta)
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
}
|
||||
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||
return typeMeta, false
|
||||
}
|
||||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||||
m.ExpireDays = 0
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
}
|
||||
return raw, true
|
||||
case model.PostTypeBounty:
|
||||
m, err := parseBountyMeta(typeMeta)
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
}
|
||||
if m.EndsAt != "" {
|
||||
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||
return typeMeta, false
|
||||
}
|
||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{3, 7, 14, 30}, publishedAt)
|
||||
m.ExpireDays = 0
|
||||
m.EndsAt = endsAtFromDays(days, publishedAt)
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
@@ -309,12 +457,11 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
}
|
||||
if m.EndsAt != "" {
|
||||
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||
return typeMeta, false
|
||||
}
|
||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||||
m.ExpireDays = 0
|
||||
m.EndsAt = endsAtFromDays(days, publishedAt)
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return typeMeta, false
|
||||
@@ -347,6 +494,7 @@ type QuestionState struct {
|
||||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||||
AcceptedFloor int `json:"accepted_floor"`
|
||||
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
|
||||
SolvedAt string `json:"solved_at,omitempty"`
|
||||
CanAccept bool `json:"can_accept"`
|
||||
CanSolve bool `json:"can_solve"`
|
||||
CanReopen bool `json:"can_reopen"`
|
||||
@@ -372,6 +520,8 @@ type PollState struct {
|
||||
Multi bool `json:"multi"`
|
||||
Closed bool `json:"closed"`
|
||||
Anonymous bool `json:"anonymous"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
ClosedAt string `json:"closed_at,omitempty"`
|
||||
TotalVotes int `json:"total_votes"`
|
||||
Voters int `json:"voters"`
|
||||
MyOptions []int `json:"my_options"`
|
||||
@@ -391,7 +541,6 @@ type BountyState struct {
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
Settled bool `json:"settled"`
|
||||
CanAccept bool `json:"can_accept"`
|
||||
CanRefund bool `json:"can_refund"`
|
||||
}
|
||||
|
||||
type LotteryEntrant struct {
|
||||
@@ -399,20 +548,23 @@ type LotteryEntrant struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条
|
||||
CommentID uint `json:"comment_id"`
|
||||
Floor int `json:"floor"`
|
||||
PrizeName string `json:"prize_name,omitempty"`
|
||||
}
|
||||
|
||||
type LotteryState struct {
|
||||
Slots int `json:"slots"`
|
||||
Drawn bool `json:"drawn"`
|
||||
Closed bool `json:"closed"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
EntryCount int `json:"entry_count"` // 回帖参与人数
|
||||
Eligible bool `json:"eligible"` // 当前用户是否在奖池
|
||||
CanDraw bool `json:"can_draw"`
|
||||
CanClose bool `json:"can_close"`
|
||||
Winners []LotteryEntrant `json:"winners"`
|
||||
Slots int `json:"slots"`
|
||||
Prizes []LotteryPrize `json:"prizes"`
|
||||
Drawn bool `json:"drawn"`
|
||||
Closed bool `json:"closed"`
|
||||
EndsAt string `json:"ends_at,omitempty"`
|
||||
EntryCount int `json:"entry_count"`
|
||||
Eligible bool `json:"eligible"`
|
||||
CooldownHours int `json:"cooldown_hours"`
|
||||
CanDraw bool `json:"can_draw"`
|
||||
CanEditPrizes bool `json:"can_edit_prizes"`
|
||||
Winners []LotteryEntrant `json:"winners"`
|
||||
}
|
||||
|
||||
func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) {
|
||||
@@ -444,18 +596,28 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
|
||||
m = &QuestionMeta{}
|
||||
}
|
||||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
||||
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
|
||||
solvedAt := m.SolvedAt
|
||||
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
|
||||
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
|
||||
}
|
||||
return &QuestionState{
|
||||
Solved: m.Solved,
|
||||
AcceptedCommentID: m.AcceptedCommentID,
|
||||
AcceptedFloor: floor,
|
||||
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
|
||||
AcceptedAnswer: answer,
|
||||
SolvedAt: solvedAt,
|
||||
CanAccept: canManage && !m.Solved,
|
||||
CanSolve: canManage && !m.Solved,
|
||||
CanReopen: canManage && m.Solved,
|
||||
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
|
||||
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
|
||||
_ = s.settleDuePollIfNeeded(post)
|
||||
s.db.Select("id", "type_meta", "post_type").First(post, post.ID)
|
||||
|
||||
m, err := parsePollMeta(post.TypeMeta)
|
||||
if err != nil || len(m.Options) == 0 {
|
||||
return nil
|
||||
@@ -531,11 +693,17 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
|
||||
opts[i] = stat
|
||||
}
|
||||
voters := len(voterSet)
|
||||
closedAt := m.ClosedAt
|
||||
if m.Closed && closedAt == "" {
|
||||
closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底
|
||||
}
|
||||
return &PollState{
|
||||
Options: opts,
|
||||
Multi: m.Multi,
|
||||
Closed: m.Closed,
|
||||
Anonymous: m.Anonymous,
|
||||
EndsAt: m.EndsAt,
|
||||
ClosedAt: closedAt,
|
||||
TotalVotes: total,
|
||||
Voters: voters,
|
||||
MyOptions: my,
|
||||
@@ -550,7 +718,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired
|
||||
accepted := m.AcceptedCommentID > 0
|
||||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
||||
return &BountyState{
|
||||
Points: m.Points,
|
||||
@@ -561,9 +729,9 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
||||
Refunded: m.Refunded,
|
||||
Expired: m.Expired,
|
||||
EndsAt: m.EndsAt,
|
||||
Settled: settled,
|
||||
CanAccept: canManage && m.Escrowed && !settled && !m.Expired,
|
||||
CanRefund: canManage && m.Escrowed && !settled && !m.Expired,
|
||||
Settled: accepted,
|
||||
// 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
|
||||
CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,14 +769,197 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
|
||||
return ans
|
||||
}
|
||||
|
||||
func (s *PostService) lotteryCooldownHours() int {
|
||||
hours := DefaultCooldownHours
|
||||
if s.setting != nil {
|
||||
if h, err := s.setting.PostCooldownHours(); err == nil {
|
||||
hours = h
|
||||
}
|
||||
}
|
||||
if hours < 0 {
|
||||
return 0
|
||||
}
|
||||
return hours
|
||||
}
|
||||
|
||||
// EnsureLotteryEntry 主评公开发布后登记抽奖报名(幂等;楼主不入池)。
|
||||
func (s *PostService) EnsureLotteryEntry(postID, userID, commentID uint, clientIP string) {
|
||||
if postID == 0 || userID == 0 {
|
||||
return
|
||||
}
|
||||
var post model.Post
|
||||
if err := s.db.Select("id", "user_id", "post_type").First(&post, postID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
||||
return
|
||||
}
|
||||
if post.UserID == userID {
|
||||
return
|
||||
}
|
||||
clientIP = normalizeLotteryIP(clientIP)
|
||||
var n int64
|
||||
s.db.Model(&model.PostLotteryEntry{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||||
Count(&n)
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
_ = s.db.Create(&model.PostLotteryEntry{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
CommentID: commentID,
|
||||
ClientIP: clientIP,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func normalizeLotteryIP(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
// 去掉 IPv6 zone(如 fe80::1%eth0)
|
||||
if i := strings.IndexByte(ip, '%'); i >= 0 {
|
||||
ip = ip[:i]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// lotteryEligibleIDs 奖池:已发布主评用户去重 − 楼主 − 新号冷静期 − 同 IP 仅留一人。
|
||||
func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := s.db.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?",
|
||||
hours := s.lotteryCooldownHours()
|
||||
q := s.db.Table("comments AS c").
|
||||
Select("c.user_id").
|
||||
Joins("INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL").
|
||||
Where("c.post_id = ? AND c.parent_id IS NULL AND c.status = ? AND c.deleted_at IS NULL AND c.user_id <> ?",
|
||||
postID, model.ContentStatusPublished, authorID).
|
||||
Distinct("user_id").
|
||||
Pluck("user_id", &ids).Error
|
||||
return ids, err
|
||||
Group("c.user_id")
|
||||
if hours > 0 {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
q = q.Where("u.created_at <= ?", cutoff)
|
||||
}
|
||||
var ids []uint
|
||||
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.lotteryDedupByIP(postID, ids), nil
|
||||
}
|
||||
|
||||
// latestSuccessLoginIPs 各用户最近一次成功登录 IP(作报名 IP 回退)
|
||||
func (s *PostService) latestSuccessLoginIPs(userIDs []uint) map[uint]string {
|
||||
out := map[uint]string{}
|
||||
if len(userIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
type row struct {
|
||||
UserID uint
|
||||
IP string
|
||||
}
|
||||
var rows []row
|
||||
_ = s.db.Raw(`
|
||||
SELECT DISTINCT ON (user_id) user_id, ip
|
||||
FROM login_logs
|
||||
WHERE user_id IN ? AND success = TRUE AND ip <> ''
|
||||
ORDER BY user_id, created_at DESC
|
||||
`, userIDs).Scan(&rows).Error
|
||||
for _, r := range rows {
|
||||
out[r.UserID] = normalizeLotteryIP(r.IP)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LatestSuccessLoginIP 单用户最近成功登录 IP
|
||||
func (s *PostService) LatestSuccessLoginIP(userID uint) string {
|
||||
if userID == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.latestSuccessLoginIPs([]uint{userID})[userID]
|
||||
}
|
||||
|
||||
// lotteryIPIgnoredForDedup 不参与同 IP 互斥:空地址;开发态下的回环地址(::1 / 127.0.0.1)
|
||||
func (s *PostService) lotteryIPIgnoredForDedup(ip string) bool {
|
||||
ip = normalizeLotteryIP(ip)
|
||||
if ip == "" {
|
||||
return true
|
||||
}
|
||||
if !s.devMode {
|
||||
return false
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
return parsed != nil && parsed.IsLoopback()
|
||||
}
|
||||
|
||||
// lotteryDedupByIP 同一非空 IP 只保留注册最早的一名;空 IP / 开发态回环不互斥。
|
||||
func (s *PostService) lotteryDedupByIP(postID uint, ids []uint) []uint {
|
||||
if len(ids) <= 1 {
|
||||
return ids
|
||||
}
|
||||
var entries []model.PostLotteryEntry
|
||||
_ = s.db.Where("post_id = ? AND user_id IN ?", postID, ids).Find(&entries).Error
|
||||
ipByUser := map[uint]string{}
|
||||
for _, e := range entries {
|
||||
if ip := normalizeLotteryIP(e.ClientIP); ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||||
ipByUser[e.UserID] = ip
|
||||
}
|
||||
}
|
||||
missing := make([]uint, 0)
|
||||
for _, id := range ids {
|
||||
if ipByUser[id] == "" {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
for id, ip := range s.latestSuccessLoginIPs(missing) {
|
||||
if ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||||
ipByUser[id] = ip
|
||||
}
|
||||
}
|
||||
|
||||
type userRow struct {
|
||||
ID uint
|
||||
CreatedAt time.Time
|
||||
}
|
||||
var users []userRow
|
||||
_ = s.db.Model(&model.User{}).Select("id", "created_at").
|
||||
Where("id IN ?", ids).Find(&users).Error
|
||||
created := map[uint]time.Time{}
|
||||
for _, u := range users {
|
||||
created[u.ID] = u.CreatedAt
|
||||
}
|
||||
|
||||
// ip -> 胜出 user(注册最早;同刻取更小 id)
|
||||
bestByIP := map[string]uint{}
|
||||
for _, id := range ids {
|
||||
ip := ipByUser[id]
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
cur, ok := bestByIP[ip]
|
||||
if !ok {
|
||||
bestByIP[ip] = id
|
||||
continue
|
||||
}
|
||||
ct, okC := created[id]
|
||||
cc, okCur := created[cur]
|
||||
if !okC {
|
||||
continue
|
||||
}
|
||||
if !okCur || ct.Before(cc) || (ct.Equal(cc) && id < cur) {
|
||||
bestByIP[ip] = id
|
||||
}
|
||||
}
|
||||
chosenIP := map[uint]bool{}
|
||||
for _, id := range bestByIP {
|
||||
chosenIP[id] = true
|
||||
}
|
||||
|
||||
out := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
ip := ipByUser[id]
|
||||
if ip == "" || chosenIP[id] {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
|
||||
@@ -633,6 +984,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cooldownHours := s.lotteryCooldownHours()
|
||||
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||||
eligible := false
|
||||
for _, id := range ids {
|
||||
@@ -642,6 +994,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
||||
}
|
||||
}
|
||||
winners := []LotteryEntrant{}
|
||||
prizeNames := expandLotteryPrizeNames(m.Prizes)
|
||||
if len(m.WinnerIDs) > 0 {
|
||||
var users []model.User
|
||||
s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users)
|
||||
@@ -649,9 +1002,13 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
||||
for _, u := range users {
|
||||
umap[u.ID] = u
|
||||
}
|
||||
for _, id := range m.WinnerIDs {
|
||||
for i, id := range m.WinnerIDs {
|
||||
if u, ok := umap[id]; ok {
|
||||
cid, floor := s.lotteryWinningFloor(post.ID, id)
|
||||
prize := ""
|
||||
if i < len(prizeNames) {
|
||||
prize = prizeNames[i]
|
||||
}
|
||||
winners = append(winners, LotteryEntrant{
|
||||
ID: u.ID,
|
||||
Nickname: u.Nickname,
|
||||
@@ -659,20 +1016,27 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
||||
Avatar: u.Avatar,
|
||||
CommentID: cid,
|
||||
Floor: floor,
|
||||
PrizeName: prize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
prizes := m.Prizes
|
||||
if prizes == nil {
|
||||
prizes = []LotteryPrize{}
|
||||
}
|
||||
return &LotteryState{
|
||||
Slots: m.Slots,
|
||||
Drawn: m.Drawn,
|
||||
Closed: m.Closed,
|
||||
EndsAt: m.EndsAt,
|
||||
EntryCount: len(ids),
|
||||
Eligible: eligible,
|
||||
CanDraw: canManage && !m.Drawn,
|
||||
CanClose: canManage && !m.Drawn && !m.Closed,
|
||||
Winners: winners,
|
||||
Slots: m.Slots,
|
||||
Prizes: prizes,
|
||||
Drawn: m.Drawn,
|
||||
Closed: m.Closed,
|
||||
EndsAt: m.EndsAt,
|
||||
EntryCount: len(ids),
|
||||
Eligible: eligible,
|
||||
CooldownHours: cooldownHours,
|
||||
CanDraw: canManage && !m.Drawn,
|
||||
CanEditPrizes: !m.Drawn && post.CommentCount == 0,
|
||||
Winners: winners,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +1052,8 @@ func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostD
|
||||
if post.Status != model.ContentStatusPublished {
|
||||
return nil, errors.New("帖子未公开,无法投票")
|
||||
}
|
||||
_ = s.settleDuePollIfNeeded(&post)
|
||||
s.db.First(&post, post.ID)
|
||||
m, err := parsePollMeta(post.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -755,6 +1121,9 @@ func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail,
|
||||
return nil, err
|
||||
}
|
||||
m.Closed = true
|
||||
if strings.TrimSpace(m.ClosedAt) == "" {
|
||||
m.ClosedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -794,6 +1163,7 @@ func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uin
|
||||
}
|
||||
m.AcceptedCommentID = commentID
|
||||
m.Solved = true
|
||||
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -819,6 +1189,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
|
||||
return nil, err
|
||||
}
|
||||
m.Solved = true
|
||||
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -830,7 +1201,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
|
||||
return s.detailAfterInteract(post, userID)
|
||||
}
|
||||
|
||||
// ReopenQuestion 重新打开
|
||||
// ReopenQuestion 重新打开(仅允许撤回「手动标已解决」;已采纳答案不可重开)
|
||||
func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
||||
post, err := s.loadManageablePost(actor, userID, postID)
|
||||
if err != nil {
|
||||
@@ -839,7 +1210,14 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
|
||||
if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
|
||||
return nil, ErrQuestionNotType
|
||||
}
|
||||
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0}
|
||||
old, err := parseQuestionMeta(post.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if old != nil && old.AcceptedCommentID > 0 {
|
||||
return nil, errors.New("已采纳答案,无法重新打开")
|
||||
}
|
||||
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0, SolvedAt: ""}
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -851,7 +1229,8 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
|
||||
return s.detailAfterInteract(post, userID)
|
||||
}
|
||||
|
||||
// AcceptBounty 采纳评论并发放悬赏
|
||||
// AcceptBounty 采纳评论并发放悬赏。
|
||||
// 托管中:从托管直接发给答主;到期已退回后:从作者余额再扣再发放(不可撤回)。
|
||||
func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
|
||||
post, err := s.loadManageablePost(actor, userID, postID)
|
||||
if err != nil {
|
||||
@@ -867,15 +1246,17 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Expired {
|
||||
return nil, ErrBountyExpired
|
||||
}
|
||||
if !m.Escrowed {
|
||||
return nil, ErrBountyNotEscrow
|
||||
}
|
||||
if m.AcceptedCommentID > 0 || m.Refunded {
|
||||
if m.AcceptedCommentID > 0 {
|
||||
return nil, ErrBountySettled
|
||||
}
|
||||
payFromBalance := false
|
||||
if m.Escrowed {
|
||||
payFromBalance = false
|
||||
} else if m.Refunded {
|
||||
payFromBalance = true
|
||||
} else {
|
||||
return nil, ErrBountyNotEscrow
|
||||
}
|
||||
var cm model.Comment
|
||||
if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
|
||||
return nil, errors.New("评论不存在")
|
||||
@@ -888,56 +1269,18 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
|
||||
}
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if payFromBalance {
|
||||
note := "悬赏截止后采纳扣款:" + post.Title
|
||||
if _, err := DebitTx(tx, post.UserID, m.Points, model.PointReasonBountyEscrow, "post", postID, note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
m.AcceptedCommentID = commentID
|
||||
m.Escrowed = false
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(post).Update("type_meta", raw).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
return s.detailAfterInteract(post, userID)
|
||||
}
|
||||
|
||||
// RefundBounty 退回悬赏积分(未采纳时)
|
||||
func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
||||
post, err := s.loadManageablePost(actor, userID, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.settleExpiredBountyIfNeeded(post)
|
||||
s.db.First(post, post.ID)
|
||||
|
||||
if model.NormalizePostType(post.PostType) != model.PostTypeBounty {
|
||||
return nil, errors.New("本文不是悬赏帖")
|
||||
}
|
||||
m, err := parseBountyMeta(post.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Expired {
|
||||
return nil, ErrBountyExpired
|
||||
}
|
||||
if !m.Escrowed {
|
||||
return nil, ErrBountyNotEscrow
|
||||
}
|
||||
if m.AcceptedCommentID > 0 || m.Refunded {
|
||||
return nil, ErrBountySettled
|
||||
}
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Refunded = true
|
||||
m.Escrowed = false
|
||||
// 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -996,36 +1339,12 @@ func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CloseLotteryEntries 截止抽奖(仍可手动开奖)
|
||||
func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
||||
post, err := s.loadManageablePost(actor, userID, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := parseLotteryMeta(post.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Drawn {
|
||||
return nil, ErrLotteryDrawn
|
||||
}
|
||||
m.Closed = true
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
post.TypeMeta = raw
|
||||
return s.detailAfterInteract(post, userID)
|
||||
}
|
||||
|
||||
func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
|
||||
ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||||
nWin := m.Slots
|
||||
if nWin > len(ids) {
|
||||
nWin = len(ids)
|
||||
@@ -1078,6 +1397,34 @@ func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetai
|
||||
return s.detailAfterInteract(post, userID)
|
||||
}
|
||||
|
||||
func (s *PostService) settleDuePollIfNeeded(post *model.Post) error {
|
||||
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
|
||||
return nil
|
||||
}
|
||||
m, err := parsePollMeta(post.TypeMeta)
|
||||
if err != nil || m.Closed {
|
||||
return nil
|
||||
}
|
||||
ends, ok := parseEndsAt(m.EndsAt)
|
||||
if !ok || time.Now().UTC().Before(ends) {
|
||||
return nil
|
||||
}
|
||||
m.Closed = true
|
||||
if strings.TrimSpace(m.ClosedAt) == "" {
|
||||
// 到期自动结束:结束时刻取截止时刻
|
||||
m.ClosedAt = ends.UTC().Format(time.RFC3339)
|
||||
}
|
||||
raw, err := encodeMeta(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
post.TypeMeta = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
|
||||
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
||||
return nil
|
||||
@@ -1093,15 +1440,29 @@ func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
|
||||
return s.drawLotteryCore(post, m)
|
||||
}
|
||||
|
||||
// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖
|
||||
// SettleDueTypedPosts 定时任务:投票到期关闭 + 悬赏过期退回 + 抽奖到期开奖
|
||||
func (s *PostService) SettleDueTypedPosts(limit int) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
n := 0
|
||||
var polls []model.Post
|
||||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypePoll).
|
||||
Order("id ASC").Limit(limit * 3).Find(&polls)
|
||||
for i := range polls {
|
||||
if n >= limit {
|
||||
break
|
||||
}
|
||||
before := polls[i].TypeMeta
|
||||
_ = s.settleDuePollIfNeeded(&polls[i])
|
||||
if polls[i].TypeMeta != before {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
var bounties []model.Post
|
||||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||||
Order("id ASC").Limit(limit * 3).Find(&bounties)
|
||||
n := 0
|
||||
for i := range bounties {
|
||||
if n >= limit {
|
||||
break
|
||||
@@ -1116,7 +1477,6 @@ func (s *PostService) SettleDueTypedPosts(limit int) {
|
||||
var lotteries []model.Post
|
||||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery).
|
||||
Order("id ASC").Limit(limit * 3).Find(&lotteries)
|
||||
n = 0
|
||||
for i := range lotteries {
|
||||
if n >= limit {
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user