restrict v1
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
)
|
||||
|
||||
// 本文件提供站方处置能力。项目刻意不做管理后台,因此运营动作全部落在 CLI:
|
||||
//
|
||||
// TamaBox reports --domain xxx 出证据包(只读)
|
||||
// TamaBox restrict --domain xxx --level L1 --days 7 --reason "..."
|
||||
// TamaBox unrestrict --domain xxx
|
||||
//
|
||||
// 处置记录由运行时读表生效(见 db.Restrictions.ActiveLevel),到期自动失效,
|
||||
// 不需要定时任务,也不需要任何后台页面。
|
||||
|
||||
// Restrict 对箱主施加一级处置。
|
||||
var Restrict = &cli.Command{
|
||||
Name: "restrict",
|
||||
Usage: "Restrict a user (L1 stop distribution / L2 narrow publishing / L3 freeze publishing / L4 suspend page)",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.StringFlag{Name: "level", Value: string(db.RestrictionL1), Usage: "Restriction level: L1 / L2 / L3 / L4"},
|
||||
&cli.IntFlag{Name: "days", Value: 7, Usage: "Effective days, counted from now"},
|
||||
&cli.StringFlag{Name: "reason", Usage: "Reason recorded for audit (required)"},
|
||||
&cli.BoolFlag{Name: "yes", Usage: "Skip confirmation"},
|
||||
},
|
||||
Action: runRestrict,
|
||||
}
|
||||
|
||||
// Unrestrict 撤销箱主当前生效的全部处置。
|
||||
var Unrestrict = &cli.Command{
|
||||
Name: "unrestrict",
|
||||
Usage: "Revoke all active restrictions of a user",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
},
|
||||
Action: runUnrestrict,
|
||||
}
|
||||
|
||||
// Reports 输出某箱主的举报证据包,或全站待处理举报列表(只读,不改数据)。
|
||||
var Reports = &cli.Command{
|
||||
Name: "reports",
|
||||
Usage: "Show abuse reports of a user, or list all pending reports",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "email", Usage: "Email of the user"},
|
||||
&cli.StringFlag{Name: "domain", Usage: "Custom domain of the user"},
|
||||
&cli.IntFlag{Name: "days", Value: 30, Usage: "Only count reports created within N days"},
|
||||
&cli.IntFlag{Name: "limit", Value: 30, Usage: "Max rows to print"},
|
||||
&cli.BoolFlag{Name: "pending", Usage: "List all pending reports across the site (ignores --email/--domain)"},
|
||||
},
|
||||
Action: runReports,
|
||||
}
|
||||
|
||||
// openDatabase 复用 ban / censor 的初始化流程:读配置 → 拼 DSN → 连库并 AutoMigrate。
|
||||
func openDatabase() (*gorm.DB, error) {
|
||||
if err := conf.Init(); err != nil {
|
||||
return nil, errors.Wrap(err, "load configuration")
|
||||
}
|
||||
|
||||
dbType := conf.Database.Type
|
||||
if dbType == "postgres" {
|
||||
conf.Database.DSN = conf.PostgresDsn()
|
||||
} else {
|
||||
conf.Database.DSN = conf.MySQLDsn()
|
||||
}
|
||||
|
||||
database, err := db.Init(dbType, conf.Database.DSN)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "connect to database")
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// findTargetUser 按 email / domain 定位用户。
|
||||
// 用 Unscoped 以支持已自助停用(软删除)的账号 —— 处置历史必须能追溯到停用账号。
|
||||
func findTargetUser(database *gorm.DB, email, domain string) (*db.User, error) {
|
||||
if email == "" && domain == "" {
|
||||
return nil, errors.New("must provide --email or --domain")
|
||||
}
|
||||
|
||||
var user db.User
|
||||
query := database.Unscoped()
|
||||
if email != "" {
|
||||
query = query.Where("email = ?", email)
|
||||
} else {
|
||||
query = query.Where("domain = ?", domain)
|
||||
}
|
||||
if err := query.First(&user).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "find user")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func runRestrict(ctx *cli.Context) error {
|
||||
level := strings.TrimSpace(ctx.String("level"))
|
||||
if !db.IsValidRestrictionLevel(level) {
|
||||
return errors.Errorf("invalid --level %q (want L1 / L2 / L3 / L4)", level)
|
||||
}
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
return errors.New("--days must be positive")
|
||||
}
|
||||
reason := strings.TrimSpace(ctx.String("reason"))
|
||||
if reason == "" {
|
||||
return errors.New("--reason is required (recorded for audit)")
|
||||
}
|
||||
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activeLevel, active, err := db.NewRestrictionsStore(database).ActiveLevel(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query active restriction")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if activeLevel != "" {
|
||||
fmt.Printf("当前处置: %s(至 %s,原因:%s)→ 本次将替换该记录\n",
|
||||
activeLevel, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
|
||||
} else {
|
||||
fmt.Println("当前处置: 无")
|
||||
}
|
||||
fmt.Printf("本次处置: %s,%d 天,原因:%s\n", level, days, reason)
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
logrus.Warn("输入大写 YES 确认执行(该操作可经 unrestrict 撤销):")
|
||||
var answer string
|
||||
if _, err := fmtScan(&answer); err != nil {
|
||||
return errors.Wrap(err, "read confirmation")
|
||||
}
|
||||
if answer != "YES" {
|
||||
return errors.New("已取消")
|
||||
}
|
||||
}
|
||||
|
||||
restriction, err := db.NewRestrictionsStore(database).Create(ctx.Context, db.CreateRestrictionOptions{
|
||||
UserID: user.ID,
|
||||
Level: level,
|
||||
Reason: reason,
|
||||
CreatedBy: "cli",
|
||||
Days: days,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create restriction")
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_id": user.ID,
|
||||
"level": level,
|
||||
"expires_at": restriction.ExpiresAt,
|
||||
}).Info("处置已生效")
|
||||
fmt.Printf("已生效,到期时间 %s(到期自动解除,无需手工操作)\n",
|
||||
restriction.ExpiresAt.Format("2006-01-02 15:04"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runUnrestrict(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
affected, err := db.NewRestrictionsStore(database).RevokeActive(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "revoke restrictions")
|
||||
}
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
if affected == 0 {
|
||||
fmt.Println("没有生效中的处置,无需操作")
|
||||
return nil
|
||||
}
|
||||
logrus.WithFields(logrus.Fields{"user_id": user.ID, "count": affected}).Info("已撤销处置")
|
||||
fmt.Printf("已撤销 %d 条生效中的处置\n", affected)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reportedQuestionRow 证据包里的一行:被举报的提问及其举报数。
|
||||
type reportedQuestionRow struct {
|
||||
ID uint
|
||||
Content string
|
||||
CreatedAt time.Time
|
||||
ReportCount int64
|
||||
}
|
||||
|
||||
// pendingReportRow 全站待处理举报的一行(按箱主聚合展示)。
|
||||
type pendingReportRow struct {
|
||||
Domain string
|
||||
QuestionID uint
|
||||
Content string
|
||||
ReportCount int64
|
||||
LastAt time.Time
|
||||
}
|
||||
|
||||
func runReports(ctx *cli.Context) error {
|
||||
database, err := openDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("pending") {
|
||||
return printPendingReports(ctx.Context, database, ctx.Int("limit"))
|
||||
}
|
||||
return printUserReport(database, ctx)
|
||||
}
|
||||
|
||||
func printUserReport(database *gorm.DB, ctx *cli.Context) error {
|
||||
user, err := findTargetUser(database, ctx.String("email"), ctx.String("domain"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
days := ctx.Int("days")
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
|
||||
fmt.Printf("箱主: %s (@%s) id=%d\n", user.Name, user.Domain, user.ID)
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
|
||||
store := db.NewRestrictionsStore(database)
|
||||
level, active, err := store.ActiveLevel(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query active restriction")
|
||||
}
|
||||
if level == "" {
|
||||
fmt.Println("当前处置: 无")
|
||||
} else {
|
||||
fmt.Printf("当前处置: %s(至 %s,原因:%s)\n",
|
||||
level, active.ExpiresAt.Format("2006-01-02 15:04"), active.Reason)
|
||||
}
|
||||
|
||||
// 公开回答总数:用于计算「被举报内容占其公开产出」的比例,
|
||||
// 这是判断「组织化挂人」与「偶发吐槽」最直接的一个量。
|
||||
var publicAnswered int64
|
||||
if err := database.WithContext(ctx.Context).
|
||||
Model(&db.Question{}).
|
||||
Where("user_id = ? AND answer != '' AND is_private = false", user.ID).
|
||||
Count(&publicAnswered).Error; err != nil {
|
||||
return errors.Wrap(err, "count public answered questions")
|
||||
}
|
||||
|
||||
var rows []*reportedQuestionRow
|
||||
if err := database.WithContext(ctx.Context).
|
||||
Raw(`SELECT q.id AS id, q.content AS content, q.created_at AS created_at,
|
||||
COUNT(r.id) AS report_count
|
||||
FROM questions q
|
||||
JOIN content_reports r ON r.question_id = q.id
|
||||
WHERE q.user_id = ? AND r.created_at >= ?
|
||||
GROUP BY q.id, q.content, q.created_at
|
||||
ORDER BY report_count DESC, q.created_at DESC
|
||||
LIMIT ?`, user.ID, since, ctx.Int("limit")).Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "query reported questions")
|
||||
}
|
||||
|
||||
var totalReports int64
|
||||
for _, row := range rows {
|
||||
totalReports += row.ReportCount
|
||||
}
|
||||
// 比例的分母是「公开回答总数」,分子必须是「被举报的条目数」——
|
||||
// ⚠️ 不能拿举报次数当分子:同一条内容可被多人举报,次数可以超过条目数,
|
||||
// 实测会出现 166.7% 这种无意义的数值(5 次举报 / 3 条回答)。
|
||||
ratio := 0.0
|
||||
if publicAnswered > 0 {
|
||||
ratio = float64(len(rows)) / float64(publicAnswered) * 100
|
||||
}
|
||||
|
||||
fmt.Printf("近 %d 天: 被举报 %d 条 / 公开回答 %d 条(%.1f%%),累计 %d 次举报\n",
|
||||
days, len(rows), publicAnswered, ratio, totalReports)
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有举报记录")
|
||||
} else {
|
||||
fmt.Println("被举报条目(按举报数降序):")
|
||||
for _, row := range rows {
|
||||
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
|
||||
row.ReportCount, row.ID, row.CreatedAt.Format("2006-01-02 15:04"),
|
||||
truncateForCLI(row.Content, 48))
|
||||
}
|
||||
}
|
||||
|
||||
history, err := store.ListByUserID(ctx.Context, user.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "list restriction history")
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
if len(history) == 0 {
|
||||
fmt.Println("历史处置: 无")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("历史处置:")
|
||||
for _, item := range history {
|
||||
state := "生效中"
|
||||
if item.RevokedAt != nil {
|
||||
state = "已撤销 " + item.RevokedAt.Format("2006-01-02")
|
||||
} else if item.ExpiresAt.Before(time.Now()) {
|
||||
state = "已到期"
|
||||
}
|
||||
fmt.Printf(" %s %s 至 %s %s(%s)\n",
|
||||
item.CreatedAt.Format("2006-01-02 15:04"), item.Level,
|
||||
item.ExpiresAt.Format("2006-01-02"), item.Reason, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printPendingReports(ctx context.Context, database *gorm.DB, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
var rows []*pendingReportRow
|
||||
if err := database.WithContext(ctx).
|
||||
Raw(`SELECT u.domain AS domain, q.id AS question_id, q.content AS content,
|
||||
COUNT(r.id) AS report_count, MAX(r.created_at) AS last_at
|
||||
FROM content_reports r
|
||||
JOIN questions q ON q.id = r.question_id
|
||||
JOIN users u ON u.id = q.user_id
|
||||
WHERE r.status = ?
|
||||
GROUP BY u.domain, q.id, q.content
|
||||
ORDER BY report_count DESC, last_at DESC
|
||||
LIMIT ?`, db.ReportStatusPending, limit).Scan(&rows).Error; err != nil {
|
||||
return errors.Wrap(err, "query pending reports")
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println("没有待处理的举报")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("待处理举报 %d 条(按举报数降序):\n", len(rows))
|
||||
lastDomain := ""
|
||||
for _, row := range rows {
|
||||
if row.Domain != lastDomain {
|
||||
fmt.Printf("\n@%s\n", row.Domain)
|
||||
lastDomain = row.Domain
|
||||
}
|
||||
fmt.Printf(" 举报 %-3d #%-6d %s %s\n",
|
||||
row.ReportCount, row.QuestionID, row.LastAt.Format("2006-01-02 15:04"),
|
||||
truncateForCLI(row.Content, 48))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateForCLI 按 rune 截断并压平换行,避免终端输出被长内容冲散。
|
||||
func truncateForCLI(s string, max int) string {
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r", " "), "\n", " ")
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:max]) + "..."
|
||||
}
|
||||
@@ -21,6 +21,8 @@ var AllTables = []interface{}{
|
||||
&Vote{}, &VoteOption{}, &VoteFormField{}, &VoteSubmission{},
|
||||
// 功能开关(提问 / 私聊 / 投票 的开关、定时、定量收集)
|
||||
&FeatureControl{},
|
||||
// 站方处置与举报(无管理后台,处置经 CLI 写入、运行时读表生效)
|
||||
&UserRestriction{}, &ContentReport{},
|
||||
}
|
||||
|
||||
func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
@@ -113,6 +115,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
VoteFormFields = NewVoteFormFieldsStore(db)
|
||||
VoteSubmissions = NewVoteSubmissionsStore(db)
|
||||
FeatureControls = NewFeatureControlsStore(db)
|
||||
Restrictions = NewRestrictionsStore(db)
|
||||
|
||||
if err := db.Use(otelgorm.NewPlugin(
|
||||
otelgorm.WithDBName(conf.Database.Name),
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RestrictionLevel 站方对箱主的处置级别。
|
||||
//
|
||||
// 本项目刻意不做管理后台:处置只能由运维经 CLI(TamaBox restrict)写入,运行时读表生效。
|
||||
// 级别用稳定字符串并依赖字典序("L1" < "L2" < "L3" < "L4",均为 L + 单个数字),
|
||||
// 取「当前最高生效级别」时可直接 ORDER BY level DESC —— 不要引入多字符级别("L10" < "L4")。
|
||||
type RestrictionLevel string
|
||||
|
||||
const (
|
||||
// RestrictionL1 停止分发:禁用单题分享卡、撤销首页与探索曝光。不涉及价值判断,可批量执行。
|
||||
RestrictionL1 RestrictionLevel = "L1"
|
||||
// RestrictionL2 收窄公开:新回答与「设为公开」动作强制转为私密。
|
||||
RestrictionL2 RestrictionLevel = "L2"
|
||||
// RestrictionL3 冻结发布:禁止回答 / 设为公开 / 回答追问,仍可收信(不剥夺收件能力)。
|
||||
RestrictionL3 RestrictionLevel = "L3"
|
||||
// RestrictionL4 页面停用:整页不可访问,数据保留。
|
||||
RestrictionL4 RestrictionLevel = "L4"
|
||||
)
|
||||
|
||||
// restrictionLevels 合法级别集合。CLI 与写入前校验共用,非法值一律拒绝,
|
||||
// 避免脏数据让「ORDER BY level DESC」选出非预期级别。
|
||||
var restrictionLevels = map[RestrictionLevel]struct{}{
|
||||
RestrictionL1: {},
|
||||
RestrictionL2: {},
|
||||
RestrictionL3: {},
|
||||
RestrictionL4: {},
|
||||
}
|
||||
|
||||
// IsValidRestrictionLevel 校验级别取值合法性。
|
||||
func IsValidRestrictionLevel(level string) bool {
|
||||
_, ok := restrictionLevels[RestrictionLevel(level)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// restrictionLevelRank 级别权重,用于「是否达到某一档」的比较。
|
||||
// 显式权重而非字符串比较:字符串比较依赖「L + 单个数字」这个隐含约束,
|
||||
// 将来加 L10 会静默排错("L10" < "L4")。
|
||||
var restrictionLevelRank = map[RestrictionLevel]int{
|
||||
RestrictionL1: 1,
|
||||
RestrictionL2: 2,
|
||||
RestrictionL3: 3,
|
||||
RestrictionL4: 4,
|
||||
}
|
||||
|
||||
// RestrictionAtLeast 判断当前级别是否达到 target(含相等)。
|
||||
// 空级别(无处置)恒为 false;未知级别按「无处置」处理,避免脏数据把箱主锁死。
|
||||
func RestrictionAtLeast(level, target RestrictionLevel) bool {
|
||||
current, ok := restrictionLevelRank[RestrictionLevel(level)]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
threshold, ok := restrictionLevelRank[target]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return current >= threshold
|
||||
}
|
||||
|
||||
// UserRestriction 一条站方处置记录。
|
||||
//
|
||||
// 刻意不嵌 dbutil.Model:
|
||||
// - 处置记录不允许软删除(撤销走 RevokedAt),必须留痕,否则无法自证处置过程;
|
||||
// - 到期自动失效靠 expires_at 与当前时间比较实现,不需要定时任务或后台解封动作。
|
||||
type UserRestriction struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index:idx_user_restrictions_user;not null" json:"user_id"`
|
||||
Level string `gorm:"type:varchar(8);not null" json:"level"`
|
||||
Reason string `gorm:"type:text" json:"reason"`
|
||||
CreatedBy string `gorm:"type:varchar(64)" json:"created_by"`
|
||||
ExpiresAt time.Time `gorm:"index:idx_user_restrictions_expires;not null" json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ContentReport 访客对单条提问/回答的举报。
|
||||
//
|
||||
// 同 IP 对同一问题只能报一次(复合唯一索引)。这里同样不嵌 dbutil.Model:
|
||||
// 举报是审计证据,不做软删除;带 DeletedAt 的唯一索引会留下「删了仍占槽位」的陷阱。
|
||||
// 注意:唯一索引不含 deleted_at,一旦将来引入软删除,重复举报会在 INSERT 处被拦成 500。
|
||||
type ContentReport struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
QuestionID uint `gorm:"uniqueIndex:idx_content_reports_question_ip,priority:1;not null" json:"question_id"`
|
||||
ReporterIP string `gorm:"type:varchar(64);uniqueIndex:idx_content_reports_question_ip,priority:2;not null" json:"reporter_ip"`
|
||||
Reason string `gorm:"type:varchar(32);not null" json:"reason"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:'pending';index:idx_content_reports_status" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 举报处理状态。
|
||||
const (
|
||||
ReportStatusPending = "pending" // 待处理
|
||||
ReportStatusAccepted = "accepted" // 举报成立
|
||||
ReportStatusRejected = "rejected" // 举报驳回
|
||||
)
|
||||
|
||||
// RestrictionsStore 处置记录读写。
|
||||
//
|
||||
// 只提供「写入 / 撤销 / 查询当前生效级别」三类动作:本项目没有管理后台,
|
||||
// 历史查询由 CLI(TamaBox reports)直接读库完成,不需要在这里堆展示型方法。
|
||||
type RestrictionsStore interface {
|
||||
// Create 追加一条处置记录。写入前会撤销该用户现有生效中的记录,
|
||||
// 保证同一用户同一时刻只有一条生效记录(升级/降级都是替换语义,避免叠加混乱)。
|
||||
Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error)
|
||||
// RevokeActive 撤销某用户当前所有生效中的记录,返回受影响行数(幂等)。
|
||||
RevokeActive(ctx context.Context, userID uint) (int64, error)
|
||||
// ActiveLevel 返回某用户当前生效的最高级别;无生效记录时返回空串与 nil。
|
||||
ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error)
|
||||
// ActiveLevels 批量版本:探索页等列表场景避免 N+1。
|
||||
ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error)
|
||||
// ListByUserID 按创建时间倒序列出某用户全部处置历史(含已撤销/已过期)。
|
||||
ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error)
|
||||
}
|
||||
|
||||
// Restrictions 处置记录全局 store,由 db.Init 装配。
|
||||
// ⚠️ 与其它 store 一致:方法内部只用自身 db 句柄,不反向引用该包级变量(测试下未装配会 nil panic)。
|
||||
var Restrictions RestrictionsStore
|
||||
|
||||
var _ RestrictionsStore = (*restrictions)(nil)
|
||||
|
||||
// CreateRestrictionOptions 新建处置记录的入参。
|
||||
type CreateRestrictionOptions struct {
|
||||
UserID uint
|
||||
Level string
|
||||
Reason string
|
||||
CreatedBy string
|
||||
// Days 生效天数,从写入时刻起算;必须为正整数,避免写入即过期的空记录。
|
||||
Days int
|
||||
}
|
||||
|
||||
func NewRestrictionsStore(db *gorm.DB) RestrictionsStore {
|
||||
return &restrictions{db: db}
|
||||
}
|
||||
|
||||
type restrictions struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s *restrictions) Create(ctx context.Context, opts CreateRestrictionOptions) (*UserRestriction, error) {
|
||||
if !IsValidRestrictionLevel(opts.Level) {
|
||||
return nil, errors.Errorf("invalid restriction level: %q", opts.Level)
|
||||
}
|
||||
if opts.Days <= 0 {
|
||||
return nil, errors.New("restriction days must be positive")
|
||||
}
|
||||
|
||||
restriction := &UserRestriction{
|
||||
UserID: opts.UserID,
|
||||
Level: opts.Level,
|
||||
Reason: opts.Reason,
|
||||
CreatedBy: opts.CreatedBy,
|
||||
ExpiresAt: time.Now().AddDate(0, 0, opts.Days),
|
||||
}
|
||||
|
||||
// 替换语义:先撤销旧的生效记录,再写新的。两条语句共用一个事务,
|
||||
// ⚠️ 事务里每条语句都要重新取句柄(tx.WithContext),复用同一 tx 会串条件。
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&UserRestriction{}).
|
||||
Where("user_id = ?", opts.UserID).
|
||||
Where("revoked_at IS NULL").
|
||||
Where("expires_at > ?", now).
|
||||
Updates(map[string]interface{}{"revoked_at": now}).Error; err != nil {
|
||||
return errors.Wrap(err, "revoke active restrictions")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(restriction).Error; err != nil {
|
||||
return errors.Wrap(err, "create restriction")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return restriction, nil
|
||||
}
|
||||
|
||||
func (s *restrictions) RevokeActive(ctx context.Context, userID uint) (int64, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&UserRestriction{}).
|
||||
Where("user_id = ?", userID).
|
||||
Where("revoked_at IS NULL").
|
||||
Where("expires_at > ?", now).
|
||||
Updates(map[string]interface{}{"revoked_at": now})
|
||||
if result.Error != nil {
|
||||
return 0, errors.Wrap(result.Error, "revoke active restrictions")
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) {
|
||||
levels, err := s.ActiveLevels(ctx, []uint{userID})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
level, ok := levels[userID]
|
||||
if !ok {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
var restriction UserRestriction
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Where("level = ?", string(level)).
|
||||
Where("revoked_at IS NULL").
|
||||
Where("expires_at > ?", time.Now()).
|
||||
Order("created_at DESC").
|
||||
First(&restriction).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return level, nil, nil
|
||||
}
|
||||
return "", nil, errors.Wrap(err, "find active restriction")
|
||||
}
|
||||
return level, &restriction, nil
|
||||
}
|
||||
|
||||
func (s *restrictions) ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) {
|
||||
levels := make(map[uint]RestrictionLevel, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
var rows []*UserRestriction
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("user_id IN ?", userIDs).
|
||||
Where("revoked_at IS NULL").
|
||||
Where("expires_at > ?", time.Now()).
|
||||
Order("level DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "list active restrictions")
|
||||
}
|
||||
|
||||
// Order 已按级别降序,每个 user 首次出现即为最高级别。
|
||||
for _, row := range rows {
|
||||
if _, ok := levels[row.UserID]; ok {
|
||||
continue
|
||||
}
|
||||
levels[row.UserID] = RestrictionLevel(row.Level)
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
func (s *restrictions) ListByUserID(ctx context.Context, userID uint) ([]*UserRestriction, error) {
|
||||
var rows []*UserRestriction
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(err, "list restrictions")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 tamakyi. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRestrictions 覆盖站方处置的读写语义与「L1 停止分发」的曝光摘除。
|
||||
// 这些行为没有管理后台兜底(处置只能经 CLI 写入),一旦语义写错无法从界面发现,
|
||||
// 因此用数据库测试把「替换 / 到期 / 撤销 / 曝光排除」四条钉死。
|
||||
func TestRestrictions(t *testing.T) {
|
||||
database, cleanup := newTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
usersStore := NewUsersStore(database)
|
||||
store := NewRestrictionsStore(database)
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := cleanup("users", "user_restrictions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
|
||||
Name: "Box Owner", Password: "super_secret", Email: "[email protected]", Domain: "rbox",
|
||||
}))
|
||||
owner, err := usersStore.GetByDomain(ctx, "rbox")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, usersStore.Create(ctx, CreateUserOptions{
|
||||
Name: "Other Owner", Password: "super_secret", Email: "[email protected]", Domain: "obox",
|
||||
}))
|
||||
other, err := usersStore.GetByDomain(ctx, "obox")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 两个箱主都上首页,用于验证处置后是否被摘除。
|
||||
require.NoError(t, usersStore.UpdateTags(ctx, owner.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
|
||||
require.NoError(t, usersStore.UpdateTags(ctx, other.ID, UpdateTagsOptions{Tags: "test", ShowInHome: true}))
|
||||
|
||||
// 无处置时:不排除任何人。
|
||||
level, active, err := store.ActiveLevel(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RestrictionLevel(""), level)
|
||||
require.Nil(t, active)
|
||||
|
||||
list, _, err := usersStore.GetShowInHome(ctx, 1, 10, "latest")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2)
|
||||
|
||||
// 施加 L1。
|
||||
created, err := store.Create(ctx, CreateRestrictionOptions{
|
||||
UserID: owner.ID, Level: string(RestrictionL1), Reason: "组织挂人", CreatedBy: "test", Days: 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Now().AddDate(0, 0, 7), created.ExpiresAt, time.Minute)
|
||||
|
||||
level, active, err = store.ActiveLevel(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RestrictionL1, level)
|
||||
require.NotNil(t, active)
|
||||
require.Equal(t, "组织挂人", active.Reason)
|
||||
|
||||
// L1 的核心语义:被处置箱主从首页 / 探索的展示池里摘除。
|
||||
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
require.Equal(t, other.ID, list[0].ID)
|
||||
|
||||
// 批量版本只返回被处置者。
|
||||
levels, err := store.ActiveLevels(ctx, []uint{owner.ID, other.ID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[uint]RestrictionLevel{owner.ID: RestrictionL1}, levels)
|
||||
|
||||
// 替换语义:升级到 L3 后,旧记录保留但被撤销,同一时刻只有一条生效。
|
||||
_, err = store.Create(ctx, CreateRestrictionOptions{
|
||||
UserID: owner.ID, Level: string(RestrictionL3), Reason: "升级", CreatedBy: "test", Days: 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
level, _, err = store.ActiveLevel(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RestrictionL3, level)
|
||||
|
||||
history, err := store.ListByUserID(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, history, 2)
|
||||
// ListByUserID 按创建时间倒序,第一条是新的 L3(未撤销),第二条是已撤销的 L1。
|
||||
require.Equal(t, string(RestrictionL3), history[0].Level)
|
||||
require.Nil(t, history[0].RevokedAt)
|
||||
require.Equal(t, string(RestrictionL1), history[1].Level)
|
||||
require.NotNil(t, history[1].RevokedAt)
|
||||
|
||||
// 到期即失效:直接把到期时间改到过去,ActiveLevel 必须返回空(处置自动解除,不需要定时任务)。
|
||||
require.NoError(t, database.Model(&UserRestriction{}).
|
||||
Where("user_id = ? AND revoked_at IS NULL", owner.ID).
|
||||
Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
||||
|
||||
level, active, err = store.ActiveLevel(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, RestrictionLevel(""), level)
|
||||
require.Nil(t, active)
|
||||
|
||||
// 过期后曝光自动恢复。
|
||||
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2)
|
||||
|
||||
// 撤销语义:有效期内撤销后立即恢复可见。
|
||||
_, err = store.Create(ctx, CreateRestrictionOptions{
|
||||
UserID: owner.ID, Level: string(RestrictionL1), Reason: "再处置", CreatedBy: "test", Days: 7,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
affected, err := store.RevokeActive(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1, affected)
|
||||
|
||||
list, _, err = usersStore.GetShowInHome(ctx, 1, 10, "latest")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 2)
|
||||
|
||||
// 幂等:再次撤销不再有任何生效记录。
|
||||
affected, err = store.RevokeActive(ctx, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, affected)
|
||||
}
|
||||
|
||||
// TestRestrictionLevelValidation 防止脏级别进入 ORDER BY level DESC 的排序("L9" 会排在 "L4" 之后)。
|
||||
func TestRestrictionLevelValidation(t *testing.T) {
|
||||
require.True(t, IsValidRestrictionLevel(string(RestrictionL1)))
|
||||
require.True(t, IsValidRestrictionLevel(string(RestrictionL4)))
|
||||
require.False(t, IsValidRestrictionLevel("L5"))
|
||||
require.False(t, IsValidRestrictionLevel("l1")) // 大小写敏感,避免 CLI 误传
|
||||
require.False(t, IsValidRestrictionLevel(""))
|
||||
}
|
||||
|
||||
// TestRestrictionAtLeast 守住发布侧拦截的判定:L2 收窄公开、L3 冻结发布都依赖这个比较。
|
||||
// 空级别(无处置)与未知级别必须恒为 false,否则脏数据会把正常箱主锁死。
|
||||
func TestRestrictionAtLeast(t *testing.T) {
|
||||
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL1))
|
||||
require.True(t, RestrictionAtLeast(RestrictionL4, RestrictionL3))
|
||||
require.True(t, RestrictionAtLeast(RestrictionL2, RestrictionL2))
|
||||
require.False(t, RestrictionAtLeast(RestrictionL1, RestrictionL2))
|
||||
require.False(t, RestrictionAtLeast(RestrictionL3, RestrictionL4))
|
||||
require.False(t, RestrictionAtLeast("", RestrictionL1))
|
||||
require.False(t, RestrictionAtLeast("L9", RestrictionL1))
|
||||
}
|
||||
@@ -626,6 +626,7 @@ func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort str
|
||||
}
|
||||
|
||||
query := db.WithContext(ctx).Where("show_in_home = ?", true)
|
||||
query = query.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
|
||||
switch sort {
|
||||
case "oldest":
|
||||
query = query.Order("created_at asc")
|
||||
@@ -653,6 +654,7 @@ func (db *users) GetByTag(ctx context.Context, tag string, page, pageSize int, s
|
||||
tagCond = "FIND_IN_SET(?, tags)"
|
||||
}
|
||||
baseQuery := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ? AND tags IS NOT NULL AND "+tagCond, true, tag)
|
||||
baseQuery = baseQuery.Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx)))
|
||||
if err := baseQuery.Count(&count).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(err, "count users by tag")
|
||||
}
|
||||
@@ -746,6 +748,7 @@ func (db *users) getRandomUsers(ctx context.Context, limit int) ([]*User, error)
|
||||
}
|
||||
if err := db.WithContext(ctx).
|
||||
Where("show_in_home = ?", true).
|
||||
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
|
||||
Order(randomFunc).
|
||||
Limit(limit).
|
||||
Find(&users).Error; err != nil {
|
||||
@@ -1047,3 +1050,15 @@ func purgeUserContent(ctx context.Context, tx *gorm.DB, userID uint) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeRestrictionsSubQuery 返回「当前处于生效中的处置」的用户 ID 子查询。
|
||||
//
|
||||
// 用于把被处置的箱主从首页 / 探索 / 标签检索 / 随机位摘除,即 L1「停止分发」的曝光部分。
|
||||
// 用子查询而不是先查一遍 ID 列表:这三处都在列表热路径上,多一次往返没有收益;
|
||||
// 没有处置记录时子查询返回空集,正常路径不产生额外代价。
|
||||
// 注意 GetRandom 有 30 秒缓存,处置后随机位最多滞后 30 秒生效(其余入口即时生效)。
|
||||
func activeRestrictionsSubQuery(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Table("user_restrictions").Select("user_id").
|
||||
Where("revoked_at IS NULL").
|
||||
Where("expires_at > ?", time.Now())
|
||||
}
|
||||
|
||||
@@ -425,6 +425,10 @@ var enDict = map[string]string{
|
||||
"deactivated.desc": "Your question box and all data have been deleted. Thank you for using Wolf's Question Box. We hope to see you again.",
|
||||
"deactivated.backHome": "Back to home",
|
||||
|
||||
// 处置停用页(L4)
|
||||
"restricted.title": "This question box is temporarily unavailable",
|
||||
"restricted.desc": "This question box has been suspended for violating community guidelines.",
|
||||
|
||||
// Anonymous voting
|
||||
"nav.votes": "Polls",
|
||||
|
||||
@@ -707,6 +711,10 @@ var enDict = map[string]string{
|
||||
"question.flash.operated": "Operation successful",
|
||||
"question.flash.markedRead": "Marked as read",
|
||||
"question.flash.markedUnread": "Marked as unread",
|
||||
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||
"question.errRestrictedPublish": "Publishing is suspended while this box is under moderation",
|
||||
"question.errRestrictedPublic": "Content cannot be made public while this box is under moderation",
|
||||
"question.flash.answeredPrivate": "Saved as private: this box is under moderation, so the answer stays hidden",
|
||||
|
||||
// Follow-ups: visitor/owner visible (P0)
|
||||
"followup.errNoPermission": "Permission denied",
|
||||
|
||||
@@ -425,6 +425,10 @@ var jaDict = map[string]string{
|
||||
"deactivated.desc": "質問箱とすべてのデータを削除しました。ご利用いただきありがとうございました。またのお越しをお待ちしています。",
|
||||
"deactivated.backHome": "ホームに戻る",
|
||||
|
||||
// 处置停用页(L4)
|
||||
"restricted.title": "この質問箱は一時的に利用できません",
|
||||
"restricted.desc": "この質問箱はコミュニティガイドライン違反のため、公開を停止しています。",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
@@ -707,6 +711,10 @@ var jaDict = map[string]string{
|
||||
"question.flash.operated": "操作が完了しました",
|
||||
"question.flash.markedRead": "既読にしました",
|
||||
"question.flash.markedUnread": "未読にしました",
|
||||
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||
"question.errRestrictedPublish": "現在はプラットフォームによる措置期間中のため、回答の公開を停止しています",
|
||||
"question.errRestrictedPublic": "措置期間中はコンテンツを公開に設定できません",
|
||||
"question.flash.answeredPrivate": "非公開で保存しました:措置期間中のため回答は公開されません",
|
||||
|
||||
// 追加質問:訪問者/所有者に表示(P0)
|
||||
"followup.errNoPermission": "権限がありません",
|
||||
|
||||
@@ -425,6 +425,10 @@ var zhCNDict = map[string]string{
|
||||
"deactivated.desc": "你的提问箱及所有数据已删除,感谢你的使用,期待未来再见。",
|
||||
"deactivated.backHome": "返回首页",
|
||||
|
||||
// 处置停用页(L4)
|
||||
"restricted.title": "提问箱暂时不可访问",
|
||||
"restricted.desc": "该提问箱因违反社区规范,已暂停对外访问。",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
@@ -707,6 +711,10 @@ var zhCNDict = map[string]string{
|
||||
"question.flash.operated": "操作成功",
|
||||
"question.flash.markedRead": "已标记为已读",
|
||||
"question.flash.markedUnread": "已标记为未读",
|
||||
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||
"question.errRestrictedPublish": "当前处于平台处置期,已暂停发布回答",
|
||||
"question.errRestrictedPublic": "当前处于平台处置期,不可将内容设为公开",
|
||||
"question.flash.answeredPrivate": "已保存为私密:当前处于平台处置期,回答不会公开",
|
||||
|
||||
// 追问:访客/箱主可见(P0)
|
||||
"followup.errNoPermission": "无权操作",
|
||||
|
||||
@@ -425,6 +425,10 @@ var zhTWDict = map[string]string{
|
||||
"deactivated.desc": "你的提問箱及所有資料已刪除,感謝你的使用,期待未來再見。",
|
||||
"deactivated.backHome": "返回首頁",
|
||||
|
||||
// 处置停用页(L4)
|
||||
"restricted.title": "提問箱暫時無法存取",
|
||||
"restricted.desc": "該提問箱因違反社群規範,已暫停對外存取。",
|
||||
|
||||
// 匿名投票
|
||||
"nav.votes": "投票",
|
||||
|
||||
@@ -707,6 +711,10 @@ var zhTWDict = map[string]string{
|
||||
"question.flash.operated": "操作成功",
|
||||
"question.flash.markedRead": "已標記為已讀",
|
||||
"question.flash.markedUnread": "已標記為未讀",
|
||||
// 平台处置(L1–L4):被处置箱主在处置期内不能发布公开内容
|
||||
"question.errRestrictedPublish": "當前處於平台處置期,已暫停發布回答",
|
||||
"question.errRestrictedPublic": "當前處於平台處置期,不可將內容設為公開",
|
||||
"question.flash.answeredPrivate": "已儲存為私密:當前處於平台處置期,回答不會公開",
|
||||
|
||||
// 追問:訪客/箱主可見(P0)
|
||||
"followup.errNoPermission": "無權操作",
|
||||
|
||||
@@ -287,6 +287,8 @@ func New() *flamego.Flame {
|
||||
// 账号停用成功页(公开):停用后会话已清空,不能再要求登录,
|
||||
// 故为独立公开页,让用户明确看到「已停用」而非被丢回登录页。
|
||||
f.Get("/deactivated", user.DeactivatedProfile)
|
||||
// 处置停用页(L4):必须注册在公开区,被处置箱主自己也只能看到这一页。
|
||||
f.Get("/restricted", user.RestrictedProfile)
|
||||
|
||||
// 匿名投票参与端(公开):token 链接入口
|
||||
f.Group("/vote/{token}", func() {
|
||||
|
||||
Reference in New Issue
Block a user