feat: Admin 设用户等级并展示徽章于用户页

补齐 SetUserLevel 表单;用户主页/资料页展示徽章并触发 EvaluateAuto;勾选 §H Exp 与防刷。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:02:24 +08:00
parent 9e403a7178
commit 4ffcaecba2
13 changed files with 194 additions and 86 deletions

View File

@@ -129,11 +129,11 @@
- [x] 每日签到:基础 5连签每日 +1封顶 15 — `POST /profile/checkin` - [x] 每日签到:基础 5连签每日 +1封顶 15 — `POST /profile/checkin`
- [x] 每日抽奖奖池加权0/2/5/10/20成本 0 — `POST /profile/lottery` - [x] 每日抽奖奖池加权0/2/5/10/20成本 0 — `POST /profile/lottery`
- [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务 - [x] 积分解锁分成:读者付全额,作者约 70% — 门控 unlock 服务
- [ ] 短龄同 IP 互刷拒绝分成 - [x] 短龄同 IP 互刷拒绝分成`UnlockPointsBlock` / `suspiciousUnlockPair`(双方注册未满 7 天且 LastLoginIP 相同)
- [ ] Exp → 等级 Lv110 - [x] Exp → 等级 Lv110 — 门槛推导 + 发帖/评论/获赞加 ExpAdmin 设等级 SSR `/admin/users`
- [ ] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子定义 + `EvaluateAuto` 已有Admin 可改定义,独立调度 UI 未做 - [x] 自动徽章(注册天数 / 获赞 / 创作分成)— 种子 + `EvaluateAuto`(用户页/资料页访问时评估);无独立定时调度 UI
- [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges` - [x] 限定徽章:管理员定义与授予/撤销 — SSR `/admin/badges`
- [x] 管理员调整积分、设定认证(免审)— SSR `/admin/users`(等级设定 UI 未做,仅只读展示 Lv - [x] 管理员调整积分、设定认证(免审)、设定等级 — SSR `/admin/users`
--- ---

View File

@@ -172,7 +172,7 @@
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱) - 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
- 收藏列表 — SSR `/favorites` - 收藏列表 — SSR `/favorites`
- 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG— SSR `/profile`;导航展示当前积分 - 积分钱包面板(余额、近 N 条流水、签到/抽奖表单 PRG— SSR `/profile`;导航展示当前积分
- 徽章展示:**未迁** - 徽章展示:用户主页 / 个人中心 — SSR访问时 `EvaluateAuto`
--- ---
@@ -196,7 +196,7 @@
| Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` | | Posts | 审核通过/拒绝 — SSR `/admin/moderation`;置顶/版顶/精华/锁编/锁评/软删 — 帖详情 Admin 条;回收站恢复/清除 — SSR `/admin/trash` |
| Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 | | Comments | 审核 — SSR `/admin/moderation`;修订查看未迁 |
| Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | | Reports | 处理动作dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
| Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`设等级 / 授徽章挂在徽章页 | | Users | 搜索;禁言;认证;调积分;设等级 — SSR `/admin/users`授徽章在 `/admin/badges` |
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
| Media | 分类浏览;批量删 | | Media | 分类浏览;批量删 |

View File

@@ -12,15 +12,15 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | Admin `/admin/media`(列表 / 删除 / 同步索引) | | **上一刀** | P2Admin 设等级 + 用户页徽章;勾选 Exp / 防刷 / 自动徽章 |
| **下一刀** | **等级/自动徽章 UI** **防刷分成**`02` §H P2开刀前确认偏好 | | **下一刀** | **P3 体验**(主题 / 伪静态 / 编辑器)**后置**OIDC / 存储热切换 / Gitea需点名 |
| **工作区** | 应干净;有未提交改动时先处理再开新刀 | | **工作区** | 应干净;有未提交改动时先处理再开新刀 |
--- ---
## 一句话状态 ## 一句话状态
论坛核心闭环 + Admin 用户/徽章/媒体 **已迁完**。余量主要是成长体系与防刷、OIDC/存储产品化与体验打磨 核心论坛 + Admin P1 + §H 成长/防刷(服务层 + Admin 设等级)**已齐**。余量P3 体验打磨与后置产品化OIDC / S3 热切换 / Gitea
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -28,43 +28,28 @@ flowchart LR
admin[Admin用户徽章媒体] admin[Admin用户徽章媒体]
growth[等级徽章防刷] growth[等级徽章防刷]
polish[主题伪静态编辑器] polish[主题伪静态编辑器]
deferred[Gitea码桶后置] deferred[OIDC存储Gitea]
core --> admin --> growth core --> admin --> growth --> polish
admin --> polish deferred -.-> polish
deferred -.-> admin
``` ```
--- ---
## 已完成里程碑 ## 已完成里程碑
### 公开站 ### 公开站 / Admin / §H
| 域 | 状态 | | 域 | 状态 |
|----|------| |----|------|
| `/install`、登录/注册/忘记密码、opaque session | 已迁 | | 五种帖类型、评论、私信、友链、单页、§O | 已迁 |
| Feed / 板块 / 搜索 / 三栏 / 右栏 widgets | 已迁 | | Adminusers / badges / media / settings 基础 | 已迁 |
| 帖详情:赞藏举报、门控、审核横幅、修订历史 | 已迁 | | Exp→Lv、Admin 设等级、用户页徽章展示 | 已迁 |
| 帖类型:`normal` / `question` / `poll` / `bounty` / `lottery` | 已迁 | | 短龄同 IP 互刷拒绝分成 | 已落地unlock 服务) |
| 评论:发评/回复/赞/私密/编辑删除/@/回复通知 | 已迁(扁平楼层;嵌套树未做 | | 自动徽章 EvaluateAuto | 已落地(访问用户页触发 |
| 私信/系统通知、收藏、个人中心、用户页 | 已迁 |
| 友链前台 + Admin含复检 | 已迁 |
| 站点单页 | 已迁 |
| `/health``robots.txt``sitemap.xml``/uploads` | 已落地(`02` §O 已勾) |
### Admin
| 路径 | 状态 |
|------|------|
| dashboard / boards / moderation / reports / trash | 已迁 |
| settings品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 |
| friend-links / pages | 已迁 |
| **users / badges / media** | **已迁** |
| settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** |
### 近期提交(摘) ### 近期提交(摘)
Admin users → badges → **media** media → **等级设定 + 徽章展示 / §H 勾选**
--- ---
@@ -72,31 +57,25 @@ Admin users → badges → **media**
### P0 — 小清理(可夹带) ### P0 — 小清理(可夹带)
- 发评路径挂 `RateLimiter` `comment` 动作服务层已有键web 未挂) - 发评路径挂 `RateLimiter` `comment` 动作
- Logo/Favicon/OG 上传、头像裁剪等声明为未做的边角 - Logo/Favicon/OG 上传、头像裁剪
### P1Admin 核心 ### P2成长与防刷
1. ~~Admin 用户~~ ~~已完成主路径~~无独立「自动徽章定时任务」Admin UI可接受
2. ~~Admin 徽章~~
3. ~~Admin 媒体~~
### P2成长与防刷 ← 当前指针候选 ### P3体验 / 编辑器 ← 当前候选
- Exp → Lv110 设定 UI、自动徽章调度说明、短龄同 IP 互刷拒绝分成(`02` §H
### P3 — 体验 / 编辑器(可砍或长期)
- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin - 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin
- TipTap / Markdown 双模(现行为 textarea + 门控) - TipTap / Markdown 双模
- 游客评论、评论嵌套树(建议:全局 `#floor` + 缩进) - 游客评论、评论嵌套树
- 修订 diff、完整 Limits 字数 - 修订 diff、完整 Limits 字数
### 后置(不阻塞验收) ### 后置
- KGitea `/projects` - KGitea `/projects`
- LOIDC 产品化 Admin CRUD - LOIDC 产品化 Admin CRUD
- MS3 热切换、WebP thumb 全链路产品化 - MS3 热切换、WebP 全链路产品化
--- ---
@@ -104,39 +83,15 @@ Admin users → badges → **media**
| 序 | 刀 | 产出 | | 序 | 刀 | 产出 |
|----|----|------| |----|----|------|
| ✓ | 五种帖类型 + 备份/复检 + §O | 核心闭环 | | ✓ | 核心闭环 + Admin P1 | … |
| ✓ | Admin users / badges / media | Admin P1 | | ✓ | §H 等级/防刷/徽章展示 | … |
| → | 等级设定 **或** 防刷分成 | 需偏好 | | → | 需点名 P3 或后置 | … |
| … | P3 / 后置 | 需明确点名再开 |
--- ---
## 下一刀实现提纲P2 候选 ## 已完成计划
> 「继续」前请点名:**等级/Exp UI**、**防刷分成**或其它OIDC/伪静态等)。 - [admin-users](plans/admin-users.md) · [admin-badges](plans/admin-badges.md) · [admin-media](plans/admin-media.md)
### A. 等级
- Admin 设用户 Exp/Lv`SetUserLevel` 已有)
- 公开页等级展示对齐
### B. 防刷分成
- 短龄同 IP 互刷拒绝创作分成(`02` §H
---
## 已完成Admin 媒体
见 [plans/admin-media.md](plans/admin-media.md)。
## 已完成Admin 徽章
见 [plans/admin-badges.md](plans/admin-badges.md)。
## 已完成Admin 用户管理
见 [plans/admin-users.md](plans/admin-users.md)。
--- ---

View File

@@ -0,0 +1,17 @@
# P2 等级设定与徽章展示 — 实现计划
> 状态:已完成
> 分支:`rebuild/gitea-ssr`
## 范围
| 能力 | 实现 |
|------|------|
| Admin 设等级 | `POST /admin/users/:id/level``SetUserLevel`Exp 调至门槛) |
| 公开等级 | 既有 `/user/:id``/profile` Lv/Exp |
| 徽章展示 | 用户页 / 资料页列表;访问时 `EvaluateAuto` |
| 防刷分成 | 确认 `suspiciousUnlockPair` 已落地并勾选 `02` |
## 验证
`build.bat`;冒烟:设用户 Lv5 → Exp=200用户页可见等级。

View File

@@ -1040,6 +1040,33 @@ points-only[data-locked="false"] {
align-items: center; align-items: center;
margin: 0; margin: 0;
} }
.j13-admin-level {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
margin: 0;
}
.j13-admin-level select {
min-width: 4.5rem;
}
.j13-user-badges {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.j13-user-badge {
display: inline-block;
padding: 0.15rem 0.45rem;
font-size: 0.8rem;
border: 1px solid var(--j13-border);
border-radius: 4px;
color: var(--j13-muted);
}
.j13-user-badge[title] { cursor: help; }
.j13-admin-badge-award-btns { .j13-admin-badge-award-btns {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -30,15 +30,16 @@ type adminUserRow struct {
type adminUsersData struct { type adminUsersData struct {
AdminChrome AdminChrome
Users []adminUserRow Users []adminUserRow
Keyword string Keyword string
Filter string Filter string
Page int Page int
HasPrev bool HasPrev bool
HasMore bool HasMore bool
PrevPage int PrevPage int
NextPage int NextPage int
QuerySuffix string QuerySuffix string
LevelOptions []int
} }
// AdminUsersGet 用户列表 // AdminUsersGet 用户列表
@@ -71,6 +72,7 @@ func (d Deps) renderAdminUsers(ctx *webctx.Context, errMsg string) {
PrevPage: page - 1, PrevPage: page - 1,
NextPage: page + 1, NextPage: page + 1,
HasPrev: page > 1, HasPrev: page > 1,
LevelOptions: adminLevelOptions(),
} }
q := url.Values{} q := url.Values{}
if keyword != "" { if keyword != "" {
@@ -120,6 +122,15 @@ func adminUserRoleLabel(role models.Role) string {
} }
} }
func adminLevelOptions() []int {
n := models.MaxLevel()
out := make([]int, n)
for i := 0; i < n; i++ {
out[i] = i + 1
}
return out
}
// AdminUserBanPost 禁言 / 解禁 // AdminUserBanPost 禁言 / 解禁
func (d Deps) AdminUserBanPost(c *gin.Context) { func (d Deps) AdminUserBanPost(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)
@@ -192,3 +203,24 @@ func (d Deps) AdminUserPointsPost(c *gin.Context) {
ctx.SetFlash(fmt.Sprintf("积分已调整,当前余额 %d", bal)) ctx.SetFlash(fmt.Sprintf("积分已调整,当前余额 %d", bal))
ctx.Redirect("/admin/users") ctx.Redirect("/admin/users")
} }
// AdminUserLevelPost 设定等级(同步 Exp 到门槛)
func (d Deps) AdminUserLevelPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminUsers(ctx, "无效请求,请重试")
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
level, err := strconv.Atoi(strings.TrimSpace(c.PostForm("level")))
if err != nil {
d.renderAdminUsers(ctx, "请选择有效等级")
return
}
if err := services.SetUserLevel(uint(id64), level); err != nil {
d.renderAdminUsers(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("已设为 Lv%d经验门槛 %d", level, models.ExpForLevel(level)))
ctx.Redirect("/admin/users")
}

View File

@@ -101,6 +101,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/users/:id/ban", deps.AdminUserBanPost) admin.POST("/users/:id/ban", deps.AdminUserBanPost)
admin.POST("/users/:id/verify", deps.AdminUserVerifyPost) admin.POST("/users/:id/verify", deps.AdminUserVerifyPost)
admin.POST("/users/:id/points", deps.AdminUserPointsPost) admin.POST("/users/:id/points", deps.AdminUserPointsPost)
admin.POST("/users/:id/level", deps.AdminUserLevelPost)
admin.GET("/badges", deps.AdminBadgesGet) admin.GET("/badges", deps.AdminBadgesGet)
admin.POST("/badges", deps.AdminBadgeCreate) admin.POST("/badges", deps.AdminBadgeCreate)
admin.POST("/badges/award", deps.AdminBadgeAwardPost) admin.POST("/badges/award", deps.AdminBadgeAwardPost)

View File

@@ -33,6 +33,7 @@ type profileData struct {
Level int Level int
Exp int Exp int
Points int Points int
Badges []userPublicBadge
PostCount int64 PostCount int64
CommentCount int64 CommentCount int64
FavoriteCount int64 FavoriteCount int64
@@ -112,6 +113,14 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
AvatarMaxMB: d.Settings.AvatarMaxMB(), AvatarMaxMB: d.Settings.AvatarMaxMB(),
SignatureMax: d.Settings.SignatureMax(), SignatureMax: d.Settings.SignatureMax(),
} }
if d.Badge != nil {
_ = d.Badge.EvaluateAuto(uid)
if rows, err := d.Badge.ListUserBadges(uid); err == nil {
for _, v := range services.BadgeViews(rows, 0) {
data.Badges = append(data.Badges, userPublicBadge{Name: v.Name, Description: v.Description})
}
}
}
if d.Points != nil { if d.Points != nil {
data.CheckIn, _ = d.Points.GetCheckInStatus(uid) data.CheckIn, _ = d.Points.GetCheckInStatus(uid)
data.Lottery, _ = d.Points.GetLotteryStatus(uid) data.Lottery, _ = d.Points.GetLotteryStatus(uid)

View File

@@ -10,6 +10,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type userPublicBadge struct {
Name string
Description string
}
type userPublicData struct { type userPublicData struct {
PageChrome PageChrome
UserID uint UserID uint
@@ -20,6 +25,7 @@ type userPublicData struct {
Level int Level int
Exp int Exp int
Points int Points int
Badges []userPublicBadge
PostCount int64 PostCount int64
CommentCount int64 CommentCount int64
FavoriteCount int64 FavoriteCount int64
@@ -75,6 +81,15 @@ func (d Deps) UserPublic(c *gin.Context) {
nick := displayName(user) nick := displayName(user)
chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "") chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "")
hasMore := int64(page*size) < total hasMore := int64(page*size) < total
badges := []userPublicBadge{}
if d.Badge != nil {
_ = d.Badge.EvaluateAuto(user.ID)
if rows, err := d.Badge.ListUserBadges(user.ID); err == nil {
for _, v := range services.BadgeViews(rows, 0) {
badges = append(badges, userPublicBadge{Name: v.Name, Description: v.Description})
}
}
}
ctx.HTML(http.StatusOK, "user/view", userPublicData{ ctx.HTML(http.StatusOK, "user/view", userPublicData{
PageChrome: chrome, PageChrome: chrome,
UserID: user.ID, UserID: user.ID,
@@ -85,6 +100,7 @@ func (d Deps) UserPublic(c *gin.Context) {
Level: models.LevelFromExp(user.Exp), Level: models.LevelFromExp(user.Exp),
Exp: user.Exp, Exp: user.Exp,
Points: user.Points, Points: user.Points,
Badges: badges,
PostCount: st.PostCount, CommentCount: st.CommentCount, PostCount: st.PostCount, CommentCount: st.CommentCount,
FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived, FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived,
IsSelf: ctx.UserID() == user.ID, IsSelf: ctx.UserID() == user.ID,

View File

@@ -86,6 +86,16 @@
<input name="note" maxlength="120" placeholder="备注(可选)" style="width:8rem"/> <input name="note" maxlength="120" placeholder="备注(可选)" style="width:8rem"/>
<button type="submit" class="j13-btn-secondary">调积分</button> <button type="submit" class="j13-btn-secondary">调积分</button>
</form> </form>
<form method="post" action="/admin/users/{{.ID}}/level" class="j13-form j13-admin-level">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
{{$cur := .Level}}
<select name="level" aria-label="设定等级">
{{range $.LevelOptions}}
<option value="{{.}}"{{if eq . $cur}} selected{{end}}>Lv{{.}}</option>
{{end}}
</select>
<button type="submit" class="j13-btn-secondary">设等级</button>
</form>
</td> </td>
</tr> </tr>
{{end}} {{end}}

View File

@@ -75,6 +75,13 @@
<p>用户名 <strong>{{.Username}}</strong></p> <p>用户名 <strong>{{.Username}}</strong></p>
<p class="j13-muted">邮箱 {{.Email}}</p> <p class="j13-muted">邮箱 {{.Email}}</p>
<p class="j13-muted">等级 Lv.{{.Level}} · 经验 {{.Exp}}</p> <p class="j13-muted">等级 Lv.{{.Level}} · 经验 {{.Exp}}</p>
{{if .Badges}}
<ul class="j13-user-badges">
{{range .Badges}}
<li class="j13-user-badge"{{if .Description}} title="{{.Description}}"{{end}}>{{.Name}}</li>
{{end}}
</ul>
{{end}}
{{if .Avatar}} {{if .Avatar}}
<p><img class="j13-avatar" src="{{.Avatar}}" alt="头像"/></p> <p><img class="j13-avatar" src="{{.Avatar}}" alt="头像"/></p>
{{end}} {{end}}

View File

@@ -11,6 +11,13 @@
<div> <div>
<h1>{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}</h1> <h1>{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}</h1>
<p class="j13-muted">@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}</p> <p class="j13-muted">@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}</p>
{{if .Badges}}
<ul class="j13-user-badges">
{{range .Badges}}
<li class="j13-user-badge"{{if .Description}} title="{{.Description}}"{{end}}>{{.Name}}</li>
{{end}}
</ul>
{{end}}
{{if .Signature}}<p class="j13-user-sig">{{.Signature}}</p>{{end}} {{if .Signature}}<p class="j13-user-sig">{{.Signature}}</p>{{end}}
{{if .IsSelf}}<p><a href="/profile">编辑资料</a></p> {{if .IsSelf}}<p><a href="/profile">编辑资料</a></p>
{{else if .LoggedIn}}<p><a href="/messages/with/{{.UserID}}">发私信</a></p>{{end}} {{else if .LoggedIn}}<p><a href="/messages/with/{{.UserID}}">发私信</a></p>{{end}}

View File

@@ -1040,6 +1040,33 @@ points-only[data-locked="false"] {
align-items: center; align-items: center;
margin: 0; margin: 0;
} }
.j13-admin-level {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
margin: 0;
}
.j13-admin-level select {
min-width: 4.5rem;
}
.j13-user-badges {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.j13-user-badge {
display: inline-block;
padding: 0.15rem 0.45rem;
font-size: 0.8rem;
border: 1px solid var(--j13-border);
border-radius: 4px;
color: var(--j13-muted);
}
.j13-user-badge[title] { cursor: help; }
.j13-admin-badge-award-btns { .j13-admin-badge-award-btns {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;