security: stop tracking env files; admin gate via VITE_SITE_ADMIN_GATE; Redis/cache/docs
Remove InfoGenie-frontend and Go .env from version control; add .env.example templates; ignore .claude local settings. Admin UI reads site gate from env only. Note: rotate secrets if repo history was ever public. Made-with: Cursor
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,6 +5,7 @@ debug-logs/
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -31,3 +32,10 @@ infogenie-backend-go/go.work*
|
||||
InfoGenie-backend/.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
**/.env
|
||||
**/.env.development
|
||||
**/.env.production
|
||||
**/.env.local
|
||||
# 允许提交模板(勿填真实密钥)
|
||||
!infogenie-frontend/.env.example
|
||||
!infogenie-backend-go/.env.example
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 复制为 .env.local 后可按需覆盖本地开发值
|
||||
VITE_API_URL=http://127.0.0.1:5002
|
||||
VITE_AUTH_URL=https://auth.shumengya.top
|
||||
VITE_AUTH_API_URL=https://auth.api.shumengya.top
|
||||
VITE_NAME=InfoGenie
|
||||
VITE_VERSION=1.0.0
|
||||
VITE_DEBUG=true
|
||||
7
InfoGenie-frontend/.env.example
Normal file
7
InfoGenie-frontend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# 复制为 .env.development / .env.production 后填写;勿提交真实密钥
|
||||
VITE_API_URL=http://127.0.0.1:5002
|
||||
VITE_AUTH_URL=https://auth.shumengya.top
|
||||
VITE_AUTH_API_URL=https://auth.api.shumengya.top
|
||||
VITE_DEBUG=true
|
||||
# 须与 Go 后端 INFOGENIE_SITE_ADMIN_TOKEN 一致
|
||||
VITE_SITE_ADMIN_GATE=
|
||||
@@ -1,7 +0,0 @@
|
||||
# 生产前端环境变量
|
||||
VITE_API_URL=https://infogenie.api.shumengya.top
|
||||
VITE_AUTH_URL=https://auth.shumengya.top
|
||||
VITE_AUTH_API_URL=https://auth.api.shumengya.top
|
||||
VITE_NAME=InfoGenie
|
||||
VITE_VERSION=1.0.0
|
||||
VITE_DEBUG=false
|
||||
@@ -4,11 +4,10 @@ import { FiUser, FiMenu, FiX, FiLogOut } from 'react-icons/fi';
|
||||
import { useUser } from '../contexts/UserContext';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import { ENV_CONFIG } from '../config/env';
|
||||
|
||||
const logoSrc = '/assets/logo.png';
|
||||
|
||||
const ADMIN_TOKEN = 'shumengya520';
|
||||
|
||||
const Header = () => {
|
||||
const { user, isLoggedIn, logout } = useUser();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -27,7 +26,7 @@ const Header = () => {
|
||||
|
||||
if (clickCountRef.current >= 5) {
|
||||
clickCountRef.current = 0;
|
||||
if (localStorage.getItem('admin_token') === ADMIN_TOKEN) {
|
||||
if (ENV_CONFIG.SITE_ADMIN_GATE && localStorage.getItem('admin_token') === ENV_CONFIG.SITE_ADMIN_GATE) {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
setAdminInput('');
|
||||
@@ -44,8 +43,8 @@ const Header = () => {
|
||||
}, [navigate]);
|
||||
|
||||
const handleAdminSubmit = () => {
|
||||
if (adminInput === ADMIN_TOKEN) {
|
||||
localStorage.setItem('admin_token', ADMIN_TOKEN);
|
||||
if (ENV_CONFIG.SITE_ADMIN_GATE && adminInput === ENV_CONFIG.SITE_ADMIN_GATE) {
|
||||
localStorage.setItem('admin_token', ENV_CONFIG.SITE_ADMIN_GATE);
|
||||
setShowAdminModal(false);
|
||||
toast.success('管理员验证通过');
|
||||
navigate('/admin');
|
||||
|
||||
@@ -1,146 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
const accent = '#22c55e';
|
||||
const soft = 'rgba(74, 222, 128, 0.18)';
|
||||
const stroke = 1.8;
|
||||
/** 与 StaticPageConfig SMALL_GAMES 的 id 对齐 */
|
||||
const GAME_EMOJI = {
|
||||
'2048': { emoji: '🔢', label: '2048' },
|
||||
'white-tile': { emoji: '🎹', label: '别踩白方块' },
|
||||
tetris: { emoji: '🧱', label: '俄罗斯方块' },
|
||||
snake: { emoji: '🐍', label: '贪吃蛇' },
|
||||
minesweeper: { emoji: '💣', label: '扫雷' },
|
||||
'dodge-leaves': { emoji: '🍃', label: '躲树叶' },
|
||||
plane: { emoji: '✈️', label: '打飞机' },
|
||||
parkour: { emoji: '🏃', label: '跑酷' },
|
||||
floppybird: { emoji: '🐦', label: 'Floppy Bird' },
|
||||
h5cube: { emoji: '🧩', label: '网页魔方' },
|
||||
sokoban: { emoji: '📦', label: '推箱子' },
|
||||
};
|
||||
|
||||
function SvgBox({ children, ...rest }) {
|
||||
export function SmallGameIcon({ gameId }) {
|
||||
const meta = GAME_EMOJI[gameId] || { emoji: '🎮', label: '小游戏' };
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
{...rest}
|
||||
<span
|
||||
role="img"
|
||||
aria-label={meta.label}
|
||||
className="inline-flex items-center justify-center text-[1.65rem] sm:text-[1.35rem] leading-none select-none"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
{meta.emoji}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 按 SMALL_GAMES 的 id 渲染矢量图标(纯 SVG,无 Emoji) */
|
||||
export function SmallGameIcon({ gameId }) {
|
||||
switch (gameId) {
|
||||
case '2048':
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" fill={accent} opacity="0.9" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" fill={accent} opacity="0.6" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" fill={accent} opacity="0.75" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" fill={accent} opacity="0.45" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'white-tile':
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" fill={accent} opacity="0.3" />
|
||||
<path d="M12 10v4M10 12h4" stroke={accent} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'tetris':
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="2" y="10" width="6" height="6" rx="1" fill={accent} />
|
||||
<rect x="9" y="10" width="6" height="6" rx="1" fill={accent} opacity="0.8" />
|
||||
<rect x="16" y="10" width="6" height="6" rx="1" fill={accent} />
|
||||
<rect x="9" y="3" width="6" height="6" rx="1" fill={accent} opacity="0.9" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'snake':
|
||||
return (
|
||||
<SvgBox>
|
||||
<path
|
||||
d="M4 16c0-4 3-7 7-7h4c3 0 5-1 7-4"
|
||||
stroke={accent}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="19" cy="5" r="2.5" fill={accent} />
|
||||
<circle cx="6" cy="16" r="2" fill={accent} opacity="0.6" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'minesweeper':
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke={accent} strokeOpacity="0.3" strokeWidth="1" />
|
||||
<circle cx="15" cy="15" r="3" fill="#a855f7" />
|
||||
<path d="M15 12v6M12 15h6" stroke="#fff" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'dodge-leaves':
|
||||
return (
|
||||
<SvgBox>
|
||||
<path
|
||||
d="M12 3c-5 3-8 8-8 14h16c0-6-3-11-8-14z"
|
||||
fill={soft}
|
||||
stroke={accent}
|
||||
strokeWidth={stroke}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M12 8v9M8 14l4-3 4 3" stroke={accent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'plane':
|
||||
return (
|
||||
<SvgBox>
|
||||
<path
|
||||
d="M4 14l8-6 8 6-8-2z"
|
||||
fill={soft}
|
||||
stroke={accent}
|
||||
strokeWidth={stroke}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M12 8v10M12 18l-3 3M12 18l3 3" stroke={accent} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="20" cy="10" r="2" fill="#ef4444" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'parkour':
|
||||
return (
|
||||
<SvgBox>
|
||||
<circle cx="8" cy="6" r="3" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<path d="M8 9v5l-4 4M8 14h6l4 4" stroke={accent} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<rect x="16" y="16" width="5" height="4" rx="1" fill={accent} opacity="0.4" />
|
||||
<circle cx="19" cy="8" r="3" fill="#fbbf24" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'floppybird':
|
||||
return (
|
||||
<SvgBox>
|
||||
<ellipse cx="12" cy="12" rx="8" ry="5" fill={soft} stroke={accent} strokeWidth={stroke} transform="rotate(-20 12 12)" />
|
||||
<circle cx="9" cy="11" r="2" fill={accent} />
|
||||
<path d="M16 10l4-2M16 12l4 2" stroke="#f97316" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'h5cube':
|
||||
return (
|
||||
<SvgBox>
|
||||
<path d="M4 8l8-4 8 4-8 4z" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<path d="M4 8v8l8 4v-8M12 20l8-4V8" stroke={accent} strokeWidth={stroke} fill="none" />
|
||||
<path d="M12 4v8" stroke={accent} strokeWidth="1" opacity="0.5" />
|
||||
</SvgBox>
|
||||
);
|
||||
case 'sokoban':
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<rect x="12" y="5" width="7" height="7" rx="1" fill={accent} opacity="0.5" />
|
||||
<rect x="5" y="12" width="7" height="7" rx="1" fill={accent} opacity="0.7" />
|
||||
<circle cx="8.5" cy="15.5" r="2.5" fill="#3b82f6" />
|
||||
</SvgBox>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<SvgBox>
|
||||
<rect x="4" y="4" width="16" height="16" rx="4" fill={soft} stroke={accent} strokeWidth={stroke} />
|
||||
<circle cx="12" cy="12" r="4" fill={accent} />
|
||||
</SvgBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SmallGameIcon;
|
||||
|
||||
@@ -45,6 +45,9 @@ const config = {
|
||||
LOG_LEVEL: 'debug',
|
||||
};
|
||||
|
||||
/** 与后端 INFOGENIE_SITE_ADMIN_TOKEN 一致;不设则无法进入 /admin */
|
||||
const siteAdminGate = String(import.meta.env.VITE_SITE_ADMIN_GATE || '').trim();
|
||||
|
||||
export const ENV_CONFIG = {
|
||||
API_URL: config.API_URL,
|
||||
AUTH_URL: config.AUTH_URL,
|
||||
@@ -55,6 +58,7 @@ export const ENV_CONFIG = {
|
||||
APP_VERSION: '2.2.3',
|
||||
CLIENT_ID: 'infogenie',
|
||||
CLIENT_NAME: '万象口袋',
|
||||
SITE_ADMIN_GATE: siteAdminGate,
|
||||
};
|
||||
|
||||
export const getEnvVar = (key, defaultValue = '') => {
|
||||
|
||||
@@ -11,8 +11,6 @@ import { ENV_CONFIG } from '../config/env';
|
||||
import { API_CATEGORIES, TOOLBOX_ITEMS, API_SOURCES } from '../config/Api60sConfig';
|
||||
import { AI_MODEL_APPS } from '../config/StaticPageConfig';
|
||||
|
||||
const ADMIN_TOKEN = 'shumengya520';
|
||||
|
||||
const InfoCard = ({ children }) => (
|
||||
<div className="bg-white/95 rounded-[14px] p-6 shadow-[0_4px_12px_rgba(0,0,0,0.08)] mb-6">{children}</div>
|
||||
);
|
||||
@@ -52,6 +50,7 @@ function normalizeHealthPayload(data) {
|
||||
: data.database === 'connected';
|
||||
const mysqlStatus = mysql?.status || data.database || (mysqlOk ? 'connected' : 'unknown');
|
||||
const sixty = data.sixty_api && typeof data.sixty_api === 'object' ? data.sixty_api : null;
|
||||
const redis = data.redis && typeof data.redis === 'object' ? data.redis : null;
|
||||
return {
|
||||
overall: data.status || 'unknown',
|
||||
ts: data.timestamp,
|
||||
@@ -59,11 +58,13 @@ function normalizeHealthPayload(data) {
|
||||
mysqlOk,
|
||||
mysqlStatus,
|
||||
sixty,
|
||||
redis,
|
||||
};
|
||||
}
|
||||
|
||||
const StatusPill = ({ ok, pending, unknown }) => {
|
||||
const StatusPill = ({ ok, pending, unknown, muted, mutedText = '未启用' }) => {
|
||||
if (pending) return <span className="text-xs font-semibold text-gray-400">检测中…</span>;
|
||||
if (muted) return <span className="text-xs font-semibold text-gray-700 bg-gray-100 px-2 py-0.5 rounded-md">{mutedText}</span>;
|
||||
if (unknown) return <span className="text-xs font-semibold text-amber-800 bg-amber-100 px-2 py-0.5 rounded-md">未上报</span>;
|
||||
return (
|
||||
<span
|
||||
@@ -77,8 +78,15 @@ const StatusPill = ({ ok, pending, unknown }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function formatPasswordConfigured(v) {
|
||||
if (v === true) return '已配置';
|
||||
if (v === false) return '未配置';
|
||||
return '—';
|
||||
}
|
||||
|
||||
const ADMIN_SECTIONS = [
|
||||
{ id: 'overview', label: '概览', short: '概览', Icon: FiHome },
|
||||
{ id: 'monitoring', label: '状态监控', short: '状态', Icon: FiActivity },
|
||||
{ id: 'ai-upstream', label: 'AI 上游配置', short: 'AI 上游', Icon: FiCpu },
|
||||
{ id: 'sixty-node', label: '60s 数据源', short: '60s 节点', Icon: FiRadio },
|
||||
{ id: 'sixty-display', label: '60s 前台展示', short: '60s 展示', Icon: FiGrid },
|
||||
@@ -114,6 +122,8 @@ const AdminPage = () => {
|
||||
const [healthRaw, setHealthRaw] = useState(null);
|
||||
const [healthLoading, setHealthLoading] = useState(false);
|
||||
const [healthFetchError, setHealthFetchError] = useState(null);
|
||||
const [diagnostics, setDiagnostics] = useState(null);
|
||||
const [diagError, setDiagError] = useState(null);
|
||||
|
||||
const disabled60sSet = useMemo(() => new Set(disabled60s), [disabled60s]);
|
||||
const disabledAIModelsSet = useMemo(() => new Set(disabledAIModels), [disabledAIModels]);
|
||||
@@ -145,18 +155,46 @@ const AdminPage = () => {
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchAggregateHealth = useCallback(async () => {
|
||||
const fetchMonitoringData = useCallback(async () => {
|
||||
setHealthLoading(true);
|
||||
setHealthFetchError(null);
|
||||
try {
|
||||
const r = await fetch(`${ENV_CONFIG.API_URL}/api/health`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const d = await r.json();
|
||||
setHealthRaw(d);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setDiagError(null);
|
||||
const token = localStorage.getItem('admin_token') || '';
|
||||
const healthP = fetch(`${ENV_CONFIG.API_URL}/api/health`).then(async (r) => {
|
||||
if (!r.ok) throw new Error(`健康检查 HTTP ${r.status}`);
|
||||
return r.json();
|
||||
});
|
||||
const diagP = fetch(`${ENV_CONFIG.API_URL}/api/admin/site/diagnostics`, {
|
||||
headers: { 'X-Site-Admin-Token': token },
|
||||
}).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`;
|
||||
try {
|
||||
const j = await r.json();
|
||||
if (j.error === 'forbidden') {
|
||||
msg = '诊断接口拒绝访问(X-Site-Admin-Token 与后端 INFOGENIE_SITE_ADMIN_TOKEN 不一致)';
|
||||
} else if (j.message) {
|
||||
msg = j.message;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json();
|
||||
});
|
||||
const [hSettled, dSettled] = await Promise.allSettled([healthP, diagP]);
|
||||
if (hSettled.status === 'fulfilled') {
|
||||
setHealthRaw(hSettled.value);
|
||||
setHealthFetchError(null);
|
||||
} else {
|
||||
setHealthRaw(null);
|
||||
setHealthFetchError(e.message || '请求失败');
|
||||
setHealthFetchError(hSettled.reason?.message || '健康检查失败');
|
||||
}
|
||||
if (dSettled.status === 'fulfilled') {
|
||||
setDiagnostics(dSettled.value);
|
||||
setDiagError(null);
|
||||
} else {
|
||||
setDiagnostics(null);
|
||||
setDiagError(dSettled.reason?.message || '诊断加载失败');
|
||||
}
|
||||
setHealthLoading(false);
|
||||
}, []);
|
||||
@@ -164,7 +202,7 @@ const AdminPage = () => {
|
||||
const healthNorm = useMemo(() => normalizeHealthPayload(healthRaw), [healthRaw]);
|
||||
|
||||
const loadAiRuntime = useCallback(async () => {
|
||||
if (localStorage.getItem('admin_token') !== ADMIN_TOKEN) return;
|
||||
if (localStorage.getItem('admin_token') !== ENV_CONFIG.SITE_ADMIN_GATE || !ENV_CONFIG.SITE_ADMIN_GATE) return;
|
||||
setLoadingAi(true);
|
||||
try {
|
||||
const token = localStorage.getItem('admin_token') || '';
|
||||
@@ -186,7 +224,7 @@ const AdminPage = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSysInfo(); }, [fetchSysInfo]);
|
||||
useEffect(() => { fetchAggregateHealth(); }, [fetchAggregateHealth]);
|
||||
useEffect(() => { fetchMonitoringData(); }, [fetchMonitoringData]);
|
||||
useEffect(() => { load60sConfig(); }, [load60sConfig]);
|
||||
useEffect(() => { loadAiRuntime(); }, [loadAiRuntime]);
|
||||
|
||||
@@ -319,7 +357,7 @@ const AdminPage = () => {
|
||||
};
|
||||
|
||||
const storedToken = localStorage.getItem('admin_token');
|
||||
if (storedToken !== ADMIN_TOKEN) return <Navigate to="/" replace />;
|
||||
if (!ENV_CONFIG.SITE_ADMIN_GATE || storedToken !== ENV_CONFIG.SITE_ADMIN_GATE) return <Navigate to="/" replace />;
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
@@ -440,96 +478,6 @@ const AdminPage = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="text-base text-white mb-3 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)] flex items-center gap-2">
|
||||
<FiActivity className="opacity-90" />
|
||||
状态监控
|
||||
</h3>
|
||||
<InfoCard>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<p className="text-sm text-gray-500 m-0 leading-snug max-w-xl">
|
||||
数据源:<code className="text-xs bg-gray-100 px-1 rounded">GET /api/health</code>
|
||||
。含 MySQL Ping 与当前配置的 60s 上游探测(请求其公开接口 <code className="text-xs bg-gray-100 px-1 rounded">/v2/ip</code>)。
|
||||
{healthNorm?.overall === 'degraded' && (
|
||||
<span className="text-amber-700 font-semibold"> 当前整体为 degraded。</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchAggregateHealth}
|
||||
disabled={healthLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<FiRefreshCw size={16} className={healthLoading ? 'animate-spin' : ''} />
|
||||
{healthLoading ? '检测中…' : '重新检测'}
|
||||
</button>
|
||||
</div>
|
||||
{healthFetchError && (
|
||||
<p className="text-sm text-red-600 mb-3 m-0">
|
||||
无法连接健康检查接口:{healthFetchError}(请确认 Go 后端已启动且已更新到包含聚合健康字段的版本)
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-gray-100">
|
||||
<div>
|
||||
<div className="text-gray-800 font-semibold text-sm">万象后端 API(InfoGenie Go)</div>
|
||||
<div className="text-gray-400 text-xs mt-0.5">{ENV_CONFIG.API_URL}</div>
|
||||
</div>
|
||||
<StatusPill
|
||||
ok={!healthFetchError && (healthNorm?.backendApiOk !== false)}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-gray-100">
|
||||
<div>
|
||||
<div className="text-gray-800 font-semibold text-sm">MySQL 数据库</div>
|
||||
<div className="text-gray-500 text-xs mt-0.5">
|
||||
{healthNorm ? `状态字段:${healthNorm.mysqlStatus}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill
|
||||
ok={!!healthNorm?.mysqlOk}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={!healthFetchError && !healthLoading && !healthRaw}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-gray-800 font-semibold text-sm">60s API 上游(当前节点)</div>
|
||||
{healthNorm?.sixty ? (
|
||||
<div className="text-gray-500 text-xs mt-1 space-y-0.5 break-all">
|
||||
<div>
|
||||
{healthNorm.sixty.label || healthNorm.sixty.source_id}
|
||||
{healthNorm.sixty.base_url ? ` · ${healthNorm.sixty.base_url}` : ''}
|
||||
</div>
|
||||
{typeof healthNorm.sixty.latency_ms === 'number' && (
|
||||
<div>探测耗时 {healthNorm.sixty.latency_ms} ms</div>
|
||||
)}
|
||||
{healthNorm.sixty.probe_url && (
|
||||
<div className="text-gray-400">探测 URL:{healthNorm.sixty.probe_url}</div>
|
||||
)}
|
||||
{healthNorm.sixty.error ? (
|
||||
<div className="text-red-600 font-medium">错误:{String(healthNorm.sixty.error)}</div>
|
||||
) : null}
|
||||
{typeof healthNorm.sixty.http_status === 'number' && healthNorm.sixty.http_status > 0 ? (
|
||||
<div>HTTP {healthNorm.sixty.http_status}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-xs mt-1">
|
||||
{!healthFetchError && healthRaw ? '响应中无 sixty_api 字段,请升级后端。' : '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StatusPill
|
||||
ok={!!healthNorm?.sixty?.ok}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={!healthFetchError && !!healthRaw && !healthNorm?.sixty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
|
||||
<h3 className="text-base text-white mb-3 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)]">系统信息</h3>
|
||||
<InfoCard>
|
||||
<InfoRow label="前端环境" value={ENV_CONFIG.ENV || 'development'} />
|
||||
@@ -557,6 +505,223 @@ const AdminPage = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{adminSection === 'monitoring' && (
|
||||
<InfoCard>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
|
||||
<p className="text-sm text-gray-500 m-0 leading-snug max-w-2xl">
|
||||
<strong>实时探测</strong>:
|
||||
<code className="text-xs bg-gray-100 px-1 rounded">GET /api/health</code>
|
||||
(MySQL Ping、60s 上游 <code className="text-xs bg-gray-100 px-1 rounded">/v2/ip</code>
|
||||
、可选 Redis Ping)。
|
||||
<strong className="font-semibold"> 配置快照</strong>:
|
||||
<code className="text-xs bg-gray-100 px-1 rounded">GET /api/admin/site/diagnostics</code>
|
||||
(监听地址、MySQL/Redis 连接目标、库名与用户、SMTP 与认证中心 URL 等;<span className="text-gray-600">不含密码明文</span>)。
|
||||
{healthNorm?.overall === 'degraded' && (
|
||||
<span className="text-amber-700 font-semibold"> 当前整体为 degraded。</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchMonitoringData}
|
||||
disabled={healthLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<FiRefreshCw size={16} className={healthLoading ? 'animate-spin' : ''} />
|
||||
{healthLoading ? '刷新中…' : '刷新探测与配置'}
|
||||
</button>
|
||||
</div>
|
||||
{healthFetchError && (
|
||||
<p className="text-sm text-red-600 mb-3 m-0">
|
||||
健康检查失败:{healthFetchError}
|
||||
</p>
|
||||
)}
|
||||
{diagError && (
|
||||
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-3 m-0">
|
||||
配置快照失败:{diagError}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div className="pb-3 border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="text-gray-800 font-semibold text-sm">万象后端 API(Go 进程)</div>
|
||||
<StatusPill
|
||||
ok={!healthFetchError && (healthNorm?.backendApiOk !== false)}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
|
||||
<div><span className="text-gray-400">前端当前 API 基址:</span>{ENV_CONFIG.API_URL}</div>
|
||||
{diagnostics?.app && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-gray-400">进程监听:</span>
|
||||
{diagnostics.app.listen_addr || `0.0.0.0:${diagnostics.app.listen_port || '—'}`}
|
||||
{diagnostics.app.listen_port ? `(端口 ${diagnostics.app.listen_port})` : ''}
|
||||
</div>
|
||||
<div><span className="text-gray-400">APP_ENV:</span>{diagnostics.app.env || '—'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-3 border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="text-gray-800 font-semibold text-sm">MySQL 数据库</div>
|
||||
<StatusPill
|
||||
ok={!!healthNorm?.mysqlOk}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={!healthFetchError && !healthLoading && !healthRaw}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-2 space-y-1">
|
||||
{healthNorm && (
|
||||
<div><span className="text-gray-400">实时状态字段:</span>{healthNorm.mysqlStatus}</div>
|
||||
)}
|
||||
{diagnostics?.mysql && (
|
||||
<>
|
||||
<div className="break-all">
|
||||
<span className="text-gray-400">连接:</span>
|
||||
{diagnostics.mysql.host || '—'}:{diagnostics.mysql.port || '—'}
|
||||
</div>
|
||||
<div><span className="text-gray-400">数据库名:</span>{diagnostics.mysql.database || '—'}</div>
|
||||
<div><span className="text-gray-400">用户:</span>{diagnostics.mysql.user || '—'}</div>
|
||||
<div>
|
||||
<span className="text-gray-400">密码(环境变量):</span>
|
||||
{formatPasswordConfigured(diagnostics.mysql.password_configured)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-3 border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="text-gray-800 font-semibold text-sm">60s API 上游</div>
|
||||
<StatusPill
|
||||
ok={!!healthNorm?.sixty?.ok}
|
||||
pending={healthLoading && !healthRaw && !healthFetchError}
|
||||
unknown={!healthFetchError && !!healthRaw && !healthNorm?.sixty}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
|
||||
{diagnostics?.sixty_upstream && (
|
||||
<div>
|
||||
<span className="text-gray-400">配置的节点:</span>
|
||||
{diagnostics.sixty_upstream.label || diagnostics.sixty_upstream.source_id}
|
||||
{diagnostics.sixty_upstream.base_url ? ` · ${diagnostics.sixty_upstream.base_url}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{healthNorm?.sixty ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-gray-400">探测目标:</span>
|
||||
{healthNorm.sixty.label || healthNorm.sixty.source_id}
|
||||
{healthNorm.sixty.base_url ? ` · ${healthNorm.sixty.base_url}` : ''}
|
||||
</div>
|
||||
{typeof healthNorm.sixty.latency_ms === 'number' && (
|
||||
<div>探测耗时 {healthNorm.sixty.latency_ms} ms</div>
|
||||
)}
|
||||
{healthNorm.sixty.probe_url && (
|
||||
<div className="text-gray-400">探测 URL:{healthNorm.sixty.probe_url}</div>
|
||||
)}
|
||||
{healthNorm.sixty.error ? (
|
||||
<div className="text-red-600 font-medium">错误:{String(healthNorm.sixty.error)}</div>
|
||||
) : null}
|
||||
{typeof healthNorm.sixty.http_status === 'number' && healthNorm.sixty.http_status > 0 ? (
|
||||
<div>HTTP {healthNorm.sixty.http_status}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-1">
|
||||
{!healthFetchError && healthRaw ? '响应中无 sixty_api 字段,请升级后端。' : '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-3 border-b border-gray-100">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="text-gray-800 font-semibold text-sm">Redis 缓存</div>
|
||||
{healthLoading && !healthRaw && !healthFetchError ? (
|
||||
<StatusPill pending />
|
||||
) : healthNorm?.redis == null && healthRaw && !healthFetchError ? (
|
||||
<StatusPill unknown />
|
||||
) : healthNorm?.redis && healthNorm.redis.enabled === false ? (
|
||||
<StatusPill muted mutedText="未启用" />
|
||||
) : healthNorm?.redis && healthNorm.redis.enabled === true ? (
|
||||
<StatusPill ok={healthNorm.redis.ok === true} unknown={false} />
|
||||
) : (
|
||||
<StatusPill unknown />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
|
||||
{!healthFetchError && healthRaw && healthNorm && healthNorm.redis == null && (
|
||||
<div>响应中无 redis 字段,请升级 Go 后端。</div>
|
||||
)}
|
||||
{healthNorm?.redis && healthNorm.redis.enabled === false && (
|
||||
<div>
|
||||
后端未启用(<code className="text-xs bg-gray-100 px-1 rounded">REDIS_ENABLED</code> 未打开);读站点配置时直连 MySQL。
|
||||
</div>
|
||||
)}
|
||||
{healthNorm?.redis && healthNorm.redis.enabled === true && healthNorm.redis.ok === false && healthNorm.redis.error && (
|
||||
<div className="text-red-600 font-medium">Ping 错误:{String(healthNorm.redis.error)}</div>
|
||||
)}
|
||||
{healthNorm?.redis && healthNorm.redis.enabled === true && healthNorm.redis.ok === true && (
|
||||
<div>健康检查 Redis Ping 成功(与业务共用同一套连接参数)。</div>
|
||||
)}
|
||||
{diagnostics?.redis && (
|
||||
<>
|
||||
<div><span className="text-gray-400">开关:</span>{diagnostics.redis.enabled ? '已启用' : '未启用'}</div>
|
||||
{diagnostics.redis.enabled ? (
|
||||
<>
|
||||
<div><span className="text-gray-400">地址(host:port):</span>{diagnostics.redis.addr || '—'}</div>
|
||||
<div><span className="text-gray-400">逻辑库编号:</span>{typeof diagnostics.redis.logical_db === 'number' ? diagnostics.redis.logical_db : '—'}</div>
|
||||
<div><span className="text-gray-400">Key 前缀:</span>{diagnostics.redis.key_prefix || '—'}</div>
|
||||
<div>
|
||||
<span className="text-gray-400">站点缓存 TTL:</span>
|
||||
{typeof diagnostics.redis.site_cache_ttl_sec === 'number'
|
||||
? `${diagnostics.redis.site_cache_ttl_sec} s`
|
||||
: '—'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">密码(环境变量):</span>
|
||||
{formatPasswordConfigured(diagnostics.redis.password_configured)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(diagnostics?.auth_center || diagnostics?.mail) && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide">其他依赖(快照)</div>
|
||||
{diagnostics.auth_center && (
|
||||
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2 text-xs text-gray-600 space-y-1">
|
||||
<div className="font-semibold text-gray-700">萌芽认证中心 API</div>
|
||||
<div className="break-all">{diagnostics.auth_center.api_url || '—'}</div>
|
||||
</div>
|
||||
)}
|
||||
{diagnostics.mail && (
|
||||
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2 text-xs text-gray-600 space-y-1">
|
||||
<div className="font-semibold text-gray-700">邮件 SMTP</div>
|
||||
<div>
|
||||
{diagnostics.mail.host || '—'}:{diagnostics.mail.port ?? '—'}
|
||||
</div>
|
||||
<div className="break-all">发件账号:{diagnostics.mail.username || '—'}</div>
|
||||
<div>
|
||||
密码(环境变量):{formatPasswordConfigured(diagnostics.mail.password_configured)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{adminSection === 'ai-upstream' && (
|
||||
<InfoCard>
|
||||
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm run dev` / `npm start` | 开发服务器(默认端口 3000) |
|
||||
| `npm run dev` / `npm start` | 开发服务器(Vite,默认端口 3000) |
|
||||
| `npm run build` | 生产构建,输出 `dist/` |
|
||||
| `npm run preview` | 本地预览 `dist/` 构建结果 |
|
||||
|
||||
@@ -23,7 +23,7 @@ infogenie-frontend/
|
||||
├── index.html ← Vite HTML 入口(根目录,非 public/)
|
||||
├── vite.config.js ← Vite 配置(React 插件、Tailwind 插件、路径别名)
|
||||
├── package.json
|
||||
├── .env.development ← 开发环境变量
|
||||
├── .env.development ← 开发环境变量(VITE_*)
|
||||
├── .env.production ← 生产环境变量
|
||||
├── public/ ← 静态资源(不参与构建,直接复制到 dist/)
|
||||
│ ├── assets/logo.png
|
||||
@@ -34,12 +34,12 @@ infogenie-frontend/
|
||||
└── src/
|
||||
├── index.js ← React 根挂载
|
||||
├── App.js ← 路由、布局、全局 Provider
|
||||
├── components/ ← 公共组件
|
||||
├── pages/ ← 页面组件
|
||||
├── components/ ← 公共组件(含 FullscreenEmbed、SmallGameIcons 等)
|
||||
├── pages/ ← 页面组件(含 AdminPage、SmallGamePage)
|
||||
├── contexts/ ← 全局 Context
|
||||
├── hooks/ ← 自定义 Hooks
|
||||
├── hooks/ ← 自定义 Hooks(如 useFeatureCardClickStats)
|
||||
├── utils/ ← Axios 封装、可见性过滤等
|
||||
├── config/ ← 环境变量解析、路由/内容配置
|
||||
├── config/ ← 环境变量解析、Api60sConfig、StaticPageConfig
|
||||
└── styles/
|
||||
├── index.css ← Tailwind 入口 + 全局 base 样式 + 自定义动画
|
||||
└── shared.js ← 设计系统组件(Tailwind 函数组件)
|
||||
@@ -52,7 +52,7 @@ infogenie-frontend/
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| `@vitejs/plugin-react` | React Fast Refresh + JSX 转换(含 `.js` 扩展名支持) |
|
||||
| `@tailwindcss/vite` | Tailwind CSS v4 Vite 插件,零配置文件 |
|
||||
| `@tailwindcss/vite` | Tailwind CSS v4 Vite 插件 |
|
||||
| `treat-js-as-jsx` | 内联插件,使 `.js` 文件中的 JSX 正常编译 |
|
||||
| `resolve.alias['@']` | `@/` 映射到 `src/` |
|
||||
| `base: '/'` | 部署根路径 |
|
||||
@@ -62,18 +62,19 @@ infogenie-frontend/
|
||||
|
||||
## 环境变量(`src/config/env.js`)
|
||||
|
||||
变量名使用 Vite 规范的 `VITE_` 前缀(通过 `import.meta.env` 访问):
|
||||
变量名使用 Vite 规范的 **`VITE_`** 前缀(通过 `import.meta.env` 访问):
|
||||
|
||||
| 变量 | 作用 | 开发默认 / 生产默认 |
|
||||
|------|------|---------------------|
|
||||
| `VITE_API_URL` | 万象口袋 **Go 后端** 根地址 | dev:`http://127.0.0.1:5002`;prod:`https://infogenie.api.shumengya.top` |
|
||||
| `VITE_API_URL` | 万象口袋 **Go 后端** 根地址 | dev:`http://127.0.0.1:5002`;prod 见 `.env.production` |
|
||||
| `VITE_AUTH_URL` | 认证中心 **页面** 域名 | `https://auth.shumengya.top` |
|
||||
| `VITE_AUTH_API_URL` | 认证中心 **API** 根地址 | `https://auth.api.shumengya.top` |
|
||||
| `VITE_DEBUG` | 调试开关 | `'true'` 开启 |
|
||||
| `VITE_SITE_ADMIN_GATE` | 管理员后台口令,须与后端 `INFOGENIE_SITE_ADMIN_TOKEN` 一致 | 本地写在 `.env.development`,勿提交 |
|
||||
|
||||
运行时可通过 `window.ENV_CONFIG` 查看解析结果。
|
||||
运行时可通过 `window.ENV_CONFIG` 查看解析结果(若已注入)。
|
||||
|
||||
> **迁移注意**:从 CRA 迁移时原 `REACT_APP_*` 变量已全部重命名为 `VITE_*`。
|
||||
> **迁移注意**:由 CRA 迁出后,原 `REACT_APP_*` 已统一为 `VITE_*`。
|
||||
|
||||
---
|
||||
|
||||
@@ -91,13 +92,27 @@ infogenie-frontend/
|
||||
| `/toolbox` | 工具箱 |
|
||||
| `/aimodel` | AI 应用(需登录) |
|
||||
| `/profile` | 个人中心 |
|
||||
| `/admin` | 管理 |
|
||||
| `/admin` | **管理员后台**(本地 `admin_token` 校验) |
|
||||
| `*` | 重定向首页 |
|
||||
|
||||
### 关键组件
|
||||
### 管理员后台(`src/pages/AdminPage.js`)
|
||||
|
||||
- **`RandomSiteBackground`**:全站随机背景(`https://randbg.api.smyhub.com/api/random?format=json`),毛玻璃模糊,会话级缓存。
|
||||
- **`Header` / `Footer`**:半透明绿 + `backdrop-filter`;**移动端 `Navigation`** 底栏为不透明渐变 + 圆角顶。
|
||||
- 浏览器 **`localStorage.admin_token`** 与内置口令一致方可进入(与后端 **`INFOGENIE_SITE_ADMIN_TOKEN`** 用于接口时是同一套站点管理员概念)。
|
||||
- **功能分区**(左侧):概览、**状态监控**、AI 上游、60s 数据源、60s 前台展示、AI 前台展示。
|
||||
- **状态监控**:
|
||||
- 并行请求 **`GET /api/health`**(MySQL / 60s 上游 / Redis Ping)与 **`GET /api/admin/site/diagnostics`**(进程监听、MySQL/Redis 连接目标、库名/用户、SMTP、认证中心 URL 等;**不含密码**)。
|
||||
- 请求诊断接口时携带头 **`X-Site-Admin-Token: <admin_token>`**。
|
||||
- 「刷新探测与配置」会同时刷新上述两个数据源。
|
||||
|
||||
### 休闲游戏页(`src/pages/SmallGamePage.js`)
|
||||
|
||||
- 游戏列表来自 **`src/config/StaticPageConfig.js`** 的 `SMALL_GAMES`。
|
||||
- 卡片图标由 **`src/components/SmallGameIcons.js`** 按 `game.id` 渲染 **Emoji**(如 2048→🔢、俄罗斯方块→🧱 等),嵌入在 **`FeatureCardIcon`** 内。
|
||||
|
||||
### 其他关键组件
|
||||
|
||||
- **`RandomSiteBackground`**:全站随机背景,毛玻璃模糊,会话级缓存。
|
||||
- **`Header` / `Footer` / `Navigation`**:导航与布局。
|
||||
- **`FullscreenEmbed`**:全屏 iframe(游戏、工具箱、AI 静态页),支持注入 token、加载超时提示。
|
||||
- **`ParticleEffect`**:全局点击粒子动画。
|
||||
|
||||
@@ -119,17 +134,12 @@ infogenie-frontend/
|
||||
|
||||
### 设计系统(`src/styles/shared.js`)
|
||||
|
||||
所有组件均为 **React 函数组件**,接受 `className` prop(可与额外 Tailwind 类合并):
|
||||
所有组件均为 **React 函数组件**,接受 `className` prop(可与 Tailwind 类合并):
|
||||
|
||||
- **`PageWrapper`**:页面容器,带 `animate-page-enter` 入场动画
|
||||
- **`Container`**:最大宽度容器,`$narrow`(800px)/ 默认(1200px)
|
||||
- **`FeatureGrid`**:响应式 5→4→3→2 列网格,带 `animate-fade-up`
|
||||
- **`CatalogCard`** / **`FeatureCard`**:统一卡片样式,顶条渐变色动态注入(`$c` prop)
|
||||
- **`PageWrapper`**、**`Container`**、**`FeatureGrid`**、**`CatalogCard`**、**`FeatureCard`**
|
||||
- **`FeatureCardUseCount`**:卡片右上角点击次数角标
|
||||
- **`ModuleCard`**:首页大模块横向卡片(包装 `react-router-dom` `Link`)
|
||||
- **`accentFromGradient`**:工具函数,从 gradient 字符串提取首色
|
||||
|
||||
> 动态样式(渐变色、动态 grid 列数等)通过 inline `style` prop 注入,Tailwind 负责静态部分。
|
||||
- **`ModuleCard`**:首页大模块卡片(`Link`)
|
||||
- **`accentFromGradient`**:从 gradient 字符串提取首色
|
||||
|
||||
---
|
||||
|
||||
@@ -146,9 +156,9 @@ infogenie-frontend/
|
||||
与 Vite 主站并列的大量**免构建**页面:
|
||||
|
||||
- **`public/aimodelapp/<应用名>/`**:AI 小应用(HTML + `script.js` + 可选 `env.js`)。
|
||||
- 统一聊天入口:**`public/aimodelapp/shared/ai-chat.js`** 的 `AiChat.complete()`。
|
||||
- 统一聊天入口:**`public/aimodelapp/shared/ai-chat.js`**。
|
||||
- **优先**请求 **`POST /api/aimodelapp/chat/stream`**(SSE),失败或无内容时回退 **`POST /api/aimodelapp/chat`**。
|
||||
- **`public/smallgame/`**、**`public/toolbox/`**:独立小游戏与工具页,由 `FullscreenEmbed` 打开。
|
||||
- **`public/smallgame/`**、**`public/toolbox/`**:独立页,由 **`FullscreenEmbed`** 打开。
|
||||
|
||||
各 `aimodelapp` 子目录的 **`env.js`** / **`API_CONFIG`** 需指向与主站一致的 Go 后端地址(通常与 `VITE_API_URL` 同源或同网段)。
|
||||
|
||||
@@ -156,14 +166,15 @@ infogenie-frontend/
|
||||
|
||||
## 与后端协作要点
|
||||
|
||||
1. **登录**:token 存 `localStorage`,AI 与受保护接口依赖 JWT。
|
||||
2. **AI**:静态页通过 **同源或配置的 API** 调 Go 的 `/api/aimodelapp/*`;流式响应类型为 **`text/event-stream`**。
|
||||
3. **站点开关**:60s / AI 应用显隐由 `GET /api/site/*-disabled` 等驱动(见后端文档)。
|
||||
4. **卡片统计**:四大板块列表页的 `CatalogCard` 已接 `feature-card-clicks` 接口。
|
||||
1. **登录**:token 存 `localStorage`,AI 与受保护接口依赖 JWT(萌芽认证中心签发)。
|
||||
2. **AI**:静态页通过配置的 API 调 Go 的 `/api/aimodelapp/*`;流式为 **`text/event-stream`**。
|
||||
3. **站点开关**:60s / AI 应用显隐由 `GET /api/site/*-disabled` 等驱动。
|
||||
4. **卡片统计**:列表页 `CatalogCard` 与 **`feature-card-clicks`** 接口对接;可选 Redis 缓存由后端控制。
|
||||
5. **管理员**:写配置类接口需后端配置 **`INFOGENIE_SITE_ADMIN_TOKEN`**,与前端管理员口令一致。
|
||||
|
||||
---
|
||||
|
||||
## 其他文档
|
||||
|
||||
- **Go 后端 API 与表结构**:[`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md)
|
||||
- **仓库与工具说明**:[`../.claude/README.md`](../.claude/README.md)
|
||||
- **Go 后端 API、Redis、健康与诊断**:[`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md)
|
||||
- **仓库总览与多模块说明**:[`../README.md`](../README.md)
|
||||
|
||||
489
README.md
489
README.md
@@ -1,472 +1,77 @@
|
||||
# InfoGenie 前端架构文档
|
||||
# InfoGenie(万象口袋)
|
||||
|
||||
## 项目概述
|
||||
多模块仓库:**React + Vite 前端**为主界面,**Go(Gin + GORM)** 为当前主力 API,另有 **Java Spring Boot** 占位模块。大量 **60s 类接口**通过上游开放 API 提供;本站负责聚合导航、认证跳转、AI 应用壳与小游戏嵌入等。
|
||||
|
||||
InfoGenie 是一个基于前后端分离架构的全栈 Web 应用,前端采用 React 单页应用(SPA)架构,结合静态 HTML 页面实现丰富的功能模块。后端提供 RESTful API 接口,支持用户认证、数据获取等核心功能。项目实现了移动优先的响应式设计,通过统一的组件系统和数据流管理,提供了包括API数据展示、小游戏、AI工具等多样化功能模块。
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
## 仓库结构
|
||||
|
||||
### 核心框架
|
||||
- **React 18.2.0**: 前端 UI 框架,使用函数式组件和 Hooks
|
||||
- **React Router DOM 6.15.0**: 客户端路由管理
|
||||
- **Axios 1.5.0**: HTTP 客户端,用于后端 API 调用
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| [`infogenie-frontend/`](infogenie-frontend/) | 主站 SPA(Vite 6、React 18、Tailwind v4) |
|
||||
| [`infogenie-backend-go/`](infogenie-backend-go/) | **万象后端 API**(Go):站点配置、AI 代理、健康检查、可选 Redis 缓存等 |
|
||||
| [`infogenie-backend-java/`](infogenie-backend-java/) | Spring Boot 3.5 骨架(当前无业务 API 层) |
|
||||
| [`InfoGenie-go-backend/`](InfoGenie-go-backend/) | 历史/备用目录,通常仅 README |
|
||||
|
||||
### 样式和 UI
|
||||
- **styled-components 6.0.7**: CSS-in-JS 样式解决方案
|
||||
- **react-icons 4.11.0**: 图标库
|
||||
- **react-hot-toast 2.4.1**: 通知提示组件
|
||||
详细 API、表结构、Redis 与诊断接口见 **[infogenie-backend-go/后端文档.md](infogenie-backend-go/后端文档.md)**。
|
||||
|
||||
### 开发工具
|
||||
- **Create React App**: 项目脚手架
|
||||
- **ESLint**: 代码规范检查
|
||||
- **Service Worker**: PWA 支持
|
||||
前端路由、Vite、管理员「状态监控」、静态页协作见 **[infogenie-frontend/前端文档.md](infogenie-frontend/前端文档.md)**。
|
||||
|
||||
## 架构设计
|
||||
开发与 AI 助手指南见根目录 **[CLAUDE.md](CLAUDE.md)**(若存在)。
|
||||
|
||||
### 整体架构
|
||||
```
|
||||
前端应用
|
||||
├── React SPA (主要页面)
|
||||
│ ├── 用户认证系统
|
||||
│ ├── 导航和布局
|
||||
│ ├── 页面路由
|
||||
│ └── 用户管理
|
||||
└── 静态 HTML 页面
|
||||
├── API 数据展示页面
|
||||
├── 小游戏页面
|
||||
└── AI 模型工具页面
|
||||
```
|
||||
---
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
src/
|
||||
├── components/ # 公共组件
|
||||
│ ├── Header.js # 顶部导航栏
|
||||
│ ├── Navigation.js # 底部导航栏(移动端)
|
||||
│ └── Footer.js # 页脚
|
||||
├── pages/ # 页面组件
|
||||
│ ├── HomePage.js # 首页
|
||||
│ ├── LoginPage.js # 登录页面
|
||||
│ ├── Api60sPage.js # API 60s 页面
|
||||
│ ├── SmallGamePage.js # 小游戏页面
|
||||
│ ├── AiModelPage.js # AI 模型页面
|
||||
│ └── UserProfilePage.js # 用户资料页面
|
||||
├── contexts/ # React Context
|
||||
│ └── UserContext.js # 用户状态管理
|
||||
├── config/ # 配置文件
|
||||
│ └── StaticPageConfig.js # 静态页面配置
|
||||
├── utils/ # 工具函数
|
||||
│ └── api.js # API 调用封装
|
||||
└── styles/ # 全局样式
|
||||
```
|
||||
## 快速启动(本地)
|
||||
|
||||
## API 接口设计
|
||||
### 前端(`infogenie-frontend/`)
|
||||
|
||||
### 基础配置
|
||||
- **Base URL**: `https://infogenie.api.shumengya.top` (这是生产环境)(可通过环境变量 `REACT_APP_API_URL` 配置测试环境)
|
||||
- **认证方式**: JWT Bearer Token
|
||||
- **请求格式**: JSON
|
||||
- **响应格式**: JSON
|
||||
- **超时时间**: 10秒
|
||||
|
||||
### 认证相关接口
|
||||
|
||||
#### 发送验证码
|
||||
```http
|
||||
POST /api/auth/send-verification
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
#### 验证验证码
|
||||
```http
|
||||
POST /api/auth/verify-code
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户登录
|
||||
```http
|
||||
POST /api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户注册
|
||||
```http
|
||||
POST /api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password",
|
||||
"verification_code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户登出
|
||||
```http
|
||||
POST /api/auth/logout
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 检查登录状态
|
||||
```http
|
||||
GET /api/auth/check
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 用户管理接口
|
||||
|
||||
#### 获取用户资料
|
||||
```http
|
||||
GET /api/user/profile
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 修改密码
|
||||
```http
|
||||
POST /api/user/change-password
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"old_password": "old_password",
|
||||
"new_password": "new_password"
|
||||
}
|
||||
```
|
||||
|
||||
#### 获取用户统计
|
||||
```http
|
||||
GET /api/user/stats
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 获取游戏数据
|
||||
```http
|
||||
GET /api/user/game-data
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 用户签到
|
||||
```http
|
||||
POST /api/user/checkin
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
#### 删除账户
|
||||
```http
|
||||
POST /api/user/delete
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"password": "password"
|
||||
}
|
||||
```
|
||||
|
||||
### 数据展示接口
|
||||
|
||||
前端包含大量静态页面用于展示各种 API 数据,这些页面直接调用后端提供的公开接口:
|
||||
|
||||
#### 热搜榜单系列
|
||||
- 百度实时热搜: `GET /v2/baidu/realtime`
|
||||
- 百度贴吧话题榜: `GET /v2/baidu/tieba`
|
||||
- 哔哩哔哩热搜榜: `GET /v2/bilibili/hot`
|
||||
- 抖音热搜榜: `GET /v2/douyin/hot`
|
||||
- 头条热搜榜: `GET /v2/toutiao/hot`
|
||||
- 微博热搜榜: `GET /v2/weibo/hot`
|
||||
- 小红书热点: `GET /v2/xiaohongshu/hot`
|
||||
- 知乎热门话题: `GET /v2/zhihu/hot`
|
||||
- Hacker News 榜单: `GET /v2/hackernews`
|
||||
|
||||
#### 日更资讯系列
|
||||
- 必应每日壁纸: `GET /v2/bing/wallpaper`
|
||||
- 历史上的今天: `GET /v2/history/today`
|
||||
- 每日国际汇率: `GET /v2/exchange/rates`
|
||||
- 每天60s读懂世界: `GET /v2/60s/world`
|
||||
|
||||
#### 实用功能系列
|
||||
- 百度百科词条: `GET /v2/baike/search?keyword={keyword}`
|
||||
- 公网IP地址: `GET /v2/ip/public`
|
||||
- 哈希解压压缩: `POST /v2/hash/{algorithm}`
|
||||
- 链接OG信息: `GET /v2/og?url={url}`
|
||||
- 密码强度检测: `POST /v2/password/strength`
|
||||
- 农历信息: `GET /v2/calendar/lunar?date={date}`
|
||||
- 配色方案: `GET /v2/color/schemes`
|
||||
- 身体健康分析: `POST /v2/health/analysis`
|
||||
- 生成二维码: `POST /v2/qrcode/generate`
|
||||
- 实时天气: `GET /v2/weather?location={location}`
|
||||
- 随机密码生成器: `GET /v2/password/random`
|
||||
- 随机颜色: `GET /v2/color/random`
|
||||
- 天气预报: `GET /v2/weather/forecast?location={location}`
|
||||
- 在线翻译: `POST /v2/translate`
|
||||
- EpicGames免费游戏: `GET /v2/epic/free-games`
|
||||
|
||||
#### 娱乐消遣系列
|
||||
- 随机唱歌音频: `GET /v2/entertainment/random-song`
|
||||
- 随机发病文学: `GET /v2/entertainment/random-meme`
|
||||
- 随机搞笑段子: `GET /v2/entertainment/random-joke`
|
||||
- 随机冷笑话: `GET /v2/entertainment/random-pun`
|
||||
- 随机一言: `GET /v2/entertainment/random-quote`
|
||||
- 随机运势: `GET /v2/entertainment/random-fortune`
|
||||
- 随机JavaScript趣味题: `GET /v2/entertainment/random-js-quiz`
|
||||
- 随机KFC文案: `GET /v2/entertainment/random-kfc`
|
||||
|
||||
## 状态管理
|
||||
|
||||
### 用户状态管理
|
||||
使用 React Context 进行全局状态管理:
|
||||
|
||||
```javascript
|
||||
const UserContext = createContext();
|
||||
|
||||
export const UserProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
// 用户登录、登出、状态检查等方法
|
||||
};
|
||||
```
|
||||
|
||||
### 本地存储
|
||||
- 用户信息和 Token 存储在 localStorage 中
|
||||
- 页面刷新后自动恢复用户状态
|
||||
|
||||
## 路由设计
|
||||
|
||||
```javascript
|
||||
const App = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/60sapi" element={<Api60sPage />} />
|
||||
<Route path="/smallgame" element={<SmallGamePage />} />
|
||||
<Route path="/aimodel" element={<AiModelPage />} />
|
||||
<Route path="/profile" element={<UserProfilePage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 响应式设计
|
||||
|
||||
- 移动优先设计理念
|
||||
- 使用 CSS Grid 和 Flexbox 实现响应式布局
|
||||
- 媒体查询适配不同屏幕尺寸
|
||||
- 移动端使用底部导航栏,桌面端使用顶部导航
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 前端安全措施
|
||||
- JWT Token 自动过期和刷新
|
||||
- XSS 防护:使用 React 自动转义
|
||||
- CSRF 防护:使用 SameSite Cookie
|
||||
- 输入验证:前端表单验证
|
||||
|
||||
### API 安全要求
|
||||
- 所有敏感接口需要 JWT 认证
|
||||
- Token 存储在 localStorage,需要后端验证
|
||||
- 密码等敏感信息前端不存储明文
|
||||
- API 请求包含 CORS 配置
|
||||
|
||||
## 部署和构建
|
||||
|
||||
### 构建命令
|
||||
```bash
|
||||
npm run build # 生产环境构建
|
||||
npm start # 开发环境启动
|
||||
cd infogenie-frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
- `REACT_APP_API_URL`: 后端 API 基础地址
|
||||
- 支持 `.env` 文件配置不同环境的变量
|
||||
默认 **http://localhost:3000**。环境变量见 `infogenie-frontend/.env.development`(`VITE_API_URL` 默认指向本机 Go **5002**)。
|
||||
|
||||
### PWA 支持
|
||||
- 注册 Service Worker 实现离线缓存
|
||||
- Web App Manifest 支持安装到桌面
|
||||
### Go 后端(`infogenie-backend-go/`)
|
||||
|
||||
## 与后端协作要点
|
||||
|
||||
1. **API 接口约定**: 遵循 RESTful 设计原则,统一响应格式
|
||||
2. **错误处理**: 后端返回统一的错误格式,前端统一处理
|
||||
3. **认证流程**: JWT Token 的生成、验证和刷新机制
|
||||
4. **数据格式**: 前后端约定清晰的数据结构
|
||||
5. **跨域配置**: 后端需要配置 CORS 允许前端域名
|
||||
6. **API 版本管理**: 使用 `/v2/` 前缀进行版本控制
|
||||
7. **性能优化**: 考虑 API 响应时间和前端缓存策略
|
||||
|
||||
## 萌芽币消费系统
|
||||
|
||||
### 系统概述
|
||||
萌芽币是平台内部的虚拟货币,用于限制和管理用户对AI功能的使用频率。每次调用AI功能需消耗100萌芽币,当用户萌芽币不足时,无法使用AI功能。
|
||||
|
||||
### 技术实现
|
||||
1. **萌芽币管理器**: `/public/aimodelapp/coin-manager.js`
|
||||
- 管理用户萌芽币余额和使用记录
|
||||
- 提供UI组件显示萌芽币信息
|
||||
- 实现API调用前的余额检查
|
||||
|
||||
2. **API集成**:
|
||||
- 在 `/src/utils/api.js` 中添加萌芽币查询接口
|
||||
- 所有AI功能API调用必须添加JWT Token认证
|
||||
- API调用后自动刷新萌芽币余额显示
|
||||
|
||||
3. **用户体验**:
|
||||
- 在页面右上角显示萌芽币余额和使用记录
|
||||
- 当萌芽币不足时,提供友好的提示
|
||||
- 引导用户通过签到等方式获取更多萌芽币
|
||||
|
||||
### 接口设计
|
||||
```http
|
||||
GET /api/aimodelapp/coins
|
||||
Authorization: Bearer <token>
|
||||
|
||||
响应:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"coins": 200,
|
||||
"ai_cost": 100,
|
||||
"can_use_ai": true,
|
||||
"username": "用户名",
|
||||
"usage_count": 5,
|
||||
"recent_usage": [
|
||||
{
|
||||
"api_type": "chat",
|
||||
"cost": 100,
|
||||
"timestamp": "2025-09-16T11:15:47.285720"
|
||||
},
|
||||
...
|
||||
]
|
||||
},
|
||||
"message": "当前萌芽币余额: 200"
|
||||
}
|
||||
```bash
|
||||
cd infogenie-backend-go
|
||||
# 按需配置 .env.development(APP_ENV、DB_*、可选 REDIS_*)
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
### 页面集成流程
|
||||
1. 引入萌芽币管理器 JavaScript 文件
|
||||
2. 在API调用前检查萌芽币余额
|
||||
3. 处理API响应中的萌芽币相关错误
|
||||
4. API调用后刷新萌芽币信息
|
||||
默认监听 **http://127.0.0.1:5002**。健康检查:**GET /api/health**。
|
||||
|
||||
详细集成步骤请参考 [前端萌芽币消费系统集成文档](/前端萌芽币消费系统集成文档.md)
|
||||
### Java 后端(可选)
|
||||
|
||||
## 技术架构亮点
|
||||
```bash
|
||||
cd infogenie-backend-java
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
### 1. 混合SPA与静态页面的创新架构
|
||||
---
|
||||
|
||||
InfoGenie 采用了创新的混合架构设计,将 React SPA 与大量静态 HTML 页面无缝集成,充分发挥两者的优势:
|
||||
- **React SPA 核心层**:处理用户认证、全局状态管理和主要导航逻辑,确保一致的用户体验
|
||||
- **静态 HTML 模块**:大量功能模块(API数据展示、小游戏、AI工具)使用原生HTML/CSS/JS实现,降低加载时间和资源消耗
|
||||
- **通信机制**:通过 postMessage API 和共享环境配置实现 SPA 与静态页面的数据交换和状态同步
|
||||
## 环境变量要点
|
||||
|
||||
### 2. 前端性能优化策略
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 前端 | `VITE_API_URL`、`VITE_AUTH_URL`、`VITE_AUTH_API_URL`、`VITE_DEBUG`(见前端文档) |
|
||||
| Go | `APP_ENV`、`APP_PORT`、`DB_*`、`INFOGENIE_SITE_ADMIN_TOKEN`、可选 `REDIS_*`、`AUTH_CENTER_API_URL` 等(见后端文档) |
|
||||
|
||||
项目实现了全面的性能优化,确保在各种设备上的流畅体验:
|
||||
- **代码分割**:基于路由的动态导入,减少首屏加载时间
|
||||
- **资源预加载**:关键资源预加载与懒加载策略结合
|
||||
- **缓存策略**:通过 Service Worker 实现静态资源和API响应的智能缓存
|
||||
- **性能监控**:页面加载性能关键指标(FCP、LCP、CLS)的实时监控与分析
|
||||
**不要**将含真实密码的 `.env` 提交到版本库。
|
||||
|
||||
### 3. 模块化与组件设计
|
||||
---
|
||||
|
||||
采用高度模块化的组件设计模式,提升代码可维护性和扩展性:
|
||||
- **原子设计系统**:从原子级别组件到页面级别的多层组件设计体系
|
||||
- **状态与UI分离**:清晰的关注点分离,提高组件复用性
|
||||
- **统一样式系统**:基于 styled-components 的主题化设计系统
|
||||
- **组件文档化**:关键组件的详细使用说明和示例代码
|
||||
## 对外能力摘要(Go)
|
||||
|
||||
### 4. 安全与用户体验融合
|
||||
- **用户**:JWT 校验;登录由萌芽认证中心完成;`/api/user/profile` 等。
|
||||
- **站点**:60s / AI 显隐、60s 上游、功能卡片点击统计;管理员 `X-Site-Admin-Token` 写入配置。
|
||||
- **AI**:`/api/aimodelapp/chat`、`/chat/stream`(SSE)及多个垂直能力;模型白名单在后端维护。
|
||||
- **可观测性**:`/api/health`;管理员 **`/api/admin/site/diagnostics`**(连接摘要,无密码明文)。
|
||||
|
||||
通过创新的用户体验设计,在保证安全性的同时提供流畅体验:
|
||||
- **无感刷新认证**:JWT Token 自动刷新机制,避免频繁登录
|
||||
- **智能错误处理**:集中式的错误处理系统,提供用户友好的错误提示
|
||||
- **渐进式数据加载**:重要数据优先加载,提升感知性能
|
||||
- **离线访问支持**:关键功能支持离线访问,增强用户体验
|
||||
---
|
||||
|
||||
## 后续扩展建议
|
||||
## 许可证与备注
|
||||
|
||||
1. **状态管理升级**: 可考虑引入 Redux 或 Zustand 进行更复杂的状态管理
|
||||
2. **组件库**: 可引入 Ant Design 或 Material-UI 统一 UI 组件
|
||||
3. **测试覆盖**: 添加单元测试和集成测试,提高代码质量和可靠性
|
||||
- Jest 与 React Testing Library 进行组件测试
|
||||
- Cypress 进行端到端测试
|
||||
- MSW 进行 API 模拟测试
|
||||
4. **性能监控**: 集成 Lighthouse CI 和 Web Vitals 进行性能监控
|
||||
5. **国际化**: 使用 i18next 支持多语言切换功能
|
||||
6. **萌芽币系统扩展**:
|
||||
- 实现萌芽币充值功能
|
||||
- 针对不同AI功能设置差异化定价
|
||||
- 添加萌芽币消费统计和分析功能
|
||||
7. **微前端架构**: 考虑将大型功能模块转换为微前端架构,提高扩展性和团队协作效率
|
||||
8. **构建优化**: 实施更先进的构建优化策略,如 Tree Shaking、代码分割、资源压缩等
|
||||
9. **渐进式Web应用升级**: 强化PWA能力,增强离线使用体验和桌面安装功能
|
||||
|
||||
## 技术债务与优化建议
|
||||
|
||||
### 1. 代码质量与一致性
|
||||
|
||||
当前项目在开发过程中出现了一些不一致的编码风格和实践,需要通过以下措施进行标准化:
|
||||
|
||||
- **代码规范统一**: 引入 ESLint + Prettier 强制执行统一的代码风格
|
||||
- **类型安全增强**: 考虑引入 TypeScript 或 PropTypes 进行类型检查,降低运行时错误
|
||||
- **代码审查流程**: 建立明确的代码审查规范和流程,确保代码质量
|
||||
- **文档完善**: 补充关键功能模块和核心组件的详细文档
|
||||
|
||||
### 2. 架构优化与重构
|
||||
|
||||
以下是需要关注的架构层面优化点:
|
||||
|
||||
- **API 层抽象**: 当前的 API 调用过于分散,建议引入统一的 API 请求层和数据模型层
|
||||
- **组件结构优化**: 部分组件承担过多职责,应按照单一职责原则进行拆分
|
||||
- **状态管理重构**: 当前 Context API 的使用在大型应用中可能导致过度渲染,考虑引入更高效的状态管理方案
|
||||
- **静态资源优化**: 优化图像、字体等静态资源的加载策略,减少页面加载时间
|
||||
|
||||
### 3. 前端基础设施升级
|
||||
|
||||
为提高开发效率和项目可维护性,建议升级以下基础设施:
|
||||
|
||||
- **现代化构建工具**: 考虑从 Create React App 迁移至 Vite,提升开发和构建速度
|
||||
- **自动化测试框架**: 建立单元测试、集成测试和 E2E 测试的完整体系
|
||||
- **CI/CD 流程**: 优化持续集成和部署流程,实现更敏捷的开发和发布
|
||||
- **前端监控系统**: 引入错误跟踪和性能监控系统,主动发现并解决问题
|
||||
|
||||
## 前端技术栈演进路线
|
||||
|
||||
### 近期优化(0-3个月)
|
||||
|
||||
1. **架构文档完善**: 补充架构决策记录(ADR)和技术选型依据
|
||||
2. **代码质量工具集成**: ESLint、Prettier、Husky 配置统一
|
||||
3. **性能优化第一阶段**: 首屏加载优化、资源懒加载实现
|
||||
4. **核心组件库重构**: 提取可复用组件,建立组件文档
|
||||
|
||||
### 中期规划(3-6个月)
|
||||
|
||||
1. **TypeScript 迁移**: 核心模块向 TypeScript 迁移
|
||||
2. **状态管理升级**: 评估并引入更适合的状态管理方案
|
||||
3. **自动化测试覆盖**: 关键功能的单元测试和集成测试实现
|
||||
4. **微前端架构设计**: 设计微前端架构方案,逐步迁移大型功能模块
|
||||
|
||||
### 长期展望(6-12个月)
|
||||
|
||||
1. **新技术栈评估与升级**: 评估 React 18+ 新特性应用
|
||||
2. **前端国际化支持**: 实现多语言和本地化支持
|
||||
3. **无障碍访问标准实现**: 符合 WCAG 2.1 AA 级标准
|
||||
4. **性能指标达成**: 优化应用性能,使所有关键指标达到业界标准
|
||||
具体许可证以前端 `package.json` 与各子项目声明为准。若文档与代码不一致,以当前代码与 **`后端文档.md` / `前端文档.md`** 为准。
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# InfoGenie Go Backend - 开发环境配置
|
||||
APP_ENV=development
|
||||
APP_PORT=5002
|
||||
|
||||
# MySQL 测试数据库
|
||||
DB_HOST=10.1.1.100
|
||||
DB_PORT=3306
|
||||
DB_NAME=infogenie-test
|
||||
DB_USER=infogenie-test
|
||||
DB_PASSWORD=infogenie-test
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8
|
||||
JWT_EXPIRE_DAYS=7
|
||||
|
||||
# 邮件服务
|
||||
MAIL_HOST=smtp.qiye.aliyun.com
|
||||
MAIL_PORT=465
|
||||
MAIL_USERNAME=notice@smyhub.com
|
||||
MAIL_PASSWORD=tyh@19900420
|
||||
|
||||
# AI 配置文件路径
|
||||
AI_CONFIG_PATH=ai_config.json
|
||||
|
||||
# 萌芽账户认证中心
|
||||
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
|
||||
AUTH_CENTER_ADMIN_TOKEN=
|
||||
|
||||
# 站点前台配置(与前端管理员口令一致,用于保存 60s 功能展示开关)
|
||||
INFOGENIE_SITE_ADMIN_TOKEN=shumengya520
|
||||
22
infogenie-backend-go/.env.example
Normal file
22
infogenie-backend-go/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
APP_ENV=development
|
||||
APP_PORT=5002
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=infogenie-test
|
||||
DB_USER=infogenie-test
|
||||
DB_PASSWORD=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRE_DAYS=7
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=465
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
|
||||
AUTH_CENTER_ADMIN_TOKEN=
|
||||
INFOGENIE_SITE_ADMIN_TOKEN=
|
||||
# REDIS_ENABLED=false
|
||||
# REDIS_ADDR=127.0.0.1:6379
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=10
|
||||
# REDIS_KEY_PREFIX=infogenie:go:v1:
|
||||
# REDIS_SITE_TTL=60
|
||||
7
infogenie-backend-go/.gitignore
vendored
7
infogenie-backend-go/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
# 含数据库密码、SMTP 等,勿提交
|
||||
.env.production
|
||||
.env.local
|
||||
# 含数据库密码、SMTP、JWT、Redis 等,勿提交
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"deepseek": {
|
||||
"api_key": "sk-832f8e5250464de08a31523c7fd712",
|
||||
"api_base": "https://api.deepseek.com",
|
||||
"model": ["deepseek-chat","deepseek-reasoner"]
|
||||
},
|
||||
|
||||
"kimi": {
|
||||
"api_key": "sk-zdg9NBpTlhOcDDpoWfaBKu0KNDdGv18SipORnL2utawja",
|
||||
"api_base": "https://api.moonshot.cn",
|
||||
"model": ["kimi-k2-0905-preview","kimi-k2-0711-preview"]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/router"
|
||||
)
|
||||
@@ -23,6 +24,13 @@ func main() {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
if err := cache.Init(cfg.Redis); err != nil {
|
||||
log.Fatalf("Redis 初始化失败: %v", err)
|
||||
}
|
||||
if cfg.Redis.Enabled {
|
||||
log.Printf("Redis 已启用: %s DB=%d prefix=%s TTL=%s", cfg.Redis.Addr, cfg.Redis.DB, cfg.Redis.KeyPrefix, cfg.Redis.SiteTTL)
|
||||
}
|
||||
|
||||
if err := database.AutoMigrate(); err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -17,10 +18,21 @@ type AppConfig struct {
|
||||
Mail MailConfig
|
||||
AI AIConfig
|
||||
AuthCenter AuthCenterConfig
|
||||
Redis RedisConfig
|
||||
// SiteAdminToken 与前端管理员口令一致,用于更新站点展示配置(如 60s 功能开关);为空则禁止写入
|
||||
SiteAdminToken string
|
||||
}
|
||||
|
||||
// RedisConfig 可选缓存;Enabled 为 false 时不连接 Redis,行为与未接入缓存时一致
|
||||
type RedisConfig struct {
|
||||
Enabled bool
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
KeyPrefix string
|
||||
SiteTTL time.Duration
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
@@ -67,6 +79,10 @@ const (
|
||||
defaultDevDBName = "infogenie-test"
|
||||
defaultDevDBUser = "infogenie-test"
|
||||
defaultDevDBPassword = "infogenie-test"
|
||||
|
||||
defaultDevRedisAddr = "10.1.1.100:6379"
|
||||
defaultRedisDB = 10
|
||||
defaultRedisKeyPrefix = "infogenie:go:v1:"
|
||||
)
|
||||
|
||||
func Load() (*AppConfig, error) {
|
||||
@@ -129,6 +145,12 @@ func Load() (*AppConfig, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redisCfg, err := loadRedisConfig(env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Redis = redisCfg
|
||||
|
||||
// AI配置现在完全从数据库读取,不再加载ai_config.json文件
|
||||
cfg.AI = AIConfig{Providers: make(map[string]AIProviderConfig)}
|
||||
|
||||
@@ -136,6 +158,53 @@ func Load() (*AppConfig, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadRedisConfig(env string) (RedisConfig, error) {
|
||||
if !parseBoolEnv(os.Getenv("REDIS_ENABLED")) {
|
||||
return RedisConfig{}, nil
|
||||
}
|
||||
var addr string
|
||||
var err error
|
||||
if env == envProduction {
|
||||
addr, err = getEnvByEnvironment(env, "REDIS_ADDR", "")
|
||||
if err != nil {
|
||||
return RedisConfig{}, err
|
||||
}
|
||||
} else {
|
||||
addr = getEnv("REDIS_ADDR", defaultDevRedisAddr)
|
||||
}
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
return RedisConfig{}, fmt.Errorf("已启用 REDIS_ENABLED 但 REDIS_ADDR 为空")
|
||||
}
|
||||
dbIdx, err := strconv.Atoi(strings.TrimSpace(getEnv("REDIS_DB", strconv.Itoa(defaultRedisDB))))
|
||||
if err != nil || dbIdx < 0 {
|
||||
return RedisConfig{}, fmt.Errorf("无效的 REDIS_DB: %s", getEnv("REDIS_DB", ""))
|
||||
}
|
||||
ttlSec, err := strconv.Atoi(strings.TrimSpace(getEnv("REDIS_SITE_TTL", "60")))
|
||||
if err != nil || ttlSec < 1 {
|
||||
return RedisConfig{}, fmt.Errorf("无效的 REDIS_SITE_TTL: %s", getEnv("REDIS_SITE_TTL", ""))
|
||||
}
|
||||
prefix := strings.TrimSpace(getEnv("REDIS_KEY_PREFIX", defaultRedisKeyPrefix))
|
||||
if prefix == "" {
|
||||
prefix = defaultRedisKeyPrefix
|
||||
}
|
||||
if !strings.HasSuffix(prefix, ":") {
|
||||
prefix += ":"
|
||||
}
|
||||
return RedisConfig{
|
||||
Enabled: true,
|
||||
Addr: addr,
|
||||
Password: getEnv("REDIS_PASSWORD", ""),
|
||||
DB: dbIdx,
|
||||
KeyPrefix: prefix,
|
||||
SiteTTL: time.Duration(ttlSec) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseBoolEnv(raw string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(raw))
|
||||
return s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
}
|
||||
|
||||
func loadEnvFile(env string) error {
|
||||
envFile := fmt.Sprintf(".env.%s", env)
|
||||
if _, err := os.Stat(envFile); err == nil {
|
||||
|
||||
@@ -2,26 +2,33 @@ module infogenie-backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/cors v1.7.6 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -34,12 +41,11 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
@@ -18,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -30,8 +41,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
@@ -54,11 +65,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -68,12 +82,20 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
@@ -89,6 +111,7 @@ google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aO
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
|
||||
119
infogenie-backend-go/internal/cache/redis.go
vendored
Normal file
119
infogenie-backend-go/internal/cache/redis.go
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"infogenie-backend/config"
|
||||
)
|
||||
|
||||
// Key suffixes(完整 key = KeyPrefix + suffix)
|
||||
const (
|
||||
KeySite60sDisabled = "site:60s-disabled"
|
||||
KeySite60sSource = "site:60s-source"
|
||||
KeySiteAIModelDisabled = "site:ai-model-disabled"
|
||||
)
|
||||
|
||||
func KeySiteFeatureClicks(section string) string {
|
||||
return "site:feature-clicks:" + section
|
||||
}
|
||||
|
||||
var (
|
||||
rdb *redis.Client
|
||||
keyPrefix string
|
||||
siteCacheTTL time.Duration
|
||||
redisOn bool
|
||||
)
|
||||
|
||||
// Init 在 REDIS_ENABLED 时连接并 Ping;未启用时为空操作
|
||||
func Init(rc config.RedisConfig) error {
|
||||
if !rc.Enabled {
|
||||
redisOn = false
|
||||
rdb = nil
|
||||
return nil
|
||||
}
|
||||
redisOn = true
|
||||
keyPrefix = rc.KeyPrefix
|
||||
siteCacheTTL = rc.SiteTTL
|
||||
if siteCacheTTL <= 0 {
|
||||
siteCacheTTL = 60 * time.Second
|
||||
}
|
||||
rdb = redis.NewClient(&redis.Options{
|
||||
Addr: rc.Addr,
|
||||
Password: rc.Password,
|
||||
DB: rc.DB,
|
||||
})
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
return fmt.Errorf("redis: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled 表示已初始化且客户端可用
|
||||
func Enabled() bool {
|
||||
return redisOn && rdb != nil
|
||||
}
|
||||
|
||||
func fullKey(suffix string) string {
|
||||
return keyPrefix + suffix
|
||||
}
|
||||
|
||||
// GetJSON 命中返回 true;未启用或未命中返回 false(err 仅表示 Redis/JSON 异常)
|
||||
func GetJSON(ctx context.Context, suffix string, dest interface{}) (bool, error) {
|
||||
if !Enabled() {
|
||||
return false, nil
|
||||
}
|
||||
val, err := rdb.Get(ctx, fullKey(suffix)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := json.Unmarshal(val, dest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetJSON ttl<=0 时使用配置的 SiteTTL
|
||||
func SetJSON(ctx context.Context, suffix string, v interface{}, ttl time.Duration) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = siteCacheTTL
|
||||
}
|
||||
return rdb.Set(ctx, fullKey(suffix), b, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete 删除若干后缀对应的 key;失败仅打日志
|
||||
func Delete(ctx context.Context, suffixes ...string) {
|
||||
if !Enabled() || len(suffixes) == 0 {
|
||||
return
|
||||
}
|
||||
keys := make([]string, 0, len(suffixes))
|
||||
for _, s := range suffixes {
|
||||
keys = append(keys, fullKey(s))
|
||||
}
|
||||
if err := rdb.Del(ctx, keys...).Err(); err != nil {
|
||||
log.Printf("redis DEL 失败: %v keys=%v", err, keys)
|
||||
}
|
||||
}
|
||||
|
||||
// Ping 未启用时返回 nil
|
||||
func Ping(ctx context.Context) error {
|
||||
if !Enabled() {
|
||||
return nil
|
||||
}
|
||||
return rdb.Ping(ctx).Err()
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/model"
|
||||
)
|
||||
@@ -43,6 +45,19 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
key := cache.KeySiteFeatureClicks(section)
|
||||
var cached struct {
|
||||
Section string `json:"section"`
|
||||
Counts map[string]uint64 `json:"counts"`
|
||||
}
|
||||
if hit, err := cache.GetJSON(ctx, key, &cached); err != nil {
|
||||
log.Printf("redis GET %s: %v", key, err)
|
||||
} else if hit {
|
||||
c.JSON(http.StatusOK, gin.H{"section": cached.Section, "counts": cached.Counts})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.SiteFeatureCardClick
|
||||
if err := database.DB.Where("section = ?", section).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
@@ -52,7 +67,11 @@ func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
counts[r.ItemID] = r.ClickCount
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"section": section, "counts": counts})
|
||||
payload := gin.H{"section": section, "counts": counts}
|
||||
if err := cache.SetJSON(ctx, key, payload, 0); err != nil {
|
||||
log.Printf("redis SET %s: %v", key, err)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
type postFeatureCardClickBody struct {
|
||||
@@ -91,5 +110,6 @@ ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()`
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
return
|
||||
}
|
||||
cache.Delete(c.Request.Context(), cache.KeySiteFeatureClicks(section))
|
||||
c.JSON(http.StatusOK, gin.H{"section": section, "item_id": itemID, "count": row.ClickCount})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/model"
|
||||
)
|
||||
@@ -31,6 +33,17 @@ func siteAdminTokenOK(headerToken string) bool {
|
||||
|
||||
// Get60sDisabled 公开:返回当前隐藏的 60s 功能 id 列表(与前端 item.id 对应)
|
||||
func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var cached struct {
|
||||
Disabled []string `json:"disabled"`
|
||||
}
|
||||
if hit, err := cache.GetJSON(ctx, cache.KeySite60sDisabled, &cached); err != nil {
|
||||
log.Printf("redis GET %s: %v", cache.KeySite60sDisabled, err)
|
||||
} else if hit {
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.Site60sDisabled
|
||||
if err := database.DB.Order("feature_id").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
@@ -40,7 +53,11 @@ func (h *SiteConfigHandler) Get60sDisabled(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.FeatureID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": ids})
|
||||
payload := gin.H{"disabled": ids}
|
||||
if err := cache.SetJSON(ctx, cache.KeySite60sDisabled, payload, 0); err != nil {
|
||||
log.Printf("redis SET %s: %v", cache.KeySite60sDisabled, err)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
type put60sDisabledBody struct {
|
||||
@@ -102,6 +119,7 @@ func (h *SiteConfigHandler) Put60sDisabled(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
return
|
||||
}
|
||||
cache.Delete(c.Request.Context(), cache.KeySite60sDisabled)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
|
||||
}
|
||||
|
||||
@@ -146,14 +164,35 @@ func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label st
|
||||
|
||||
// Get60sSource 公开:当前站点使用的 60s 上游 base_url(供静态页 iframe 传参)
|
||||
func (h *SiteConfigHandler) Get60sSource(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var cached struct {
|
||||
SourceID string `json:"source_id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if hit, err := cache.GetJSON(ctx, cache.KeySite60sSource, &cached); err != nil {
|
||||
log.Printf("redis GET %s: %v", cache.KeySite60sSource, err)
|
||||
} else if hit {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source_id": cached.SourceID,
|
||||
"base_url": cached.BaseURL,
|
||||
"label": cached.Label,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var row model.Site60sUpstream
|
||||
_ = database.DB.First(&row, 1).Error
|
||||
sid, info := resolve60sUpstream(row.SourceID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
payload := gin.H{
|
||||
"source_id": sid,
|
||||
"base_url": info.Base,
|
||||
"label": info.Label,
|
||||
})
|
||||
}
|
||||
if err := cache.SetJSON(ctx, cache.KeySite60sSource, payload, 0); err != nil {
|
||||
log.Printf("redis SET %s: %v", cache.KeySite60sSource, err)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
type put60sSourceBody struct {
|
||||
@@ -195,6 +234,7 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
return
|
||||
}
|
||||
cache.Delete(c.Request.Context(), cache.KeySite60sSource)
|
||||
_, info := resolve60sUpstream(sid)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "source_id": sid, "base_url": info.Base, "label": info.Label})
|
||||
}
|
||||
@@ -203,6 +243,17 @@ func (h *SiteConfigHandler) Put60sSource(c *gin.Context) {
|
||||
|
||||
// GetAIModelDisabled 公开:返回当前隐藏的 AI 应用 id 列表(与前端 StaticPageConfig 中 AI_MODEL_APPS 的索引对应)
|
||||
func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var cached struct {
|
||||
Disabled []string `json:"disabled"`
|
||||
}
|
||||
if hit, err := cache.GetJSON(ctx, cache.KeySiteAIModelDisabled, &cached); err != nil {
|
||||
log.Printf("redis GET %s: %v", cache.KeySiteAIModelDisabled, err)
|
||||
} else if hit {
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": cached.Disabled})
|
||||
return
|
||||
}
|
||||
|
||||
var rows []model.SiteAIModelDisabled
|
||||
if err := database.DB.Order("app_id").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
@@ -212,7 +263,11 @@ func (h *SiteConfigHandler) GetAIModelDisabled(c *gin.Context) {
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.AppID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"disabled": ids})
|
||||
payload := gin.H{"disabled": ids}
|
||||
if err := cache.SetJSON(ctx, cache.KeySiteAIModelDisabled, payload, 0); err != nil {
|
||||
log.Printf("redis SET %s: %v", cache.KeySiteAIModelDisabled, err)
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
|
||||
type putAIModelDisabledBody struct {
|
||||
@@ -274,5 +329,63 @@ func (h *SiteConfigHandler) PutAIModelDisabled(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
|
||||
return
|
||||
}
|
||||
cache.Delete(c.Request.Context(), cache.KeySiteAIModelDisabled)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(clean)})
|
||||
}
|
||||
|
||||
// GetDiagnostics 返回当前进程解析到的连接配置摘要(不含密码/密钥明文),需 X-Site-Admin-Token
|
||||
func (h *SiteConfigHandler) GetDiagnostics(c *gin.Context) {
|
||||
if config.Cfg == nil || strings.TrimSpace(config.Cfg.SiteAdminToken) == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "admin_not_configured",
|
||||
"message": "服务端未配置 INFOGENIE_SITE_ADMIN_TOKEN",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !siteAdminTokenOK(c.GetHeader("X-Site-Admin-Token")) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
cfg := config.Cfg
|
||||
|
||||
sixtyID, sixtyBase, sixtyLabel := EffectiveSixtyUpstream(database.DB)
|
||||
|
||||
redisOut := gin.H{"enabled": cfg.Redis.Enabled}
|
||||
if cfg.Redis.Enabled {
|
||||
redisOut["addr"] = cfg.Redis.Addr
|
||||
redisOut["logical_db"] = cfg.Redis.DB
|
||||
redisOut["key_prefix"] = cfg.Redis.KeyPrefix
|
||||
redisOut["site_cache_ttl_sec"] = int(cfg.Redis.SiteTTL.Seconds())
|
||||
redisOut["password_configured"] = strings.TrimSpace(cfg.Redis.Password) != ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"app": gin.H{
|
||||
"env": cfg.Env,
|
||||
"listen_addr": "0.0.0.0:" + cfg.Port,
|
||||
"listen_port": cfg.Port,
|
||||
},
|
||||
"mysql": gin.H{
|
||||
"host": cfg.DB.Host,
|
||||
"port": cfg.DB.Port,
|
||||
"database": cfg.DB.Name,
|
||||
"user": cfg.DB.User,
|
||||
"password_configured": strings.TrimSpace(cfg.DB.Password) != "",
|
||||
},
|
||||
"redis": redisOut,
|
||||
"sixty_upstream": gin.H{
|
||||
"source_id": sixtyID,
|
||||
"base_url": sixtyBase,
|
||||
"label": sixtyLabel,
|
||||
},
|
||||
"auth_center": gin.H{
|
||||
"api_url": cfg.AuthCenter.APIURL,
|
||||
},
|
||||
"mail": gin.H{
|
||||
"host": cfg.Mail.Host,
|
||||
"port": cfg.Mail.Port,
|
||||
"username": cfg.Mail.Username,
|
||||
"password_configured": strings.TrimSpace(cfg.Mail.Password) != "",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"infogenie-backend/config"
|
||||
"infogenie-backend/internal/cache"
|
||||
"infogenie-backend/internal/database"
|
||||
"infogenie-backend/internal/handler"
|
||||
"infogenie-backend/internal/middleware"
|
||||
@@ -91,6 +93,15 @@ func Setup(r *gin.Engine) {
|
||||
overall = "degraded"
|
||||
}
|
||||
|
||||
redisHealth := gin.H{"enabled": false}
|
||||
if config.Cfg != nil && config.Cfg.Redis.Enabled {
|
||||
if err := cache.Ping(ctx); err != nil {
|
||||
redisHealth = gin.H{"enabled": true, "ok": false, "error": err.Error()}
|
||||
} else {
|
||||
redisHealth = gin.H{"enabled": true, "ok": true}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": overall,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
@@ -99,6 +110,7 @@ func Setup(r *gin.Engine) {
|
||||
"ok": mysqlOK,
|
||||
"status": dbStatus,
|
||||
},
|
||||
"redis": redisHealth,
|
||||
"backend_api": gin.H{
|
||||
"ok": true,
|
||||
},
|
||||
@@ -136,6 +148,7 @@ func Setup(r *gin.Engine) {
|
||||
r.PUT("/api/admin/site/ai-model-disabled", siteH.PutAIModelDisabled)
|
||||
r.GET("/api/admin/site/ai-runtime", aiRtH.GetAIRuntime)
|
||||
r.PUT("/api/admin/site/ai-runtime", aiRtH.PutAIRuntime)
|
||||
r.GET("/api/admin/site/diagnostics", siteH.GetDiagnostics)
|
||||
|
||||
ai := r.Group("/api/aimodelapp")
|
||||
{
|
||||
|
||||
84
infogenie-backend-go/scripts/redis_keyspace/main.go
Normal file
84
infogenie-backend-go/scripts/redis_keyspace/main.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// 用法: go run . <host:port> [password]
|
||||
// 例: go run . 10.1.1.100:6379
|
||||
func main() {
|
||||
addr := "10.1.1.100:6379"
|
||||
pass := os.Getenv("REDIS_PASSWORD")
|
||||
if len(os.Args) >= 2 {
|
||||
addr = os.Args[1]
|
||||
}
|
||||
if len(os.Args) >= 3 {
|
||||
pass = os.Args[2]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := redis.NewClient(&redis.Options{Addr: addr, Password: pass})
|
||||
defer r.Close()
|
||||
if err := r.Ping(ctx).Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "连接失败 %s: %v\n", addr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s, err := r.Info(ctx, "keyspace").Result()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "INFO keyspace: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
used := make(map[int]int64)
|
||||
for _, line := range strings.Split(s, "\r\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "db") {
|
||||
continue
|
||||
}
|
||||
colon := strings.IndexByte(line, ':')
|
||||
if colon <= 2 {
|
||||
continue
|
||||
}
|
||||
dbStr := line[2:colon]
|
||||
dbNum, err := strconv.Atoi(dbStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var keys int64
|
||||
for _, part := range strings.Split(line[colon+1:], ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "keys=") {
|
||||
keys, _ = strconv.ParseInt(strings.TrimPrefix(part, "keys="), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
used[dbNum] = keys
|
||||
}
|
||||
|
||||
fmt.Printf("Redis %s — 逻辑库 key 统计(INFO keyspace)\n\n", addr)
|
||||
for i := 0; i < 16; i++ {
|
||||
k, ok := used[i]
|
||||
if !ok {
|
||||
fmt.Printf(" db%-2d : 无记录(通常为 0 个 key / 未写入过)\n", i)
|
||||
} else if k == 0 {
|
||||
fmt.Printf(" db%-2d : 0 keys\n", i)
|
||||
} else {
|
||||
fmt.Printf(" db%-2d : %d keys (已占用)\n", i, k)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("说明: Redis 默认 16 个逻辑库(0-15);「无记录」一般可当空闲选用。")
|
||||
fmt.Println("若 redis.conf 里 databases=N,实际库数量可能更大,可用 redis-cli CONFIG GET databases 查看。")
|
||||
}
|
||||
@@ -1,117 +1,173 @@
|
||||
# 万象口袋 — Go 后端文档
|
||||
|
||||
**技术栈**:Go 1.25+ · Gin · GORM · MySQL
|
||||
|
||||
**模块路径**:`infogenie-backend`(见 `go.mod`)
|
||||
|
||||
**入口**:`cmd/server/main.go` — 加载配置、连接数据库、`AutoMigrate`、启动 HTTP 服务。
|
||||
|
||||
---
|
||||
|
||||
## 运行与配置
|
||||
|
||||
- 环境由 **`APP_ENV`** 决定:`development` 或 `production`(见 `config.Load()`)。
|
||||
- 若存在 **`.env.development`** / **`.env.production`**,会通过 `godotenv` 加载对应文件。
|
||||
- **`APP_PORT`** 默认 **5002**(与前端 `REACT_APP_API_URL` 开发默认一致)。
|
||||
- 数据库、邮件、认证中心、`INFOGENIE_SITE_ADMIN_TOKEN` 等从环境变量读取,详见 `config/config.go`。
|
||||
|
||||
**健康检查**:`GET /api/health` — 返回服务状态与数据库 `Ping` 结果。
|
||||
|
||||
**根路径**:`GET /` — 返回服务说明与主要 endpoint 分组(`version` 当前为 **3.3.0-go**)。
|
||||
|
||||
---
|
||||
|
||||
## 数据库(GORM AutoMigrate)
|
||||
|
||||
启动时会迁移以下模型(见 `internal/database/mysql.go`):
|
||||
|
||||
| 模型 | 用途 |
|
||||
|------|------|
|
||||
| `AIConfig` | 多厂商 AI Key / Base / 模型列表(如 deepseek、kimi) |
|
||||
| `Site60sDisabled` | 60s 功能在前端隐藏的 `feature_id` |
|
||||
| `SiteAIRuntime` | DeepSeek 兼容网关(Base + Key + 默认模型),优先级高于部分 AIConfig |
|
||||
| `Site60sUpstream` | 60s 上游节点(单例 id=1) |
|
||||
| `SiteAIModelDisabled` | AI 应用在前端隐藏的 `app_id` |
|
||||
| `SiteFeatureCardClick` | 四大板块功能卡片点击统计(`section` + `item_id` 联合主键) |
|
||||
|
||||
---
|
||||
|
||||
## 路由概览(`internal/router/router.go`)
|
||||
|
||||
### CORS
|
||||
|
||||
全局 `middleware.CORS()`,放行常用 Method/Header(含 `Authorization`、`X-Site-Admin-Token`)。
|
||||
|
||||
### 认证与用户
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/auth/check` | 可选 JWT:校验登录态 |
|
||||
| GET | `/api/user/profile` | **需 JWT**:用户资料 |
|
||||
|
||||
实际登录、发 token 由 **萌芽账户认证中心** 完成;后端校验 JWT。
|
||||
|
||||
### 站点公开配置(无需登录)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/site/60s-disabled` | 被隐藏的 60s `feature_id` 列表 |
|
||||
| GET | `/api/site/60s-source` | 60s 上游 `source_id` / `base_url` |
|
||||
| GET | `/api/site/ai-model-disabled` | 被隐藏的 AI 应用 id 列表 |
|
||||
| GET | `/api/site/feature-card-clicks?section=` | 功能卡片点击次数(见下) |
|
||||
| POST | `/api/site/feature-card-clicks/increment` | 上报一次点击,返回最新 count |
|
||||
|
||||
**`section` 合法值**:`60sapi` · `smallgame` · `toolbox` · `aimodel`
|
||||
|
||||
**increment 请求体**:`{ "section": "...", "item_id": "..." }`
|
||||
|
||||
### 站点管理(需 `X-Site-Admin-Token`,与环境变量 `INFOGENIE_SITE_ADMIN_TOKEN` 一致)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| PUT | `/api/admin/site/60s-disabled` | 更新 60s 隐藏列表 |
|
||||
| PUT | `/api/admin/site/60s-source` | 切换 60s 上游 |
|
||||
| PUT | `/api/admin/site/ai-model-disabled` | 更新 AI 应用隐藏列表 |
|
||||
| GET/PUT | `/api/admin/site/ai-runtime` | 读取/更新 DeepSeek 兼容运行时配置 |
|
||||
|
||||
### AI 应用(`/api/aimodelapp`,默认 **需 JWT**)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/chat` | 非流式对话,JSON 返回全文 |
|
||||
| POST | `/chat/stream` | **SSE 流式**:透传上游 OpenAI 兼容流(`text/event-stream`) |
|
||||
| POST | `/name-analysis` 等 | 各垂直能力(姓名、变量命名、写诗、翻译等) |
|
||||
| GET | `/models` | 模型列表 |
|
||||
|
||||
**流式说明**(`internal/handler/aimodel.go` + `internal/service/ai.go`):
|
||||
|
||||
- 上游请求带 `stream: true`,成功后将上游 body **分块写入并 Flush** 到客户端。
|
||||
- 支持 **deepseek**(运行时或 `AIConfig`)与 **kimi**(`AIConfig`)。
|
||||
- 与 `/chat` 共用同一套 `bindAIModelChat` 校验(消息条数、长度、模型白名单等)。
|
||||
|
||||
**模型白名单**:见 `internal/handler/aimodel.go` 中 `allowedModels`(如 deepseek-chat、deepseek-reasoner、部分 kimi 模型)。
|
||||
|
||||
---
|
||||
|
||||
## 核心源码目录
|
||||
|
||||
```
|
||||
cmd/server/ # main
|
||||
config/ # 配置加载
|
||||
internal/
|
||||
database/ # MySQL 初始化、AutoMigrate
|
||||
handler/ # HTTP 处理器(auth、user、aimodel、siteconfig、ai_runtime、feature_card_clicks)
|
||||
middleware/ # CORS、JWT
|
||||
model/ # GORM 模型
|
||||
router/ # 路由注册
|
||||
service/ # AI 调用(含 OpenAI 兼容非流式与流式)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与其他工程的关系
|
||||
|
||||
- **前端 SPA** 通过 `REACT_APP_API_URL` 指向本服务(开发默认 `http://127.0.0.1:5002`)。
|
||||
- **`public/aimodelapp/*/shared/ai-chat.js`** 优先调用 `/api/aimodelapp/chat/stream`,失败时回退 `/chat`。
|
||||
|
||||
更完整的前端集成说明见 **`infogenie-frontend/前端文档.md`**。
|
||||
# 万象口袋 — Go 后端文档
|
||||
|
||||
**技术栈**:Go 1.25+ · Gin · GORM · MySQL ·(可选)Redis(go-redis v9)
|
||||
|
||||
**模块路径**:`infogenie-backend`(见 `go.mod`)
|
||||
|
||||
**入口**:`cmd/server/main.go` — 加载配置、连接 MySQL、`cache.Init`(若启用 Redis)、`AutoMigrate`、启动 HTTP 服务。
|
||||
|
||||
---
|
||||
|
||||
## 运行与配置
|
||||
|
||||
- 环境由 `**APP_ENV`** 决定:`development` 或 `production`(见 `config.Load()`)。
|
||||
- 若存在 `**.env.development**` / `**.env.production**`,会通过 `godotenv` 加载对应文件。
|
||||
- `**APP_PORT**` 默认 **5002**(与前端 `VITE_API_URL` 开发默认一致)。
|
||||
- 数据库、邮件、认证中心、`INFOGENIE_SITE_ADMIN_TOKEN`、可选 Redis 等从环境变量读取,详见 `config/config.go`。
|
||||
|
||||
### Redis(可选)
|
||||
|
||||
|
||||
| 变量 | 说明 |
|
||||
| ------------------ | --------------------------------------------------------- |
|
||||
| `REDIS_ENABLED` | `true` / `1` / `yes` / `on` 时启用;未启用则不连 Redis,站点接口直连 MySQL |
|
||||
| `REDIS_ADDR` | `host:port`;生产环境必填;开发未设时默认 `10.1.1.100:6379` |
|
||||
| `REDIS_PASSWORD` | 可选 |
|
||||
| `REDIS_DB` | 逻辑库编号,默认 **10**(与 `db0` 等业务隔离) |
|
||||
| `REDIS_KEY_PREFIX` | Key 前缀,默认 `infogenie:go:v1:`(可自动补 `:`) |
|
||||
| `REDIS_SITE_TTL` | 站点类 JSON 缓存 TTL(秒),默认 **60** |
|
||||
|
||||
|
||||
启用时启动阶段会 **Ping**;失败则进程退出。站点只读接口对 `60s-disabled`、`60s-source`、`ai-model-disabled`、`feature-card-clicks` 等做 cache-aside,管理端写入成功后删对应 Key(见 `internal/cache/redis.go` 与 `internal/handler/siteconfig.go`、`feature_card_clicks.go`)。
|
||||
|
||||
---
|
||||
|
||||
## 健康与诊断
|
||||
|
||||
### `GET /api/health`(公开)
|
||||
|
||||
- `**status`**:`running` 或 `degraded`(MySQL 未连通 **或** 60s 上游探测失败时为 degraded;**不**因 Redis 失败而 degraded)。
|
||||
- `**mysql`**:`ok`、`status`(`connected` / `disconnected` / `not_initialized`)。
|
||||
- `**sixty_api**`:当前生效的上游 `source_id`、`base_url`、`label`,以及对 `…/v2/ip` 的探测结果(`probe_url`、`http_status`、`latency_ms`、`error`)。
|
||||
- `**redis**`:未启用时 `{ "enabled": false }`;启用时 `{ "enabled": true, "ok": bool, "error"?: string }`。
|
||||
|
||||
### `GET /api/admin/site/diagnostics`(需管理员)
|
||||
|
||||
请求头 `**X-Site-Admin-Token**` 须与 `**INFOGENIE_SITE_ADMIN_TOKEN**` 一致。
|
||||
|
||||
返回**不含密码明文**,仅连接与进程摘要,供运维/后台展示:
|
||||
|
||||
|
||||
| 字段 | 内容 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `app` | `env`、`listen_addr`(`0.0.0.0:端口`)、`listen_port` |
|
||||
| `mysql` | `host`、`port`、`database`、`user`、`password_configured` |
|
||||
| `redis` | `enabled`;若启用则含 `addr`、`logical_db`、`key_prefix`、`site_cache_ttl_sec`、`password_configured` |
|
||||
| `sixty_upstream` | 库内当前 60s 节点 `source_id` / `base_url` / `label` |
|
||||
| `auth_center` | `api_url` |
|
||||
| `mail` | SMTP `host`、`port`、`username`、`password_configured` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
**根路径**:`GET /` — 返回服务说明与主要 endpoint 分组(`version` 当前为 **3.3.0-go**)。
|
||||
|
||||
---
|
||||
|
||||
## 数据库(GORM AutoMigrate)
|
||||
|
||||
启动时会迁移以下模型(见 `internal/database/mysql.go`):
|
||||
|
||||
|
||||
| 模型 | 用途 |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| `AIConfig` | 多厂商 AI Key / Base / 模型列表(如 deepseek、kimi) |
|
||||
| `Site60sDisabled` | 60s 功能在前端隐藏的 `feature_id` |
|
||||
| `SiteAIRuntime` | DeepSeek 兼容网关(Base + Key + 默认模型),优先级高于部分 AIConfig |
|
||||
| `Site60sUpstream` | 60s 上游节点(单例 id=1) |
|
||||
| `SiteAIModelDisabled` | AI 应用在前端隐藏的 `app_id` |
|
||||
| `SiteFeatureCardClick` | 四大板块功能卡片点击统计(`section` + `item_id` 联合主键) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 路由概览(`internal/router/router.go`)
|
||||
|
||||
### CORS
|
||||
|
||||
全局 `middleware.CORS()`,放行常用 Method/Header(含 `Authorization`、`X-Site-Admin-Token`)。
|
||||
|
||||
### 认证与用户
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | ------------------- | -------------- |
|
||||
| GET | `/api/auth/check` | 可选 JWT:校验登录态 |
|
||||
| GET | `/api/user/profile` | **需 JWT**:用户资料 |
|
||||
|
||||
|
||||
实际登录、发 token 由 **萌芽账户认证中心** 完成;后端校验 JWT。
|
||||
|
||||
### 站点公开配置(无需登录)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ----------------------------------------- | ------------------------------- |
|
||||
| GET | `/api/site/60s-disabled` | 被隐藏的 60s `feature_id` 列表 |
|
||||
| GET | `/api/site/60s-source` | 60s 上游 `source_id` / `base_url` |
|
||||
| GET | `/api/site/ai-model-disabled` | 被隐藏的 AI 应用 id 列表 |
|
||||
| GET | `/api/site/feature-card-clicks?section=` | 功能卡片点击次数(见下) |
|
||||
| POST | `/api/site/feature-card-clicks/increment` | 上报一次点击,返回最新 count |
|
||||
|
||||
|
||||
`**section` 合法值**:`60sapi` · `smallgame` · `toolbox` · `aimodel`
|
||||
|
||||
**increment 请求体**:`{ "section": "...", "item_id": "..." }`
|
||||
|
||||
### 站点管理(需 `X-Site-Admin-Token`,与环境变量 `INFOGENIE_SITE_ADMIN_TOKEN` 一致)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------- | ----------------------------------- | ---------------------- |
|
||||
| PUT | `/api/admin/site/60s-disabled` | 更新 60s 隐藏列表 |
|
||||
| PUT | `/api/admin/site/60s-source` | 切换 60s 上游 |
|
||||
| PUT | `/api/admin/site/ai-model-disabled` | 更新 AI 应用隐藏列表 |
|
||||
| GET/PUT | `/api/admin/site/ai-runtime` | 读取/更新 DeepSeek 兼容运行时配置 |
|
||||
| GET | `/api/admin/site/diagnostics` | 连接与进程配置快照(不含密钥明文) |
|
||||
|
||||
|
||||
### AI 应用(`/api/aimodelapp`,默认 **需 JWT**)
|
||||
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ------------------ | ----------------------------------------------- |
|
||||
| POST | `/chat` | 非流式对话,JSON 返回全文 |
|
||||
| POST | `/chat/stream` | **SSE 流式**:透传上游 OpenAI 兼容流(`text/event-stream`) |
|
||||
| POST | `/name-analysis` 等 | 各垂直能力(姓名、变量命名、写诗、翻译等) |
|
||||
| GET | `/models` | 模型列表 |
|
||||
|
||||
|
||||
**流式说明**(`internal/handler/aimodel.go` + `internal/service/ai.go`):
|
||||
|
||||
- 上游请求带 `stream: true`,成功后将上游 body **分块写入并 Flush** 到客户端。
|
||||
- 支持 **deepseek**(运行时或 `AIConfig`)与 **kimi**(`AIConfig`)。
|
||||
- 与 `/chat` 共用同一套 `bindAIModelChat` 校验(消息条数、长度、模型白名单等)。
|
||||
|
||||
**模型白名单**:见 `internal/handler/aimodel.go` 中 `allowedModels`(如 deepseek-chat、deepseek-reasoner、部分 kimi 模型)。
|
||||
|
||||
---
|
||||
|
||||
## 核心源码目录
|
||||
|
||||
```
|
||||
cmd/server/ # main
|
||||
config/ # 配置加载(含 Redis)
|
||||
internal/
|
||||
cache/ # Redis 可选封装(Get/Set JSON、Delete、Ping)
|
||||
database/ # MySQL 初始化、AutoMigrate
|
||||
handler/ # HTTP 处理器(auth、user、aimodel、siteconfig、ai_runtime、feature_card_clicks)
|
||||
middleware/ # CORS、JWT
|
||||
model/ # GORM 模型
|
||||
router/ # 路由注册(含 /api/health、diagnostics)
|
||||
service/ # AI 调用(含 OpenAI 兼容非流式与流式)
|
||||
scripts/redis_keyspace/ # 可选:本地查看各逻辑库 keyspace(go run,需 REDIS_PASSWORD)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与其他工程的关系
|
||||
|
||||
- **前端 SPA** 通过 `VITE_API_URL` 指向本服务(开发默认 `http://127.0.0.1:5002`)。
|
||||
- `**public/aimodelapp/*/shared/ai-chat.js`** 优先调用 `/api/aimodelapp/chat/stream`,失败时回退 `/chat`。
|
||||
|
||||
更完整的前端集成说明见 `**infogenie-frontend/前端文档.md**`。
|
||||
67
万象口袋项目经历.md
67
万象口袋项目经历.md
@@ -1,61 +1,6 @@
|
||||
# 万象口袋项目-跨平台一站式信息聚合式软件 面试资料
|
||||
|
||||
**项目名称**:万象口袋项目-跨平台一站式信息聚合式软件
|
||||
**时间**:2025.08 - 2026.01
|
||||
**技术栈**:Golang,Gin,MySQL,React、
|
||||
**技术栈**:Java,SpringBoot,MySQL,React、
|
||||
**描述**:跨平台一站式信息聚合式软件
|
||||
**上线地址**:https://infogenie.shumengya.top
|
||||
**开源地址**:https://github.com/shumengya/infogenie
|
||||
|
||||
**本地实现技术栈(当前仓库版本)**
|
||||
- 前端:React 18.2.0、React Router DOM 6.15.0、Styled Components 6.0.7、Axios 1.5.0、React Hot Toast、React Icons、Create React App
|
||||
- 前端架构:React Context API(用户态管理)、混合架构(SPA + public 静态HTML模块)、PWA(Service Worker 离线缓存与 SWR 策略)
|
||||
- 后端:Go 1.25、Gin 1.12、GORM 1.31 + MySQL、gin-contrib/cors、go-playground/validator、joho/godotenv
|
||||
- 后端能力:外部认证中心 JWT 校验与用户态透传、AI供应商(DeepSeek/Kimi) OpenAI兼容接口调用、并发扣费互斥与重试回退、站点展示配置与AI上游运行时管理
|
||||
- 数据库:MySQL(AIUsageRecord、Site60sDisabled、Site60sUpstream、SiteAIRuntime 等表,GORM 自动迁移)
|
||||
- 工程化:后端 Dockerfile + docker-compose,.env 多环境配置;前端 CRA 构建与 Service Worker 预缓存
|
||||
|
||||
## 技术特点总结(可用于简历)
|
||||
- 前后端分离与RESTful:Gin 路由分组与中间件清晰,统一 JSON 响应;前端 Axios 拦截器统一鉴权与错误提示
|
||||
- 混合前端架构:React SPA 与 public 静态HTML模块并存,静态页通过 iframe + window.parent 环境注入与本地 token 传递,兼顾体验与加载速度
|
||||
- 认证与用户态:对接外部认证中心,后端中间件验证 JWT 并透传用户态;前端 Context 管理登录态与本地持久化
|
||||
- AI接口与计费:DeepSeek/Kimi OpenAI兼容调用、超时与指数回退;并发扣费互斥锁、扣费失败与余额不足的严格返回码
|
||||
- 站点运行时配置:管理员口令保护的 60s 展示开关与数据源切换、AI上游 base/key/model 运行时配置写入数据库并脱敏展示
|
||||
- 安全与合规:CORS 策略受控、敏感配置环境变量化、后端仅在开发打印基础设施地址;管理员端采用常量口令 + 服务端令牌比对
|
||||
- 性能优化:PWA 预缓存入口与 runtime 缓存、SW 导航离线回退、静态模块直出;前端渐变背景与轻量组件保持流畅交互
|
||||
- 工程化与部署:后端 Dockerfile + docker-compose 支持;前端 CRA 标准构建流程与静态资源缓存策略;多环境 .env
|
||||
|
||||
## 架构设计亮点
|
||||
- SPA核心层:React Router v6 管理路由;UserContext 统一登录态;Navigation/Header/Footer 组成稳定布局
|
||||
- 静态模块:60sAPI/小游戏/工具箱大量原生HTML/CSS/JS模块,FullscreenEmbed 全屏嵌入并注入 token
|
||||
- 站点后台:AdminPage 管理 60s 展示开关、数据源与 AI 上游;与后端 X-Site-Admin-Token 双向校验
|
||||
- 后端分层:router/middleware/handler/service/database/model 分层清晰;GORM 自动迁移与表结构约束
|
||||
- 可靠性:AI 调用指数退避重试、健康检查检测数据库连接状态、并发扣费互斥锁规避竞态
|
||||
|
||||
## 关键功能模块
|
||||
- 60s聚合信息展示:周期资讯/热门榜单/实用功能/消遣娱乐分类配置;支持节点切换(self/official),静态页按 itemId 映射
|
||||
- 小游戏集合:2048、俄罗斯方块、别踩白方块、贪吃蛇、扫雷、跑酷、打飞机、FloppyBird、网页魔方等,移动端优化即点即玩
|
||||
- AI工具集:变量命名、写诗、姓名评测、语言翻译、文章转文言文、表情生成、Linux命令、Markdown排版、亲戚称呼计算器
|
||||
- 认证与用户:认证中心接入,后端 JWT 验证并透传用户资料;前端登录回调与本地存储持久化
|
||||
- 计费与统计:每次 AI 调用扣除 100 萌芽币、扣费通过管理员接口更新认证中心;记录 AIUsageRecord 便于统计
|
||||
|
||||
## 安全与性能实践
|
||||
- 认证与保护:后端 JWT 验证中间件、管理员令牌常量 + 服务端校验;并发扣费互斥锁避免竞态
|
||||
- 配置安全:环境变量/.env 管理敏感信息,管理员口令与服务端令牌一致性校验;AI Key 脱敏回显与保留策略
|
||||
- 前端性能:PWA 预缓存 + runtime SWR、离线导航回退、静态模块直出;Axios 错误统一提示与 401 清理会话
|
||||
- 后端健壮:AI 调用超时与指数退避、健康检查检测数据库连接状态、GORM 连接池与生命周期管理
|
||||
|
||||
## 部署与运维
|
||||
- 前端:npm run build 标准构建,Service Worker 自动预缓存入口;支持静态资源离线加载
|
||||
- 后端:Dockerfile + docker-compose 一键编排,环境变量控制端口与数据库;GORM 自动迁移表结构
|
||||
- 可观测性:AIUsageRecord 与站点配置表为运营与审计提供基础数据;脚本与工具支撑静态页一致性校验
|
||||
|
||||
## 个人职责与成果(示例措辞)
|
||||
- 推动 React SPA + 静态HTML 混合架构落地,封装 FullscreenEmbed 组件实现 token 注入与全屏嵌入
|
||||
- 设计后端 Gin 分层与中间件体系,接入外部认证中心,完成 JWT 验证与用户态透传
|
||||
- 实现 AI 调用链路(DeepSeek/Kimi),加入指数回退与超时控制;并发扣费互斥锁与扣费回滚提示
|
||||
- 搭建后端 Docker 与 compose,GORM 自动迁移与连接池优化;完善 .env 多环境配置
|
||||
- 开发管理员后台:60s 展示开关、数据源切换、AI 上游运行时配置(密钥脱敏与保留策略)
|
||||
- 完成 PWA 缓存策略与离线回退、Axios 拦截器统一错误提示,保证移动端与弱网下的可用性
|
||||
|
||||
- **项目名称**:万象口袋- 跨平台信息聚合网站
|
||||
- **时间**:2025.08 - 至今
|
||||
- **技术栈**:Golang,Gin,GORM,MySQL,Redis,React,Vite,Tailwind,Docker
|
||||
- **描述**:产品定位为一站式资讯与工具导航:首页与栏目以配置驱动,整合 60s 类 API 浏览、休闲小游戏、实用工具箱及多款 AI 应用(后端 OpenAI 格式兼容与 SSE 流式输出)。后端采用 Go + Gin + GORM,MySQL 自动迁移存站点开关;可选接入 Redis 对高频只读配置做 cache-aside,独立逻辑库与 Key 前缀避免污染其他业务。提供聚合健康检查与仅管理员可调用的诊断快照接口。前端基于 React 与 Vite,Tailwind 统一视觉,axios 封装与路由守卫;对接萌芽认证中心 JWT,管理员后台支持 AI 上游、前台显隐配置及运行状态监控。支持多环境变量与开发/生产数据库隔离约束,兼顾可运维与交付效率。
|
||||
- **项目地址**:[https://infogenie.smyhub.com](https://infogenie.smyhub.com) **|** **开源地址**:[https://github.com/shumengya/infogenie](https://github.com/shumengya/infogenie)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user