+
+
+
+
+
+ {filters.map(f => (
+
+ ))}
+
+
+
{loading ? (
-
+
+ ) : users.length === 0 ? (
+
+ {search || filter !== 'all' ? '没有符合条件的用户' : '暂无用户'}
+
) : (
<>
-
-
-
-
- | ID |
- 用户名 |
- 昵称 |
- 邮箱 |
- 角色 |
- 状态 |
- 上次登录 |
- 登录 IP |
- 注册时间 |
- 操作 |
-
-
-
- {users.map(u => (
-
- | {u.id} |
- {u.username} |
-
-
- |
- {u.email || '—'} |
-
- {u.role === 'admin'
- ? 管理员
- : 用户}
- |
- {u.banned ? 已禁言 : '正常'} |
- {u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'} |
- {u.last_login_ip || '—'} |
- {u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'} |
-
- {u.role !== 'admin' && (
-
- )}
- |
-
- ))}
-
-
+
+ 成员
+ 等级
+ 积分
+ 最近登录
+
+
+
+ {users.map(u => (
+ -
+
+
+
+
+
+ {u.role === 'admin' && 站长}
+ {u.role !== 'admin' && u.verified && 认证}
+ {u.banned && 禁言}
+
+
@{u.username}
+ {u.email?.trim() ? (
+
{u.email}
+ ) : null}
+
+
+
+
+ Lv.{resolveUserLevel(u)}
+
+
+ {u.points ?? 0}
+
+
+ {fmtRel(u.last_login_at)}
+
+
+
+
+
+
+
+
+ openManage(u)}>
+
+ 账户详情
+
+ nav(`/user/${u.id}`)}>
+ 查看主页
+
+ {u.role !== 'admin' && (
+ <>
+
+ toggleVerify(u)}>
+
+ {u.verified ? '取消认证' : '设为认证'}
+
+ setBanTarget(u)}
+ >
+
+ {u.banned ? '解除禁言' : '禁言'}
+
+ >
+ )}
+
+
+
+
+ ))}
+
+
+
+
{total} 位成员
+ {totalPages > 1 && (
+
+
+ {page} / {totalPages}
+
+
+ )}
- {users.length === 0 &&
暂无用户
}
- {totalPages > 1 && (
-
-
- 第 {page} / {totalPages} 页
-
-
- )}
>
)}
+
+
+
+
{ if (!open) setBanTarget(null); }}>
+
+
+
+ {banTarget?.banned ? '解除禁言' : '确认禁言'}
+
+
+ {banTarget?.banned
+ ? `确定解除对 ${banTarget?.nickname} 的禁言吗?`
+ : `确定禁言 ${banTarget?.nickname}?被禁言用户将无法发帖与评论。`}
+
+
+
+ 取消
+
+ {banTarget?.banned ? '解除禁言' : '确认禁言'}
+
+
+
+
);
}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index a2d5bde..0b55af1 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -8353,20 +8353,77 @@ button.profile-stat:hover strong {
}
.admin-body { display: flex; height: calc(100vh - 56px); overflow: hidden; }
.admin-sidebar {
- width: 200px; flex-shrink: 0; padding: 16px 10px; border-right: 1px solid var(--j13-border);
+ width: 220px; flex-shrink: 0; padding: 12px 10px 20px; border-right: 1px solid var(--j13-border);
background: hsl(var(--card)); overflow-y: auto;
}
+.admin-nav-group { margin-bottom: 14px; }
+.admin-nav-group:last-child { margin-bottom: 0; }
+.admin-nav-group-label {
+ padding: 6px 12px 6px;
+ font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
+ text-transform: uppercase; color: hsl(var(--muted-foreground) / 0.85);
+}
.admin-nav-item {
display: flex; align-items: center; gap: 8px; width: 100%;
- padding: 9px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px;
- color: hsl(var(--muted-foreground)); text-decoration: none; transition: background .15s, color .15s;
+ padding: 8px 12px; margin-bottom: 2px; border-radius: 8px; font-size: 13px;
+ color: hsl(var(--muted-foreground)); text-decoration: none;
+ border-left: 2px solid transparent;
+ transition: background .15s, color .15s, border-color .15s;
}
+.admin-nav-icon { flex-shrink: 0; }
+.admin-nav-text { flex: 1; min-width: 0; }
+.admin-nav-badge {
+ flex-shrink: 0; min-width: 18px; height: 18px; padding: 0 5px;
+ border-radius: 999px; font-size: 11px; font-weight: 600; line-height: 18px; text-align: center;
+ background: hsl(24 90% 48% / 0.16); color: hsl(24 90% 40%);
+}
+.dark .admin-nav-badge { background: hsl(24 90% 55% / 0.22); color: hsl(24 95% 72%); }
.admin-nav-item:hover { background: var(--j13-green-bg); color: var(--j13-green); }
-.admin-nav-item.active { background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600; }
+.admin-nav-item.active {
+ background: var(--j13-green-bg); color: var(--j13-green); font-weight: 600;
+ border-left-color: var(--j13-green);
+}
.admin-main { flex: 1; min-width: 0; min-height: 0; padding: 24px; overflow-y: auto; }
.admin-page-head { margin-bottom: 20px; }
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
+.admin-section-label {
+ display: flex; align-items: center; gap: 8px;
+ margin-bottom: 10px; font-size: 13px; font-weight: 600;
+}
+.admin-section-muted { font-size: 12px; font-weight: 500; color: hsl(var(--muted-foreground)); }
+.admin-queue-section { margin-bottom: 22px; }
+.admin-queue-grid {
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 12px;
+}
+.admin-queue-card {
+ display: block; width: 100%; text-align: left;
+ padding: 14px 16px; border-radius: 10px;
+ border: 1px solid var(--j13-border); background: hsl(var(--card));
+ cursor: pointer; transition: border-color .15s, background .15s, box-shadow .15s;
+}
+.admin-queue-card:hover {
+ border-color: var(--j13-green); background: var(--j13-green-bg);
+}
+.admin-queue-card.has-work {
+ border-color: hsl(24 80% 55% / 0.45);
+ background: hsl(24 90% 48% / 0.06);
+}
+.admin-queue-card.has-work:hover {
+ border-color: hsl(24 80% 50%);
+ background: hsl(24 90% 48% / 0.1);
+}
+.admin-queue-card-top {
+ display: flex; align-items: center; justify-content: space-between;
+ color: hsl(var(--muted-foreground)); margin-bottom: 8px;
+}
+.admin-queue-count { font-size: 22px; font-weight: 700; color: hsl(var(--foreground)); line-height: 1; }
+.admin-queue-card.has-work .admin-queue-count { color: hsl(24 90% 40%); }
+.dark .admin-queue-card.has-work .admin-queue-count { color: hsl(24 95% 68%); }
+.admin-queue-label { font-size: 13px; font-weight: 600; }
+.admin-queue-hint { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 2px; line-height: 1.4; }
+.admin-stat-section { margin-bottom: 8px; }
.admin-stat-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px; margin-bottom: 20px;
@@ -9624,3 +9681,890 @@ a.pm-thread-head__name:hover {
display: inline-flex;
}
}
+
+/* —— 用户徽章 / 等级 —— */
+.user-badges {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: 0.35rem;
+ vertical-align: middle;
+ flex-wrap: wrap;
+}
+.user-badges--compact {
+ gap: 0.2rem;
+}
+.user-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.15rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ line-height: 1;
+ padding: 0.15rem 0.35rem;
+ border-radius: 0.25rem;
+ background: color-mix(in srgb, var(--j13-muted, #94a3b8) 18%, transparent);
+ color: var(--j13-text-secondary, #64748b);
+}
+.user-badge--owner {
+ background: color-mix(in srgb, #d97706 22%, transparent);
+ color: #b45309;
+}
+.user-badge--verified {
+ background: color-mix(in srgb, var(--j13-green, #16a34a) 20%, transparent);
+ color: var(--j13-green, #16a34a);
+}
+.user-badge--level-muted { color: #64748b; }
+.user-badge--level-blue {
+ background: color-mix(in srgb, #2563eb 18%, transparent);
+ color: #1d4ed8;
+}
+.user-badge--level-amber {
+ background: color-mix(in srgb, #d97706 20%, transparent);
+ color: #b45309;
+}
+.user-badge--level-gold {
+ background: color-mix(in srgb, #ca8a04 22%, transparent);
+ color: #a16207;
+}
+.user-badge--ach {
+ padding: 0.15rem;
+}
+
+/* —— 积分钱包 —— */
+.points-wallet {
+ margin-top: 1.25rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.5rem;
+ background: var(--j13-card, #fff);
+}
+.points-wallet-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.85rem;
+}
+.points-wallet-head h3 {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin: 0;
+ font-size: 1rem;
+}
+.points-wallet-balance {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+}
+.points-wallet-balance strong {
+ font-size: 1.5rem;
+ font-variant-numeric: tabular-nums;
+}
+.points-wallet-balance span {
+ color: var(--j13-text-secondary, #64748b);
+ font-size: 0.85rem;
+}
+.points-wallet-balance em {
+ font-style: normal;
+ font-size: 0.8rem;
+ color: var(--j13-text-secondary, #64748b);
+ margin-left: 0.5rem;
+}
+.points-wallet-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+.points-wallet-ledger h4 {
+ margin: 0 0 0.5rem;
+ font-size: 0.9rem;
+}
+.points-wallet-ledger ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.points-wallet-ledger li {
+ display: grid;
+ grid-template-columns: 4rem 1fr auto;
+ gap: 0.5rem;
+ padding: 0.35rem 0;
+ border-top: 1px solid var(--j13-border, #e2e8f0);
+ font-size: 0.85rem;
+}
+.points-wallet-ledger .pos { color: var(--j13-green, #16a34a); font-weight: 600; }
+.points-wallet-ledger .neg { color: #dc2626; font-weight: 600; }
+.points-wallet-empty {
+ margin: 0;
+ color: var(--j13-text-secondary, #64748b);
+ font-size: 0.85rem;
+}
+
+/* —— 积分可见区块 —— */
+.post-points-only__gate,
+.post-points-only__badge {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+.post-points-only--locked .post-points-only__gate {
+ margin: 0.75rem 0;
+ padding: 0.75rem 1rem;
+ border: 1px dashed color-mix(in srgb, #ca8a04 45%, transparent);
+ border-radius: 0.5rem;
+ background: color-mix(in srgb, #ca8a04 8%, transparent);
+}
+.post-points-only__gate-btn,
+.post-points-only__remove-btn {
+ cursor: pointer;
+ border: 1px solid color-mix(in srgb, #ca8a04 40%, transparent);
+ background: #fff;
+ border-radius: 0.35rem;
+ padding: 0.3rem 0.65rem;
+ font-size: 0.85rem;
+}
+.post-points-only__cost {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.8rem;
+}
+.post-points-only__cost input {
+ width: 4rem;
+ padding: 0.15rem 0.3rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.25rem;
+}
+.editor-points-only {
+ margin: 0.5rem 0;
+ padding: 0.5rem;
+ border: 1px solid color-mix(in srgb, #ca8a04 35%, transparent);
+ border-radius: 0.4rem;
+}
+.admin-user-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+/* —— 用户管理:成员目录式布局 —— */
+.admin-users-page {
+ max-width: 920px;
+}
+.admin-users-page .admin-page-head {
+ margin-bottom: 28px;
+}
+.admin-users-page .admin-page-head h1 {
+ font-size: 26px;
+ letter-spacing: -0.02em;
+}
+.admin-users-page .admin-page-head p {
+ margin-top: 6px;
+ font-size: 14px;
+}
+
+.admin-users-panel {
+ border: 1px solid var(--j13-border);
+ border-radius: 14px;
+ background: hsl(var(--card));
+ overflow: hidden;
+}
+.admin-users-panel-head {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px 20px 16px;
+ border-bottom: 1px solid var(--j13-border);
+ background: linear-gradient(
+ 180deg,
+ hsl(var(--muted) / 0.35) 0%,
+ hsl(var(--card)) 100%
+ );
+}
+.admin-users-search {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.admin-users-search-field {
+ position: relative;
+ flex: 1;
+ min-width: 200px;
+}
+.admin-users-search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: hsl(var(--muted-foreground));
+ pointer-events: none;
+}
+.admin-users-search-field input {
+ height: 40px;
+ padding-left: 38px;
+ border-radius: 10px;
+ background: hsl(var(--background));
+}
+.admin-users-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.admin-users-filter {
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ background: transparent;
+ font-size: 13px;
+ font-weight: 500;
+ color: hsl(var(--muted-foreground));
+ cursor: pointer;
+ transition: background .15s, color .15s, border-color .15s;
+}
+.admin-users-filter:hover {
+ color: hsl(var(--foreground));
+ background: hsl(var(--muted) / 0.5);
+}
+.admin-users-filter.active {
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+ border-color: color-mix(in srgb, var(--j13-green) 28%, transparent);
+}
+
+.admin-users-cols {
+ display: none;
+ grid-template-columns: minmax(0, 1fr) 72px 72px 96px 72px;
+ gap: 12px;
+ padding: 10px 20px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: hsl(var(--muted-foreground) / 0.9);
+}
+.admin-users-cols-who { padding-left: 56px; }
+.admin-users-cols > span:not(.admin-users-cols-who):not(.admin-users-cols-action) {
+ text-align: right;
+}
+
+.admin-users-list {
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+}
+.admin-users-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px 12px;
+ align-items: center;
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--j13-border);
+ transition: background .15s;
+}
+.admin-users-row:last-of-type { border-bottom: none; }
+.admin-users-row:hover { background: hsl(var(--muted) / 0.28); }
+.admin-users-row--banned {
+ background: hsl(0 70% 50% / 0.035);
+}
+.admin-users-row--banned:hover {
+ background: hsl(0 70% 50% / 0.06);
+}
+
+.admin-users-who {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ min-width: 0;
+}
+.admin-users-who .admin-user-avatar {
+ width: 44px;
+ height: 44px;
+ margin-top: 1px;
+ box-shadow: 0 0 0 1px var(--j13-border);
+}
+.admin-users-who-text { min-width: 0; }
+.admin-users-who-line {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.admin-user-nick {
+ border: none;
+ background: none;
+ padding: 0;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: hsl(var(--foreground));
+ cursor: pointer;
+ line-height: 1.3;
+}
+.admin-user-nick:hover {
+ color: var(--j13-green);
+}
+.admin-users-handle {
+ margin-top: 3px;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-users-mail {
+ margin-top: 2px;
+ font-size: 12.5px;
+ color: hsl(var(--muted-foreground) / 0.92);
+ max-width: 280px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-users-metric {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.admin-users-metric::before {
+ content: attr(data-label);
+ font-size: 11px;
+ font-weight: 500;
+ color: hsl(var(--muted-foreground));
+}
+.admin-users-metric-value {
+ font-size: 14px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ color: hsl(var(--foreground));
+}
+.admin-users-action {
+ display: flex;
+ justify-content: flex-start;
+}
+.admin-users-manage-btn {
+ gap: 4px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-users-manage-btn:hover {
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+}
+
+.admin-users-empty {
+ padding: 56px 24px;
+ text-align: center;
+ font-size: 14px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-users-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 20px;
+ border-top: 1px solid var(--j13-border);
+ background: hsl(var(--muted) / 0.18);
+}
+.admin-users-total {
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+.admin-users-pager {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ color: hsl(var(--muted-foreground));
+}
+
+.admin-user-avatar {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ background: hsl(var(--muted));
+}
+.admin-user-avatar--fallback {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--j13-green);
+ background: var(--j13-green-bg);
+}
+.admin-user-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.admin-user-email {
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+ margin-top: 2px;
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.admin-user-manage-dialog { gap: 0; }
+.admin-user-manage-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 8px 0 16px;
+}
+.admin-user-manage-head .admin-user-avatar {
+ width: 52px;
+ height: 52px;
+}
+.admin-user-manage-name { font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
+.admin-user-manage-section {
+ padding: 14px 0;
+ border-top: 1px solid var(--j13-border);
+}
+.admin-user-manage-section-title {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+.admin-user-fact-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px 14px;
+ margin: 0;
+}
+.admin-user-fact-grid dt {
+ font-size: 11px;
+ font-weight: 500;
+ color: hsl(var(--muted-foreground));
+ margin-bottom: 2px;
+}
+.admin-user-fact-grid dd {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 500;
+}
+.admin-user-manage-actions,
+.admin-user-manage-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.admin-user-manage-row > input,
+.admin-user-manage-row > .admin-user-select { flex: 1; min-width: 120px; }
+.admin-user-manage-hint {
+ margin-top: 6px;
+ font-size: 12px;
+ color: hsl(var(--muted-foreground));
+ line-height: 1.4;
+}
+.admin-user-select {
+ height: 36px;
+ border-radius: 8px;
+ border: 1px solid hsl(var(--input));
+ background: hsl(var(--background));
+ padding: 0 10px;
+ font-size: 13px;
+ color: hsl(var(--foreground));
+}
+.admin-user-badge-preview {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: hsl(var(--muted) / 0.35);
+ font-size: 13px;
+}
+.admin-user-badge-preview .admin-user-manage-hint { margin: 0; }
+
+@media (min-width: 760px) {
+ .admin-users-cols { display: grid; }
+ .admin-users-row {
+ grid-template-columns: minmax(0, 1fr) 72px 72px 96px 72px;
+ gap: 12px;
+ padding: 16px 20px;
+ }
+ .admin-users-metric::before { display: none; }
+ .admin-users-metric {
+ align-items: flex-end;
+ text-align: right;
+ }
+ .admin-users-metric-value { font-size: 13.5px; font-weight: 500; }
+ .admin-users-metric--time .admin-users-metric-value {
+ font-weight: 500;
+ color: hsl(var(--muted-foreground));
+ }
+ .admin-users-action { justify-content: flex-end; }
+ .admin-users-mail { max-width: 220px; }
+}
+
+@media (max-width: 759px) {
+ .admin-users-row {
+ padding: 16px 16px 18px;
+ grid-template-columns: 1fr 1fr 1fr;
+ }
+ .admin-users-who {
+ grid-column: 1 / -1;
+ }
+ .admin-users-metric {
+ padding-top: 4px;
+ }
+ .admin-users-action {
+ grid-column: 1 / -1;
+ justify-content: flex-start;
+ padding-top: 4px;
+ }
+ .admin-users-panel-head { padding: 16px; }
+}
+.admin-badge-form {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.75rem 1rem;
+}
+.admin-badge-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.85rem;
+}
+.admin-badge-form-check {
+ flex-direction: row !important;
+ align-items: center;
+ gap: 0.4rem !important;
+ margin-top: 1.4rem;
+}
+.admin-select {
+ height: 2.25rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.35rem;
+ padding: 0 0.5rem;
+ background: var(--j13-card, #fff);
+ width: 100%;
+}
+
+/* —— 徽章管理(卡片式) —— */
+.admin-badge-stats {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+.admin-badge-stat {
+ padding: 0.85rem 1rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.55rem;
+ background: var(--j13-card, #fff);
+}
+.admin-badge-stat strong {
+ display: block;
+ font-size: 1.35rem;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.2;
+}
+.admin-badge-stat span {
+ font-size: 0.78rem;
+ color: hsl(var(--muted-foreground));
+}
+.admin-badge-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+.admin-badge-search {
+ position: relative;
+ min-width: min(100%, 240px);
+ flex: 1 1 220px;
+ max-width: 320px;
+}
+.admin-badge-search > svg {
+ position: absolute;
+ left: 0.7rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: hsl(var(--muted-foreground));
+ pointer-events: none;
+}
+.admin-badge-search input {
+ padding-left: 2rem;
+}
+.admin-badge-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 0.9rem;
+}
+.admin-badge-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.7rem;
+ padding: 1rem 1.05rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ border-radius: 0.7rem;
+ background: var(--j13-card, #fff);
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.admin-badge-card:hover {
+ border-color: color-mix(in srgb, var(--j13-green, #16a34a) 35%, var(--j13-border, #e2e8f0));
+ box-shadow: 0 6px 20px hsl(var(--foreground) / 0.04);
+}
+.admin-badge-card.is-disabled {
+ opacity: 0.72;
+}
+.admin-badge-card-top {
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+}
+.admin-badge-preview {
+ width: 2.75rem;
+ height: 2.75rem;
+ border-radius: 0.7rem;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ color: #1d4ed8;
+ background: color-mix(in srgb, #2563eb 14%, transparent);
+ border: 1px solid color-mix(in srgb, #2563eb 22%, transparent);
+}
+.admin-badge-preview.is-limited {
+ color: #b45309;
+ background: color-mix(in srgb, #d97706 16%, transparent);
+ border-color: color-mix(in srgb, #d97706 28%, transparent);
+}
+.admin-badge-preview--lg {
+ width: 3.25rem;
+ height: 3.25rem;
+ border-radius: 0.85rem;
+}
+.admin-badge-card-meta {
+ min-width: 0;
+ flex: 1;
+}
+.admin-badge-card-title-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+}
+.admin-badge-card-title-row h3 {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.admin-badge-code {
+ display: inline-block;
+ margin-top: 0.2rem;
+ font-size: 0.72rem;
+ color: hsl(var(--muted-foreground));
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+.admin-badge-desc {
+ margin: 0;
+ font-size: 0.84rem;
+ line-height: 1.45;
+ color: hsl(var(--muted-foreground));
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.admin-badge-card-foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.55rem;
+ padding-top: 0.35rem;
+ border-top: 1px solid var(--j13-border, #e2e8f0);
+}
+.admin-badge-condition {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: hsl(var(--muted-foreground));
+ max-width: 100%;
+}
+.admin-badge-card-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-left: auto;
+}
+.admin-badge-empty {
+ text-align: center;
+ padding: 3.5rem 1.5rem;
+ border: 1px dashed var(--j13-border, #e2e8f0);
+ border-radius: 0.75rem;
+ color: hsl(var(--muted-foreground));
+}
+.admin-badge-empty h3 {
+ margin: 0.75rem 0 0.35rem;
+ font-size: 1.05rem;
+ color: hsl(var(--foreground));
+}
+.admin-badge-empty p {
+ margin: 0 0 1rem;
+ font-size: 0.9rem;
+}
+.admin-badge-dialog-preview {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 0.85rem 1rem;
+ border-radius: 0.65rem;
+ background: hsl(var(--muted) / 0.35);
+ border: 1px solid var(--j13-border, #e2e8f0);
+}
+.admin-badge-dialog-preview-name {
+ font-weight: 600;
+ font-size: 1rem;
+}
+.admin-badge-dialog-preview-cond {
+ margin-top: 0.15rem;
+ font-size: 0.8rem;
+ color: hsl(var(--muted-foreground));
+}
+.admin-badge-dialog-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+.admin-badge-kind-seg {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.4rem;
+ padding: 0.25rem;
+ border-radius: 0.55rem;
+ background: hsl(var(--muted) / 0.45);
+}
+.admin-badge-kind-seg button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ height: 2.15rem;
+ border: none;
+ border-radius: 0.4rem;
+ background: transparent;
+ font-size: 0.86rem;
+ font-weight: 550;
+ color: hsl(var(--muted-foreground));
+ cursor: pointer;
+}
+.admin-badge-kind-seg button.active {
+ background: var(--j13-card, #fff);
+ color: hsl(var(--foreground));
+ box-shadow: 0 1px 3px hsl(var(--foreground) / 0.08);
+}
+.admin-badge-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-width: 0;
+}
+.admin-badge-field-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+.admin-badge-field-row--end {
+ align-items: end;
+}
+.admin-badge-field-hint {
+ font-size: 0.75rem;
+ color: hsl(var(--muted-foreground));
+ line-height: 1.35;
+}
+.admin-badge-icon-picker {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(2.4rem, 1fr));
+ gap: 0.35rem;
+}
+.admin-badge-icon-opt {
+ height: 2.4rem;
+ display: grid;
+ place-items: center;
+ border-radius: 0.45rem;
+ border: 1px solid var(--j13-border, #e2e8f0);
+ background: var(--j13-card, #fff);
+ color: hsl(var(--muted-foreground));
+ cursor: pointer;
+}
+.admin-badge-icon-opt:hover {
+ border-color: color-mix(in srgb, var(--j13-green, #16a34a) 40%, var(--j13-border));
+ color: hsl(var(--foreground));
+}
+.admin-badge-icon-opt.active {
+ border-color: var(--j13-green, #16a34a);
+ color: var(--j13-green, #16a34a);
+ background: color-mix(in srgb, var(--j13-green, #16a34a) 10%, transparent);
+}
+.admin-badge-enable-row {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.88rem;
+ min-height: 2.25rem;
+ cursor: pointer;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (max-width: 720px) {
+ .admin-badge-stats {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .admin-badge-field-row {
+ grid-template-columns: 1fr;
+ }
+ .admin-badge-toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .admin-badge-search {
+ max-width: none;
+ }
+}
+.article-tool-btn--points.is-active,
+.article-tool-btn--points[aria-pressed='true'] {
+ color: #a16207;
+}
+.profile-badge-wall {
+ margin: 0.75rem 0 0.25rem;
+}
+.profile-badge-wall-title {
+ margin: 0 0 0.4rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+}
+
diff --git a/frontend/src/utils/badgeIcons.ts b/frontend/src/utils/badgeIcons.ts
new file mode 100644
index 0000000..8081f53
--- /dev/null
+++ b/frontend/src/utils/badgeIcons.ts
@@ -0,0 +1,64 @@
+import {
+ Award,
+ Calendar,
+ CalendarHeart,
+ Coins,
+ Flame,
+ Gem,
+ Heart,
+ HeartHandshake,
+ Medal,
+ Shield,
+ Sparkles,
+ Star,
+ Trophy,
+ type LucideIcon,
+} from 'lucide-react';
+
+/** 徽章可用图标(与前端展示、后台选择器一致) */
+export const BADGE_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
+ { key: 'star', label: '星星', Icon: Star },
+ { key: 'award', label: '奖章', Icon: Award },
+ { key: 'medal', label: '勋章', Icon: Medal },
+ { key: 'trophy', label: '奖杯', Icon: Trophy },
+ { key: 'sparkles', label: '闪光', Icon: Sparkles },
+ { key: 'shield', label: '盾牌', Icon: Shield },
+ { key: 'heart', label: '爱心', Icon: Heart },
+ { key: 'heart-handshake', label: '人心', Icon: HeartHandshake },
+ { key: 'flame', label: '火焰', Icon: Flame },
+ { key: 'coins', label: '金币', Icon: Coins },
+ { key: 'gem', label: '宝石', Icon: Gem },
+ { key: 'calendar', label: '日历', Icon: Calendar },
+ { key: 'calendar-heart', label: '纪念日', Icon: CalendarHeart },
+];
+
+const ICON_MAP: Record
= Object.fromEntries(
+ BADGE_ICON_OPTIONS.map(o => [o.key, o.Icon]),
+);
+
+export function badgeIcon(key?: string): LucideIcon {
+ if (!key) return Star;
+ return ICON_MAP[key] || Star;
+}
+
+export const BADGE_METRIC_OPTIONS: { value: string; label: string; hint: string }[] = [
+ { value: 'tenure_days', label: '注册天数', hint: '账号注册满 N 天自动获得' },
+ { value: 'likes_received', label: '帖子获赞', hint: '公开帖累计获赞达到 N' },
+ { value: 'creator_income', label: '创作收入', hint: '积分解锁分成累计达到 N' },
+];
+
+export function metricLabel(metric?: string): string {
+ return BADGE_METRIC_OPTIONS.find(m => m.value === metric)?.label || metric || '—';
+}
+
+export function formatBadgeCondition(b: {
+ kind?: string;
+ metric?: string;
+ threshold?: number;
+ description?: string;
+}): string {
+ if (b.kind === 'auto') {
+ return `${metricLabel(b.metric)} ≥ ${b.threshold ?? 0}`;
+ }
+ return b.description?.trim() || '由站长手动颁发';
+}
diff --git a/frontend/src/utils/postContent.ts b/frontend/src/utils/postContent.ts
index 7bb6fe5..69debf6 100644
--- a/frontend/src/utils/postContent.ts
+++ b/frontend/src/utils/postContent.ts
@@ -9,9 +9,9 @@ import { enhanceHeadingAnchors } from './postHeadings';
* 全局选择器仍会污染整页,故显式禁止。
*/
export const POST_CONTENT_PURIFY_CONFIG: Config = {
- ADD_TAGS: ['members-only', 'reply-only'],
+ ADD_TAGS: ['members-only', 'reply-only', 'points-only'],
ADD_ATTR: [
- 'data-locked', 'data-length', 'data-gate', 'target', 'rel',
+ 'data-locked', 'data-length', 'data-gate', 'data-cost', 'data-block-key', 'target', 'rel',
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
'data-image-group', 'data-layout', 'data-display',
@@ -27,6 +27,29 @@ const LOCK_ICON_SVG = ``;
+/** 积分解锁门控 */
+function buildPointsLockedGateHtml(charLength: number, cost: number, blockKey: string, isLoggedIn: boolean): string {
+ const lengthHint = charLength > 0 ? `约 ${charLength} 字` : '付费内容';
+ const actions = isLoggedIn
+ ? ``
+ : `
+ `;
+
+ return `
+
+
+
${LOCK_ICON_SVG}
+
+
积分可见(${lengthHint})
+
解锁需 ${cost} 积分,作者获得分成
+
+
+ ${actions}
+
+
+
`;
+}
+
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
function buildLockedGateHtml(charLength: number): string {
const lengthHint = charLength > 0
@@ -150,6 +173,27 @@ export function renderPostContentHtml(
el.innerHTML = `${innerHtml}
`;
});
+ doc.querySelectorAll('points-only').forEach(el => {
+ const locked = el.getAttribute('data-locked') === 'true';
+ const cost = parseInt(el.getAttribute('data-cost') || '10', 10) || 10;
+ const blockKey = el.getAttribute('data-block-key') || '';
+
+ if (locked) {
+ const charLength = parseInt(el.getAttribute('data-length') || '0', 10) || 0;
+ el.className = 'post-points-only post-points-only--locked';
+ el.innerHTML = buildPointsLockedGateHtml(charLength, cost, blockKey, isLoggedIn);
+ return;
+ }
+
+ const innerHtml = extractGatedInnerHtml(
+ el,
+ 'post-points-only__body',
+ 'post-points-only__badge',
+ );
+ el.className = 'post-points-only post-points-only--visible';
+ el.innerHTML = `${innerHtml}
`;
+ });
+
doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
diff --git a/frontend/src/utils/userMeta.ts b/frontend/src/utils/userMeta.ts
new file mode 100644
index 0000000..0a13fa1
--- /dev/null
+++ b/frontend/src/utils/userMeta.ts
@@ -0,0 +1,20 @@
+/** 等级门槛(与后端 model.LevelThresholds 一致) */
+export const LEVEL_THRESHOLDS = [0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000];
+
+export function levelFromExp(exp = 0): number {
+ let level = 1;
+ for (let i = 0; i < LEVEL_THRESHOLDS.length; i += 1) {
+ if (exp >= LEVEL_THRESHOLDS[i]) level = i + 1;
+ }
+ return level;
+}
+
+export function resolveUserLevel(u?: { level?: number; exp?: number } | null): number {
+ if (u?.level && u.level > 0) return u.level;
+ return levelFromExp(u?.exp ?? 0);
+}
+
+/** 是否免审发帖/评论 */
+export function skipsModeration(u?: { role?: string; verified?: boolean } | null): boolean {
+ return !!u && (u.role === 'admin' || !!u.verified);
+}
diff --git a/handler/api.go b/handler/api.go
index cdba00c..e783d63 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -29,8 +29,17 @@ func (h *Handlers) APIMe(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
+ if h.Badge != nil {
+ _ = h.Badge.EvaluateAuto(user.ID)
+ }
+ view := user.ToSelf()
+ if h.Badge != nil {
+ if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
+ view.Badges = service.BadgeViews(badges, 0)
+ }
+ }
c.JSON(http.StatusOK, gin.H{
- "user": user.ToSelf(),
+ "user": view,
})
}
@@ -123,6 +132,9 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
model.DB.Model(&model.Board{}).Count(&boardCount)
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
+ pendingPosts, _ := h.Post.PendingPostCount()
+ pendingComments, _ := h.Comment.PendingCommentCount()
+ pendingReports, _ := h.Report.PendingCount()
recentPosts, _, _ := h.Post.List(service.PostListQuery{
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
})
@@ -132,7 +144,10 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"users": userCount, "posts": postCount, "boards": boardCount,
"comments": commentCount,
- "recent_posts": recentPosts,
+ "pending_posts": pendingPosts,
+ "pending_comments": pendingComments,
+ "pending_reports": pendingReports,
+ "recent_posts": recentPosts,
})
}
@@ -375,7 +390,11 @@ func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) {
func (h *Handlers) APIAdminUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
- users, total, err := h.User.ListUsers(page, size)
+ keyword := strings.TrimSpace(c.Query("keyword"))
+ filter := strings.TrimSpace(c.DefaultQuery("filter", "all"))
+ users, total, err := h.User.ListUsers(service.UserListQuery{
+ Page: page, Size: size, Keyword: keyword, Filter: filter,
+ })
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -386,6 +405,8 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"users": model.UsersToAdmin(users), "total": total, "page": page,
"total_pages": calcTotalPages(total, size),
+ "keyword": keyword,
+ "filter": filter,
})
}
@@ -853,6 +874,15 @@ func (h *Handlers) APIPosts(c *gin.Context) {
if items == nil {
items = []service.PostListItem{}
}
+ if h.Badge != nil {
+ users := make([]*model.User, 0, len(items))
+ for i := range items {
+ if items[i].User.ID > 0 {
+ users = append(users, &items[i].User)
+ }
+ }
+ h.Badge.AttachBadgeSummaries(users, 2)
+ }
c.JSON(http.StatusOK, gin.H{
"posts": items,
"total": total,
@@ -889,7 +919,20 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
// 作者与管理员始终可见;其他用户需已回复
post.Content = service.RedactReplyOnlyHTML(post.Content)
}
+ // 积分解锁块:作者/站长全文;其他人按解锁记录 redact
+ if isAdmin || post.UserID == uid {
+ post.Content = service.RevealAllPointsOnly(post.Content)
+ } else {
+ unlocked, _ := service.ListUnlockedKeys(uid, uint(id))
+ post.Content = service.RedactPointsOnlyHTML(post.Content, unlocked)
+ }
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
+ if h.Badge != nil {
+ if post.User.ID > 0 {
+ h.Badge.AttachBadgeSummaries([]*model.User{&post.User}, 3)
+ }
+ h.Badge.AttachBadgeSummariesOnComments(comments, 2)
+ }
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
editReason := ""
if !canEdit && uid > 0 {
@@ -931,6 +974,9 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
if comments == nil {
comments = []model.Comment{}
}
+ if h.Badge != nil {
+ h.Badge.AttachBadgeSummariesOnComments(comments, 2)
+ }
c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)})
}
diff --git a/handler/economy.go b/handler/economy.go
new file mode 100644
index 0000000..e97d2a6
--- /dev/null
+++ b/handler/economy.go
@@ -0,0 +1,208 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "git.iioio.com/freefire/jiang13-forum/service"
+)
+
+// APIMePoints 余额与流水
+func (h *Handlers) APIMePoints(c *gin.Context) {
+ uid := h.currentUserID(c)
+ user, err := h.User.GetByID(uid)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
+ return
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+ rows, total, err := h.Points.ListLedger(uid, page, size)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ checkIn, _ := h.Points.GetCheckInStatus(uid)
+ lottery, _ := h.Points.GetLotteryStatus(uid)
+ c.JSON(http.StatusOK, gin.H{
+ "points": user.Points,
+ "creator_income_total": user.CreatorIncomeTotal,
+ "ledger": rows,
+ "total": total,
+ "page": page,
+ "total_pages": calcTotalPages(total, size),
+ "check_in": checkIn,
+ "lottery": lottery,
+ })
+}
+
+// APIMeCheckIn 每日签到
+func (h *Handlers) APIMeCheckIn(c *gin.Context) {
+ st, err := h.Points.CheckIn(h.currentUserID(c))
+ if err != nil {
+ if errors.Is(err, service.ErrAlreadyCheckedIn) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ user, _ := h.User.GetByID(h.currentUserID(c))
+ pts := 0
+ if user != nil {
+ pts = user.Points
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "签到成功", "check_in": st, "points": pts})
+}
+
+// APIMeLottery GET 状态 / POST 抽奖
+func (h *Handlers) APIMeLotteryGet(c *gin.Context) {
+ st, err := h.Points.GetLotteryStatus(h.currentUserID(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"lottery": st})
+}
+
+func (h *Handlers) APIMeLotteryDraw(c *gin.Context) {
+ st, err := h.Points.DrawLottery(h.currentUserID(c))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ user, _ := h.User.GetByID(h.currentUserID(c))
+ pts := 0
+ if user != nil {
+ pts = user.Points
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "抽奖完成", "lottery": st, "points": pts})
+}
+
+// APIUnlockPostBlock 积分解锁付费块
+func (h *Handlers) APIUnlockPostBlock(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ BlockKey string `json:"block_key"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.BlockKey == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
+ return
+ }
+ res, err := service.UnlockPointsBlock(h.currentUserID(c), uint(id), req.BlockKey)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "解锁成功", "unlock": res})
+}
+
+// APIAdminVerifyUser 认证开关
+func (h *Handlers) APIAdminVerifyUser(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ Verified bool `json:"verified"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := service.SetVerified(uint(id), req.Verified); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ msg := "已取消认证"
+ if req.Verified {
+ msg = "已认证"
+ }
+ c.JSON(http.StatusOK, gin.H{"message": msg, "verified": req.Verified})
+}
+
+// APIAdminSetUserLevel 设等级
+func (h *Handlers) APIAdminSetUserLevel(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ Level int `json:"level"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := service.SetUserLevel(uint(id), req.Level); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "等级已更新", "level": req.Level, "exp": model.ExpForLevel(req.Level)})
+}
+
+// APIAdminAdjustPoints 调积分
+func (h *Handlers) APIAdminAdjustPoints(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ Delta int `json:"delta"`
+ Note string `json:"note"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ bal, err := h.Points.AdminAdjust(uint(id), req.Delta, req.Note)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "积分已调整", "points": bal})
+}
+
+// APIAdminListBadges 徽章定义列表
+func (h *Handlers) APIAdminListBadges(c *gin.Context) {
+ rows, err := h.Badge.ListDefs(true)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"badges": rows})
+}
+
+// APIAdminUpsertBadge 创建/更新徽章定义
+func (h *Handlers) APIAdminUpsertBadge(c *gin.Context) {
+ var def model.BadgeDef
+ if err := c.ShouldBindJSON(&def); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.Badge.UpsertDef(&def); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已保存", "badge": def})
+}
+
+// APIAdminAwardBadge 颁发/收回限定徽章
+func (h *Handlers) APIAdminAwardBadge(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
+ var req struct {
+ BadgeID uint `json:"badge_id"`
+ Revoke bool `json:"revoke"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.BadgeID == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if req.Revoke {
+ if err := h.Badge.Revoke(uint(id), req.BadgeID); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已收回徽章"})
+ return
+ }
+ if err := h.Badge.AwardLimited(uint(id), req.BadgeID, h.currentUserID(c)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "已颁发徽章"})
+}
diff --git a/handler/handlers.go b/handler/handlers.go
index 20a177b..64bf271 100644
--- a/handler/handlers.go
+++ b/handler/handlers.go
@@ -2,6 +2,7 @@ package handler
import (
"encoding/base64"
+ "errors"
"fmt"
"net/http"
"strconv"
@@ -35,6 +36,8 @@ type Handlers struct {
EmailCode *service.EmailCodeService
OIDC *service.OIDCService
Gitea *service.GiteaService
+ Points *service.PointsService
+ Badge *service.BadgeService
}
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
@@ -55,6 +58,23 @@ func (h *Handlers) isAdmin(c *gin.Context) bool {
return false
}
+// loadCurrentUser 加载当前登录用户完整资料(含认证/积分)
+func (h *Handlers) loadCurrentUser(c *gin.Context) (*model.User, error) {
+ uid := h.currentUserID(c)
+ if uid == 0 {
+ return nil, errors.New("未登录")
+ }
+ return h.User.GetByID(uid)
+}
+
+func (h *Handlers) skipsModeration(c *gin.Context) bool {
+ u, err := h.loadCurrentUser(c)
+ if err != nil {
+ return h.isAdmin(c)
+ }
+ return u.SkipsModeration()
+}
+
func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint {
raw := c.Query("my_ids")
if raw == "" {
@@ -226,8 +246,15 @@ func (h *Handlers) APIUserPublic(c *gin.Context) {
if viewerID != user.ID {
st.FavoriteCount = 0
}
+ view := user.ToPublic()
+ if h.Badge != nil {
+ _ = h.Badge.EvaluateAuto(user.ID)
+ if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil {
+ view.Badges = service.BadgeViews(badges, 0)
+ }
+ }
c.JSON(http.StatusOK, gin.H{
- "user": user.ToPublic(),
+ "user": view,
"stats": st,
})
}
@@ -315,7 +342,8 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
content := c.PostForm("content")
tags := c.PostForm("tags")
postType := c.PostForm("post_type")
- post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, h.isAdmin(c))
+ skip := h.skipsModeration(c)
+ post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, postType, skip)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -334,14 +362,15 @@ func (h *Handlers) APIUpdatePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
isAdmin := h.isAdmin(c)
- err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin,
+ skip := h.skipsModeration(c)
+ err := h.Post.Update(h.currentUserID(c), uint(id), isAdmin, skip,
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), c.PostForm("post_type"), uint(boardID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- // 普通用户修改后重新进入审核
- if !isAdmin && h.Notify != nil {
+ // 非免审用户修改后重新进入审核
+ if !skip && h.Notify != nil {
if post, getErr := h.Post.FindByID(uint(id)); getErr == nil {
h.Notify.AsyncNotifyPendingPost(post)
}
@@ -461,7 +490,8 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
func (h *Handlers) APIUpdateComment(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
content := c.PostForm("content")
- saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
+ skip := h.skipsModeration(c)
+ saved, enteredPending, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), skip, content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
diff --git a/middleware/auth.go b/middleware/auth.go
index 1d9f1ba..2df0ac4 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -39,6 +39,7 @@ func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
c.Set(CtxUserID, user.ID)
c.Set(CtxUsername, user.Username)
c.Set(CtxRole, user.Role)
+ m.auth.TouchLastAccess(user.ID)
}
}
}
@@ -71,6 +72,7 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUsername, claims.Username)
c.Set(CtxRole, claims.Role)
+ m.auth.TouchLastAccess(claims.UserID)
c.Next()
}
}
diff --git a/model/db.go b/model/db.go
index 6e5cf61..118299a 100644
--- a/model/db.go
+++ b/model/db.go
@@ -40,6 +40,8 @@ func InitDB(dbPath string) error {
&GiteaRepo{},
&PrivateMessage{}, &PostReport{},
&Media{},
+ &PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
+ &BadgeDef{}, &UserBadge{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
@@ -50,6 +52,47 @@ func InitDB(dbPath string) error {
_ = db.Model(&Post{}).Where("post_type = '' OR post_type IS NULL").Update("post_type", PostTypeNormal).Error
DB = db
+ seedDefaultBadges(db)
+ backfillUserExp(db)
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
return nil
}
+
+// seedDefaultBadges 写入内置自动徽章(已存在则跳过)
+func seedDefaultBadges(db *gorm.DB) {
+ defs := []BadgeDef{
+ {Code: "tenure_30", Name: "初来乍到", Description: "注册满 30 天", Icon: "calendar", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 30, SortOrder: 10, Enabled: true},
+ {Code: "tenure_365", Name: "资深居民", Description: "注册满 365 天", Icon: "calendar-heart", Kind: BadgeKindAuto, Metric: BadgeMetricTenureDays, Threshold: 365, SortOrder: 20, Enabled: true},
+ {Code: "likes_10", Name: "小有人气", Description: "帖子获赞累计 10", Icon: "heart", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 10, SortOrder: 30, Enabled: true},
+ {Code: "likes_100", Name: "人气作者", Description: "帖子获赞累计 100", Icon: "heart-handshake", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 100, SortOrder: 40, Enabled: true},
+ {Code: "likes_1000", Name: "人气巨星", Description: "帖子获赞累计 1000", Icon: "flame", Kind: BadgeKindAuto, Metric: BadgeMetricLikesReceived, Threshold: 1000, SortOrder: 50, Enabled: true},
+ {Code: "income_100", Name: "小有进账", Description: "创作分成累计 100 积分", Icon: "coins", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 100, SortOrder: 60, Enabled: true},
+ {Code: "income_1000", Name: "创作达人", Description: "创作分成累计 1000 积分", Icon: "gem", Kind: BadgeKindAuto, Metric: BadgeMetricCreatorIncome, Threshold: 1000, SortOrder: 70, Enabled: true},
+ }
+ for _, d := range defs {
+ var n int64
+ db.Model(&BadgeDef{}).Where("code = ?", d.Code).Count(&n)
+ if n == 0 {
+ _ = db.Create(&d).Error
+ }
+ }
+}
+
+// backfillUserExp 对 Exp 仍为 0 的用户按存量公开内容粗算经验(仅补一次量级)
+func backfillUserExp(db *gorm.DB) {
+ var users []User
+ if err := db.Select("id", "exp").Where("exp = 0").Find(&users).Error; err != nil {
+ return
+ }
+ for _, u := range users {
+ var posts, comments int64
+ var likeSum int64
+ _ = db.Model(&Post{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&posts).Error
+ _ = db.Model(&Comment{}).Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Count(&comments).Error
+ _ = db.Model(&Post{}).Select("COALESCE(SUM(like_count), 0)").Where("user_id = ? AND status = ?", u.ID, ContentStatusPublished).Scan(&likeSum).Error
+ exp := int(posts)*10 + int(comments)*2 + int(likeSum)
+ if exp > 0 {
+ _ = db.Model(&User{}).Where("id = ? AND exp = 0", u.ID).Update("exp", exp).Error
+ }
+ }
+}
diff --git a/model/level.go b/model/level.go
new file mode 100644
index 0000000..a2644f5
--- /dev/null
+++ b/model/level.go
@@ -0,0 +1,34 @@
+package model
+
+// LevelThresholds 各等级所需最低 Exp(下标 0 对应 Lv1)
+var LevelThresholds = []int{0, 20, 50, 100, 200, 400, 800, 1500, 3000, 5000}
+
+// LevelFromExp 由经验推导等级(1–10)
+func LevelFromExp(exp int) int {
+ if exp < 0 {
+ exp = 0
+ }
+ level := 1
+ for i, th := range LevelThresholds {
+ if exp >= th {
+ level = i + 1
+ }
+ }
+ return level
+}
+
+// ExpForLevel 某等级的门槛 Exp(超出范围则钳制)
+func ExpForLevel(level int) int {
+ if level < 1 {
+ level = 1
+ }
+ if level > len(LevelThresholds) {
+ level = len(LevelThresholds)
+ }
+ return LevelThresholds[level-1]
+}
+
+// MaxLevel 最高等级
+func MaxLevel() int {
+ return len(LevelThresholds)
+}
diff --git a/model/models.go b/model/models.go
index 66792de..3750a88 100644
--- a/model/models.go
+++ b/model/models.go
@@ -28,24 +28,44 @@ const (
)
// User 用户表
-// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
+// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
// 个人中心与后台列表请用 UserSelf / UserAdmin。
type User struct {
- ID uint `gorm:"primaryKey" json:"id"`
- Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
- Email string `gorm:"index;size:128;default:''" json:"-"`
- Password string `gorm:"size:128;not null" json:"-"`
- Nickname string `gorm:"size:64" json:"nickname"`
- Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
- Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
- Role Role `gorm:"size:16;default:user" json:"role"`
- Banned bool `gorm:"default:false" json:"banned"`
- BannedAt *time.Time `json:"banned_at,omitempty"`
- LastLoginAt *time.Time `json:"-"`
- LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
+ Email string `gorm:"index;size:128;default:''" json:"-"`
+ Password string `gorm:"size:128;not null" json:"-"`
+ Nickname string `gorm:"size:64" json:"nickname"`
+ Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
+ Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
+ Role Role `gorm:"size:16;default:user" json:"role"`
+ Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
+ Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
+ Points int `gorm:"default:0" json:"points"` // 可用积分
+ CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
+ Banned bool `gorm:"default:false" json:"banned"`
+ BannedAt *time.Time `json:"banned_at,omitempty"`
+ LastLoginAt *time.Time `json:"-"`
+ LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
+ LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+
+ // 仅序列化展示用,不落库
+ Level int `gorm:"-" json:"level"`
+ Badges []UserBadgeView `gorm:"-" json:"badges,omitempty"`
+}
+
+// AfterFind 填充展示用等级
+func (u *User) AfterFind(tx *gorm.DB) (err error) {
+ u.Level = LevelFromExp(u.Exp)
+ return nil
+}
+
+// SkipsModeration 站长或认证用户发帖/评论免审
+func (u *User) SkipsModeration() bool {
+ return u != nil && (u.Role == RoleAdmin || u.Verified)
}
// Board 论坛板块
@@ -246,3 +266,93 @@ type Media struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+
+// 积分流水原因
+const (
+ PointReasonCheckIn = "check_in"
+ PointReasonLottery = "lottery"
+ PointReasonUnlockSpend = "unlock_spend"
+ PointReasonCreatorIncome = "creator_income"
+ PointReasonAdminAdjust = "admin_adjust"
+)
+
+// PointLedger 积分流水
+type PointLedger struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Delta int `gorm:"not null" json:"delta"`
+ Balance int `gorm:"not null" json:"balance"` // 变动后余额
+ Reason string `gorm:"size:32;index;not null" json:"reason"`
+ RefType string `gorm:"size:32;default:''" json:"ref_type"`
+ RefID uint `gorm:"default:0" json:"ref_id"`
+ Note string `gorm:"size:256;default:''" json:"note"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// CheckIn 每日签到(用户+自然日唯一)
+type CheckIn struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"uniqueIndex:idx_checkin_user_day;not null" json:"user_id"`
+ Day string `gorm:"uniqueIndex:idx_checkin_user_day;size:10;not null" json:"day"` // YYYY-MM-DD
+ Points int `gorm:"not null" json:"points"`
+ Streak int `gorm:"default:1" json:"streak"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// LotteryDraw 每日抽奖记录
+type LotteryDraw struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"uniqueIndex:idx_lottery_user_day;not null" json:"user_id"`
+ Day string `gorm:"uniqueIndex:idx_lottery_user_day;size:10;not null" json:"day"`
+ Points int `gorm:"not null" json:"points"` // 抽中积分(可为 0)
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PostContentUnlock 帖子积分解锁记录
+type PostContentUnlock struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;not null" json:"user_id"`
+ PostID uint `gorm:"uniqueIndex:idx_unlock_user_post_block;index;not null" json:"post_id"`
+ BlockKey string `gorm:"uniqueIndex:idx_unlock_user_post_block;size:64;not null" json:"block_key"`
+ Cost int `gorm:"not null" json:"cost"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// 徽章类型
+const (
+ BadgeKindAuto = "auto"
+ BadgeKindLimited = "limited"
+)
+
+// 自动徽章指标
+const (
+ BadgeMetricTenureDays = "tenure_days"
+ BadgeMetricLikesReceived = "likes_received"
+ BadgeMetricCreatorIncome = "creator_income"
+)
+
+// BadgeDef 徽章定义
+type BadgeDef struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ Description string `gorm:"size:256;default:''" json:"description"`
+ Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
+ Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
+ Metric string `gorm:"size:32;default:''" json:"metric"`
+ Threshold int `gorm:"default:0" json:"threshold"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ Enabled bool `gorm:"default:true;index" json:"enabled"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// UserBadge 用户已获徽章
+type UserBadge struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
+ BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
+ AwardedAt time.Time `json:"awarded_at"`
+ AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
+ Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
+}
diff --git a/model/user_view.go b/model/user_view.go
index 4a02c6a..4025521 100644
--- a/model/user_view.go
+++ b/model/user_view.go
@@ -2,99 +2,140 @@ package model
import "time"
+// UserBadgeView 对外展示的徽章摘要
+type UserBadgeView struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ Kind string `json:"kind"`
+}
+
// UserPublic 公开用户主页视图(无邮箱与登录信息)
type UserPublic struct {
- ID uint `json:"id"`
- Username string `json:"username"`
- Nickname string `json:"nickname"`
- Signature string `json:"signature"`
- Avatar string `json:"avatar"`
- Role Role `json:"role"`
- Banned bool `json:"banned"`
- BannedAt *time.Time `json:"banned_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ Signature string `json:"signature"`
+ Avatar string `json:"avatar"`
+ Role Role `json:"role"`
+ Verified bool `json:"verified"`
+ Exp int `json:"exp"`
+ Level int `json:"level"`
+ CreatorIncomeTotal int `json:"creator_income_total"`
+ Badges []UserBadgeView `json:"badges,omitempty"`
+ Banned bool `json:"banned"`
+ BannedAt *time.Time `json:"banned_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP)
type UserSelf struct {
- ID uint `json:"id"`
- Username string `json:"username"`
- Email string `json:"email"`
- Nickname string `json:"nickname"`
- Signature string `json:"signature"`
- Avatar string `json:"avatar"`
- Role Role `json:"role"`
- Banned bool `json:"banned"`
- BannedAt *time.Time `json:"banned_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Nickname string `json:"nickname"`
+ Signature string `json:"signature"`
+ Avatar string `json:"avatar"`
+ Role Role `json:"role"`
+ Verified bool `json:"verified"`
+ Exp int `json:"exp"`
+ Level int `json:"level"`
+ Points int `json:"points"`
+ CreatorIncomeTotal int `json:"creator_income_total"`
+ Badges []UserBadgeView `json:"badges,omitempty"`
+ Banned bool `json:"banned"`
+ BannedAt *time.Time `json:"banned_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
-// UserAdmin 后台用户管理视图(含邮箱与上次登录信息)
+// UserAdmin 后台用户管理视图(含邮箱、上次登录与最近访问)
type UserAdmin struct {
- ID uint `json:"id"`
- Username string `json:"username"`
- Email string `json:"email"`
- Nickname string `json:"nickname"`
- Signature string `json:"signature"`
- Avatar string `json:"avatar"`
- Role Role `json:"role"`
- Banned bool `json:"banned"`
- BannedAt *time.Time `json:"banned_at,omitempty"`
- LastLoginAt *time.Time `json:"last_login_at,omitempty"`
- LastLoginIP string `json:"last_login_ip,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Nickname string `json:"nickname"`
+ Signature string `json:"signature"`
+ Avatar string `json:"avatar"`
+ Role Role `json:"role"`
+ Verified bool `json:"verified"`
+ Exp int `json:"exp"`
+ Level int `json:"level"`
+ Points int `json:"points"`
+ CreatorIncomeTotal int `json:"creator_income_total"`
+ Banned bool `json:"banned"`
+ BannedAt *time.Time `json:"banned_at,omitempty"`
+ LastLoginAt *time.Time `json:"last_login_at,omitempty"`
+ LastLoginIP string `json:"last_login_ip,omitempty"`
+ LastAccessAt *time.Time `json:"last_access_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// ToPublic 转为公开主页视图
func (u *User) ToPublic() UserPublic {
return UserPublic{
- ID: u.ID,
- Username: u.Username,
- Nickname: u.Nickname,
- Signature: u.Signature,
- Avatar: u.Avatar,
- Role: u.Role,
- Banned: u.Banned,
- BannedAt: u.BannedAt,
- CreatedAt: u.CreatedAt,
+ ID: u.ID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Signature: u.Signature,
+ Avatar: u.Avatar,
+ Role: u.Role,
+ Verified: u.Verified,
+ Exp: u.Exp,
+ Level: LevelFromExp(u.Exp),
+ CreatorIncomeTotal: u.CreatorIncomeTotal,
+ Banned: u.Banned,
+ BannedAt: u.BannedAt,
+ CreatedAt: u.CreatedAt,
}
}
// ToSelf 转为个人中心 /api/me 视图
func (u *User) ToSelf() UserSelf {
return UserSelf{
- ID: u.ID,
- Username: u.Username,
- Email: u.Email,
- Nickname: u.Nickname,
- Signature: u.Signature,
- Avatar: u.Avatar,
- Role: u.Role,
- Banned: u.Banned,
- BannedAt: u.BannedAt,
- CreatedAt: u.CreatedAt,
- UpdatedAt: u.UpdatedAt,
+ ID: u.ID,
+ Username: u.Username,
+ Email: u.Email,
+ Nickname: u.Nickname,
+ Signature: u.Signature,
+ Avatar: u.Avatar,
+ Role: u.Role,
+ Verified: u.Verified,
+ Exp: u.Exp,
+ Level: LevelFromExp(u.Exp),
+ Points: u.Points,
+ CreatorIncomeTotal: u.CreatorIncomeTotal,
+ Banned: u.Banned,
+ BannedAt: u.BannedAt,
+ CreatedAt: u.CreatedAt,
+ UpdatedAt: u.UpdatedAt,
}
}
// ToAdmin 转为后台用户列表视图
func (u *User) ToAdmin() UserAdmin {
return UserAdmin{
- ID: u.ID,
- Username: u.Username,
- Email: u.Email,
- Nickname: u.Nickname,
- Signature: u.Signature,
- Avatar: u.Avatar,
- Role: u.Role,
- Banned: u.Banned,
- BannedAt: u.BannedAt,
- LastLoginAt: u.LastLoginAt,
- LastLoginIP: u.LastLoginIP,
- CreatedAt: u.CreatedAt,
- UpdatedAt: u.UpdatedAt,
+ ID: u.ID,
+ Username: u.Username,
+ Email: u.Email,
+ Nickname: u.Nickname,
+ Signature: u.Signature,
+ Avatar: u.Avatar,
+ Role: u.Role,
+ Verified: u.Verified,
+ Exp: u.Exp,
+ Level: LevelFromExp(u.Exp),
+ Points: u.Points,
+ CreatorIncomeTotal: u.CreatorIncomeTotal,
+ Banned: u.Banned,
+ BannedAt: u.BannedAt,
+ LastLoginAt: u.LastLoginAt,
+ LastLoginIP: u.LastLoginIP,
+ LastAccessAt: u.LastAccessAt,
+ CreatedAt: u.CreatedAt,
+ UpdatedAt: u.UpdatedAt,
}
}
diff --git a/router/router.go b/router/router.go
index 34a8259..0f9b759 100644
--- a/router/router.go
+++ b/router/router.go
@@ -84,6 +84,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc,
+ Points: service.NewPointsService(), Badge: service.NewBadgeService(),
}
authMW := middleware.NewAuthMiddleware(authSvc)
@@ -159,6 +160,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/comments/:id/report", middleware.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
api.DELETE("/comments/:id", h.APIDeleteComment)
api.PUT("/comments/:id", h.APIUpdateComment)
+ api.GET("/me/points", h.APIMePoints)
+ api.POST("/me/check-in", h.APIMeCheckIn)
+ api.GET("/me/lottery", h.APIMeLotteryGet)
+ api.POST("/me/lottery", h.APIMeLotteryDraw)
+ api.POST("/posts/:id/unlock", middleware.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
}
// 管理员 API(React SPA 后台统一使用 JSON)
@@ -204,6 +210,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
adminAPI.GET("/users", h.APIAdminUsers)
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
+ adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser)
+ adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel)
+ adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints)
+ adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge)
+ adminAPI.GET("/badges", h.APIAdminListBadges)
+ adminAPI.POST("/badges", h.APIAdminUpsertBadge)
adminAPI.GET("/media", h.APIAdminMedia)
adminAPI.POST("/media/delete", h.APIAdminDeleteMedia)
adminAPI.POST("/backup", h.APIAdminBackup)
@@ -220,7 +232,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
{
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
- for _, page := range []string{"dashboard", "boards", "posts", "comments", "reports", "users", "media", "settings"} {
+ for _, page := range []string{"dashboard", "boards", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
}
}
diff --git a/service/auth.go b/service/auth.go
index be6a383..a57c286 100644
--- a/service/auth.go
+++ b/service/auth.go
@@ -2,12 +2,18 @@ package service
import (
"errors"
+ "sync"
"time"
"github.com/golang-jwt/jwt/v5"
"git.iioio.com/freefire/jiang13-forum/model"
)
+// 最近访问写入节流,避免每次 API 都打库
+const lastAccessTouchInterval = 5 * time.Minute
+
+var lastAccessTouchCache sync.Map // userID(uint) -> time.Time
+
const TokenExpire = 7 * 24 * time.Hour
type Claims struct {
@@ -100,7 +106,7 @@ func (s *AuthService) Login(username, password, clientIP string) (string, *model
return token, &user, err
}
-// recordLogin 记录上次登录时间与 IP(失败不影响登录)
+// recordLogin 记录上次登录时间与 IP;登录同时视为一次访问(失败不影响登录)
func (s *AuthService) recordLogin(user *model.User, clientIP string) {
now := time.Now()
ip := clientIP
@@ -108,11 +114,29 @@ func (s *AuthService) recordLogin(user *model.User, clientIP string) {
ip = ip[:45]
}
_ = model.DB.Model(user).Updates(map[string]interface{}{
- "last_login_at": now,
- "last_login_ip": ip,
+ "last_login_at": now,
+ "last_login_ip": ip,
+ "last_access_at": now,
}).Error
user.LastLoginAt = &now
user.LastLoginIP = ip
+ user.LastAccessAt = &now
+ lastAccessTouchCache.Store(user.ID, now)
+}
+
+// TouchLastAccess 记录最近访问时间(节流写入,失败忽略)
+func (s *AuthService) TouchLastAccess(userID uint) {
+ if userID == 0 {
+ return
+ }
+ now := time.Now()
+ if v, ok := lastAccessTouchCache.Load(userID); ok {
+ if t, ok := v.(time.Time); ok && now.Sub(t) < lastAccessTouchInterval {
+ return
+ }
+ }
+ lastAccessTouchCache.Store(userID, now)
+ _ = model.DB.Model(&model.User{}).Where("id = ?", userID).Update("last_access_at", now).Error
}
// GenerateToken 生成 JWT
diff --git a/service/badge.go b/service/badge.go
new file mode 100644
index 0000000..7af58b1
--- /dev/null
+++ b/service/badge.go
@@ -0,0 +1,272 @@
+package service
+
+import (
+ "errors"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+)
+
+// BadgeService 徽章定义与发放
+type BadgeService struct{}
+
+func NewBadgeService() *BadgeService { return &BadgeService{} }
+
+// ListDefs 列出徽章定义
+func (s *BadgeService) ListDefs(includeDisabled bool) ([]model.BadgeDef, error) {
+ q := model.DB.Order("sort_order asc, id asc")
+ if !includeDisabled {
+ q = q.Where("enabled = ?", true)
+ }
+ var rows []model.BadgeDef
+ err := q.Find(&rows).Error
+ return rows, err
+}
+
+// UpsertDef 创建或更新徽章定义(按 code)
+func (s *BadgeService) UpsertDef(def *model.BadgeDef) error {
+ if def.Code == "" || def.Name == "" {
+ return errors.New("徽章代码与名称不能为空")
+ }
+ if def.Kind != model.BadgeKindAuto && def.Kind != model.BadgeKindLimited {
+ return errors.New("无效的徽章类型")
+ }
+ var existing model.BadgeDef
+ err := model.DB.Where("code = ?", def.Code).Limit(1).Find(&existing).Error
+ if err != nil {
+ return err
+ }
+ if existing.ID == 0 {
+ return model.DB.Create(def).Error
+ }
+ def.ID = existing.ID
+ return model.DB.Model(&existing).Updates(map[string]interface{}{
+ "name": def.Name,
+ "description": def.Description,
+ "icon": def.Icon,
+ "kind": def.Kind,
+ "metric": def.Metric,
+ "threshold": def.Threshold,
+ "sort_order": def.SortOrder,
+ "enabled": def.Enabled,
+ }).Error
+}
+
+// AwardLimited 站长颁发限定徽章
+func (s *BadgeService) AwardLimited(userID, badgeID, adminID uint) error {
+ var def model.BadgeDef
+ if err := model.DB.First(&def, badgeID).Error; err != nil {
+ return errors.New("徽章不存在")
+ }
+ if def.Kind != model.BadgeKindLimited {
+ return errors.New("仅可颁发限定徽章")
+ }
+ if !def.Enabled {
+ return errors.New("徽章已停用")
+ }
+ var n int64
+ model.DB.Model(&model.UserBadge{}).Where("user_id = ? AND badge_id = ?", userID, badgeID).Count(&n)
+ if n > 0 {
+ return errors.New("用户已拥有该徽章")
+ }
+ return model.DB.Create(&model.UserBadge{
+ UserID: userID,
+ BadgeID: badgeID,
+ AwardedAt: time.Now(),
+ AwardedBy: adminID,
+ }).Error
+}
+
+// Revoke 收回徽章
+func (s *BadgeService) Revoke(userID, badgeID uint) error {
+ res := model.DB.Where("user_id = ? AND badge_id = ?", userID, badgeID).Delete(&model.UserBadge{})
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errors.New("用户未拥有该徽章")
+ }
+ return nil
+}
+
+// ListUserBadges 用户已获徽章(含定义)
+func (s *BadgeService) ListUserBadges(userID uint) ([]model.UserBadge, error) {
+ var rows []model.UserBadge
+ err := model.DB.Preload("Badge").Where("user_id = ?", userID).
+ Order("awarded_at desc").Find(&rows).Error
+ return rows, err
+}
+
+// BadgeViews 转为展示视图(最多 limit 枚,0=全部)
+func BadgeViews(rows []model.UserBadge, limit int) []model.UserBadgeView {
+ out := make([]model.UserBadgeView, 0, len(rows))
+ for _, r := range rows {
+ if r.Badge.ID == 0 || !r.Badge.Enabled {
+ continue
+ }
+ out = append(out, model.UserBadgeView{
+ Code: r.Badge.Code,
+ Name: r.Badge.Name,
+ Description: r.Badge.Description,
+ Icon: r.Badge.Icon,
+ Kind: r.Badge.Kind,
+ })
+ if limit > 0 && len(out) >= limit {
+ break
+ }
+ }
+ return out
+}
+
+// EvaluateAuto 检查并授予符合条件的自动徽章
+func (s *BadgeService) EvaluateAuto(userID uint) error {
+ var user model.User
+ if err := model.DB.First(&user, userID).Error; err != nil {
+ return err
+ }
+ var defs []model.BadgeDef
+ if err := model.DB.Where("kind = ? AND enabled = ?", model.BadgeKindAuto, true).Find(&defs).Error; err != nil {
+ return err
+ }
+ tenureDays := int(time.Since(user.CreatedAt).Hours() / 24)
+ var likes int64
+ _ = model.DB.Model(&model.Post{}).
+ Select("COALESCE(SUM(like_count), 0)").
+ Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).
+ Scan(&likes).Error
+ income := user.CreatorIncomeTotal
+
+ owned := map[uint]bool{}
+ var existing []model.UserBadge
+ _ = model.DB.Where("user_id = ?", userID).Find(&existing).Error
+ for _, e := range existing {
+ owned[e.BadgeID] = true
+ }
+
+ for _, d := range defs {
+ if owned[d.ID] {
+ continue
+ }
+ ok := false
+ switch d.Metric {
+ case model.BadgeMetricTenureDays:
+ ok = tenureDays >= d.Threshold
+ case model.BadgeMetricLikesReceived:
+ ok = int(likes) >= d.Threshold
+ case model.BadgeMetricCreatorIncome:
+ ok = income >= d.Threshold
+ }
+ if !ok {
+ continue
+ }
+ _ = model.DB.Create(&model.UserBadge{
+ UserID: userID,
+ BadgeID: d.ID,
+ AwardedAt: time.Now(),
+ AwardedBy: 0,
+ }).Error
+ }
+ return nil
+}
+
+// AttachBadgeSummaries 批量为用户填充展示用徽章(最多 perUser 枚)
+func (s *BadgeService) AttachBadgeSummaries(users []*model.User, perUser int) {
+ if len(users) == 0 {
+ return
+ }
+ if perUser <= 0 {
+ perUser = 3
+ }
+ ids := make([]uint, 0, len(users))
+ seen := map[uint]bool{}
+ for _, u := range users {
+ if u == nil || u.ID == 0 {
+ continue
+ }
+ u.Level = model.LevelFromExp(u.Exp)
+ if !seen[u.ID] {
+ seen[u.ID] = true
+ ids = append(ids, u.ID)
+ }
+ }
+ if len(ids) == 0 {
+ return
+ }
+ var rows []model.UserBadge
+ _ = model.DB.Preload("Badge").Where("user_id IN ?", ids).
+ Order("awarded_at desc").Find(&rows).Error
+ grouped := map[uint][]model.UserBadgeView{}
+ for _, r := range rows {
+ if r.Badge.ID == 0 || !r.Badge.Enabled {
+ continue
+ }
+ list := grouped[r.UserID]
+ if len(list) >= perUser {
+ continue
+ }
+ list = append(list, model.UserBadgeView{
+ Code: r.Badge.Code,
+ Name: r.Badge.Name,
+ Description: r.Badge.Description,
+ Icon: r.Badge.Icon,
+ Kind: r.Badge.Kind,
+ })
+ grouped[r.UserID] = list
+ }
+ for _, u := range users {
+ if u == nil || u.ID == 0 {
+ continue
+ }
+ u.Badges = grouped[u.ID]
+ }
+}
+
+// AttachBadgeSummariesOnPosts 给帖子作者填充徽章摘要
+func (s *BadgeService) AttachBadgeSummariesOnPosts(posts []model.Post, perUser int) {
+ users := make([]*model.User, 0, len(posts))
+ for i := range posts {
+ if posts[i].User.ID > 0 {
+ users = append(users, &posts[i].User)
+ }
+ }
+ s.AttachBadgeSummaries(users, perUser)
+}
+
+// AttachBadgeSummariesOnComments 给评论作者填充徽章摘要
+func (s *BadgeService) AttachBadgeSummariesOnComments(comments []model.Comment, perUser int) {
+ users := make([]*model.User, 0, len(comments))
+ for i := range comments {
+ if comments[i].User.ID > 0 {
+ users = append(users, &comments[i].User)
+ }
+ }
+ s.AttachBadgeSummaries(users, perUser)
+}
+
+// AddExp 增加经验(不可为负消耗;delta<=0 忽略)
+func AddExp(userID uint, delta int) {
+ if userID == 0 || delta <= 0 {
+ return
+ }
+ _ = model.DB.Model(&model.User{}).Where("id = ?", userID).
+ UpdateColumn("exp", gorm.Expr("exp + ?", delta)).Error
+}
+
+// SetUserLevel 站长设等级(调整 Exp 到门槛)
+func SetUserLevel(userID uint, level int) error {
+ if level < 1 || level > model.MaxLevel() {
+ return errors.New("等级须在 1–10")
+ }
+ exp := model.ExpForLevel(level)
+ return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("exp", exp).Error
+}
+
+// SetVerified 设置认证
+func SetVerified(userID uint, verified bool) error {
+ var user model.User
+ if err := model.DB.First(&user, userID).Error; err != nil {
+ return errors.New("用户不存在")
+ }
+ return model.DB.Model(&user).Update("verified", verified).Error
+}
diff --git a/service/comment.go b/service/comment.go
index a72f764..025efac 100644
--- a/service/comment.go
+++ b/service/comment.go
@@ -215,7 +215,7 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
}
status := model.ContentStatusPending
- if user.Role == model.RoleAdmin {
+ if user.SkipsModeration() {
status = model.ContentStatusPublished
}
@@ -231,7 +231,13 @@ func (s *CommentService) Create(in CommentCreateInput) (*model.Comment, error) {
IsPrivate: in.IsPrivate,
Status: status,
}
- return comment, model.DB.Create(comment).Error
+ if err := model.DB.Create(comment).Error; err != nil {
+ return nil, err
+ }
+ if status == model.ContentStatusPublished {
+ AddExp(in.UserID, 2)
+ }
+ return comment, nil
}
// SetStatus 设置评论审核状态
@@ -241,6 +247,11 @@ func (s *CommentService) SetStatus(commentID uint, status string) error {
default:
return errors.New("无效的审核状态")
}
+ var comment model.Comment
+ if err := model.DB.Select("id", "user_id", "status").First(&comment, commentID).Error; err != nil {
+ return ErrCommentNotFound
+ }
+ prev := comment.Status
res := model.DB.Model(&model.Comment{}).Where("id = ?", commentID).Update("status", status)
if res.Error != nil {
return res.Error
@@ -248,6 +259,9 @@ func (s *CommentService) SetStatus(commentID uint, status string) error {
if res.RowsAffected == 0 {
return ErrCommentNotFound
}
+ if status == model.ContentStatusPublished && prev != model.ContentStatusPublished && comment.UserID > 0 {
+ AddExp(comment.UserID, 2)
+ }
return nil
}
@@ -332,7 +346,7 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
return s.AdminDelete(commentID)
}
-func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, bool, error) {
+func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
var comment model.Comment
if err := model.DB.First(&comment, commentID).Error; err != nil {
return "", false, ErrCommentNotFound
@@ -369,7 +383,7 @@ func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content st
return err
}
updates := map[string]interface{}{"content": content}
- if !isAdmin {
+ if !skipModeration {
updates["status"] = model.ContentStatusPending
enteredPending = true
}
diff --git a/service/content.go b/service/content.go
index 72ec436..4c8eb6d 100644
--- a/service/content.go
+++ b/service/content.go
@@ -25,9 +25,9 @@ func RedactReplyOnlyHTML(html string) string {
return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only")
}
-// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见与回复可见正文
+// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见、回复可见与积分解锁正文
func RedactGatedPostHTML(html string) string {
- return RedactReplyOnlyHTML(RedactMembersOnlyHTML(html))
+ return RedactPointsOnlyHTML(RedactReplyOnlyHTML(RedactMembersOnlyHTML(html)), nil)
}
func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
diff --git a/service/points.go b/service/points.go
new file mode 100644
index 0000000..cd96907
--- /dev/null
+++ b/service/points.go
@@ -0,0 +1,272 @@
+package service
+
+import (
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "math/big"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var (
+ ErrInsufficientPoints = errors.New("积分不足")
+ ErrAlreadyCheckedIn = errors.New("今日已签到")
+ ErrAlreadyLottery = errors.New("今日已抽奖")
+ ErrInvalidPointsDelta = errors.New("无效的积分变动")
+)
+
+// PointsService 积分钱包、签到、抽奖
+type PointsService struct{}
+
+func NewPointsService() *PointsService { return &PointsService{} }
+
+func todayLocal() string {
+ return time.Now().Format("2006-01-02")
+}
+
+// AdjustPointsTx 在已有事务内调整积分并写流水;返回变动后余额
+func AdjustPointsTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ if delta == 0 {
+ var u model.User
+ if err := tx.Select("points").First(&u, userID).Error; err != nil {
+ return 0, err
+ }
+ return u.Points, nil
+ }
+ var user model.User
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
+ return 0, err
+ }
+ newBal := user.Points + delta
+ if newBal < 0 {
+ return 0, ErrInsufficientPoints
+ }
+ if err := tx.Model(&user).Update("points", newBal).Error; err != nil {
+ return 0, err
+ }
+ led := model.PointLedger{
+ UserID: userID,
+ Delta: delta,
+ Balance: newBal,
+ Reason: reason,
+ RefType: refType,
+ RefID: refID,
+ Note: note,
+ }
+ if err := tx.Create(&led).Error; err != nil {
+ return 0, err
+ }
+ return newBal, nil
+}
+
+// AdjustPoints 独立事务调整积分
+func (s *PointsService) AdjustPoints(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ var bal int
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ var e error
+ bal, e = AdjustPointsTx(tx, userID, delta, reason, refType, refID, note)
+ return e
+ })
+ return bal, err
+}
+
+// AdminAdjust 站长调账
+func (s *PointsService) AdminAdjust(userID uint, delta int, note string) (int, error) {
+ if delta == 0 {
+ return 0, ErrInvalidPointsDelta
+ }
+ return s.AdjustPoints(userID, delta, model.PointReasonAdminAdjust, "admin", 0, note)
+}
+
+// CheckInStatus 今日签到状态
+type CheckInStatus struct {
+ CheckedIn bool `json:"checked_in"`
+ Streak int `json:"streak"`
+ TodayPoints int `json:"today_points"` // 若已签到为实得;否则为预计可得
+ Day string `json:"day"`
+}
+
+func (s *PointsService) GetCheckInStatus(userID uint) (CheckInStatus, error) {
+ day := todayLocal()
+ st := CheckInStatus{Day: day}
+ var row model.CheckIn
+ err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error
+ if err != nil {
+ return st, err
+ }
+ if row.ID > 0 {
+ st.CheckedIn = true
+ st.Streak = row.Streak
+ st.TodayPoints = row.Points
+ return st, nil
+ }
+ streak := s.computeNextStreak(userID, day)
+ st.Streak = streak
+ st.TodayPoints = checkInReward(streak)
+ return st, nil
+}
+
+func (s *PointsService) computeNextStreak(userID uint, today string) int {
+ yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
+ var prev model.CheckIn
+ model.DB.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev)
+ if prev.ID > 0 {
+ return prev.Streak + 1
+ }
+ return 1
+}
+
+func checkInReward(streak int) int {
+ // 基础 5,连续每日 +1,封顶 15
+ pts := 5 + (streak - 1)
+ if pts > 15 {
+ pts = 15
+ }
+ if pts < 5 {
+ pts = 5
+ }
+ return pts
+}
+
+// CheckIn 每日签到
+func (s *PointsService) CheckIn(userID uint) (CheckInStatus, error) {
+ day := todayLocal()
+ var out CheckInStatus
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ var existing model.CheckIn
+ if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
+ return err
+ }
+ if existing.ID > 0 {
+ return ErrAlreadyCheckedIn
+ }
+ yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
+ var prev model.CheckIn
+ _ = tx.Where("user_id = ? AND day = ?", userID, yesterday).Limit(1).Find(&prev).Error
+ streak := 1
+ if prev.ID > 0 {
+ streak = prev.Streak + 1
+ }
+ pts := checkInReward(streak)
+ row := model.CheckIn{UserID: userID, Day: day, Points: pts, Streak: streak}
+ if err := tx.Create(&row).Error; err != nil {
+ return err
+ }
+ if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonCheckIn, "check_in", row.ID, fmt.Sprintf("连续签到 %d 天", streak)); err != nil {
+ return err
+ }
+ out = CheckInStatus{CheckedIn: true, Streak: streak, TodayPoints: pts, Day: day}
+ return nil
+ })
+ return out, err
+}
+
+// LotteryPrize 奖池项
+type LotteryPrize struct {
+ Points int `json:"points"`
+ Weight int `json:"weight"`
+}
+
+var defaultLotteryPool = []LotteryPrize{
+ {Points: 0, Weight: 40},
+ {Points: 2, Weight: 30},
+ {Points: 5, Weight: 18},
+ {Points: 10, Weight: 10},
+ {Points: 20, Weight: 2},
+}
+
+// LotteryStatus 抽奖状态
+type LotteryStatus struct {
+ Drawn bool `json:"drawn"`
+ Points int `json:"points"` // 今日已抽中
+ Day string `json:"day"`
+ Pool []LotteryPrize `json:"pool"`
+ Cost int `json:"cost"` // 抽奖消耗,首版 0
+}
+
+func (s *PointsService) GetLotteryStatus(userID uint) (LotteryStatus, error) {
+ day := todayLocal()
+ st := LotteryStatus{Day: day, Pool: defaultLotteryPool, Cost: 0}
+ var row model.LotteryDraw
+ if err := model.DB.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&row).Error; err != nil {
+ return st, err
+ }
+ if row.ID > 0 {
+ st.Drawn = true
+ st.Points = row.Points
+ }
+ return st, nil
+}
+
+func pickLottery(pool []LotteryPrize) (int, error) {
+ total := 0
+ for _, p := range pool {
+ total += p.Weight
+ }
+ if total <= 0 {
+ return 0, errors.New("奖池无效")
+ }
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(total)))
+ if err != nil {
+ return 0, err
+ }
+ v := int(n.Int64())
+ for _, p := range pool {
+ if v < p.Weight {
+ return p.Points, nil
+ }
+ v -= p.Weight
+ }
+ return pool[len(pool)-1].Points, nil
+}
+
+// DrawLottery 每日抽奖
+func (s *PointsService) DrawLottery(userID uint) (LotteryStatus, error) {
+ day := todayLocal()
+ var out LotteryStatus
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ var existing model.LotteryDraw
+ if err := tx.Where("user_id = ? AND day = ?", userID, day).Limit(1).Find(&existing).Error; err != nil {
+ return err
+ }
+ if existing.ID > 0 {
+ return ErrAlreadyLottery
+ }
+ pts, err := pickLottery(defaultLotteryPool)
+ if err != nil {
+ return err
+ }
+ row := model.LotteryDraw{UserID: userID, Day: day, Points: pts}
+ if err := tx.Create(&row).Error; err != nil {
+ return err
+ }
+ if pts > 0 {
+ if _, err := AdjustPointsTx(tx, userID, pts, model.PointReasonLottery, "lottery", row.ID, "每日抽奖"); err != nil {
+ return err
+ }
+ }
+ out = LotteryStatus{Drawn: true, Points: pts, Day: day, Pool: defaultLotteryPool, Cost: 0}
+ return nil
+ })
+ return out, err
+}
+
+// ListLedger 积分流水
+func (s *PointsService) ListLedger(userID uint, page, size int) ([]model.PointLedger, int64, error) {
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 || size > 50 {
+ size = 20
+ }
+ var total int64
+ model.DB.Model(&model.PointLedger{}).Where("user_id = ?", userID).Count(&total)
+ var rows []model.PointLedger
+ err := model.DB.Where("user_id = ?", userID).Order("id desc").
+ Offset((page - 1) * size).Limit(size).Find(&rows).Error
+ return rows, total, err
+}
diff --git a/service/post.go b/service/post.go
index 47cac41..fa84458 100644
--- a/service/post.go
+++ b/service/post.go
@@ -330,7 +330,7 @@ func (s *PostService) GetByID(id uint) (*model.Post, error) {
return post, nil
}
-func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, isAdmin bool) (*model.Post, error) {
+func (s *PostService) Create(userID, boardID uint, title, content, tags, postType string, skipModeration bool) (*model.Post, error) {
title = s.filter.Filter(strings.TrimSpace(title))
content = s.filter.Filter(SanitizePostHTML(content))
tags = s.filter.Filter(strings.TrimSpace(tags))
@@ -351,7 +351,7 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
return nil, err
}
status := model.ContentStatusPending
- if isAdmin {
+ if skipModeration {
status = model.ContentStatusPublished
}
post := &model.Post{
@@ -365,12 +365,18 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
QuestionResolved: false,
Status: status,
}
- return post, model.DB.Create(post).Error
+ if err := model.DB.Create(post).Error; err != nil {
+ return nil, err
+ }
+ if status == model.ContentStatusPublished {
+ AddExp(userID, 10)
+ }
+ return post, nil
}
// Update 更新帖子。boardID>0 时可改板块;为 0 时保持原板块。
// postType 为空时保持原类型;改为非 question 时清除已解决标记。
-func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content, tags, postType string, boardID uint) error {
+func (s *PostService) Update(userID, postID uint, isAdmin, skipModeration bool, title, content, tags, postType string, boardID uint) error {
var post model.Post
if err := model.DB.First(&post, postID).Error; err != nil {
return ErrPostNotFound
@@ -425,8 +431,8 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
"post_type": nextType,
"question_resolved": nextResolved,
}
- // 普通用户修改后重新进入审核
- if !isAdmin {
+ // 非免审用户修改后重新进入审核
+ if !skipModeration {
updates["status"] = model.ContentStatusPending
}
return tx.Model(&post).Updates(updates).Error
@@ -440,6 +446,11 @@ func (s *PostService) SetStatus(postID uint, status string) error {
default:
return errors.New("无效的审核状态")
}
+ var post model.Post
+ if err := model.DB.Select("id", "user_id", "status").First(&post, postID).Error; err != nil {
+ return ErrPostNotFound
+ }
+ prev := post.Status
res := model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("status", status)
if res.Error != nil {
return res.Error
@@ -447,6 +458,10 @@ func (s *PostService) SetStatus(postID uint, status string) error {
if res.RowsAffected == 0 {
return ErrPostNotFound
}
+ // 首次变为已发布时加经验
+ if status == model.ContentStatusPublished && prev != model.ContentStatusPublished {
+ AddExp(post.UserID, 10)
+ }
return nil
}
@@ -698,6 +713,10 @@ func (s *PostService) SetQuestionResolved(userID, postID uint, isAdmin bool, res
}
func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
+ var post model.Post
+ if err := model.DB.Select("id", "user_id").First(&post, postID).Error; err != nil {
+ return false, ErrPostNotFound
+ }
var like model.PostLike
result := model.DB.Where("post_id = ? AND user_id = ?", postID, userID).Limit(1).Find(&like)
if result.Error != nil {
@@ -713,6 +732,13 @@ func (s *PostService) ToggleLike(userID, postID uint) (liked bool, err error) {
return false, err
}
model.DB.Model(&model.Post{}).Where("id = ?", postID).UpdateColumn("like_count", gorm.Expr("like_count + 1"))
+ // 他人点赞给作者加经验;自赞不计
+ if userID != post.UserID {
+ AddExp(post.UserID, 1)
+ go func() {
+ _ = NewBadgeService().EvaluateAuto(post.UserID)
+ }()
+ }
return true, nil
}
diff --git a/service/sanitize_html.go b/service/sanitize_html.go
index ba905d0..d4de16b 100644
--- a/service/sanitize_html.go
+++ b/service/sanitize_html.go
@@ -17,16 +17,16 @@ func postContentHTMLPolicy() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
// TipTap / Markdown 转换会用到的结构
- p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only")
+ p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only", "points-only")
p.AllowAttrs("class").OnElements(
"p", "div", "span", "pre", "code", "img", "a",
"h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
- "members-only", "reply-only",
+ "members-only", "reply-only", "points-only",
)
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
p.AllowAttrs(
- "data-locked", "data-length", "data-gate",
+ "data-locked", "data-length", "data-gate", "data-cost", "data-block-key",
"data-code-copy", "data-code-fold", "data-lang", "data-full",
"data-code-style", "data-line-numbers", "data-collapsed",
"data-line-count", "data-lineno-digits",
diff --git a/service/unlock.go b/service/unlock.go
new file mode 100644
index 0000000..fe8fdc0
--- /dev/null
+++ b/service/unlock.go
@@ -0,0 +1,248 @@
+package service
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.iioio.com/freefire/jiang13-forum/model"
+ "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)]*)>([\s\S]*?)`)
+
+// 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(`%s`, cost, key, inner)
+ }
+ cost := parseDataCost(attrs)
+ if cost < 1 {
+ cost = 1
+ }
+ length := gatedContentLength(inner)
+ return fmt.Sprintf(``, cost, key, 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 []model.PostContentUnlock
+ if err := model.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 model.Post
+ if err := model.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
+ model.DB.Model(&model.PostContentUnlock{}).Where("user_id = ? AND post_id = ? AND block_key = ?", readerID, postID, blockKey).Count(&n)
+ if n == 0 {
+ _ = model.DB.Create(&model.PostContentUnlock{
+ UserID: readerID, PostID: postID, BlockKey: blockKey, Cost: 0,
+ }).Error
+ }
+ return &UnlockResult{BlockKey: blockKey, Cost: 0, AuthorShare: 0, InnerHTML: block.Inner}, nil
+ }
+
+ var existing model.PostContentUnlock
+ model.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 model.User
+ if err := model.DB.First(&reader, readerID).Error; err != nil {
+ return nil, err
+ }
+ if err := model.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 := model.DB.Transaction(func(tx *gorm.DB) error {
+ var again model.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, model.PointReasonUnlockSpend, "post_unlock", postID, "解锁付费内容")
+ if e != nil {
+ return e
+ }
+ if authorShare > 0 {
+ if _, e = AdjustPointsTx(tx, author.ID, authorShare, model.PointReasonCreatorIncome, "post_unlock", postID, "创作分成"); e != nil {
+ return e
+ }
+ if e = tx.Model(&model.User{}).Where("id = ?", author.ID).
+ UpdateColumn("creator_income_total", gorm.Expr("creator_income_total + ?", authorShare)).Error; e != nil {
+ return e
+ }
+ }
+ return tx.Create(&model.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 *model.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(`%s`, cost, key, inner)
+ })
+}
diff --git a/service/user.go b/service/user.go
index 5e8236f..a135354 100644
--- a/service/user.go
+++ b/service/user.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"mime/multipart"
+ "strconv"
"strings"
"time"
@@ -136,12 +137,51 @@ func (s *UserService) UploadAvatar(userID uint, file *multipart.FileHeader, stor
}
// ListUsers 管理员列出用户
-func (s *UserService) ListUsers(page, size int) ([]model.User, int64, error) {
- var users []model.User
+// UserListQuery 后台用户列表筛选
+type UserListQuery struct {
+ Page int
+ Size int
+ Keyword string // 匹配用户名/昵称/邮箱
+ Filter string // all | verified | banned | admin
+}
+
+func (s *UserService) ListUsers(q UserListQuery) ([]model.User, int64, error) {
+ if q.Page < 1 {
+ q.Page = 1
+ }
+ if q.Size < 1 {
+ q.Size = 20
+ }
+ if q.Size > 100 {
+ q.Size = 100
+ }
+
+ db := model.DB.Model(&model.User{})
+ kw := strings.TrimSpace(q.Keyword)
+ if kw != "" {
+ like := "%" + kw + "%"
+ if id, err := strconv.ParseUint(kw, 10, 64); err == nil {
+ db = db.Where("id = ? OR username LIKE ? OR nickname LIKE ? OR email LIKE ?", id, like, like, like)
+ } else {
+ db = db.Where("username LIKE ? OR nickname LIKE ? OR email LIKE ?", like, like, like)
+ }
+ }
+ switch strings.TrimSpace(q.Filter) {
+ case "verified":
+ db = db.Where("verified = ? AND role <> ?", true, model.RoleAdmin)
+ case "banned":
+ db = db.Where("banned = ?", true)
+ case "admin":
+ db = db.Where("role = ?", model.RoleAdmin)
+ }
+
var total int64
- model.DB.Model(&model.User{}).Count(&total)
- offset := (page - 1) * size
- err := model.DB.Order("id desc").Offset(offset).Limit(size).Find(&users).Error
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var users []model.User
+ offset := (q.Page - 1) * q.Size
+ err := db.Order("id desc").Offset(offset).Limit(q.Size).Find(&users).Error
return users, total, err
}