feat: 可选社区上报与官方精选展柜

默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 04:22:59 +08:00
parent 0abfb32194
commit 95c2154087
26 changed files with 1730 additions and 147 deletions

View File

@@ -196,6 +196,7 @@ docker run -d --name jiang13 \
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret` | | `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret` |
| `JIANG13_CONFIG` | 配置文件路径 | | `JIANG13_CONFIG` | 配置文件路径 |
| `JIANG13_WORK_PATH` | 工作目录 | | `JIANG13_WORK_PATH` | 工作目录 |
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报(默认关闭,见 `app.ini.example` |
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。 **健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
@@ -292,7 +293,7 @@ JWT_SECRET =
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | | `--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. 注册为系统服务(可选) ### 5. 注册为系统服务(可选)

View File

@@ -13,3 +13,11 @@ DATA = data
[security] [security]
; 留空则自动生成并持久化到 data/.jwt_secret勿把生产密钥提交到仓库 ; 留空则自动生成并持久化到 data/.jwt_secret勿把生产密钥提交到仓库
JWT_SECRET = JWT_SECRET =
; ---------------------------------------------------------------------------
; 维护者选项(默认关闭;普通自托管无需开启)
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
; 等价环境变量JIANG13_COMMUNITY_HUB=1
; ---------------------------------------------------------------------------
; [community]
; HUB = false

View File

@@ -8,12 +8,14 @@ import (
"github.com/kardianos/service" "github.com/kardianos/service"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
appsvc "git.iioio.com/freefire/jiang13-forum/service"
) )
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入 // version 由构建脚本通过 -ldflags "-X main.version=..." 注入
var version = "dev" var version = "dev"
func main() { func main() {
appsvc.SetAppVersion(version)
cfg, err := config.Parse() cfg, err := config.Parse()
if err != nil { if err != nil {
log.Fatalf("配置解析失败: %v", err) log.Fatalf("配置解析失败: %v", err)

View File

@@ -32,6 +32,8 @@ type Config struct {
ServiceAction string ServiceAction string
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源) // 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
DevMode bool DevMode bool
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)
CommunityHub bool
} }
// Parse 解析命令行、环境变量与 app.ini并初始化数据目录 // Parse 解析命令行、环境变量与 app.ini并初始化数据目录
@@ -108,6 +110,11 @@ func Parse() (*Config, error) {
jwtSecret = strings.TrimSpace(*jwtFlag) jwtSecret = strings.TrimSpace(*jwtFlag)
} }
communityHub := fileCfg.CommunityHub
if v := envBoolOrNil(envCommunityHub); v != nil {
communityHub = *v
}
cfg := &Config{ cfg := &Config{
WorkPath: workPath, WorkPath: workPath,
ConfigFile: configFile, ConfigFile: configFile,
@@ -117,6 +124,7 @@ func Parse() (*Config, error) {
LogFile: filepath.Join(absData, "jiang13.log"), LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action, ServiceAction: action,
DevMode: *devFlag, DevMode: *devFlag,
CommunityHub: communityHub,
} }
needDirs := action == "" || action == "install" needDirs := action == "" || action == "install"

View File

@@ -13,6 +13,7 @@ const (
envHTTPPort = "JIANG13_HTTP_PORT" envHTTPPort = "JIANG13_HTTP_PORT"
envData = "JIANG13_DATA" envData = "JIANG13_DATA"
envJWTSecret = "JIANG13_JWT_SECRET" envJWTSecret = "JIANG13_JWT_SECRET"
envCommunityHub = "JIANG13_COMMUNITY_HUB"
) )
func envOrDefault(key string) string { func envOrDefault(key string) string {
@@ -30,3 +31,21 @@ func envIntOrZero(key string) int {
} }
return n 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
}
}

View File

@@ -21,6 +21,7 @@ type fileSettings struct {
Port int Port int
DataRel string DataRel string
JWTSecret string JWTSecret string
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
} }
func defaultFileSettings() fileSettings { func defaultFileSettings() fileSettings {
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String()) 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 return out, nil
} }

View File

@@ -32,6 +32,7 @@ const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage')); const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage')); const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage')); const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage')); const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage')); const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage')); 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 AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView')); const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage')); const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage')); const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
const router = createBrowserRouter( const router = createBrowserRouter(
@@ -61,6 +63,7 @@ const router = createBrowserRouter(
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} /> <Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} /> <Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} /> <Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
<Route path="community" element={<Suspense fallback={<PageLoader />}><AdminCommunityPage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} /> <Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} /> <Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} /> <Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
@@ -82,6 +85,7 @@ const router = createBrowserRouter(
<Route path="/favorites" element={<FavoritesPage />} /> <Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
<Route path="/links" element={<LinksPage />} /> <Route path="/links" element={<LinksPage />} />
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
<Route path="/messages" element={<MessagesPage />} /> <Route path="/messages" element={<MessagesPage />} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} /> <Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} /> <Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />

View File

@@ -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 = ''; const BASE = '';
@@ -63,6 +63,19 @@ export const api = {
// 管理后台 API // 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'), adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'), adminSettings: () => request<AdminSettings>('/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 }) => { adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page)); if (params.page) q.set('page', String(params.page));

View File

@@ -376,10 +376,45 @@ export interface AdminSettings {
gitea?: GiteaSyncConfig; gitea?: GiteaSyncConfig;
storage?: StorageConfig; storage?: StorageConfig;
branding?: SiteBranding; branding?: SiteBranding;
community?: CommunityConfig;
filter_words: string; filter_words: string;
filter_word_count: number; 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 { export interface StorageConfig {
type: 'local' | 's3'; type: 'local' | 's3';
endpoint: string; endpoint: string;

View File

@@ -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<CommunityConfig>(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 (
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
<div className="admin-community-support-strip-main">
<Heart size={16} className="admin-community-support-strip-icon" aria-hidden />
<div className="admin-community-support-strip-copy">
<strong></strong>
<span>
bbs.iioio.com
</span>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={community.report_enabled}
aria-busy={saving}
disabled={saving || !ready}
className={`admin-settings-switch${community.report_enabled ? ' is-on' : ''}`}
onClick={() => void handleToggle()}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import { 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'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
@@ -23,6 +23,8 @@ type NavItem = {
label: string; label: string;
icon: typeof LayoutDashboard; icon: typeof LayoutDashboard;
badgeKey?: BadgeKey; badgeKey?: BadgeKey;
/** 仅社区枢纽开启时显示 */
hubOnly?: boolean;
}; };
type NavGroup = { type NavGroup = {
@@ -54,6 +56,7 @@ const NAV_GROUPS: NavGroup[] = [
{ to: '/admin/users', label: '用户管理', icon: Users }, { to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/badges', label: '徽章管理', icon: Award }, { to: '/admin/badges', label: '徽章管理', icon: Award },
{ to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' }, { 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 isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false); const [navOpen, setNavOpen] = useState(false);
const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0, links: 0 }); const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0, links: 0 });
const [communityHub, setCommunityHub] = useState(false);
const nav = useNavigate(); const nav = useNavigate();
const location = useLocation(); const location = useLocation();
const drawerRef = useRef<HTMLElement>(null); const drawerRef = useRef<HTMLElement>(null);
@@ -107,6 +111,13 @@ export default function AdminLayout() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
if (loading || !user || user.role !== 'admin') return;
api.adminSettings()
.then(s => setCommunityHub(!!s.community?.hub_enabled))
.catch(() => setCommunityHub(false));
}, [loading, user]);
useEffect(() => { useEffect(() => {
if (loading || !user || user.role !== 'admin') return; if (loading || !user || user.role !== 'admin') return;
refreshPending(); refreshPending();
@@ -156,7 +167,7 @@ export default function AdminLayout() {
NAV_GROUPS.map(group => ( NAV_GROUPS.map(group => (
<div key={group.label} className="admin-nav-group"> <div key={group.label} className="admin-nav-group">
<div className="admin-nav-group-label">{group.label}</div> <div className="admin-nav-group-label">{group.label}</div>
{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; const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null;
return ( return (
<NavLink <NavLink

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { ExternalLink, Globe2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { InFlowSiteFooter } from '../components/SiteFooter';
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
export default function ShowcasePage() {
const { branding } = useSiteBranding();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
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 (
<div className="showcase-page">
<header className="showcase-head">
<div className="showcase-head-mark" aria-hidden>
<Globe2 size={22} />
</div>
<div>
<h1 className="showcase-title"></h1>
<p className="showcase-desc">
</p>
</div>
</header>
{loading ? (
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : items.length === 0 ? (
<p className="showcase-empty"></p>
) : (
<ul className="showcase-list">
{items.map((item) => (
<li key={item.site_url} className="showcase-item">
<a
href={item.site_url}
target="_blank"
rel="noopener noreferrer"
className="showcase-item-link"
>
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span>
<span className="showcase-item-url">
{item.site_url}
<ExternalLink size={12} aria-hidden />
</span>
{(item.featured_note || item.version) && (
<span className="showcase-item-meta">
{item.featured_note || null}
{item.featured_note && item.version ? ' · ' : null}
{item.version ? `v${item.version}` : null}
</span>
)}
</a>
</li>
))}
</ul>
)}
<InFlowSiteFooter />
</div>
);
}

View File

@@ -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<CommunityInstance[]>([]);
const [loading, setLoading] = useState(true);
const [featuringId, setFeaturingId] = useState<string | null>(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 <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
return (
<div className="admin-page">
<div className="admin-page-head">
<div>
<h1 className="admin-page-title">
<Globe2 size={22} aria-hidden />
</h1>
<p className="admin-page-desc">
{' '}
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer"></Link>
</p>
</div>
</div>
{!hubEnabled && (
<div className="admin-card admin-settings-card" style={{ marginBottom: 16 }}>
<div className="admin-card-body">
<p>
<code>app.ini</code> <code>[community] hub = true</code>
{' '} <code>JIANG13_COMMUNITY_HUB=1</code>
</p>
</div>
</div>
)}
<div className="admin-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{list.length} </span>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{list.length === 0 ? (
<p className="admin-empty" style={{ padding: 24 }}></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{list.map((row) => (
<tr key={row.instance_id}>
<td>
<Badge variant={row.online ? 'default' : 'secondary'}>
{row.online ? '在线' : '离线'}
</Badge>
</td>
<td>
<div className="admin-community-site">
<strong>
{row.featured && <Star size={12} className="admin-community-star" aria-hidden />}
{row.site_name || '未命名站点'}
</strong>
<a
href={row.site_url}
target="_blank"
rel="noopener noreferrer"
className={cn('admin-inline-link', 'admin-community-url')}
>
{row.site_url}
<ExternalLink size={12} aria-hidden />
</a>
</div>
</td>
<td><code>{row.version || '—'}</code></td>
<td>{row.users}</td>
<td>{row.posts}</td>
<td title={row.last_seen_at}>{formatTime(row.last_seen_at)}</td>
<td>
<Button
variant="outline"
size="sm"
loading={featuringId === row.instance_id}
disabled={!!featuringId}
onClick={() => void handleToggleFeatured(row)}
>
{row.featured ? '取消精选' : '精选'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout'; import { useAdminGuard } from '../../layouts/AdminLayout';
import type { AdminDashboard } from '../../api/types'; import type { AdminDashboard } from '../../api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const nav = useNavigate(); const nav = useNavigate();
@@ -127,6 +128,8 @@ export default function AdminDashboardPage() {
</div> </div>
</section> </section>
<CommunitySupportStrip />
<div className="admin-card"> <div className="admin-card">
<div className="admin-card-head"> <div className="admin-card-head">
<span></span> <span></span>

View File

@@ -14006,6 +14006,201 @@ button.post-poll__option,
} }
.admin-inline-link:hover { text-decoration: underline; } .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 { .admin-links-logo-thumb {
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 40px;

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1 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/kardianos/service v1.2.2
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/minio/minio-go/v7 v7.0.98 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/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // 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/gorilla/css v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect

View File

@@ -9,10 +9,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
) )
// APIMe 当前登录用户 // APIMe 当前登录用户
@@ -574,6 +574,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
"gitea": h.Settings.GiteaSyncConfigPublic(), "gitea": h.Settings.GiteaSyncConfigPublic(),
"storage": h.Settings.StorageConfigPublic(), "storage": h.Settings.StorageConfigPublic(),
"branding": h.Settings.SiteBranding(), "branding": h.Settings.SiteBranding(),
"community": h.Settings.CommunityConfig(),
"filter_words": filterContent, "filter_words": filterContent,
"filter_word_count": service.CountFilterWords(filterContent), "filter_word_count": service.CountFilterWords(filterContent),
}) })

139
handler/community.go Normal file
View File

@@ -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 优先用浏览器 OriginVite 代理时 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)
}

View File

@@ -8,11 +8,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/config" "git.iioio.com/freefire/jiang13-forum/config"
"git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/middleware"
"git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service" "git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
) )
// Handlers 聚合所有 HTTP 处理器 // Handlers 聚合所有 HTTP 处理器
@@ -28,6 +28,7 @@ type Handlers struct {
Notify *service.NotifyService Notify *service.NotifyService
Report *service.ReportService Report *service.ReportService
Backup *service.BackupService Backup *service.BackupService
Community *service.CommunityService
Filter *service.SensitiveFilter Filter *service.SensitiveFilter
Limiter *service.RateLimiter Limiter *service.RateLimiter
Settings *service.ForumSettingsService Settings *service.ForumSettingsService

View File

@@ -43,6 +43,7 @@ func InitDB(dbPath string) error {
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{}, &PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
&BadgeDef{}, &UserBadge{}, &BadgeDef{}, &UserBadge{},
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{}, &SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
&CommunityInstance{},
); err != nil { ); err != nil {
return fmt.Errorf("自动迁移失败: %w", err) return fmt.Errorf("自动迁移失败: %w", err)
} }

View File

@@ -465,3 +465,19 @@ type UserBadge struct {
AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统 AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"` 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"`
}

View File

@@ -36,6 +36,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
filter.LoadFromFile(cfg.FilterWordsPath()) filter.LoadFromFile(cfg.FilterWordsPath())
settingsSvc := service.NewForumSettingsService() 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避免刷新时先闪默认文案 // SPA 入口 HTML 注入标题与品牌 JSON避免刷新时先闪默认文案
embed_static.SetSPADocumentTitle(func() string { embed_static.SetSPADocumentTitle(func() string {
return settingsSvc.SiteBranding().DocumentTitle() return settingsSvc.SiteBranding().DocumentTitle()
@@ -87,7 +93,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
h := &handler.Handlers{ h := &handler.Handlers{
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc, Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
Backup: backupSvc, Backup: backupSvc, Community: communitySvc,
Filter: filter, Limiter: limiter, Settings: settingsSvc, Filter: filter, Limiter: limiter, Settings: settingsSvc,
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
OIDC: oidcSvc, Gitea: giteaSvc, OIDC: oidcSvc, Gitea: giteaSvc,
@@ -124,6 +130,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/me", h.APIMe) pubAPI.GET("/me", h.APIMe)
pubAPI.GET("/boards", h.APIBoards) pubAPI.GET("/boards", h.APIBoards)
pubAPI.GET("/stats", h.APIStats) 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("/forum-limits", h.APIForumLimits)
pubAPI.GET("/site-branding", h.APISiteBranding) pubAPI.GET("/site-branding", h.APISiteBranding)
pubAPI.GET("/pages", h.APIPages) pubAPI.GET("/pages", h.APIPages)
@@ -205,6 +213,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminAPI.GET("/dashboard", h.APIAdminDashboard) adminAPI.GET("/dashboard", h.APIAdminDashboard)
adminAPI.GET("/settings", h.APIAdminSettings) adminAPI.GET("/settings", h.APIAdminSettings)
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings) 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.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail) adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings) adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)

379
service/community.go Normal file
View File

@@ -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
}

283
service/community_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -54,6 +54,9 @@ func (r *RateLimiter) limitFor(action string) int {
if action == "friend_link" { if action == "friend_link" {
return 5 return 5
} }
if action == "community_heartbeat" {
return 30
}
return r.settings.RateLimitFor(action) return r.settings.RateLimitFor(action)
} }
@@ -61,6 +64,9 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
if action == "friend_link" { if action == "friend_link" {
return time.Hour return time.Hour
} }
if action == "community_heartbeat" {
return time.Hour
}
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
} }

View File

@@ -93,6 +93,15 @@ const (
SettingSiteFriendLinks = "site_friend_links" SettingSiteFriendLinks = "site_friend_links"
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check" 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 单次列表请求条数硬上限(防客户端传超大 size非后台可配项
pageSizeAPIMax = 100 pageSizeAPIMax = 100
) )
@@ -282,6 +291,14 @@ var friendLinkSettingDefaults = map[string]string{
SettingFooterShowFriendLinks: "1", SettingFooterShowFriendLinks: "1",
} }
var communitySettingDefaults = map[string]string{
SettingCommunityReportEnabled: "0",
SettingCommunityHubEnabled: "0",
SettingCommunityInstanceID: "",
SettingCommunityHubURL: DefaultCommunityHubURL,
SettingCommunitySiteURL: "",
}
var siteBrandingDefaults = map[string]string{ var siteBrandingDefaults = map[string]string{
SettingSiteName: "姜十三论坛", SettingSiteName: "姜十三论坛",
SettingSiteSlogan: "拾三一隅,自在交流", SettingSiteSlogan: "拾三一隅,自在交流",
@@ -375,6 +392,15 @@ type GiteaSyncConfig struct {
RepoCount int64 `json:"repo_count"` 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 // OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients
type OIDCConfig struct { type OIDCConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -392,6 +418,7 @@ type OIDCConfig struct {
// ForumSettingsService 论坛全局设置 // ForumSettingsService 论坛全局设置
type ForumSettingsService struct { type ForumSettingsService struct {
mu sync.RWMutex mu sync.RWMutex
communityHubEnabled bool // 运维配置注入,非后台可改
} }
func NewForumSettingsService() *ForumSettingsService { func NewForumSettingsService() *ForumSettingsService {
@@ -400,6 +427,13 @@ func NewForumSettingsService() *ForumSettingsService {
return s return s
} }
// SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽
func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) {
s.mu.Lock()
s.communityHubEnabled = enabled
s.mu.Unlock()
}
func (s *ForumSettingsService) ensureDefaults() { func (s *ForumSettingsService) ensureDefaults() {
for _, def := range forumSettingDefs { for _, def := range forumSettingDefs {
var count int64 var count int64
@@ -464,6 +498,13 @@ func (s *ForumSettingsService) ensureDefaults() {
model.DB.Create(&model.ForumSetting{Key: key, Value: val}) 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 { func (s *ForumSettingsService) getString(key, fallback string) string {
@@ -1090,6 +1131,74 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
return nil 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 读取上传存储配置(含密钥明文,供内部使用) // StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
func (s *ForumSettingsService) StorageConfig() StorageConfig { func (s *ForumSettingsService) StorageConfig() StorageConfig {
secret := s.getString(SettingStorageSecretKey, "") secret := s.getString(SettingStorageSecretKey, "")