更新前后端配置并清理仓库内容
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,3 +39,7 @@ InfoGenie-backend/.env
|
||||
# 允许提交模板(勿填真实密钥)
|
||||
!infogenie-frontend/.env.example
|
||||
!infogenie-backend-go/.env.example
|
||||
|
||||
# Local docs / prompt assets not meant for repository upload
|
||||
万象口袋项目经历-*.md
|
||||
infogenie-frontend/public/aimodelapp/shared/ai-prompts.js
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Preload fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="//fonts.googleapis.com">
|
||||
<link rel="preconnect" href="//fonts.gstatic.com" crossorigin>
|
||||
|
||||
<title>万象口袋</title>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"short_name": "万象口袋",
|
||||
"name": "万象口袋 - 多功能聚合应用",
|
||||
"description": "万象口袋 - 多功能聚合应用",
|
||||
"name": "万象口袋",
|
||||
"description": "万象口袋",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
@@ -36,9 +36,9 @@
|
||||
"categories": ["utilities", "productivity", "entertainment"],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 800'><rect width='400' height='800' fill='%23667eea'/><text x='200' y='400' text-anchor='middle' fill='white' font-size='32'>万象口袋</text></svg>",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "400x800",
|
||||
"src": "/icons/icon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* 60s API 聚合接口配置
|
||||
* 基于 https://docs.60s-api.viki.moe/ 文档整理
|
||||
* 基于 60s-api 官方文档整理
|
||||
* 所有 endpoint 均为 /v2/ 前缀的相对路径,配合 apiBaseUrl 使用
|
||||
*/
|
||||
|
||||
import { tlsHost } from './urlJoin.js';
|
||||
|
||||
// 可选的 API 数据源列表,前端可切换
|
||||
export const API_SOURCES = [
|
||||
{ id: 'self', label: '萌芽节点', baseUrl: 'https://60s.api.shumengya.top' },
|
||||
{ id: 'official', label: '官方节点', baseUrl: 'https://60s.viki.moe' },
|
||||
{ id: 'self', label: '萌芽节点', baseUrl: tlsHost('60s.api.shumengya.top') },
|
||||
{ id: 'official', label: '官方节点', baseUrl: tlsHost('60s.viki.moe') },
|
||||
];
|
||||
|
||||
// 默认 API 数据源
|
||||
@@ -126,6 +128,9 @@ const TOOLBOX_IMAGE = [
|
||||
{ id: 'tb-img-b64', title: '图片转 Base64', icon: '🔀', link: '/toolbox/图片处理/图片转Base64编码/index.html', desc: '编码转换', offlineOk: true },
|
||||
{ id: 'tb-img-gif-split', title: 'GIF拆帧', icon: '🎞️', link: '/toolbox/图片处理/GIF拆帧/index.html', desc: 'GIF帧提取', offlineOk: true },
|
||||
{ id: 'tb-img-webp', title: '图片转 WebP', icon: '🌐', link: '/toolbox/图片处理/图片转webp格式/index.html', desc: 'WebP格式转换', offlineOk: true },
|
||||
{ id: 'tb-img-watermark', title: '图片加文字水印', icon: '✏️', link: '/toolbox/图片处理/图片加文字水印/图片加文字水印.html', desc: '为图片添加文字水印', offlineOk: true },
|
||||
{ id: 'tb-img-compress', title: '图片压缩', icon: '🗜️', link: '/toolbox/图片处理/图片压缩处理/图片压缩处理.html', desc: '压缩图片体积', offlineOk: true },
|
||||
{ id: 'tb-img-png-jpg', title: 'PNG / JPG 互转', icon: '🔁', link: '/toolbox/图片处理/png和jpg格式互转/png和jpg相互转化.html', desc: 'PNG 与 JPG 格式转换', offlineOk: true },
|
||||
];
|
||||
|
||||
const TOOLBOX_UTIL = [
|
||||
@@ -135,6 +140,7 @@ const TOOLBOX_UTIL = [
|
||||
{ id: 'tb-json', title: 'JSON 编辑器', icon: '📑', link: '/toolbox/实用工具/Json编辑器/index.html', desc: '格式化与校验', offlineOk: true },
|
||||
{ id: 'tb-markdown', title: 'Markdown', icon: '⌨️', link: '/toolbox/实用工具/Markdown解析器/index.html', desc: '实时预览', offlineOk: true },
|
||||
{ id: 'tb-js', title: 'JavaScript', icon: '🟩', link: '/toolbox/实用工具/JavaScript编译器/index.html', desc: '本地运行脚本', offlineOk: true },
|
||||
{ id: 'tb-c-lang', title: 'C 语言编译器', icon: '⚙️', link: '/toolbox/实用工具/C语言编译器/网页C语言编译器.html', desc: '网页端编写与运行 C', offlineOk: true },
|
||||
{ id: 'tb-random-num', title: '随机数', icon: '🎲', link: '/toolbox/实用工具/随机数生成器/index.html', desc: '自定义范围', offlineOk: true },
|
||||
{ id: 'tb-wheel', title: '做决定转盘', icon: '🎡', link: '/toolbox/实用工具/做决定转盘/index.html', desc: '随机选择', offlineOk: true },
|
||||
{ id: 'tb-calc', title: '计算器', icon: '🔢', link: '/toolbox/实用工具/计算器/index.html', desc: '基础运算', offlineOk: true },
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { tlsHost, loopbackPort } from './urlJoin.js';
|
||||
|
||||
const resolveApiUrl = () => {
|
||||
const mode = import.meta.env.MODE;
|
||||
const envApiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
if (mode === 'production') {
|
||||
if (envApiUrl && envApiUrl.trim() !== '') return envApiUrl;
|
||||
return 'https://infogenie.api.shumengya.top';
|
||||
return tlsHost('infogenie.api.shumengya.top');
|
||||
}
|
||||
|
||||
if (envApiUrl && envApiUrl.trim() !== '') return envApiUrl;
|
||||
return 'http://127.0.0.1:5002';
|
||||
return loopbackPort('127.0.0.1', '5002');
|
||||
};
|
||||
|
||||
const resolveAuthUrl = () => {
|
||||
@@ -17,11 +19,11 @@ const resolveAuthUrl = () => {
|
||||
|
||||
if (mode === 'production') {
|
||||
if (envAuthUrl && envAuthUrl.trim() !== '') return envAuthUrl;
|
||||
return 'https://auth.shumengya.top';
|
||||
return tlsHost('auth.shumengya.top');
|
||||
}
|
||||
|
||||
if (envAuthUrl && envAuthUrl.trim() !== '') return envAuthUrl;
|
||||
return 'https://auth.shumengya.top';
|
||||
return tlsHost('auth.shumengya.top');
|
||||
};
|
||||
|
||||
const resolveAuthApiUrl = () => {
|
||||
@@ -30,11 +32,11 @@ const resolveAuthApiUrl = () => {
|
||||
|
||||
if (mode === 'production') {
|
||||
if (envAuthApiUrl && envAuthApiUrl.trim() !== '') return envAuthApiUrl;
|
||||
return 'https://auth.api.shumengya.top';
|
||||
return tlsHost('auth.api.shumengya.top');
|
||||
}
|
||||
|
||||
if (envAuthApiUrl && envAuthApiUrl.trim() !== '') return envAuthApiUrl;
|
||||
return 'https://auth.api.shumengya.top';
|
||||
return tlsHost('auth.api.shumengya.top');
|
||||
};
|
||||
|
||||
const config = {
|
||||
|
||||
12
InfoGenie-frontend/src/config/urlJoin.js
Normal file
12
InfoGenie-frontend/src/config/urlJoin.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** 避免源码中出现 https/http 连续字符,否则 Tailwind 扫描会误生成无效任意属性类 */
|
||||
const c = String.fromCharCode(58);
|
||||
const tlsScheme = String.fromCharCode(104, 116, 116, 112, 115);
|
||||
const plainScheme = String.fromCharCode(104, 116, 116, 112);
|
||||
|
||||
export function tlsHost(host) {
|
||||
return `${tlsScheme}${c}//${host}`;
|
||||
}
|
||||
|
||||
export function loopbackPort(host, port) {
|
||||
return `${plainScheme}${c}//${host}${c}${port}`;
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import { ENV_CONFIG } from '../config/env';
|
||||
import { API_CATEGORIES, TOOLBOX_ITEMS, API_SOURCES } from '../config/Api60sConfig';
|
||||
import { AI_MODEL_APPS } from '../config/StaticPageConfig';
|
||||
|
||||
const gridOverviewStats = { gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' };
|
||||
const gridQuickActions = { gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' };
|
||||
const gridAdminItems = { gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))' };
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -458,7 +462,7 @@ const AdminPage = () => {
|
||||
|
||||
{adminSection === 'overview' && (
|
||||
<>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3 mb-6">
|
||||
<div className="grid gap-3 mb-6" style={gridOverviewStats}>
|
||||
{[
|
||||
{ icon: FiGrid, bg: 'linear-gradient(135deg,#4ade80,#22c55e)', label: '60sAPI', value: apiCount },
|
||||
{ icon: FiBox, bg: 'linear-gradient(135deg,#60a5fa,#3b82f6)', label: '休闲游戏', value: gameCount },
|
||||
@@ -496,7 +500,7 @@ const AdminPage = () => {
|
||||
</InfoCard>
|
||||
|
||||
<h3 className="text-base text-white mb-3 mt-2 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)]">快捷操作</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3 mb-2">
|
||||
<div className="grid gap-3 mb-2" style={gridQuickActions}>
|
||||
<ActionBtn onClick={fetchSysInfo} disabled={loading}><FiRefreshCw size={16} /> 刷新后端状态</ActionBtn>
|
||||
<ActionBtn onClick={handleClearBuildCache}><FiMonitor size={16} /> 清除 SW 缓存</ActionBtn>
|
||||
<ActionBtn onClick={handleClearLocalStorage}><FiBox size={16} /> 清理本地存储</ActionBtn>
|
||||
@@ -733,7 +737,7 @@ const AdminPage = () => {
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
{ id: 'ai-base', label: 'API Base URL', type: 'text', value: aiBase, onChange: setAiBase, placeholder: 'https://api.deepseek.com' },
|
||||
{ id: 'ai-base', label: 'API Base URL', type: 'text', value: aiBase, onChange: setAiBase, placeholder: '例如 api.deepseek.com' },
|
||||
{ id: 'ai-key', label: `API Key${aiKeySet ? `(已配置:${aiKeyHint})` : ''}`, type: 'password', value: aiKey, onChange: setAiKey, placeholder: aiKeySet ? '留空保留原密钥;填写则覆盖' : '必填,保存后仅显示脱敏尾号', autoComplete: 'new-password' },
|
||||
{ id: 'ai-model', label: '默认模型 ID', type: 'text', value: aiModel, onChange: setAiModel, placeholder: 'deepseek-chat' },
|
||||
].map(({ id, label, type, value, onChange, placeholder, autoComplete }) => (
|
||||
@@ -756,11 +760,11 @@ const AdminPage = () => {
|
||||
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
|
||||
选择 60s 类接口的<strong>上游根地址</strong>(与 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">Api60sConfig.API_SOURCES</code> 对齐)。
|
||||
</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px] max-w-[520px]">
|
||||
<div className="grid gap-[8px_14px] max-w-[520px]" style={gridAdminItems}>
|
||||
{API_SOURCES.map((s) => (
|
||||
<SixtyItem key={s.id} htmlFor={`admin-sixty-${s.id}`}>
|
||||
<input type="radio" id={`admin-sixty-${s.id}`} name="admin_sixty_src" checked={sixtySrcId === s.id} onChange={() => setSixtySrcId(s.id)} />
|
||||
<span>{s.label} <span className="text-gray-400 text-[11px] font-medium">({s.baseUrl.replace(/^https?:\/\//, '')})</span></span>
|
||||
<span>{s.label} <span className="text-gray-400 text-[11px] font-medium">({s.baseUrl.replace(/^[^/]+\/\//, '')})</span></span>
|
||||
</SixtyItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -783,7 +787,7 @@ const AdminPage = () => {
|
||||
{API_CATEGORIES.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<h3 className="text-[15px] text-[#166534] mt-[18px] mb-2.5 first:mt-1">{cat.icon} {cat.title}</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px]">
|
||||
<div className="grid gap-[8px_14px]" style={gridAdminItems}>
|
||||
{cat.items.map((item) => (
|
||||
<SixtyItem key={item.id}>
|
||||
<input type="checkbox" checked={!disabled60sSet.has(item.id)} onChange={(e) => setItemVisible(item.id, e.target.checked)} />
|
||||
@@ -811,7 +815,7 @@ const AdminPage = () => {
|
||||
<p className="text-sm text-gray-500">正在加载配置…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px]">
|
||||
<div className="grid gap-[8px_14px]" style={gridAdminItems}>
|
||||
{AI_MODEL_APPS.map((app) => (
|
||||
<SixtyItem key={app.id}>
|
||||
<input type="checkbox" checked={!disabledAIModelsSet.has(app.id)} onChange={(e) => setAIModelVisible(app.id, e.target.checked)} />
|
||||
|
||||
@@ -187,9 +187,9 @@ export function accentFromGradient(gradient) {
|
||||
export const ModuleGrid = ({ children, className = '' }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid grid-cols-1 min-[520px]:grid-cols-2',
|
||||
'gap-3 min-[520px]:gap-3.5',
|
||||
'max-w-[900px] mx-auto px-3 min-[520px]:px-4',
|
||||
'grid grid-cols-1 sm:grid-cols-2',
|
||||
'gap-3 sm:gap-3.5',
|
||||
'max-w-[900px] mx-auto px-3 sm:px-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
// 若为 true,esbuild 会对 Tailwind 误生成的 gri 等无效规则告警;可改回 true 并接受提示
|
||||
build: { cssMinify: false },
|
||||
plugins: [
|
||||
// Allow JSX in .js files (all files under src/)
|
||||
{
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
23
infogenie-backend-go/.env.production.example
Normal file
23
infogenie-backend-go/.env.production.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# 复制为 .env.production 后按需填写(勿提交真实密钥)
|
||||
APP_ENV=production
|
||||
APP_PORT=5002
|
||||
|
||||
# ===== MySQL(容器内访问宿主机时勿用 127.0.0.1)=====
|
||||
# 例:宿主机 MySQL 监听 3306 → DB_HOST=172.17.0.1(Linux 默认网桥)或宿主机局域网 IP
|
||||
# Docker Desktop(Windows/Mac):DB_HOST=host.docker.internal
|
||||
DB_HOST=172.17.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=infogenie
|
||||
DB_USER=infogenie
|
||||
DB_PASSWORD=
|
||||
|
||||
# ===== Redis(可选;启用则必须能 Ping 通,否则服务无法启动)=====
|
||||
# REDIS_ENABLED=false
|
||||
# REDIS_ADDR=172.17.0.1:6379
|
||||
# REDIS_PASSWORD=
|
||||
# REDIS_DB=10
|
||||
# REDIS_KEY_PREFIX=infogenie:go:v1:
|
||||
# REDIS_SITE_TTL=60
|
||||
|
||||
AUTH_CENTER_API_URL=https://auth.api.shumengya.top
|
||||
INFOGENIE_SITE_ADMIN_TOKEN=
|
||||
4
infogenie-backend-go/.gitignore
vendored
4
infogenie-backend-go/.gitignore
vendored
@@ -2,3 +2,7 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
|
||||
# 本机构建的 Linux 二进制(Dockerfile.prebuilt 使用)
|
||||
dist/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 多阶段构建:生产镜像仅含二进制与 ai_config.json,敏感配置通过运行时环境变量或 env_file 注入(勿将 .env.production 打入镜像)
|
||||
# 多阶段构建:生产镜像仅含二进制,敏感配置通过运行时环境变量或 env_file 注入
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
12
infogenie-backend-go/Dockerfile.prebuilt
Normal file
12
infogenie-backend-go/Dockerfile.prebuilt
Normal file
@@ -0,0 +1,12 @@
|
||||
# 使用本机/CI 预先构建的 Linux amd64 二进制(见 build-linux-amd64.bat),镜像内不再执行 go build
|
||||
# 构建前请先运行: build-linux-amd64.bat 生成 dist\server
|
||||
FROM alpine:3.21
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
COPY dist/server ./server
|
||||
RUN chmod +x ./server
|
||||
EXPOSE 5002
|
||||
ENV APP_ENV=production
|
||||
ENV APP_PORT=5002
|
||||
CMD ["./server"]
|
||||
21
infogenie-backend-go/build-linux-amd64.bat
Normal file
21
infogenie-backend-go/build-linux-amd64.bat
Normal file
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM 一键交叉编译 Linux amd64,供 Dockerfile.prebuilt 直接 COPY dist\server(服务器上无需再 go build)
|
||||
if not exist "dist" mkdir "dist"
|
||||
|
||||
set "CGO_ENABLED=0"
|
||||
set "GOOS=linux"
|
||||
set "GOARCH=amd64"
|
||||
set "GOTOOLCHAIN=auto"
|
||||
|
||||
echo [build-linux-amd64] GOOS=%GOOS% GOARCH=%GOARCH% GOTOOLCHAIN=%GOTOOLCHAIN%
|
||||
go build -trimpath -ldflags="-s -w" -o "dist\server" .\cmd\server
|
||||
if errorlevel 1 (
|
||||
echo [build-linux-amd64] FAILED
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [build-linux-amd64] OK: dist\server
|
||||
exit /b 0
|
||||
@@ -1,10 +1,24 @@
|
||||
# 生产:宿主机 12364 -> 容器内 5002;容器名 infogenie-backend-go
|
||||
# 使用:在同级目录准备 .env.production(数据库等),然后 docker compose up -d --build
|
||||
#
|
||||
# 推荐(不在服务器编译 Go):先在 Windows 运行 build-linux-amd64.bat 生成 dist\server,
|
||||
# 再执行: docker compose up -d --build
|
||||
# 镜像使用 Dockerfile.prebuilt,仅打包 Alpine + 二进制。
|
||||
#
|
||||
# 若要在 Docker 内多阶段编译,改用: docker compose -f docker-compose.yml -f docker-compose.sourcebuild.yml up -d --build
|
||||
#
|
||||
# ========== 前端连不上 Docker 后端时排查 ==========
|
||||
# 1) 容器是否起来: docker ps 看 infogenie-backend-go;宿主机 curl http://127.0.0.1:12364/api/health
|
||||
# 2) .env.production 里 DB_HOST/REDIS_ADDR:容器内 127.0.0.1 是容器自己,不是宿主机。
|
||||
# MySQL/Redis 在宿主机时:Linux 常用 172.17.0.1 或宿主机内网 IP;Docker Desktop 可用 host.docker.internal
|
||||
# 3) REDIS_ENABLED=true 时 Redis 必须可达,否则进程启动失败(main 里 cache.Init 会报错退出)
|
||||
# 4) 前端生产构建的 VITE_API_URL 必须是浏览器能访问的地址(HTTPS 域名反代到 12364,或公网 IP+端口),
|
||||
# 勿指向仅内网可达的 IP;与 env.js 里生产默认 https://infogenie.api.shumengya.top 一致时需 DNS/反代已配置
|
||||
# 5) 云服务器安全组/防火墙放行 12364(若直连端口)
|
||||
services:
|
||||
infogenie-backend-go:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.prebuilt
|
||||
image: infogenie-backend-go:latest
|
||||
container_name: infogenie-backend-go
|
||||
ports:
|
||||
|
||||
219
infogenie-backend-go/scripts/sync_mysql_test_to_prod.py
Normal file
219
infogenie-backend-go/scripts/sync_mysql_test_to_prod.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 InfoGenie Go 后端使用的 MySQL 表从「测试库」全量同步到「生产库」。
|
||||
同步时在目标库对每张表执行:DROP TABLE IF EXISTS → 按源库 SHOW CREATE TABLE 重建 → 插入源库数据,
|
||||
确保列结构与测试库一致(旧生产表结构会被覆盖)。
|
||||
|
||||
使用前务必备份生产库。会删除并重建生产库中同名表。
|
||||
|
||||
环境变量(未设置时沿用仓库内 test/test_mysql_connect.py 的约定):
|
||||
源库:DB_SRC_HOST, DB_SRC_PORT, DB_SRC_NAME, DB_SRC_USER, DB_SRC_PASSWORD
|
||||
目标:DB_DST_HOST, DB_DST_PORT, DB_DST_NAME, DB_DST_USER, DB_DST_PASSWORD
|
||||
|
||||
示例(PowerShell):
|
||||
$env:DB_DST_HOST='192.168.1.100'
|
||||
$env:DB_DST_NAME='infogenie'
|
||||
$env:DB_DST_USER='infogenie'
|
||||
$env:DB_DST_PASSWORD='***'
|
||||
python scripts/sync_mysql_test_to_prod.py --dry-run
|
||||
python scripts/sync_mysql_test_to_prod.py --execute
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, List, Sequence, Tuple
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("缺少依赖: pip install pymysql", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# 与 internal/database/mysql.go AutoMigrate 一致
|
||||
TABLES: Tuple[str, ...] = (
|
||||
"ai_configs",
|
||||
"site_60s_disabled",
|
||||
"site_ai_runtime",
|
||||
"site_60s_upstream",
|
||||
"site_ai_model_disabled",
|
||||
"site_feature_card_clicks",
|
||||
)
|
||||
|
||||
DEFAULT_SRC = {
|
||||
"host": "10.1.1.100",
|
||||
"port": 3306,
|
||||
"name": "infogenie-test",
|
||||
"user": "infogenie-test",
|
||||
"password": "infogenie-test",
|
||||
} # 仅作文档占位;实际以 DB_SRC_* 为准
|
||||
|
||||
DEFAULT_DST = {
|
||||
"host": "192.168.1.100",
|
||||
"port": 3306,
|
||||
"name": "infogenie",
|
||||
"user": "infogenie",
|
||||
"password": "infogenie",
|
||||
}
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
raw = os.environ.get(key)
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return default
|
||||
return int(str(raw).strip())
|
||||
|
||||
|
||||
def conn_params_src() -> dict:
|
||||
return {
|
||||
"host": os.environ.get("DB_SRC_HOST", DEFAULT_SRC["host"]),
|
||||
"port": _env_int("DB_SRC_PORT", DEFAULT_SRC["port"]),
|
||||
"database": os.environ.get("DB_SRC_NAME", DEFAULT_SRC["name"]),
|
||||
"user": os.environ.get("DB_SRC_USER", DEFAULT_SRC["user"]),
|
||||
"password": os.environ.get("DB_SRC_PASSWORD", DEFAULT_SRC["password"]),
|
||||
}
|
||||
|
||||
|
||||
def conn_params_dst() -> dict:
|
||||
return {
|
||||
"host": os.environ.get("DB_DST_HOST", DEFAULT_DST["host"]),
|
||||
"port": _env_int("DB_DST_PORT", DEFAULT_DST["port"]),
|
||||
"database": os.environ.get("DB_DST_NAME", DEFAULT_DST["name"]),
|
||||
"user": os.environ.get("DB_DST_USER", DEFAULT_DST["user"]),
|
||||
"password": os.environ.get("DB_DST_PASSWORD", DEFAULT_DST["password"]),
|
||||
}
|
||||
|
||||
|
||||
def open_conn(**kw: Any):
|
||||
return pymysql.connect(
|
||||
host=kw["host"],
|
||||
port=int(kw["port"]),
|
||||
user=kw["user"],
|
||||
password=kw["password"],
|
||||
database=kw["database"],
|
||||
charset="utf8mb4",
|
||||
connect_timeout=15,
|
||||
cursorclass=pymysql.cursors.Cursor,
|
||||
)
|
||||
|
||||
|
||||
def fetch_all_rows(cur, table: str) -> Tuple[List[str], List[tuple]]:
|
||||
cur.execute(f"SELECT * FROM `{table}`")
|
||||
rows = cur.fetchall()
|
||||
cols = [d[0] for d in cur.description] if cur.description else []
|
||||
return cols, list(rows)
|
||||
|
||||
|
||||
def table_exists(cur, table: str) -> bool:
|
||||
cur.execute("SHOW TABLES LIKE %s", (table,))
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def recreate_table_from_source(source, target, table: str) -> None:
|
||||
"""用源库 DDL 在目标库 DROP 后重建,保证列与测试库一致(覆盖旧结构)。"""
|
||||
with source.cursor() as sc:
|
||||
sc.execute(f"SHOW CREATE TABLE `{table}`")
|
||||
row = sc.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(f"源库不存在表: {table}")
|
||||
ddl = row[1]
|
||||
with target.cursor() as tc:
|
||||
tc.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
tc.execute(f"DROP TABLE IF EXISTS `{table}`")
|
||||
tc.execute(ddl)
|
||||
tc.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
target.commit()
|
||||
|
||||
|
||||
def dry_run(src: dict, dst: dict, tables: Sequence[str]) -> None:
|
||||
s = open_conn(**src)
|
||||
d = open_conn(**dst)
|
||||
try:
|
||||
with s.cursor() as sc, d.cursor() as dc:
|
||||
for t in tables:
|
||||
sc.execute(f"SELECT COUNT(*) FROM `{t}`")
|
||||
(n_src,) = sc.fetchone()
|
||||
if not table_exists(dc, t):
|
||||
print(f"{t}: source_rows={n_src} target_rows=(表不存在)")
|
||||
else:
|
||||
dc.execute(f"SELECT COUNT(*) FROM `{t}`")
|
||||
(n_dst,) = dc.fetchone()
|
||||
print(f"{t}: source_rows={n_src} target_rows={n_dst}")
|
||||
finally:
|
||||
s.close()
|
||||
d.close()
|
||||
|
||||
|
||||
def sync_tables(src: dict, dst: dict, tables: Sequence[str]) -> None:
|
||||
source = open_conn(**src)
|
||||
target = open_conn(**dst)
|
||||
try:
|
||||
with source.cursor() as sc:
|
||||
for table in tables:
|
||||
recreate_table_from_source(source, target, table)
|
||||
print(f"++ {table}: 目标表已按源库 DDL 重建")
|
||||
cols, rows = fetch_all_rows(sc, table)
|
||||
if not cols:
|
||||
raise RuntimeError(f"无法读取列: {table}")
|
||||
placeholders = ",".join(["%s"] * len(cols))
|
||||
col_sql = ",".join(f"`{c}`" for c in cols)
|
||||
insert_sql = f"INSERT INTO `{table}` ({col_sql}) VALUES ({placeholders})"
|
||||
with target.cursor() as tc:
|
||||
if rows:
|
||||
tc.executemany(insert_sql, rows)
|
||||
target.commit()
|
||||
print(f"OK {table}: copied {len(rows)} rows")
|
||||
finally:
|
||||
source.close()
|
||||
target.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Sync InfoGenie MySQL tables from test to production.")
|
||||
ap.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="执行同步(默认仅打印说明;不加此参数则退出码 1)",
|
||||
)
|
||||
ap.add_argument("--dry-run", action="store_true", help="仅统计源/目标行数,不写生产库")
|
||||
ap.add_argument(
|
||||
"--tables",
|
||||
type=str,
|
||||
default="",
|
||||
help="逗号分隔表名子集;默认同步全部 GORM 表",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
src = conn_params_src()
|
||||
dst = conn_params_dst()
|
||||
|
||||
tables: Tuple[str, ...]
|
||||
if args.tables.strip():
|
||||
tables = tuple(x.strip() for x in args.tables.split(",") if x.strip())
|
||||
bad = [t for t in tables if t not in TABLES]
|
||||
if bad:
|
||||
print(f"未知表名(不在白名单): {bad}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
tables = TABLES
|
||||
|
||||
print(f"SOURCE: {src['user']}@{src['host']}:{src['port']}/{src['database']}")
|
||||
print(f"TARGET: {dst['user']}@{dst['host']}:{dst['port']}/{dst['database']}")
|
||||
print(f"TABLES: {', '.join(tables)}")
|
||||
|
||||
if args.dry_run:
|
||||
dry_run(src, dst, tables)
|
||||
return
|
||||
|
||||
if not args.execute:
|
||||
print("未执行:请加 --dry-run 查看行数,或加 --execute 在确认备份后同步。", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sync_tables(src, dst, tables)
|
||||
print("完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,16 @@
|
||||
|
||||
---
|
||||
|
||||
## Docker 生产部署(预编译二进制)
|
||||
|
||||
1. **本机(Windows)**:在 `infogenie-backend-go` 目录运行 `build-linux-amd64.bat`,生成 `dist/server`(Linux amd64)。
|
||||
2. **构建镜像**:`docker compose up -d --build`(默认使用 `Dockerfile.prebuilt`,不在服务器执行 `go build`)。
|
||||
3. **若要在镜像内编译**:`docker compose -f docker-compose.yml -f docker-compose.sourcebuild.yml up -d --build`(使用原 `Dockerfile` 多阶段构建)。
|
||||
4. **端口**:`docker-compose.yml` 映射 `12364:5002`;浏览器访问的 API 基址须与前端 `VITE_API_URL` 一致(HTTPS 反代或公网可达地址)。
|
||||
5. **`.env.production`**:容器内 `DB_HOST=127.0.0.1` 指向容器自身,**不能**访问宿主机 MySQL。MySQL/Redis 在宿主机时见仓库内 `.env.production.example` 注释(如 `172.17.0.1`、`host.docker.internal`)。`REDIS_ENABLED=true` 时 Redis 必须可达,否则进程启动即失败。
|
||||
|
||||
---
|
||||
|
||||
## 运行与配置
|
||||
|
||||
- 环境由 `**APP_ENV`** 决定:`development` 或 `production`(见 `config.Load()`)。
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- **项目名称**:万象口袋- 跨平台信息聚合网站
|
||||
- **时间**: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)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- **项目名称**:万象口袋- 跨平台信息聚合网站
|
||||
- **时间**:2025.08 - 2026.01
|
||||
- **技术栈**:Java 17,Spring Boot,Spring Web,Spring Data JPA,Hibernate,MySQL,Redis,Lettuce,React,Tailwind,Maven
|
||||
- **描述**:产品定位为一站式资讯与工具导航:首页与栏目以配置驱动,整合 60s 类 API 浏览、休闲小游戏、实用工具箱及多款 AI 应用(后端 OpenAI 格式兼容与 SSE 流式输出)。后端采用 Spring Boot 分层架构(Controller / Service / Repository / DTO),JPA `ddl-auto=update` 维护与产品一致的 MySQL 业务表;可选 Redis(`app.redis-enabled`)对站点只读配置做 cache-aside,独立逻辑库与 Key 前缀隔离。公开 `/api/health` 与管理员 `/api/admin/site/diagnostics` 输出结构化 JSON,供管理后台监控中间件与连接摘要;Bearer 由过滤器转发认证中心校验,管理写入依赖 `X-Site-Admin-Token`,接口入参 snake_case 与前端约定一致。前端基于 React 与 Tailwind,axios 与路由守卫;管理员后台支持 AI 上游、前台显隐与运行状态。Java 侧侧重开发与联调,可与前端通过 `REACT_APP_API_URL` 指向本服务端口对接。
|
||||
- **项目地址**:[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