diff --git a/README.md b/README.md index a338b0d..2c22523 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ docker run -d --name jiang13 \ | `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) | | `JIANG13_CONFIG` | 配置文件路径 | | `JIANG13_WORK_PATH` | 工作目录 | +| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报(默认关闭,见 `app.ini.example`) | **健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。 @@ -292,7 +293,7 @@ JWT_SECRET = | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | | `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | -**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH` +**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(维护者选项,见上表) ### 5. 注册为系统服务(可选) diff --git a/app.ini.example b/app.ini.example index f2e88ee..07deba0 100644 --- a/app.ini.example +++ b/app.ini.example @@ -13,3 +13,11 @@ DATA = data [security] ; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库) JWT_SECRET = + +; --------------------------------------------------------------------------- +; 维护者选项(默认关闭;普通自托管无需开启) +; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。 +; 等价环境变量:JIANG13_COMMUNITY_HUB=1 +; --------------------------------------------------------------------------- +; [community] +; HUB = false diff --git a/cmd/jiang13/main.go b/cmd/jiang13/main.go index 1fd57d9..04747d7 100644 --- a/cmd/jiang13/main.go +++ b/cmd/jiang13/main.go @@ -8,12 +8,14 @@ import ( "github.com/kardianos/service" "git.iioio.com/freefire/jiang13-forum/config" + appsvc "git.iioio.com/freefire/jiang13-forum/service" ) // version 由构建脚本通过 -ldflags "-X main.version=..." 注入 var version = "dev" func main() { + appsvc.SetAppVersion(version) cfg, err := config.Parse() if err != nil { log.Fatalf("配置解析失败: %v", err) diff --git a/config/config.go b/config/config.go index e84e4fa..c3739a7 100644 --- a/config/config.go +++ b/config/config.go @@ -32,6 +32,8 @@ type Config struct { ServiceAction string // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源) DevMode bool + // CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭) + CommunityHub bool } // Parse 解析命令行、环境变量与 app.ini,并初始化数据目录 @@ -108,6 +110,11 @@ func Parse() (*Config, error) { jwtSecret = strings.TrimSpace(*jwtFlag) } + communityHub := fileCfg.CommunityHub + if v := envBoolOrNil(envCommunityHub); v != nil { + communityHub = *v + } + cfg := &Config{ WorkPath: workPath, ConfigFile: configFile, @@ -117,6 +124,7 @@ func Parse() (*Config, error) { LogFile: filepath.Join(absData, "jiang13.log"), ServiceAction: action, DevMode: *devFlag, + CommunityHub: communityHub, } needDirs := action == "" || action == "install" diff --git a/config/env.go b/config/env.go index db1ac4e..bf09925 100644 --- a/config/env.go +++ b/config/env.go @@ -8,11 +8,12 @@ import ( // 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认) const ( - envWorkPath = "JIANG13_WORK_PATH" - envConfig = "JIANG13_CONFIG" - envHTTPPort = "JIANG13_HTTP_PORT" - envData = "JIANG13_DATA" - envJWTSecret = "JIANG13_JWT_SECRET" + envWorkPath = "JIANG13_WORK_PATH" + envConfig = "JIANG13_CONFIG" + envHTTPPort = "JIANG13_HTTP_PORT" + envData = "JIANG13_DATA" + envJWTSecret = "JIANG13_JWT_SECRET" + envCommunityHub = "JIANG13_COMMUNITY_HUB" ) func envOrDefault(key string) string { @@ -30,3 +31,21 @@ func envIntOrZero(key string) int { } return n } + +// envBoolOrNil 解析布尔环境变量;未设置返回 nil +func envBoolOrNil(key string) *bool { + v := strings.ToLower(envOrDefault(key)) + if v == "" { + return nil + } + switch v { + case "1", "true", "yes", "on": + t := true + return &t + case "0", "false", "no", "off": + f := false + return &f + default: + return nil + } +} diff --git a/config/ini.go b/config/ini.go index 4a33659..0ad1e23 100644 --- a/config/ini.go +++ b/config/ini.go @@ -18,9 +18,10 @@ const ( // fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径) type fileSettings struct { - Port int - DataRel string - JWTSecret string + Port int + DataRel string + JWTSecret string + CommunityHub bool // 维护者选项:是否作为社区枢纽收报 } func defaultFileSettings() fileSettings { @@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) { out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String()) } + if sec, err := cfg.GetSection("community"); err == nil { + if k := sec.Key("HUB"); k.String() != "" { + out.CommunityHub = k.MustBool(false) + } + } + return out, nil } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6eb33a2..9873195 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage')); const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); const LinksPage = lazyWithRetry(() => import('./pages/LinksPage')); +const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage')); const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage')); const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage')); const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); @@ -44,6 +45,7 @@ const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSit const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage')); const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); +const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage')); const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); const router = createBrowserRouter( @@ -61,6 +63,7 @@ const router = createBrowserRouter( }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> }>} /> @@ -82,6 +85,7 @@ const router = createBrowserRouter( } /> } /> } /> + }>} /> } /> }>} /> }>} /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 657a66f..21dec71 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types'; +import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem } from './types'; const BASE = ''; @@ -63,6 +63,19 @@ export const api = { // 管理后台 API adminDashboard: () => request('/api/admin/dashboard'), adminSettings: () => request('/api/admin/settings'), + adminUpdateCommunitySettings: (body: CommunityConfig) => + request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', { + method: 'PUT', body: JSON.stringify(body), + }), + adminCommunityInstances: () => + request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'), + adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) => + request<{ message: string; instance: CommunityInstance }>( + `/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`, + { method: 'PUT', body: JSON.stringify(body) }, + ), + communityShowcase: () => + request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'), adminPosts: (params: { page?: number; keyword?: string; status?: string }) => { const q = new URLSearchParams(); if (params.page) q.set('page', String(params.page)); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index ce01b17..25dfe94 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -376,10 +376,45 @@ export interface AdminSettings { gitea?: GiteaSyncConfig; storage?: StorageConfig; branding?: SiteBranding; + community?: CommunityConfig; filter_words: string; filter_word_count: number; } +/** 社区上报配置(hub_url / site_url 只读) */ +export interface CommunityConfig { + report_enabled: boolean; + /** 只读:是否作为社区枢纽 */ + hub_enabled: boolean; + /** 只读:固定为官方演示站 */ + hub_url: string; + /** 只读:服务端自动推断的本站地址 */ + site_url: string; + instance_id: string; +} + +export interface CommunityInstance { + instance_id: string; + site_url: string; + site_name: string; + version: string; + users: number; + posts: number; + first_seen_at: string; + last_seen_at: string; + online: boolean; + featured: boolean; + featured_note: string; +} + +/** 公开展柜条目 */ +export interface CommunityShowcaseItem { + site_url: string; + site_name: string; + version: string; + featured_note?: string; +} + export interface StorageConfig { type: 'local' | 's3'; endpoint: string; diff --git a/frontend/src/components/admin/CommunitySupportStrip.tsx b/frontend/src/components/admin/CommunitySupportStrip.tsx new file mode 100644 index 0000000..5935b2d --- /dev/null +++ b/frontend/src/components/admin/CommunitySupportStrip.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from 'react'; +import { Heart } from 'lucide-react'; +import { notify } from '@/lib/notify'; +import { api } from '../../api/client'; +import type { CommunityConfig } from '../../api/types'; + +const EMPTY_COMMUNITY: CommunityConfig = { + report_enabled: false, + hub_enabled: false, + hub_url: 'https://bbs.iioio.com', + site_url: '', + instance_id: '', +}; + +/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存) */ +export default function CommunitySupportStrip() { + const [community, setCommunity] = useState(EMPTY_COMMUNITY); + const [saving, setSaving] = useState(false); + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + api.adminSettings() + .then((s) => { + if (!cancelled) { + setCommunity({ ...EMPTY_COMMUNITY, ...(s.community ?? {}) }); + setReady(true); + } + }) + .catch(() => { + if (!cancelled) setReady(true); + }); + return () => { cancelled = true; }; + }, []); + + const handleToggle = async () => { + if (saving || !ready) return; + const next = !community.report_enabled; + const prev = community; + setCommunity((c) => ({ ...c, report_enabled: next })); + setSaving(true); + try { + const r = await api.adminUpdateCommunitySettings({ + ...EMPTY_COMMUNITY, + report_enabled: next, + }); + setCommunity({ ...EMPTY_COMMUNITY, ...r.community }); + if (r.heartbeat_error) { + notify.warning(`${r.message}:${r.heartbeat_error}`); + } else { + notify.success(next ? '已开启社区上报' : '已关闭社区上报'); + } + } catch (e: unknown) { + setCommunity(prev); + notify.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ +
+ 支持姜十三开源 + + 匿名向 bbs.iioio.com 上报站点地址、版本与规模;开启后有机会获官方演示站展示与推荐,可随时关闭 + +
+
+ +
+ ); +} diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index cf7f79f..bf5d4f1 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { - LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, + LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { useAuth } from '../hooks/useAuth'; @@ -23,6 +23,8 @@ type NavItem = { label: string; icon: typeof LayoutDashboard; badgeKey?: BadgeKey; + /** 仅社区枢纽开启时显示 */ + hubOnly?: boolean; }; type NavGroup = { @@ -54,6 +56,7 @@ const NAV_GROUPS: NavGroup[] = [ { to: '/admin/users', label: '用户管理', icon: Users }, { to: '/admin/badges', label: '徽章管理', icon: Award }, { to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' }, + { to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true }, ], }, { @@ -86,6 +89,7 @@ export default function AdminLayout() { const isNarrow = useMediaQuery('(max-width: 768px)'); const [navOpen, setNavOpen] = useState(false); const [pending, setPending] = useState({ posts: 0, comments: 0, reports: 0, links: 0 }); + const [communityHub, setCommunityHub] = useState(false); const nav = useNavigate(); const location = useLocation(); const drawerRef = useRef(null); @@ -107,6 +111,13 @@ export default function AdminLayout() { .catch(() => {}); }, []); + useEffect(() => { + if (loading || !user || user.role !== 'admin') return; + api.adminSettings() + .then(s => setCommunityHub(!!s.community?.hub_enabled)) + .catch(() => setCommunityHub(false)); + }, [loading, user]); + useEffect(() => { if (loading || !user || user.role !== 'admin') return; refreshPending(); @@ -156,7 +167,7 @@ export default function AdminLayout() { NAV_GROUPS.map(group => (
{group.label}
- {group.items.map(({ to, label, icon: Icon, badgeKey }) => { + {group.items.filter(item => !item.hubOnly || communityHub).map(({ to, label, icon: Icon, badgeKey }) => { const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null; return ( ([]); + const [loading, setLoading] = useState(true); + + usePageSEO({ + title: '开源部署展柜', + description: `${branding.name} 精选的姜十三论坛公网部署`, + keywords: joinSEOKeywords('开源', '部署', '展柜', getCachedSiteBranding().keywords), + canonicalPath: '/showcase', + }); + + useEffect(() => { + let cancelled = false; + api.communityShowcase() + .then((r) => { + if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []); + }) + .catch(() => { + if (!cancelled) setItems([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, []); + + return ( +
+
+
+ +
+
+

开源部署展柜

+

+ 以下站点自愿开启社区上报,并由官方演示站精选推荐(非全量目录) +

+
+
+ + {loading ? ( +
+ ) : items.length === 0 ? ( +

暂无精选实例

+ ) : ( + + )} + + +
+ ); +} diff --git a/frontend/src/pages/admin/AdminCommunityPage.tsx b/frontend/src/pages/admin/AdminCommunityPage.tsx new file mode 100644 index 0000000..4e72c5d --- /dev/null +++ b/frontend/src/pages/admin/AdminCommunityPage.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Globe2, ExternalLink, Star } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { notify } from '@/lib/notify'; +import { api } from '../../api/client'; +import type { CommunityInstance } from '../../api/types'; +import { useAdminGuard } from '../../layouts/AdminLayout'; +import { formatTime } from '../../utils/content'; +import { cn } from '@/lib/utils'; + +export default function AdminCommunityPage() { + const { ready } = useAdminGuard(); + const [hubEnabled, setHubEnabled] = useState(false); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [featuringId, setFeaturingId] = useState(null); + + useEffect(() => { + if (!ready) return; + setLoading(true); + api.adminCommunityInstances() + .then((r) => { + setHubEnabled(!!r.hub_enabled); + setList(Array.isArray(r.instances) ? r.instances : []); + }) + .catch(() => { + setList([]); + }) + .finally(() => setLoading(false)); + }, [ready]); + + const handleToggleFeatured = async (row: CommunityInstance) => { + if (featuringId) return; + setFeaturingId(row.instance_id); + try { + const r = await api.adminFeatureCommunityInstance(row.instance_id, { + featured: !row.featured, + featured_note: row.featured_note || '', + }); + setList((prev) => prev.map((item) => ( + item.instance_id === row.instance_id ? { ...item, ...r.instance } : item + ))); + notify.success(r.message); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '操作失败'); + } finally { + setFeaturingId(null); + } + }; + + if (!ready || loading) { + return
; + } + + return ( +
+
+
+

+ + 公网实例 +

+

+ 接收自愿上报的心跳;设为精选后会出现在 + {' '} + 公开展柜 +

+
+
+ + {!hubEnabled && ( +
+
+

+ 本站未开启社区枢纽。该能力仅供官方主站运维配置开启( + app.ini[community] hub = true + {' '}或环境变量 JIANG13_COMMUNITY_HUB=1 + ),普通部署无需也无法在后台打开。 +

+
+
+ )} + +
+
+ 实例列表 + {list.length} 个 +
+
+ {list.length === 0 ? ( +

暂无上报记录

+ ) : ( +
+ + + + + + + + + + + + + + {list.map((row) => ( + + + + + + + + + + ))} + +
状态站点版本用户帖子最近心跳精选
+ + {row.online ? '在线' : '离线'} + + +
+ + {row.featured && } + {row.site_name || '未命名站点'} + + + {row.site_url} + + +
+
{row.version || '—'}{row.users}{row.posts}{formatTime(row.last_seen_at)} + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminDashboardPage.tsx b/frontend/src/pages/admin/AdminDashboardPage.tsx index aa49fff..dfc86d6 100644 --- a/frontend/src/pages/admin/AdminDashboardPage.tsx +++ b/frontend/src/pages/admin/AdminDashboardPage.tsx @@ -7,6 +7,7 @@ import { api } from '../../api/client'; import { useAdminGuard } from '../../layouts/AdminLayout'; import type { AdminDashboard } from '../../api/types'; import { cn } from '@/lib/utils'; +import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip'; export default function AdminDashboardPage() { const nav = useNavigate(); @@ -127,6 +128,8 @@ export default function AdminDashboardPage() {
+ +
最新帖子 diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f5bd65b..1b2bc05 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -14006,6 +14006,201 @@ button.post-poll__option, } .admin-inline-link:hover { text-decoration: underline; } +.admin-community-support-strip { + display: inline-flex; + width: fit-content; + max-width: 100%; + align-items: center; + justify-content: flex-start; + gap: 20px; + margin: 16px 0; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--j13-green) 22%, var(--j13-border-light)); + border-left: 3px solid var(--j13-green); + border-radius: 8px; + background: color-mix(in srgb, var(--j13-green) 5%, var(--j13-bg-surface)); +} + +.admin-community-support-strip-main { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.admin-community-support-strip-icon { + flex-shrink: 0; + margin-top: 2px; + color: var(--j13-green); +} + +.admin-community-support-strip-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 28rem; +} + +.admin-community-support-strip-copy strong { + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); +} + +.admin-community-support-strip-copy span { + font-size: 12px; + line-height: 1.45; + color: var(--color-text-2); +} + +.admin-community-support-strip .admin-settings-switch { + flex-shrink: 0; +} + +.admin-community-support-strip .admin-settings-switch:disabled { + opacity: 0.6; + cursor: wait; +} + +@media (max-width: 640px) { + .admin-community-support-strip { + display: flex; + width: 100%; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + } + + .admin-community-support-strip-copy { + max-width: none; + } +} + +.admin-community-site { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.admin-community-site strong { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.admin-community-star { + color: var(--j13-green); + flex-shrink: 0; +} + +.admin-community-url { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + word-break: break-all; +} + +.showcase-page { + max-width: 720px; + margin: 0 auto; + padding: 28px 16px 48px; +} + +.showcase-head { + display: flex; + gap: 14px; + align-items: flex-start; + margin-bottom: 24px; +} + +.showcase-head-mark { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + color: var(--j13-green); + background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface)); + border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--j13-border-light)); + flex-shrink: 0; +} + +.showcase-title { + margin: 0; + font-size: 1.35rem; + font-weight: 650; + color: var(--color-text-1); +} + +.showcase-desc { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--color-text-2); +} + +.showcase-empty { + margin: 0; + padding: 32px 0; + text-align: center; + font-size: 14px; + color: var(--color-text-2); +} + +.showcase-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.showcase-item { + border: 1px solid var(--j13-border-light); + border-radius: 10px; + background: var(--j13-bg-surface); +} + +.showcase-item-link { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + text-decoration: none; + color: inherit; + border-radius: 10px; + transition: background 0.15s ease; +} + +.showcase-item-link:hover { + background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface)); +} + +.showcase-item-name { + font-size: 15px; + font-weight: 600; + color: var(--color-text-1); +} + +.showcase-item-url { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--j13-green); + word-break: break-all; +} + +.showcase-item-meta { + font-size: 12px; + color: var(--color-text-2); +} + .admin-links-logo-thumb { flex-shrink: 0; width: 40px; diff --git a/go.mod b/go.mod index c43a285..7e155ff 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 github.com/kardianos/service v1.2.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.0.98 @@ -31,7 +32,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/handler/api.go b/handler/api.go index 3887d5d..fa82e9f 100644 --- a/handler/api.go +++ b/handler/api.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/gin-gonic/gin" "git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/service" + "github.com/gin-gonic/gin" ) // APIMe 当前登录用户 @@ -160,12 +160,12 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ "users": userCount, "posts": postCount, "boards": boardCount, - "comments": commentCount, - "pending_posts": pendingPosts, - "pending_comments": pendingComments, - "pending_reports": pendingReports, + "comments": commentCount, + "pending_posts": pendingPosts, + "pending_comments": pendingComments, + "pending_reports": pendingReports, "pending_friend_links": pendingFriendLinks, - "recent_posts": recentPosts, + "recent_posts": recentPosts, }) } @@ -574,6 +574,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) { "gitea": h.Settings.GiteaSyncConfigPublic(), "storage": h.Settings.StorageConfigPublic(), "branding": h.Settings.SiteBranding(), + "community": h.Settings.CommunityConfig(), "filter_words": filterContent, "filter_word_count": service.CountFilterWords(filterContent), }) @@ -994,10 +995,10 @@ func (h *Handlers) APIPosts(c *gin.Context) { h.Badge.AttachBadgeSummaries(users, 2) } c.JSON(http.StatusOK, gin.H{ - "posts": items, - "total": total, - "page": page, - "size": size, + "posts": items, + "total": total, + "page": page, + "size": size, "has_more": int64(page*size) < total, }) } diff --git a/handler/community.go b/handler/community.go new file mode 100644 index 0000000..3f4a1cb --- /dev/null +++ b/handler/community.go @@ -0,0 +1,139 @@ +package handler + +import ( + "errors" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "git.iioio.com/freefire/jiang13-forum/service" +) + +// APICommunityHeartbeat 公开心跳入口(仅枢纽开启时写入) +func (h *Handlers) APICommunityHeartbeat(c *gin.Context) { + if h.Community == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未启用"}) + return + } + var req service.CommunityHeartbeatPayload + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + if err := h.Community.ReceiveHeartbeat(req, c.ClientIP()); err != nil { + if errors.Is(err, service.ErrCommunityHubDisabled) { + c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"}) + return + } + if errors.Is(err, service.ErrCommunityBadPayload) { + c.JSON(http.StatusBadRequest, gin.H{"error": "心跳参数无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// APICommunityShowcase 公开展柜(仅精选) +func (h *Handlers) APICommunityShowcase(c *gin.Context) { + if h.Community == nil { + c.JSON(http.StatusOK, gin.H{"items": []any{}}) + return + } + list, err := h.Community.ListShowcase() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": list}) +} + +// APIAdminCommunityInstances 公网实例列表 +func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) { + if h.Community == nil { + c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false}) + return + } + cfg := h.Settings.CommunityConfig() + list, err := h.Community.ListInstances() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "hub_enabled": cfg.HubEnabled, + "instances": list, + }) +} + +// APIAdminFeatureCommunityInstance 精选 / 取消精选 +func (h *Handlers) APIAdminFeatureCommunityInstance(c *gin.Context) { + if h.Community == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "未启用"}) + return + } + var req service.CommunityFeatureInput + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + view, err := h.Community.SetInstanceFeatured(c.Param("id"), req) + if err != nil { + if errors.Is(err, service.ErrCommunityBadPayload) { + c.JSON(http.StatusBadRequest, gin.H{"error": "实例无效"}) + return + } + c.JSON(http.StatusNotFound, gin.H{"error": "实例不存在"}) + return + } + msg := "已取消精选" + if view.Featured { + msg = "已设为精选,将出现在公开展柜" + } + c.JSON(http.StatusOK, gin.H{"message": msg, "instance": view}) +} + +// APIAdminUpdateCommunitySettings 更新社区上报设置 +func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) { + var req service.CommunityConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + return + } + _, err := h.Settings.UpdateCommunityConfig(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cfg := h.Settings.CommunityConfig() + out := gin.H{ + "message": "社区设置已保存", + "community": cfg, + } + if cfg.ReportEnabled && h.Community != nil { + origin := communityRequestOrigin(c) + if err := h.Community.SendHeartbeatOnce(origin); err != nil { + out["message"] = "社区设置已保存,但心跳未成功" + out["heartbeat_error"] = err.Error() + // 刷新 site_url(可能已由 Origin 持久化) + out["community"] = h.Settings.CommunityConfig() + } + } + c.JSON(http.StatusOK, out) +} + +// communityRequestOrigin 优先用浏览器 Origin(Vite 代理时 Host 可能是后端端口) +func communityRequestOrigin(c *gin.Context) string { + if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" { + return o + } + if ref := strings.TrimSpace(c.GetHeader("Referer")); ref != "" { + if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host + } + } + return requestOrigin(c) +} diff --git a/handler/handlers.go b/handler/handlers.go index dbb47c9..3df33aa 100644 --- a/handler/handlers.go +++ b/handler/handlers.go @@ -8,37 +8,38 @@ import ( "strconv" "strings" - "github.com/gin-gonic/gin" "git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/service" + "github.com/gin-gonic/gin" ) // Handlers 聚合所有 HTTP 处理器 type Handlers struct { - Cfg *config.Config - Store *service.UploadStore - Auth *service.AuthService - User *service.UserService - Board *service.BoardService - Post *service.PostService - Comment *service.CommentService - Message *service.MessageService - Notify *service.NotifyService - Report *service.ReportService - Backup *service.BackupService - Filter *service.SensitiveFilter - Limiter *service.RateLimiter - Settings *service.ForumSettingsService - Captcha *service.CaptchaService - Mail *service.MailService - EmailCode *service.EmailCodeService - OIDC *service.OIDCService - Gitea *service.GiteaService - Points *service.PointsService - Badge *service.BadgeService - SitePage *service.SitePageService + Cfg *config.Config + Store *service.UploadStore + Auth *service.AuthService + User *service.UserService + Board *service.BoardService + Post *service.PostService + Comment *service.CommentService + Message *service.MessageService + Notify *service.NotifyService + Report *service.ReportService + Backup *service.BackupService + Community *service.CommunityService + Filter *service.SensitiveFilter + Limiter *service.RateLimiter + Settings *service.ForumSettingsService + Captcha *service.CaptchaService + Mail *service.MailService + EmailCode *service.EmailCodeService + OIDC *service.OIDCService + Gitea *service.GiteaService + Points *service.PointsService + Badge *service.BadgeService + SitePage *service.SitePageService FriendLinkApply *service.FriendLinkApplyService } diff --git a/model/db.go b/model/db.go index 94a1ad2..fea011c 100644 --- a/model/db.go +++ b/model/db.go @@ -43,6 +43,7 @@ func InitDB(dbPath string) error { &PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{}, &BadgeDef{}, &UserBadge{}, &SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{}, + &CommunityInstance{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } diff --git a/model/models.go b/model/models.go index 7fdd7c8..f41543b 100644 --- a/model/models.go +++ b/model/models.go @@ -47,26 +47,26 @@ const ( // 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"` - 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:"-"` + 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"` @@ -99,31 +99,31 @@ type Board struct { // Post 帖子 type Post struct { - ID uint `gorm:"primaryKey" json:"id"` - BoardID uint `gorm:"index;not null" json:"board_id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Title string `gorm:"size:256;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引 - Tags string `gorm:"size:256" json:"tags"` - PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery - QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义 - BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty) - BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded - BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论 - LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery) - LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn - Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶 - BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶 - Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖 - EditLocked bool `gorm:"default:false" json:"edit_locked"` - CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴) - Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected - LikeCount int `gorm:"default:0" json:"like_count"` - ViewCount int `gorm:"default:0" json:"view_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + BoardID uint `gorm:"index;not null" json:"board_id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Title string `gorm:"size:256;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引 + Tags string `gorm:"size:256" json:"tags"` + PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery + QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义 + BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty) + BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded + BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论 + LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery) + LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn + Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶 + BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶 + Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖 + EditLocked bool `gorm:"default:false" json:"edit_locked"` + CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴) + Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected + LikeCount int `gorm:"default:0" json:"like_count"` + ViewCount int `gorm:"default:0" json:"view_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` @@ -162,21 +162,21 @@ type ForumSetting struct { // Comment 楼层评论 type Comment struct { - ID uint `gorm:"primaryKey" json:"id"` - PostID uint `gorm:"index;not null" json:"post_id"` - UserID uint `gorm:"index" json:"user_id"` // 0 表示游客 - Floor int `gorm:"not null" json:"floor"` - Content string `gorm:"type:text;not null" json:"content"` - ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"` - GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"` - GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"` - GuestURL string `gorm:"size:256" json:"guest_url,omitempty"` - IsPrivate bool `gorm:"default:false" json:"is_private"` - Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected - LikeCount int `gorm:"default:0" json:"like_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"index;not null" json:"post_id"` + UserID uint `gorm:"index" json:"user_id"` // 0 表示游客 + Floor int `gorm:"not null" json:"floor"` + Content string `gorm:"type:text;not null" json:"content"` + ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"` + GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"` + GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"` + GuestURL string `gorm:"size:256" json:"guest_url,omitempty"` + IsPrivate bool `gorm:"default:false" json:"is_private"` + Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected + LikeCount int `gorm:"default:0" json:"like_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` @@ -266,23 +266,23 @@ const ( // FriendLinkApply 友情链接申请 type FriendLinkApply struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` - Name string `gorm:"size:32;not null" json:"name"` - URL string `gorm:"size:512;not null" json:"url"` - Description string `gorm:"size:200;default:''" json:"description,omitempty"` - Logo string `gorm:"size:512;default:''" json:"logo"` - ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"` - LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"` - ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"` - ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"` - ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"` - Status string `gorm:"size:16;default:pending;index" json:"status"` - ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"` - ReviewedAt *time.Time `json:"reviewed_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:32;not null" json:"name"` + URL string `gorm:"size:512;not null" json:"url"` + Description string `gorm:"size:200;default:''" json:"description,omitempty"` + Logo string `gorm:"size:512;default:''" json:"logo"` + ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"` + LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"` + ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"` + ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"` + ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"` + Status string `gorm:"size:16;default:pending;index" json:"status"` + ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } @@ -301,10 +301,10 @@ type PostReport struct { CreatedAt time.Time `json:"created_at"` HandledAt *time.Time `json:"handled_at,omitempty"` - Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` - Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"` - Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"` - Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"` + Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"` + Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"` + Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"` + Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"` } // Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计) @@ -377,17 +377,17 @@ type PostContentUnlock struct { // SitePage 自定义单页(关于我们、版规等) type SitePage struct { - ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:128;not null" json:"title"` - Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"` - Content string `gorm:"type:text;not null" json:"content"` - Published bool `gorm:"default:false;index" json:"published"` - SortOrder int `gorm:"default:0" json:"sort_order"` - ShowInFooter bool `gorm:"default:false" json:"show_in_footer"` - ShowInNav bool `gorm:"default:false" json:"show_in_nav"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:128;not null" json:"title"` + Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"` + Content string `gorm:"type:text;not null" json:"content"` + Published bool `gorm:"default:false;index" json:"published"` + SortOrder int `gorm:"default:0" json:"sort_order"` + ShowInFooter bool `gorm:"default:false" json:"show_in_footer"` + ShowInNav bool `gorm:"default:false" json:"show_in_nav"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } // Poll 投票帖配置 @@ -446,7 +446,7 @@ type BadgeDef struct { 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 + 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"` @@ -458,10 +458,26 @@ type BadgeDef struct { // 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"` + 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"` +} + +// CommunityInstance 社区枢纽收到的公网实例心跳 +type CommunityInstance struct { + ID uint `gorm:"primaryKey" json:"id"` + InstanceID string `gorm:"size:64;uniqueIndex;not null" json:"instance_id"` + SiteURL string `gorm:"size:512;not null" json:"site_url"` + SiteName string `gorm:"size:128" json:"site_name"` + Version string `gorm:"size:32" json:"version"` + Users int64 `gorm:"default:0" json:"users"` + Posts int64 `gorm:"default:0" json:"posts"` + RemoteIP string `gorm:"size:64" json:"remote_ip,omitempty"` + Featured bool `gorm:"default:false;index" json:"featured"` // 人工精选后进入公开展柜 + FeaturedNote string `gorm:"size:64" json:"featured_note"` // 展柜短注 + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `gorm:"index" json:"last_seen_at"` } diff --git a/router/router.go b/router/router.go index 3270bc1..f2d05f1 100644 --- a/router/router.go +++ b/router/router.go @@ -36,6 +36,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { filter.LoadFromFile(cfg.FilterWordsPath()) settingsSvc := service.NewForumSettingsService() + settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub) + communitySvc := service.NewCommunityService(settingsSvc) + communitySvc.StartBackground() + if cfg.CommunityHub { + fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n") + } // SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案 embed_static.SetSPADocumentTitle(func() string { return settingsSvc.SiteBranding().DocumentTitle() @@ -87,7 +93,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { h := &handler.Handlers{ Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, - Backup: backupSvc, + Backup: backupSvc, Community: communitySvc, Filter: filter, Limiter: limiter, Settings: settingsSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, OIDC: oidcSvc, Gitea: giteaSvc, @@ -124,6 +130,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/me", h.APIMe) pubAPI.GET("/boards", h.APIBoards) pubAPI.GET("/stats", h.APIStats) + pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat) + pubAPI.GET("/community/showcase", h.APICommunityShowcase) pubAPI.GET("/forum-limits", h.APIForumLimits) pubAPI.GET("/site-branding", h.APISiteBranding) pubAPI.GET("/pages", h.APIPages) @@ -205,6 +213,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAPI.GET("/dashboard", h.APIAdminDashboard) adminAPI.GET("/settings", h.APIAdminSettings) adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings) + adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings) + adminAPI.GET("/community/instances", h.APIAdminCommunityInstances) + adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance) adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings) adminAPI.POST("/settings/mail/test", h.APIAdminTestMail) adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings) diff --git a/service/community.go b/service/community.go new file mode 100644 index 0000000..000126c --- /dev/null +++ b/service/community.go @@ -0,0 +1,379 @@ +package service + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "git.iioio.com/freefire/jiang13-forum/model" +) + +// AppVersion 由 cmd 通过 SetAppVersion 注入(ldflags) +var AppVersion = "dev" + +// SetAppVersion 设置运行时版本号 +func SetAppVersion(v string) { + v = strings.TrimSpace(v) + if v != "" { + AppVersion = v + } +} + +func newCommunityInstanceID() string { + return uuid.NewString() +} + +const ( + communityHeartbeatInterval = 24 * time.Hour + communityHeartbeatTimeout = 8 * time.Second + communityOnlineWithin = 72 * time.Hour + maxCommunitySiteURLLen = 512 + maxCommunitySiteNameLen = 128 + maxCommunityVersionLen = 32 + maxCommunityInstanceIDLen = 64 + maxCommunityFeaturedNoteLen = 64 +) + +var ( + ErrCommunityHubDisabled = errors.New("本站未开启社区枢纽") + ErrCommunityBadPayload = errors.New("心跳参数无效") + + // communityHubBaseURL 出站枢纽根地址(写死官方站;测试可临时覆盖) + communityHubBaseURL = DefaultCommunityHubURL +) + +// CommunityHeartbeatPayload 出站 / 入站心跳体 +type CommunityHeartbeatPayload struct { + InstanceID string `json:"instance_id"` + SiteURL string `json:"site_url"` + SiteName string `json:"site_name"` + Version string `json:"version"` + Users int64 `json:"users"` + Posts int64 `json:"posts"` +} + +// CommunityInstanceView 管理端列表项 +type CommunityInstanceView struct { + InstanceID string `json:"instance_id"` + SiteURL string `json:"site_url"` + SiteName string `json:"site_name"` + Version string `json:"version"` + Users int64 `json:"users"` + Posts int64 `json:"posts"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` + Online bool `json:"online"` + Featured bool `json:"featured"` + FeaturedNote string `json:"featured_note"` +} + +// CommunityShowcaseItem 公开展柜条目(不含敏感字段) +type CommunityShowcaseItem struct { + SiteURL string `json:"site_url"` + SiteName string `json:"site_name"` + Version string `json:"version"` + FeaturedNote string `json:"featured_note,omitempty"` +} + +// CommunityFeatureInput 管理端精选请求 +type CommunityFeatureInput struct { + Featured bool `json:"featured"` + FeaturedNote string `json:"featured_note"` +} + +// CommunityService 可选社区上报 + 枢纽接收 +type CommunityService struct { + settings *ForumSettingsService + client *http.Client + stopCh chan struct{} + wg sync.WaitGroup + kickCh chan struct{} +} + +// NewCommunityService 创建社区服务 +func NewCommunityService(settings *ForumSettingsService) *CommunityService { + return &CommunityService{ + settings: settings, + client: &http.Client{Timeout: communityHeartbeatTimeout}, + stopCh: make(chan struct{}), + kickCh: make(chan struct{}, 1), + } +} + +// StartBackground 启动 24h 心跳循环 +func (c *CommunityService) StartBackground() { + c.wg.Add(1) + go func() { + defer c.wg.Done() + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + for { + select { + case <-c.stopCh: + return + case <-c.kickCh: + c.trySendHeartbeat() + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(communityHeartbeatInterval) + case <-timer.C: + c.trySendHeartbeat() + timer.Reset(communityHeartbeatInterval) + } + } + }() +} + +// Stop 停止后台心跳 +func (c *CommunityService) Stop() { + select { + case <-c.stopCh: + default: + close(c.stopCh) + } + c.wg.Wait() +} + +// KickHeartbeat 请求尽快发送一次心跳(开启上报时调用) +func (c *CommunityService) KickHeartbeat() { + select { + case c.kickCh <- struct{}{}: + default: + } +} + +func (c *CommunityService) trySendHeartbeat() { + _ = c.SendHeartbeatOnce("") +} + +// SendHeartbeatOnce 立即发送一次心跳;requestOrigin 可在管理端保存时传入以补全本站地址 +func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error { + cfg := c.settings.CommunityConfig() + if !cfg.ReportEnabled { + return nil + } + if requestOrigin != "" { + if _, err := c.settings.EnsureCommunitySiteURL(requestOrigin); err != nil { + log.Printf("[community] 组装心跳失败: %v", err) + return err + } + } + payload, err := c.buildPayload(requestOrigin) + if err != nil { + log.Printf("[community] 组装心跳失败: %v", err) + return err + } + hub := strings.TrimRight(communityHubBaseURL, "/") + if hub == "" { + hub = DefaultCommunityHubURL + } + endpoint := hub + "/api/community/heartbeat" + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + log.Printf("[community] 创建请求失败: %v", err) + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "jiang13-forum/"+AppVersion) + resp, err := c.client.Do(req) + if err != nil { + log.Printf("[community] 上报失败: %v", err) + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + err := fmt.Errorf("上报被拒绝: HTTP %d", resp.StatusCode) + log.Printf("[community] %v", err) + return err + } + return nil +} + +func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbeatPayload, error) { + id, err := c.settings.EnsureCommunityInstanceID() + if err != nil { + return nil, err + } + siteURL := c.settings.CommunitySiteURL(requestOrigin) + if siteURL == "" { + return nil, fmt.Errorf("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报") + } + var users, posts int64 + _ = model.DB.Model(&model.User{}).Count(&users).Error + _ = model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts).Error + brand := c.settings.SiteBranding() + return &CommunityHeartbeatPayload{ + InstanceID: id, + SiteURL: truncateRunes(siteURL, maxCommunitySiteURLLen), + SiteName: truncateRunes(brand.Name, maxCommunitySiteNameLen), + Version: truncateRunes(AppVersion, maxCommunityVersionLen), + Users: users, + Posts: posts, + }, nil +} + +// ReceiveHeartbeat 枢纽接收心跳并 upsert +func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP string) error { + if !c.settings.CommunityConfig().HubEnabled { + return ErrCommunityHubDisabled + } + in.InstanceID = strings.TrimSpace(in.InstanceID) + in.SiteURL = strings.TrimSpace(in.SiteURL) + in.SiteName = strings.TrimSpace(in.SiteName) + in.Version = strings.TrimSpace(in.Version) + if in.InstanceID == "" || len(in.InstanceID) > maxCommunityInstanceIDLen { + return ErrCommunityBadPayload + } + if err := validateCommunitySiteURL(in.SiteURL); err != nil { + return err + } + in.SiteURL = truncateRunes(in.SiteURL, maxCommunitySiteURLLen) + in.SiteName = truncateRunes(in.SiteName, maxCommunitySiteNameLen) + in.Version = truncateRunes(in.Version, maxCommunityVersionLen) + if in.Users < 0 { + in.Users = 0 + } + if in.Posts < 0 { + in.Posts = 0 + } + now := time.Now() + var row model.CommunityInstance + res := model.DB.Where("instance_id = ?", in.InstanceID).Limit(1).Find(&row) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + row = model.CommunityInstance{ + InstanceID: in.InstanceID, + SiteURL: in.SiteURL, + SiteName: in.SiteName, + Version: in.Version, + Users: in.Users, + Posts: in.Posts, + RemoteIP: truncateRunes(remoteIP, 64), + FirstSeenAt: now, + LastSeenAt: now, + } + return model.DB.Create(&row).Error + } + row.SiteURL = in.SiteURL + row.SiteName = in.SiteName + row.Version = in.Version + row.Users = in.Users + row.Posts = in.Posts + row.RemoteIP = truncateRunes(remoteIP, 64) + row.LastSeenAt = now + return model.DB.Save(&row).Error +} + +// ListInstances 管理端实例列表(按最近心跳倒序) +func (c *CommunityService) ListInstances() ([]CommunityInstanceView, error) { + var rows []model.CommunityInstance + if err := model.DB.Order("last_seen_at DESC").Find(&rows).Error; err != nil { + return nil, err + } + now := time.Now() + out := make([]CommunityInstanceView, 0, len(rows)) + for _, r := range rows { + out = append(out, CommunityInstanceView{ + InstanceID: r.InstanceID, + SiteURL: r.SiteURL, + SiteName: r.SiteName, + Version: r.Version, + Users: r.Users, + Posts: r.Posts, + FirstSeenAt: r.FirstSeenAt, + LastSeenAt: r.LastSeenAt, + Online: now.Sub(r.LastSeenAt) <= communityOnlineWithin, + Featured: r.Featured, + FeaturedNote: r.FeaturedNote, + }) + } + return out, nil +} + +// SetInstanceFeatured 人工精选 / 取消;心跳无法自助上柜 +func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFeatureInput) (*CommunityInstanceView, error) { + instanceID = strings.TrimSpace(instanceID) + if instanceID == "" { + return nil, ErrCommunityBadPayload + } + var row model.CommunityInstance + if err := model.DB.Where("instance_id = ?", instanceID).First(&row).Error; err != nil { + return nil, err + } + row.Featured = in.Featured + if in.Featured { + row.FeaturedNote = truncateRunes(strings.TrimSpace(in.FeaturedNote), maxCommunityFeaturedNoteLen) + } else { + row.FeaturedNote = "" + } + if err := model.DB.Save(&row).Error; err != nil { + return nil, err + } + now := time.Now() + return &CommunityInstanceView{ + InstanceID: row.InstanceID, + SiteURL: row.SiteURL, + SiteName: row.SiteName, + Version: row.Version, + Users: row.Users, + Posts: row.Posts, + FirstSeenAt: row.FirstSeenAt, + LastSeenAt: row.LastSeenAt, + Online: now.Sub(row.LastSeenAt) <= communityOnlineWithin, + Featured: row.Featured, + FeaturedNote: row.FeaturedNote, + }, nil +} + +// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空 +func (c *CommunityService) ListShowcase() ([]CommunityShowcaseItem, error) { + if !c.settings.CommunityConfig().HubEnabled { + return []CommunityShowcaseItem{}, nil + } + var rows []model.CommunityInstance + if err := model.DB.Where("featured = ?", true).Order("last_seen_at DESC").Find(&rows).Error; err != nil { + return nil, err + } + out := make([]CommunityShowcaseItem, 0, len(rows)) + for _, r := range rows { + if validateCommunitySiteURL(r.SiteURL) != nil { + continue + } + out = append(out, CommunityShowcaseItem{ + SiteURL: r.SiteURL, + SiteName: r.SiteName, + Version: r.Version, + FeaturedNote: r.FeaturedNote, + }) + } + return out, nil +} + +func validateCommunitySiteURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return ErrCommunityBadPayload + } + if u.Scheme != "http" && u.Scheme != "https" { + return ErrCommunityBadPayload + } + return nil +} diff --git a/service/community_test.go b/service/community_test.go new file mode 100644 index 0000000..0923016 --- /dev/null +++ b/service/community_test.go @@ -0,0 +1,283 @@ +package service + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "git.iioio.com/freefire/jiang13-forum/model" +) + +func setupCommunityTest(t *testing.T) (*ForumSettingsService, *CommunityService) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.ForumSetting{}, + &model.CommunityInstance{}, + &model.User{}, + &model.Post{}, + ); err != nil { + t.Fatal(err) + } + prev := model.DB + model.DB = db + t.Cleanup(func() { model.DB = prev }) + + settings := NewForumSettingsService() + svc := NewCommunityService(settings) + return settings, svc +} + +func TestCommunityHeartbeatHubDisabled(t *testing.T) { + _, svc := setupCommunityTest(t) + err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{ + InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + SiteURL: "https://example.com", + SiteName: "测试站", + Version: "1.0.0", + Users: 1, + Posts: 2, + }, "127.0.0.1") + if !errors.Is(err, ErrCommunityHubDisabled) { + t.Fatalf("want ErrCommunityHubDisabled, got %v", err) + } +} + +func TestCommunityHeartbeatAcceptAndList(t *testing.T) { + settings, svc := setupCommunityTest(t) + settings.SetCommunityHubEnabled(true) + + payload := CommunityHeartbeatPayload{ + InstanceID: "11111111-2222-3333-4444-555555555555", + SiteURL: "https://forum.example.org", + SiteName: "示例论坛", + Version: "1.2.3", + Users: 10, + Posts: 20, + } + if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil { + t.Fatal(err) + } + payload.Users = 11 + payload.Posts = 21 + if err := svc.ReceiveHeartbeat(payload, "203.0.113.9"); err != nil { + t.Fatal(err) + } + + list, err := svc.ListInstances() + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("want 1 instance, got %d", len(list)) + } + got := list[0] + if got.Users != 11 || got.Posts != 21 || !got.Online { + t.Fatalf("unexpected row: %+v", got) + } + if got.SiteURL != payload.SiteURL || got.SiteName != payload.SiteName { + t.Fatalf("site fields mismatch: %+v", got) + } +} + +func TestCommunityUpdateIgnoresHubFields(t *testing.T) { + settings, _ := setupCommunityTest(t) + if settings.CommunityConfig().HubEnabled { + t.Fatal("hub should be off by default") + } + if _, err := settings.UpdateCommunityConfig(CommunityConfig{ + ReportEnabled: true, + HubEnabled: true, + HubURL: "https://evil.example", + SiteURL: "https://should-be-ignored.example", + }); err != nil { + t.Fatal(err) + } + cfg := settings.CommunityConfig() + if cfg.HubEnabled { + t.Fatal("UpdateCommunityConfig must not enable hub") + } + if cfg.HubURL != DefaultCommunityHubURL { + t.Fatalf("hub_url must stay official, got %s", cfg.HubURL) + } + if cfg.SiteURL == "https://should-be-ignored.example" { + t.Fatal("client site_url must be ignored") + } + if !cfg.ReportEnabled { + t.Fatal("report should be enabled") + } + settings.SetCommunityHubEnabled(true) + if !settings.CommunityConfig().HubEnabled { + t.Fatal("SetCommunityHubEnabled should enable hub") + } +} + +func TestCommunityHeartbeatBadURL(t *testing.T) { + settings, svc := setupCommunityTest(t) + settings.SetCommunityHubEnabled(true) + err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{ + InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + SiteURL: "javascript:alert(1)", + SiteName: "坏", + }, "127.0.0.1") + if !errors.Is(err, ErrCommunityBadPayload) { + t.Fatalf("want ErrCommunityBadPayload, got %v", err) + } +} + +func TestCommunityOutboundHeartbeat(t *testing.T) { + settings, svc := setupCommunityTest(t) + var hits atomic.Int32 + var lastBody CommunityHeartbeatPayload + + hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/community/heartbeat" { + http.NotFound(w, r) + return + } + defer r.Body.Close() + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &lastBody) + hits.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(hub.Close) + + prevHub := communityHubBaseURL + communityHubBaseURL = hub.URL + t.Cleanup(func() { communityHubBaseURL = prevHub }) + + svc.trySendHeartbeat() + if hits.Load() != 0 { + t.Fatal("report disabled should not send") + } + + if err := settings.setString(SettingOIDCRootURL, "http://reporter.local"); err != nil { + t.Fatal(err) + } + if err := settings.setString(SettingSiteName, "上报测试站"); err != nil { + t.Fatal(err) + } + if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil { + t.Fatal(err) + } + + if err := svc.SendHeartbeatOnce(""); err != nil { + t.Fatal(err) + } + if hits.Load() != 1 { + t.Fatalf("want 1 outbound hit, got %d", hits.Load()) + } + if lastBody.InstanceID == "" || lastBody.SiteURL == "" { + t.Fatalf("empty payload: %+v", lastBody) + } + + if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: false}); err != nil { + t.Fatal(err) + } + if err := svc.SendHeartbeatOnce(""); err != nil { + t.Fatal(err) + } + if hits.Load() != 1 { + t.Fatalf("after disable want still 1 hit, got %d", hits.Load()) + } +} + +func TestCommunityFeatureAndShowcase(t *testing.T) { + settings, svc := setupCommunityTest(t) + settings.SetCommunityHubEnabled(true) + + payload := CommunityHeartbeatPayload{ + InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + SiteURL: "https://forum.example.org", + SiteName: "示例论坛", + Version: "2.0.0", + } + if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil { + t.Fatal(err) + } + + empty, err := svc.ListShowcase() + if err != nil { + t.Fatal(err) + } + if len(empty) != 0 { + t.Fatal("showcase should be empty before feature") + } + + view, err := svc.SetInstanceFeatured(payload.InstanceID, CommunityFeatureInput{ + Featured: true, + FeaturedNote: "精选自托管", + }) + if err != nil { + t.Fatal(err) + } + if !view.Featured || view.FeaturedNote != "精选自托管" { + t.Fatalf("unexpected view: %+v", view) + } + + items, err := svc.ListShowcase() + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].SiteURL != payload.SiteURL { + t.Fatalf("showcase=%+v", items) + } + + // 心跳更新不得清掉精选 + payload.Users = 9 + if err := svc.ReceiveHeartbeat(payload, "127.0.0.1"); err != nil { + t.Fatal(err) + } + items, err = svc.ListShowcase() + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].FeaturedNote != "精选自托管" { + t.Fatalf("featured lost after heartbeat: %+v", items) + } + + settings.SetCommunityHubEnabled(false) + items, err = svc.ListShowcase() + if err != nil { + t.Fatal(err) + } + if len(items) != 0 { + t.Fatal("hub off should hide showcase") + } +} + +func TestCommunitySiteURLFromOrigin(t *testing.T) { + settings, svc := setupCommunityTest(t) + if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil { + t.Fatal(err) + } + u, err := settings.EnsureCommunitySiteURL("http://localhost:5173") + if err != nil { + t.Fatal(err) + } + if u != "http://localhost:5173" { + t.Fatalf("got %s", u) + } + if settings.CommunitySiteURL("") != "http://localhost:5173" { + t.Fatal("should persist for ticker") + } + payload, err := svc.buildPayload("") + if err != nil { + t.Fatal(err) + } + if payload.SiteURL != "http://localhost:5173" { + t.Fatalf("payload site_url=%s", payload.SiteURL) + } +} diff --git a/service/ratelimit.go b/service/ratelimit.go index c78826b..1aa6408 100644 --- a/service/ratelimit.go +++ b/service/ratelimit.go @@ -54,6 +54,9 @@ func (r *RateLimiter) limitFor(action string) int { if action == "friend_link" { return 5 } + if action == "community_heartbeat" { + return 30 + } return r.settings.RateLimitFor(action) } @@ -61,6 +64,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration { if action == "friend_link" { return time.Hour } + if action == "community_heartbeat" { + return time.Hour + } return time.Duration(r.settings.RateLimitWindowSec()) * time.Second } diff --git a/service/settings.go b/service/settings.go index 42b2d51..962f26c 100644 --- a/service/settings.go +++ b/service/settings.go @@ -93,6 +93,15 @@ const ( SettingSiteFriendLinks = "site_friend_links" SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check" + SettingCommunityReportEnabled = "community_report_enabled" + SettingCommunityHubEnabled = "community_hub_enabled" // 遗留键,不再作为开关来源 + SettingCommunityInstanceID = "community_instance_id" + SettingCommunityHubURL = "community_hub_url" + SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL) + + // DefaultCommunityHubURL 官方演示站(社区枢纽默认地址) + DefaultCommunityHubURL = "https://bbs.iioio.com" + // pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项 pageSizeAPIMax = 100 ) @@ -282,6 +291,14 @@ var friendLinkSettingDefaults = map[string]string{ SettingFooterShowFriendLinks: "1", } +var communitySettingDefaults = map[string]string{ + SettingCommunityReportEnabled: "0", + SettingCommunityHubEnabled: "0", + SettingCommunityInstanceID: "", + SettingCommunityHubURL: DefaultCommunityHubURL, + SettingCommunitySiteURL: "", +} + var siteBrandingDefaults = map[string]string{ SettingSiteName: "姜十三论坛", SettingSiteSlogan: "拾三一隅,自在交流", @@ -375,6 +392,15 @@ type GiteaSyncConfig struct { RepoCount int64 `json:"repo_count"` } +// CommunityConfig 社区上报配置(HubEnabled 只读,来自运维配置) +type CommunityConfig struct { + ReportEnabled bool `json:"report_enabled"` + HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量 + HubURL string `json:"hub_url"` + SiteURL string `json:"site_url"` // 上报用的本站公开地址 + InstanceID string `json:"instance_id"` +} + // OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients) type OIDCConfig struct { Enabled bool `json:"enabled"` @@ -391,7 +417,8 @@ type OIDCConfig struct { // ForumSettingsService 论坛全局设置 type ForumSettingsService struct { - mu sync.RWMutex + mu sync.RWMutex + communityHubEnabled bool // 运维配置注入,非后台可改 } func NewForumSettingsService() *ForumSettingsService { @@ -400,6 +427,13 @@ func NewForumSettingsService() *ForumSettingsService { return s } +// SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽 +func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) { + s.mu.Lock() + s.communityHubEnabled = enabled + s.mu.Unlock() +} + func (s *ForumSettingsService) ensureDefaults() { for _, def := range forumSettingDefs { var count int64 @@ -464,6 +498,13 @@ func (s *ForumSettingsService) ensureDefaults() { model.DB.Create(&model.ForumSetting{Key: key, Value: val}) } } + for key, val := range communitySettingDefaults { + var count int64 + model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count) + if count == 0 { + model.DB.Create(&model.ForumSetting{Key: key, Value: val}) + } + } } func (s *ForumSettingsService) getString(key, fallback string) string { @@ -1090,6 +1131,74 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error { return nil } +// CommunityConfig 读取社区上报配置 +func (s *ForumSettingsService) CommunityConfig() CommunityConfig { + s.mu.RLock() + hubEnabled := s.communityHubEnabled + s.mu.RUnlock() + return CommunityConfig{ + ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1", + HubEnabled: hubEnabled, + HubURL: DefaultCommunityHubURL, + SiteURL: s.CommunitySiteURL(""), + InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")), + } +} + +// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin +func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string { + if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" { + return strings.TrimRight(u, "/") + } + return s.SitePublicBaseURL(requestOrigin) +} + +// EnsureCommunitySiteURL 在开启上报时确保有可用的本站公开地址;origin 可来自当前管理请求 +func (s *ForumSettingsService) EnsureCommunitySiteURL(requestOrigin string) (string, error) { + if u := s.CommunitySiteURL(requestOrigin); u != "" { + // 若仅靠 Origin 推断,持久化以便后台 ticker 使用 + if normalizeRootURL(s.getString(SettingCommunitySiteURL, "")) == "" && + normalizeRootURL(s.getString(SettingOIDCRootURL, "")) == "" { + if err := s.setString(SettingCommunitySiteURL, u); err != nil { + return "", err + } + } + return u, nil + } + return "", errors.New("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报") +} + +// EnsureCommunityInstanceID 确保本机有稳定的匿名实例 ID +func (s *ForumSettingsService) EnsureCommunityInstanceID() (string, error) { + id := strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")) + if id != "" { + return id, nil + } + id = newCommunityInstanceID() + if err := s.setString(SettingCommunityInstanceID, id); err != nil { + return "", err + } + return id, nil +} + +// UpdateCommunityConfig 仅更新上报开关;忽略客户端传入的 hub_url / site_url +func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasReportEnabled bool, err error) { + wasReportEnabled = s.getString(SettingCommunityReportEnabled, "0") == "1" + report := "0" + if in.ReportEnabled { + report = "1" + } + if err := s.setString(SettingCommunityReportEnabled, report); err != nil { + return wasReportEnabled, err + } + if in.ReportEnabled { + if _, err := s.EnsureCommunityInstanceID(); err != nil { + return wasReportEnabled, err + } + } + return wasReportEnabled, nil +} + // StorageConfig 读取上传存储配置(含密钥明文,供内部使用) func (s *ForumSettingsService) StorageConfig() StorageConfig { secret := s.getString(SettingStorageSecretKey, "")