Files
jiang13-forum/services/unlock.go
freefire 204e7fdb32 feat: SSR 内容门控与积分钱包/签到抽奖
闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 06:20:12 +08:00

252 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/models"
"gorm.io/gorm"
)
const (
// CreatorSharePercent 作者分成比例(读者支付的百分比)
CreatorSharePercent = 70
// SockpuppetAccountAgeDays 短龄号判定天数(同 IP 互刷拒绝分成)
SockpuppetAccountAgeDays = 7
)
var (
ErrBlockNotFound = errors.New("付费块不存在")
ErrAlreadyUnlocked = errors.New("已解锁")
ErrSuspiciousTrade = errors.New("检测到异常关联账号,无法完成解锁分成")
)
var pointsOnlyBlockRe = regexp.MustCompile(`(?is)<points-only\b([^>]*)>([\s\S]*?)</points-only>`)
// PointsOnlyBlock 解析出的付费块
type PointsOnlyBlock struct {
Key string
Cost int
Inner string
AttrRaw string
}
// ParsePointsOnlyBlocks 按出现顺序解析付费块block_key = sha256(inner)[:16]
func ParsePointsOnlyBlocks(html string) []PointsOnlyBlock {
matches := pointsOnlyBlockRe.FindAllStringSubmatch(html, -1)
out := make([]PointsOnlyBlock, 0, len(matches))
for _, m := range matches {
attrs := m[1]
inner := m[2]
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
out = append(out, PointsOnlyBlock{Key: key, Cost: cost, Inner: inner, AttrRaw: attrs})
}
return out
}
func parseDataCost(attrs string) int {
re := regexp.MustCompile(`(?i)data-cost\s*=\s*["']?(\d+)`)
m := re.FindStringSubmatch(attrs)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// FindPointsBlock 按 key 查找块
func FindPointsBlock(html, blockKey string) (PointsOnlyBlock, bool) {
for _, b := range ParsePointsOnlyBlocks(html) {
if b.Key == blockKey {
return b, true
}
}
return PointsOnlyBlock{}, false
}
// RedactPointsOnlyHTML 遮盖未解锁付费块unlocked 为已解锁的 block_key 集合
func RedactPointsOnlyHTML(html string, unlocked map[string]bool) string {
if html == "" {
return html
}
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
m := pointsOnlyBlockRe.FindStringSubmatch(full)
if len(m) < 3 {
return full
}
attrs, inner := m[1], m[2]
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
if unlocked != nil && unlocked[key] {
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
}
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
length := gatedContentLength(inner)
return fmt.Sprintf(
`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="true" data-length="%d"><div class="j13-gate j13-gate--points" data-gate-shell="points"><p class="j13-gate__title">积分可见</p><p class="j13-gate__meta">需 %d 积分 · 约 %d 字</p><p class="j13-gate__hint">登录后可解锁</p></div></points-only>`,
cost, key, length, cost, length,
)
})
}
// ListUnlockedKeys 用户在某帖已解锁的 block_key
func ListUnlockedKeys(userID, postID uint) (map[string]bool, error) {
out := map[string]bool{}
if userID == 0 || postID == 0 {
return out, nil
}
var rows []models.PostContentUnlock
if err := models.DB.Select("block_key").Where("user_id = ? AND post_id = ?", userID, postID).Find(&rows).Error; err != nil {
return out, err
}
for _, r := range rows {
out[r.BlockKey] = true
}
return out, nil
}
// UnlockResult 解锁结果
type UnlockResult struct {
BlockKey string `json:"block_key"`
Cost int `json:"cost"`
AuthorShare int `json:"author_share"`
PointsBalance int `json:"points_balance"`
InnerHTML string `json:"inner_html"`
}
// UnlockPointsBlock 积分解锁付费块
func UnlockPointsBlock(readerID, postID uint, blockKey string) (*UnlockResult, error) {
var post models.Post
if err := models.DB.First(&post, postID).Error; err != nil {
return nil, errors.New("帖子不存在")
}
block, ok := FindPointsBlock(post.Content, blockKey)
if !ok {
return nil, ErrBlockNotFound
}
// 作者自己免费解锁记录(无分成)
if readerID == post.UserID {
var n int64
models.DB.Model(&models.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n)
if n == 0 {
_ = models.DB.Create(&models.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0,
}).Error
}
return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil
}
var existing models.PostContentUnlock
models.DB.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&existing)
if existing.ID > 0 {
return nil, ErrAlreadyUnlocked
}
var reader, author models.User
if err := models.DB.First(&reader, readerID).Error; err != nil {
return nil, err
}
if err := models.DB.First(&author, post.UserID).Error; err != nil {
return nil, err
}
// 短龄号 + 同登录 IP拒绝整单防互刷套现
if suspiciousUnlockPair(&reader, &author) {
return nil, ErrSuspiciousTrade
}
cost := block.Cost
authorShare := cost * CreatorSharePercent / 100
var bal int
err := models.DB.Transaction(func(tx *gorm.DB) error {
var again models.PostContentUnlock
if err := tx.Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Limit(1).Find(&again).Error; err != nil {
return err
}
if again.ID > 0 {
return ErrAlreadyUnlocked
}
var e error
bal, e = AdjustPointsTx(tx, readerID, -cost, models.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容")
if e != nil {
return e
}
if authorShare > 0 {
if _, e = AdjustPointsTx(tx, author.ID, authorShare, models.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil {
return e
}
if e = tx.Model(&models.User{}).Where("id = ?", author.ID).
UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil {
return e
}
}
return tx.Create(&models.PostContentUnlock{
UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: cost,
}).Error
})
if err != nil {
return nil, err
}
// 异步检查作者徽章
go func() {
_ = NewBadgeService().EvaluateAuto(author.ID)
}()
return &UnlockResult{
BlockKey: blockKey, Cost: cost, AuthorShare: authorShare,
PointsBalance: bal, InnerHTML: block.Inner,
}, nil
}
func suspiciousUnlockPair(reader, author *models.User) bool {
if reader == nil || author == nil {
return false
}
ipR := strings.TrimSpace(reader.LastLoginIP)
ipA := strings.TrimSpace(author.LastLoginIP)
if ipR == "" || ipA == "" || ipR != ipA {
return false
}
cutoff := time.Now().AddDate(0, 0, -SockpuppetAccountAgeDays)
return reader.CreatedAt.After(cutoff) && author.CreatedAt.After(cutoff)
}
// RevealAllPointsOnly 作者/站长:保留正文并写入 block-key标记未锁定
func RevealAllPointsOnly(html string) string {
if html == "" {
return html
}
return pointsOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
m := pointsOnlyBlockRe.FindStringSubmatch(full)
if len(m) < 3 {
return full
}
attrs, inner := m[1], m[2]
sum := sha256.Sum256([]byte(inner))
key := hex.EncodeToString(sum[:])[:16]
cost := parseDataCost(attrs)
if cost < 1 {
cost = 1
}
return fmt.Sprintf(`<points-only data-gate="points" data-cost="%d" data-block-key="%s" data-locked="false">%s</points-only>`, cost, key, inner)
})
}