@@ -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 {
Slot s int ` json:"slot s" `
Drawn bool ` json:"drawn" `
WinnerIDs [ ] uint ` json:"winner_ids " `
Closed bool ` json:"closed " `
EndsAt string ` json:"ends_at " `
ExpireDays int ` json:"expire_days,omitempty " `
Prize s [ ] LotteryPrize ` json:"prize s" `
Slots int ` json:"slots" ` // 由 prizes 数量合计推导,兼容旧数据
Drawn bool ` json:"drawn " `
WinnerIDs [ ] uint ` json:"winner_ids " `
Closed bool ` json:"closed " `
EndsAt str ing ` 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 str ing ` 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
}
settl ed := m . AcceptedCommentID > 0 || m . Refunded || m . Expired
accept ed := 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 : settl ed,
CanAccept : canManage && m . Escrowed && ! settled && ! m . Expired ,
CanRefund : canManage && m . Escrow ed && ! settl ed && ! m . Expired ,
Settled : accept ed,
// 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
CanAccept : canManage && ! accept ed && ( m . Escrow ed || 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 := parse QuestionMeta( 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