feat: 可选社区上报与官方精选展柜
默认关闭,仪表盘一键开关;枢纽由运维配置收报,人工精选后展示于 /showcase。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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. 注册为系统服务(可选)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>} />
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
85
frontend/src/components/admin/CommunitySupportStrip.tsx
Normal file
85
frontend/src/components/admin/CommunitySupportStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
87
frontend/src/pages/ShowcasePage.tsx
Normal file
87
frontend/src/pages/ShowcasePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/src/pages/admin/AdminCommunityPage.tsx
Normal file
159
frontend/src/pages/admin/AdminCommunityPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
2
go.mod
@@ -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
|
||||||
|
|||||||
@@ -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
139
handler/community.go
Normal 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 优先用浏览器 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
379
service/community.go
Normal 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
283
service/community_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user