Compare commits

...

113 Commits

Author SHA1 Message Date
whm
800adb3322 nginx: 关闭 HTTPS 的 HTTP/2,缓解大分片 multipart 断连(status 0)
Made-with: Cursor
2026-04-15 17:52:39 +08:00
whm
65574e3762 fix(upload): 分片用 multipart 字段 chunk、路由顺序与串行上传
- 前端 FormData+chunk,避免 raw body 被中间层断连
- Gin 分片路由置于 POST .../assets 之前
- 分片并发降为 1

Made-with: Cursor
2026-04-14 09:30:09 +08:00
whm
cce3d158d5 fix(upload): 分片改 POST 并放宽 Nginx 反代,避免 PUT 大 body 断连
- 管理端分片请求改为 POST;后端同时保留 PUT
- /api/ 增加 proxy_request_buffering off;CORS Allow-Headers 略扩展

Made-with: Cursor
2026-04-13 15:09:31 +08:00
whm
0800982224 feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时
- .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔
- 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限

Made-with: Cursor
2026-04-13 14:50:27 +08:00
whm
03f5fbb41a feat(admin): 开播页带宽观测;首页侧栏下载支持选择链接
Made-with: Cursor
2026-04-02 15:21:45 +08:00
whm
f161ff0e4e fix(deploy): ??????????????????
Made-with: Cursor
2026-03-27 16:00:18 +08:00
whm
645965b609 fix(deploy): ???????????????????
Made-with: Cursor
2026-03-27 15:26:11 +08:00
whm
774cee0afa fix(deploy): ??????? 80/443 ???????????
Made-with: Cursor
2026-03-27 14:33:36 +08:00
whm
89cc1d2368 fix(deploy): ???????? Nginx ???????
Made-with: Cursor
2026-03-27 12:04:22 +08:00
whm
0980d1fa9c refactor(deploy): ?????????? Nginx ????
Made-with: Cursor
2026-03-27 11:55:00 +08:00
whm
38c4c465c5 feat(deploy): 宿主机单 Nginx 方案、compose 覆盖与启动脚本
Made-with: Cursor
2026-03-27 11:15:00 +08:00
whm
2f78fd0d52 fix(live): 管控区与预览横排断点、礼物条移至画面下方
Made-with: Cursor
2026-03-27 10:30:37 +08:00
whm
435fbfd47e feat(live): 按用户名禁言、后台右侧管控栏、前台抖音式布局与禁言 Toast
Made-with: Cursor
2026-03-27 09:11:39 +08:00
whm
fe8d5a34cc 直播后台:新增按 IP 发言管控与在线会话视图。
支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。

Made-with: Cursor
2026-03-26 17:57:50 +08:00
whm
0da93fb1be 后台:修复直播开播页空白(补全 watch 导入);控制台展示应用带宽观测与 HTTP 流量统计
Made-with: Cursor
2026-03-26 16:05:48 +08:00
whm
e6ac5a107a 直播:铺满画面与黑屏重播、弹幕礼物与全站特效、礼物列表与 WebRTC nudge
Made-with: Cursor
2026-03-26 15:46:11 +08:00
whm
4112ea4447 直播:status 返回观看人数,后台开播页轮询展示
Made-with: Cursor
2026-03-26 15:30:15 +08:00
whm
0aa11575a6 文案:明确观看直播无需登录,仅发弹幕需账号
Made-with: Cursor
2026-03-26 15:26:48 +08:00
whm
8d800eee62 直播:音量与全屏叠在画面底部,全屏内可调
Made-with: Cursor
2026-03-26 15:17:30 +08:00
whm
d441fe33fd 直播页:全屏包含底部弹幕区,可登录与发送
Made-with: Cursor
2026-03-26 15:07:47 +08:00
whm
07ae6c02ef 弹幕:广播带半显用户名(前两字+***),前端展示前缀
Made-with: Cursor
2026-03-26 15:04:32 +08:00
whm
2e675bda51 直播弹幕:前台注册登录与 JWT;Nginx 缓解间歇 502;site_users 集合
Made-with: Cursor
2026-03-26 14:52:28 +08:00
whm
f28b80354f 直播:三模式采集、Canvas 单轨与可拖动小窗;观众端单路视频
Made-with: Cursor
2026-03-26 14:27:07 +08:00
whm
26e90c30f9 开播页:预览放大;信令重连上限与健康检查提示 502
Made-with: Cursor
2026-03-26 11:46:22 +08:00
whm
8c9c573a1c 开播:摄像头选择+屏幕共享小窗;观众双路视频;去后台长提示与精简状态
Made-with: Cursor
2026-03-26 11:30:46 +08:00
whm
9329151976 直播:开播信令断线自动重连;弹幕代次防误重连、断线排队发送
Made-with: Cursor
2026-03-26 10:38:18 +08:00
whm
8d730a2a75 直播页:弹幕 WebSocket 自动重连与失败提示
Made-with: Cursor
2026-03-26 10:30:30 +08:00
whm
10a842b4ef 修复开播卡正在连接:移除未定义 quality;画质改官网选择+localStorage
Made-with: Cursor
2026-03-26 10:22:00 +08:00
whm
106e6e1f16 直播:画质可选、只读 /live/info、弹幕 WS 透传;Nginx 弹幕路径
Made-with: Cursor
2026-03-26 10:07:49 +08:00
whm
6b3210f714 直播:转发音频、观众 recv audio、全屏与开声音按钮、后台采集麦克风
Made-with: Cursor
2026-03-26 09:55:27 +08:00
whm
2295410e1b 直播页:移除本站/外链说明文案
Made-with: Cursor
2026-03-25 17:08:49 +08:00
whm
da0bcae823 直播观众:negotiate 失败后恢复轮询;防并发 poll;长时 502 提示
Made-with: Cursor
2026-03-25 16:55:05 +08:00
whm
7e24a965bc 修复:weblive 协程 panic 防护、Mongo 空指针防护、直播状态轮询退避
Made-with: Cursor
2026-03-25 16:45:22 +08:00
whm
70e6782713 直播:PLI 关键帧请求、观众 RTCP 读取、断线自动重连
Made-with: Cursor
2026-03-25 16:30:48 +08:00
whm
d83a69c23a 直播:LIVE_PUBLIC_IP 与 ICE 诊断;摄像头错误中文提示与约束放宽
Made-with: Cursor
2026-03-25 16:19:19 +08:00
whm
996dc3778d 后台直播:修复信令误断(onclose 提示与严格模式 onUnmounted)
Made-with: Cursor
2026-03-25 16:01:22 +08:00
whm
7811adca66 直播:后台 JWT 推流、前台画中画;WebRTC 服务与 Nginx WebSocket 代理
Made-with: Cursor
2026-03-25 15:00:14 +08:00
whm
b83ec91b1a 首页:联系我们区块动效与居中布局(玻璃卡片、光晕与入场动画)
Made-with: Cursor
2026-03-24 16:14:45 +08:00
whm
65d5e425d7 首页:恢复星空层,门户区块背景透明以透出星云
Made-with: Cursor
2026-03-24 15:28:03 +08:00
whm
5ea23ba657 style(home): 宣传册区域加宽、收窄留白
Made-with: Cursor
2026-03-24 15:01:14 +08:00
whm
3222dffc64 feat(home): 简介与视频前置、侧栏 Win/安卓直链;后台与 API 默认中文
Made-with: Cursor
2026-03-24 14:48:39 +08:00
whm
f5852bc04e fix(nginx): admin 用 upstream+proxy_pass 去前缀,替代变量/rewrite
Made-with: Cursor
2026-03-23 19:43:47 +08:00
whm
78055dbe68 fix(nginx): 变量 proxy_pass 须 rewrite 去掉 /admin,否则静态资源回退 index.html
Made-with: Cursor
2026-03-23 18:28:33 +08:00
whm
2c0898fffd fix(deploy): restart.sh admin 挂载项目根;增加 verify-admin-dist 防白屏
Made-with: Cursor
2026-03-23 18:17:58 +08:00
whm
03878848dd fix(nginx): 移除 /admin/assets/ rewrite 块,避免变量 proxy_pass 返回 500
Made-with: Cursor
2026-03-23 18:10:15 +08:00
whm
ee9394f410 fix(nginx): admin 对齐 assets 配置;443 显式 /admin/assets/ 反代
Made-with: Cursor
2026-03-23 18:04:23 +08:00
whm
7980c1922a chore(compose): web 仅保留 public 挂载,推广走 dist/API;验证仅 yh_nginx
Made-with: Cursor
2026-03-23 17:57:09 +08:00
whm
80176ea6fc fix(admin): nginx.conf 与 deploy 对齐,避免 assets 回退为 HTML MIME 白屏
Made-with: Cursor
2026-03-23 17:00:27 +08:00
whm
0a1fe41314 fix(nginx): 优先 127.0.0.11 与延长 DNS valid,缓解 api/web 间歇无法解析致 502
Made-with: Cursor
2026-03-23 16:53:36 +08:00
whm
5da4941913 fix(admin): index.html 与 assets 缓存策略,避免发版后白屏
Made-with: Cursor
2026-03-23 16:43:16 +08:00
whm
ea90052e7e feat: 服务端 promotion 视频自动转码;首页宣传册预览与 mp4 配置
Made-with: Cursor
2026-03-23 16:12:43 +08:00
whm
d37e9a3663 feat(home): 右侧固定关注我们、宣传资料横向滚动与列表优化
Made-with: Cursor
2026-03-23 11:18:56 +08:00
whm
52991d1e49 feat(home): 门户式主侧栏布局,联系我们置底,侧栏关注我们
Made-with: Cursor
2026-03-23 10:57:43 +08:00
whm
eb6923998f fix(nginx): 验证文件改用 root+try_files,443 层挂载 verify-root 直连避免 403
Made-with: Cursor
2026-03-23 09:20:25 +08:00
whm
c6e5779b76 perf(nginx): 静态图/推广资源 Cache-Control 7d,index.html 禁止长期缓存
Made-with: Cursor
2026-03-22 01:54:43 +08:00
whm
6f87e0c260 fix(web): 关注我们列表图用响应式候选下标,避免重渲染覆盖 @error 导致裂图
Made-with: Cursor
2026-03-22 01:45:03 +08:00
whm
948494bca0 fix(nginx): 启动时从 resolv.conf 注入 resolver + tpl 生成配置,修复 Podman host not found api
Made-with: Cursor
2026-03-22 01:34:23 +08:00
whm
7c9649356a chore(web): 关注我们二维码放入 public/social 供首路径 /social/ 加载
Made-with: Cursor
2026-03-22 01:18:03 +08:00
whm
5ff300d0f7 fix(web): 关注我们图片多 URL 回退(public→promotion),移除部署路径提示;Nginx 增加 /social/
Made-with: Cursor
2026-03-22 01:15:20 +08:00
whm
66b873d0b0 feat(deploy): 挂载 web/public 与 web/promotion 到 yh_web,支持热更新无需重建 dist
Made-with: Cursor
2026-03-22 00:58:19 +08:00
whm
122f5b8fba feat(web): public/logo.png 作为 favicon 与首页/宣传册导航栏图标
Made-with: Cursor
2026-03-22 00:52:08 +08:00
whm
5830fdfba3 fix(nginx): 等待脚本增加 nc/TCP 探测、默认 120s、compose 设 180s 避免 Podman 下 yh_nginx 超时退出
Made-with: Cursor
2026-03-22 00:42:00 +08:00
whm
2660f8edd8 fix(web): 首屏产品视频在 VITE_PROMOTION_API_ONLY 时走 promotion-media,避免误请求 /promotion/ 404
Made-with: Cursor
2026-03-22 00:26:09 +08:00
whm
5bfdd04f21 fix(nginx): Podman 下弃用 127.0.0.11 resolver,启动前等待上游可达
Made-with: Cursor
2026-03-21 23:57:36 +08:00
whm
89cd8f83bc fix(nginx): 延迟解析 upstream,避免 Docker/Podman 启动时 host not found
Made-with: Cursor
2026-03-21 23:46:28 +08:00
whm
77febfacc7 fix(promotion-import): 双实例目录配对 demo-1/2;配对含仅封面目录
Made-with: Cursor
2026-03-21 23:03:17 +08:00
whm
d04799db5f fix(promotion-import): 扫描实例(一)(二)目录、多mov取最大文件
Made-with: Cursor
2026-03-21 21:58:57 +08:00
whm
6d049fe0e8 fix(promotion-import): 实例(一)(二)多备选源路径;目录内唯一 mov/jpg 自动匹配
Made-with: Cursor
2026-03-21 21:47:06 +08:00
whm
1710a11dad feat(deploy): 拉取后自动合并 server/.env.example 缺失键,服务器只跑 pull-and-restart
Made-with: Cursor
2026-03-21 21:35:11 +08:00
whm
0896bd3bab chore: 官网 site_id 写入 .env.example 与 .env.production(私人仓库)
Made-with: Cursor
2026-03-21 21:31:06 +08:00
whm
f4e51165a7 feat(deploy): compose up 后自动 promotion-import(YH_IMPORT_PROMOTION_SITE_ID + Docker go run)
Made-with: Cursor
2026-03-21 21:28:56 +08:00
whm
c1fb5f3440 fix(web): 产品视频静态探测改为批量(2次),支持 VITE_PROMOTION_API_ONLY
Made-with: Cursor
2026-03-21 13:20:10 +08:00
whm
dd05748c85 feat: 视频发布导入 API(uploads+site_assets);首页视频先拉 routes 与 VITE_DEFAULT_SITE_ID 回退
Made-with: Cursor
2026-03-21 13:14:02 +08:00
whm
db3a8d8cd1 fix(web): /promotion 独立 try_files 防 SPA 误判;静态探测支持 Range 与 HTML 识别
Made-with: Cursor
2026-03-21 13:01:25 +08:00
whm
d6767c2c5c feat(web): 产品视频静态优先,缺失时回退 promotion-media API
Made-with: Cursor
2026-03-20 22:33:16 +08:00
whm
7336c42af0 feat(promotion): social 素材同步 dist、迁移脚本与文档;Brochure 侧栏路由与文案
Made-with: Cursor
2026-03-20 18:18:22 +08:00
whm
dfcfb477c5 fix(web): 产品视频区移除后台上传提示文案
Made-with: Cursor
2026-03-20 18:03:56 +08:00
whm
b69dde0f7e fix(deploy): 构建后同步 web/promotion 至 dist,解决 /promotion 静态 404
Made-with: Cursor
2026-03-20 17:59:13 +08:00
whm
b95fcdeb8c chore: 忽略推广视频与 PPT 解压产物;移除 _pptx_extract 及重复社交图;更新部署脚本说明
Made-with: Cursor
2026-03-20 17:41:27 +08:00
whm
654b683067 feat: 产品视频改后台上传,promotion-media 公开访问;gitignore 大文件;保留原文件名上传
Made-with: Cursor
2026-03-20 17:10:56 +08:00
whm
5067fb6f76 chore: 全量提交(推广页、宣传册、社交资源、视频与后台同步)
Made-with: Cursor
2026-03-20 16:31:12 +08:00
whm
0360ee5261 fix: 权限列表JSON字段与角色可编辑; 前台site_id与SPA; 首页积木扩展区
Made-with: Cursor
2026-03-19 17:31:18 +08:00
whm
e1fc257435 feat: 前台404页与通配路由; 积木拖拽排序(vuedraggable); nginx SPA说明
Made-with: Cursor
2026-03-19 17:11:16 +08:00
whm
88f9d42f91 feat(admin): 首页编辑支持链接选择器(本站页面/他站首页/文件)与试跳
Made-with: Cursor
2026-03-19 16:47:40 +08:00
whm
6df5cf029d feat(admin): 积木页面可视化编辑与链接选择器(站内页/可下载文件)
Made-with: Cursor
2026-03-19 16:33:54 +08:00
whm
ea163dbf8e feat: 前台动态路由与积木页面、网页路径/发布/模式、PAGE_BUILDER 文档
Made-with: Cursor
2026-03-19 16:20:48 +08:00
whm
b17e99eb93 1.修改首页下载功能 2026-03-18 18:43:34 +08:00
whm
c67346626a 修改上传的挂载路径 2026-03-18 18:31:20 +08:00
whm
7a97ba8c66 feat: 角色创建与赋权、文件管理单页多级目录与上传可下载、api上传目录可写卷
Made-with: Cursor
2026-03-18 18:26:08 +08:00
whm
07f55e0139 1 2026-03-18 18:11:59 +08:00
whm
c9d9224a68 1.修改挂载路径 2026-03-18 17:58:05 +08:00
whm
5492456148 deploy: 挂目录+替换文件部署,构建产物到 deploy/,不重建 web/admin 镜像
Made-with: Cursor
2026-03-18 17:42:52 +08:00
whm
d5bc102bd7 fix: 上传200MB限制、备案信息、超级管理员仅首注、文件管理菜单
Made-with: Cursor
2026-03-18 16:50:20 +08:00
whm
31b1f2bb4c chore: 将验证文件改为宿主机挂载热加载
Made-with: Cursor
2026-03-18 10:53:35 +08:00
whm
81cd9dce75 chore: 将网站验证文件放入 web/public 目录
Made-with: Cursor
2026-03-18 09:39:55 +08:00
whm
483560bcfc fix: API 反代保留 /api 路径修复 404;admin 容器 location /;宿主机 Nginx 反代到 8443 说明
Made-with: Cursor
2026-03-17 22:49:48 +08:00
whm
6044786380 fix: API 改为 8088 且不映射宿主机端口,避免与 sshd 的 9527 冲突;脚本增加 9527 检查
Made-with: Cursor
2026-03-17 22:30:19 +08:00
whm
20a035a745 1 2026-03-17 21:55:45 +08:00
whm
bae341e1bc 1 2026-03-17 21:51:49 +08:00
whm
eb2d5f6579 chore: 提交部署脚本与 nginx 证书私钥(pull-and-restart.sh, restart.sh, yuheng.yuxindazhineng.com.key)
Made-with: Cursor
2026-03-17 21:41:34 +08:00
whm
3993f7322e chore: 添加 nginx 容器用配置 yuheng.docker.conf(仅 443)
Made-with: Cursor
2026-03-17 21:36:30 +08:00
whm
9babb2ac7e fix: API 宿主机端口改为 8088,nginx 反代同步
Made-with: Cursor
2026-03-17 20:51:37 +08:00
whm
ed930cbe12 1 2026-03-17 20:41:15 +08:00
whm
1ece933a1e 1 2026-03-17 20:07:47 +08:00
whm
2851b0913c 1 2026-03-17 20:01:33 +08:00
whm
4373d2a0ee 1 2026-03-17 18:10:17 +08:00
whm
16a77ab3c8 feat: Nginx 反代配置、证书与 server/.env.example(证书日后可删或改由服务器单独放)
Made-with: Cursor
2026-03-17 17:27:25 +08:00
whm
022a71dfd3 chore: 从仓库移除 pull-and-restart.sh、restart.sh(改由本地/服务器维护,不提交)
Made-with: Cursor
2026-03-17 15:42:26 +08:00
whm
be2b5470c5 chore: 为 pull-and-restart.sh、restart.sh 设置可执行权限
Made-with: Cursor
2026-03-17 15:19:27 +08:00
whm
1022d99708 chore: 仅保留 pull-and-restart.sh 与 restart.sh,两脚本均检测并安装 Docker;删除其余脚本与 .bat
Made-with: Cursor
2026-03-17 15:16:15 +08:00
whm
20e7f3a65d 1.修改代码适配阿里云的服务器 2026-03-17 14:29:40 +08:00
whm
826617d737 fix: ensure .sh scripts are LF in repo, add README note for bash\\r
Made-with: Cursor
2026-03-17 01:49:29 +08:00
whm
a0df3a8a41 fix: use LF for .sh scripts (.gitattributes) to fix bash\r on Linux
Made-with: Cursor
2026-03-17 01:45:52 +08:00
2951 changed files with 938944 additions and 564 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# 强制 shell 脚本使用 LF避免在 Linux 上出现 bash\r 错误
*.sh text eol=lf

33
.gitignore vendored
View File

@@ -3,6 +3,8 @@ node_modules/
web/dist/
admin/dist/
server/server
server/yh_api
server/bin/
*.exe
# 环境与密钥(服务器上单独配置)
@@ -11,6 +13,10 @@ server/.env
!.env.example
!server/.env.example
# Nginx 证书私钥(除 yuheng 域名外不提交)
nginx/*.key
!nginx/yuheng.yuxindazhineng.com.key
# 日志
logs/
*.log
@@ -23,5 +29,32 @@ Thumbs.db
*.swp
*.swo
# 挂目录部署:构建产物由脚本生成,不提交
deploy/web/dist/
deploy/admin/dist/
deploy/api/server
# 功能模块上传目录API 写入,不提交)
data/uploads/
# Docker 本地卷(不提交)
# mongo_data 等由 compose 管理
# 推广素材:视频不入库(本地可保留;线上后台上传或静态部署)
web/promotion/**/*.mov
web/promotion/**/*.MOV
web/promotion/**/*.mp4
web/promotion/**/*.webm
web/promotion/**/*.mkv
web/promotion/**/*.avi
web/promotion/**/*.m4v
# 「视频发布」封面等图片不入库(与视频配套,见该目录 README
web/promotion/视频发布/**/*.jpg
web/promotion/视频发布/**/*.jpeg
web/promotion/视频发布/**/*.png
web/promotion/视频发布/**/*.webp
# PPT 解压临时目录与压缩包副本(仅保留 .pptx 源文件即可)
web/promotion/_pptx_extract/
web/promotion/_pptx.zip

View File

@@ -81,4 +81,21 @@ if (!db.getCollectionNames().includes("role_permissions")) {
}
db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true });
// 11. site_users前台直播弹幕账号与后台 users 分离)
if (!db.getCollectionNames().includes("site_users")) {
db.createCollection("site_users");
print("已创建集合: site_users");
}
db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
// 12. yuheng_cloud_register_records宇恒云 POST /register 本地留痕username、password
if (!db.getCollectionNames().includes("yuheng_cloud_register_records")) {
db.createCollection("yuheng_cloud_register_records");
print("已创建集合: yuheng_cloud_register_records");
}
db.yuheng_cloud_register_records.createIndex(
{ created_at: -1 },
{ name: "idx_created_at", background: true }
);
print("集合与索引处理完成。");

View File

@@ -14,6 +14,21 @@ yh_web/
## 快速启动
### 0. 本地开发:先启动 MongoDB
后端依赖 MongoDB二选一即可
**方式一:本机已安装 MongoDB**
启动 MongoDB 服务后,在 `server/.env` 中设置 `MONGODB_URI=mongodb://localhost:27017`(复制 `.env.example``.env` 后改此项即可)。
**方式二:只用 Docker 跑一个 MongoDB推荐无需本机安装**
```bash
docker run -d --name yh_mongo -p 27017:27017 mongo:7
```
同样在 `server/.env` 中保持 `MONGODB_URI=mongodb://localhost:27017`。停止:`docker stop yh_mongo`,再启:`docker start yh_mongo`
### 1. 启动后端
```bash
@@ -44,15 +59,83 @@ npm run dev
- 前台: http://localhost:3001
- API: http://localhost:8080
## 用 Docker 一键跑起来(推荐)
在项目根目录执行,用 Docker 拉镜像并启动全部服务API、Web、Admin、MongoDB
**1. 准备环境变量**
若还没有 `server/.env`,复制一份并可按需修改:
```bash
cp server/.env.example server/.env
```
**2. 国内网络可设镜像再拉镜像(避免超时)**
```bash
# Windows PowerShell
$env:REGISTRY_MIRROR="docker.m.daocloud.io/library/"
docker compose up -d --build
# Linux / Mac / Git Bash
export REGISTRY_MIRROR=docker.m.daocloud.io/library/
docker compose up -d --build
```
不设 `REGISTRY_MIRROR` 时直连 Docker Hub若拉取超时再按上面设置镜像后重试。
**3. 访问**
- 当前 compose 仅暴露 443由 Nginx 反代API 容器内监听 8088通过 https 访问 /api。
停止:`docker compose down`
## 线上部署(项目根目录:~/project/yh_web
线上路径为 `yxd@server1:~/project/yh_web`(即 `/home/yxd/project/yh_web`)。
首次或克隆后需加执行权限:
### 线上跑起来(以 Gitea 为准)
1. **本地**:提交并推送含「镜像加速」的代码(`docker-compose.yml`、各 `Dockerfile`、若已改脚本也同步到服务器)。
2. **服务器**:进入项目目录,**以 Gitea 为准**,直接执行拉取并重启脚本(脚本内会 `git fetch` + `git reset --hard origin/master`,本地修改会被覆盖)。
```bash
cd /www/yh_web # 或你的项目路径
./pull-and-restart.sh
```
**重要**`deploy/` 下构建产物web/admin 静态文件、api 二进制)**不在仓库里**,仅靠 `git pull` 不会生成。拉取后**必须执行** `./pull-and-restart.sh`(或 `./restart.sh`)才会构建并启动,否则访问会 403/404。脚本已记录可执行权限拉取后可直接 `./pull-and-restart.sh`,无需 chmod。
若你已手动执行过 `git pull` 且报错 `Your local changes would be overwritten`,先以远程为准:`git fetch origin && git reset --hard origin/master`,再执行 `./pull-and-restart.sh`。
采用**挂目录 + 替换文件**部署:脚本将 web/admin 构建到 `deploy/web/dist`、`deploy/admin/dist`api 二进制到 `deploy/api/server`,容器挂载这些目录;更新时只重新构建产物并重启,无需重建前端镜像,仅 api 使用轻量运行时镜像。详见 `deploy/README.md`。构建会从 DaoCloud 等镜像拉取 node、nginx、golang、alpine、mongo启动后**对外仅 443**,由 compose 内 Nginx 反代到 api/web/admin证书按脚本自动处理。
**脚本说明**:仅保留两个脚本,均会检测 Docker 并在未安装时一键安装apt 或 yum/dnf
- **拉取代码并重启**`./pull-and-restart.sh` — 若无 Git 仓库会提示设置 `GIT_REPO_URL` 后自动克隆;然后拉取并构建、启动。
- **仅重启**`./restart.sh` — 不拉代码,仅 `docker compose down` 后 `up -d`。
**Permission denied**:拉取后脚本可能无可执行权限,任选其一即可:
```bash
chmod +x pull-and-restart.sh restart.sh
# 或直接用 bash 执行(脚本内会自修复权限供下次使用)
bash pull-and-restart.sh
```
若报错 `bash\r`,先执行 `sed -i 's/\r$//' pull-and-restart.sh restart.sh`。
首次部署若目录为空,可先放入两个脚本,设置 `export GIT_REPO_URL='https://用户:Token@gitea.../web.git'` 后执行 `./pull-and-restart.sh` 完成克隆与启动。配置好 `server/.env` 后再次运行即可。
- **拉取代码并重启**`cd ~/project/yh_web && ./pull-and-restart.sh`
- **仅重启服务**`cd ~/project/yh_web && ./restart.sh`
- 对外域名https://yuheng.yuxindazhineng.com
**产品视频自动导入**`server/.env.example` 已含默认 `YH_IMPORT_PROMOTION_SITE_ID`;首次或拉代码后脚本会把 **`.env.example` 里尚未出现在 `server/.env` 的键自动追加**到 `server/.env`**服务器只需执行 `./pull-and-restart.sh`**,无需手改配置。每次部署在 `compose up` 后会将 `web/promotion/视频发布/` 导入 `data/uploads` + `site_assets`(与 [官网](https://yuheng.yuxindazhineng.com/) `promotion-media` 一致)。多站点请改仓库内 `server/.env.example` 后再部署。
- **拉取并重启**`cd ~/project/yh_web && ./pull-and-restart.sh`
- **仅重启**`cd ~/project/yh_web && ./restart.sh`
- **对外域名**https://yuheng.yuxindazhineng.com所有请求均通过该域名见下
**所有请求通过域名**:前台/后台生产环境使用同一域名API 也走同域 `/api`。
- **有 Nginx Proxy Manager 时**:在 NPM 中配置反代即可:
- `https://yuheng.yuxindazhineng.com/` → 本机 9528web
- `https://yuheng.yuxindazhineng.com/admin/` → 本机 9529admin
- `https://yuheng.yuxindazhineng.com/api` → 本机 8088api
- **新服务器无 NPM、自建 Nginx 时**:使用项目内 **`nginx/`** 配置,强制 HTTPSSSL 证书按域名单独目录存放。详见 **`nginx/README.md`**(证书目录:`/etc/ssl/yh_web/yuheng.yuxindazhineng.com/`,复制 `nginx/yuheng.yuxindazhineng.com.conf` 到 `/etc/nginx/conf.d/` 后重载 Nginx
前端 `VITE_API_BASE` 留空即请求同域 `/api`;后端 `server/.env` 中 `ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com` 用于 CORS。
**服务器无法访问 Docker Hub 时**(报错 `dial tcp ... i/o timeout`):一键脚本会自动写入 Podman 镜像配置到 `/etc/containers/registries.conf.d/99-docker-mirror.conf`DaoCloud / 1ms / 徐轩辕等镜像)。若仍拉取失败:
- **Podman**:确认主配置会加载该目录。若无效,可把 `99-docker-mirror.conf` 中的 `[[registry]]` 段合并进 `/etc/containers/registries.conf`。
- **Docker**:编辑 `/etc/docker/daemon.json` 添加 `"registry-mirrors": ["https://docker.1ms.run"]` 后 `sudo systemctl restart docker`。
然后重新运行启动脚本。
**报错 `listen tcp4 :8088: bind: address already in use`**:当前 `docker-compose.yml` 中 **api 未映射任何宿主机端口**`PORT=8088` 仅容器内监听。该错误多为服务器上曾用旧版 compose 映射过 8088或宿主机其他进程占用。处理确认已 `git pull` 到最新compose 无 api 的 `ports`),执行 `docker compose down`(或直接再次执行 `./pull-and-restart.sh`,脚本内已先 down 再 up后重试若仍报错在服务器上执行 `ss -tlnp | grep 8088` 或 `lsof -i :8088` 查看占用进程并结束。

3
admin/.env.development Normal file
View File

@@ -0,0 +1,3 @@
# 开发环境API 请求走域名,不用 localhost
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
VITE_API_BASE=https://yuheng.yuxindazhineng.com

View File

@@ -1,12 +1,17 @@
FROM node:20-alpine AS builder
# 国内默认走镜像;海外可 --build-arg REGISTRY_MIRROR= 直连
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
COPY . .
RUN chmod -R +x node_modules/.bin 2>/dev/null || true
RUN npm run build
FROM nginx:alpine
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
RUN echo 'server { listen 80; location /admin/ { alias /usr/share/nginx/html/; try_files $uri $uri/ /admin/index.html; } }' > /etc/nginx/conf.d/default.conf
# 与 deploy/admin/default.conf 同逻辑:^~ /assets/ 避免缺失 chunk 时回退到 index.html → MIME text/html 白屏
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

24
admin/nginx.conf Normal file
View File

@@ -0,0 +1,24 @@
# 与 deploy/admin/default.conf 保持一致Compose 挂载该文件;镜像内也用此配置)
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# 发版后勿长期缓存入口,否则浏览器保留旧 index.html、却拉新 chunk 名 → 白屏
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
}
location ^~ /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -12,7 +12,8 @@
"axios": "^1.6.2",
"element-plus": "^2.4.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
"vue-router": "^4.2.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",
@@ -1585,6 +1586,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1689,6 +1696,18 @@
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
}
}
}

View File

@@ -9,11 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2",
"element-plus": "^2.4.4",
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"element-plus": "^2.4.4",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2"
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.2",

View File

@@ -8,7 +8,9 @@ export const getMyPermissions = () => request.get('/admin/my-permissions')
// 角色权限管理
export const getRolePermissionsList = () => request.get('/admin/role-permissions')
export const createRole = (data) => request.post('/admin/role-permissions', data)
export const updateRolePermissions = (roleId, data) => request.put(`/admin/role-permissions/${roleId}`, data)
export const deleteRole = (roleId) => request.delete(`/admin/role-permissions/${roleId}`)
// 后台注册(手机号+验证码)
export const sendCode = (mobile) => request.post('/admin/send-code', { mobile })
@@ -26,6 +28,11 @@ export const createUser = (data) => request.post('/admin/users', data)
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
// 宇恒云账号POST 云端 /register + 本地 Mongo 仅记 username/password
export const createYuhengCloudAccount = (data) =>
request.post('/admin/yuheng-cloud-accounts', data, { timeout: 60000 })
export const listYuhengCloudAccounts = (params) => request.get('/admin/yuheng-cloud-accounts', { params })
// 工作空间
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
@@ -44,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
export const getOfficialSite = () => request.get('/admin/system/official-site')
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
/** 分片上传临时目录清理:保留时长、扫描间隔(需 site:manage */
export const getChunkUploadCleanup = () => request.get('/admin/system/chunk-upload-cleanup')
export const updateChunkUploadCleanup = (data) => request.put('/admin/system/chunk-upload-cleanup', data)
// 站点管理
export const getSites = () => request.get('/admin/sites')
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
@@ -62,12 +73,57 @@ export const deletePage = (id) => request.delete(`/admin/pages/${id}`)
export const getHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage`)
export const updateHomepage = (siteId, data) => request.put(`/admin/sites/${siteId}/homepage`, data)
export const downloadHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage/download`, { responseType: 'blob' })
export const getDownloadableAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets/downloadable`)
// 功能模块上传
export const getSiteAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets`)
export const uploadSiteAsset = (siteId, file) => {
// 文件管理(功能模块:多级目录、可下载)
export const getSiteAssets = (siteId, path, opts = {}) => {
const params = {}
if (path) params.path = path
if (opts.downloadable) params.downloadable = '1'
return request.get(`/admin/sites/${siteId}/assets`, { params })
}
export const uploadSiteAsset = (siteId, file, opts = {}) => {
const form = new FormData()
form.append('file', file)
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
if (opts.folder != null) form.append('folder', opts.folder)
form.append('downloadable', opts.downloadable ? 'true' : 'false')
if (opts.preserveFilename) form.append('preserve_filename', 'true')
// 大文件上传timeout 0 = Axios 不设置请求超时(仍可能受浏览器/系统/代理断开影响)
// 超大文件请用分片 API见 uploadSiteAssetWithResume
return request.post(`/admin/sites/${siteId}/assets`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 0
})
}
/** 分片上传:创建会话(断点续传) */
export const initMultipartUpload = (siteId, body) =>
request.post(`/admin/sites/${siteId}/assets/init-multipart`, body, { timeout: 60000 })
export const getMultipartUploadStatus = (siteId, uploadId) =>
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => {
const fd = new FormData()
fd.append('chunk', blob, 'part.bin')
// 不传 Content-Type由浏览器带 boundary与整文件 multipart 一致,减少中间层断连
return request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, fd, {
timeout: 180000
})
}
export const completeMultipartUpload = (siteId, uploadId) =>
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
export const abortMultipartUpload = (siteId, uploadId) =>
request.delete(`/admin/sites/${siteId}/assets/multipart/${uploadId}`, { timeout: 60000 })
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
// 直播发言管控(全体 / IP / 用户名)
export const getLiveModeration = () => request.get('/admin/live/moderation')
export const setLiveMuteAll = (enabled) => request.put('/admin/live/moderation/mute-all', { enabled })
export const setLiveMuteIP = (ip, enabled) => request.put('/admin/live/moderation/mute-ip', { ip, enabled })
export const setLiveMuteUser = (username, enabled) =>
request.put('/admin/live/moderation/mute-user', { username, enabled })

View File

@@ -0,0 +1,226 @@
<template>
<el-dialog
:model-value="modelValue"
title="选择链接"
width="680px"
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<el-tabs v-model="tab">
<el-tab-pane label="本站页面" name="pages">
<p class="tab-tip">当前站点下已创建的网页含首页 /</p>
<el-table
v-loading="loadingPages"
:data="pageRows"
highlight-current-row
max-height="320"
@row-click="(row) => confirm(row.path)"
>
<el-table-column prop="title" label="标题" min-width="130" />
<el-table-column prop="path" label="前台路径" min-width="140" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="primary" link @click.stop="confirm(row.path)">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingPages && !pageRows.length" description="暂无页面请先在网页管理中创建" />
</el-tab-pane>
<el-tab-pane v-if="showOtherSites" label="其他站点首页" name="sites">
<p class="tab-tip">同账号下其他站点的首页链接(需在站点管理中填写「域名」)</p>
<el-table
v-loading="loadingSites"
:data="otherSiteRows"
max-height="320"
>
<el-table-column prop="name" label="站点名称" min-width="120" />
<el-table-column prop="domain" label="已配置域名" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.domain">{{ row.domain }}</span>
<el-text v-else type="warning" size="small">未填写</el-text>
</template>
</el-table-column>
<el-table-column prop="previewUrl" label="将填入的链接" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
type="primary"
link
:disabled="!row.homeUrl"
@click="confirmOtherSite(row)"
>选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingSites && !otherSiteRows.length" description="没有其他站点" />
</el-tab-pane>
<el-tab-pane v-if="showDownloadableFiles" label="可下载文件" name="files">
<el-table
v-loading="loadingFiles"
:data="files"
max-height="320"
@row-click="(row) => confirm(fileUrl(row))"
>
<el-table-column prop="name" label="文件名" min-width="160" />
<el-table-column prop="file_path" label="存储路径" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="primary" link @click.stop="confirm(fileUrl(row))">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingFiles && !files.length" description="无标记为可下载的文件请到文件管理上传并勾选允许下载" />
</el-tab-pane>
<el-tab-pane label="自定义地址" name="custom">
<el-input v-model="customUrl" placeholder="https:// 或 /path" clearable />
<div style="margin-top: 16px">
<el-button type="primary" @click="confirmCustom">使用此地址</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getPages, getDownloadableAssets, getSites } from '../api/admin'
const props = defineProps({
modelValue: { type: Boolean, default: false },
siteId: { type: String, default: '' },
showDownloadableFiles: { type: Boolean, default: true },
showOtherSites: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'select'])
const tab = ref('pages')
const pageRows = ref([])
const otherSiteRows = ref([])
const files = ref([])
const loadingPages = ref(false)
const loadingSites = ref(false)
const loadingFiles = ref(false)
const customUrl = ref('')
function effectivePath(p) {
if (p.route_path) {
const r = String(p.route_path).trim()
return r.startsWith('/') ? r : '/' + r
}
if (!p.slug || p.slug === 'index') return '/'
return '/' + p.slug
}
/** 站点首页完整 URL依赖站点 domain无域名则无法生成外链 */
function siteHomepageUrl(site) {
let d = (site.domain || '').trim()
if (!d) return ''
if (!/^https?:\/\//i.test(d)) d = 'https://' + d
return d.replace(/\/$/, '') + '/'
}
async function loadPages() {
if (!props.siteId) return
loadingPages.value = true
try {
const res = await getPages({ site_id: props.siteId })
const list = res.list || []
pageRows.value = list.map((p) => ({
title: (p.type === 'homepage' || p.slug === 'index' ? '【首页】' : '') + (p.title || p.slug),
path: effectivePath(p),
slug: p.slug
}))
} catch {
pageRows.value = []
} finally {
loadingPages.value = false
}
}
async function loadOtherSites() {
loadingSites.value = true
try {
const res = await getSites()
const list = (res.list || []).filter((s) => s.id !== props.siteId)
otherSiteRows.value = list.map((s) => {
const homeUrl = siteHomepageUrl(s)
return {
id: s.id,
name: s.name || s.id,
domain: (s.domain || '').trim(),
homeUrl,
previewUrl: homeUrl || '(未配置域名)'
}
})
} catch {
otherSiteRows.value = []
} finally {
loadingSites.value = false
}
}
function confirmOtherSite(row) {
if (!row.homeUrl) {
ElMessage.warning('请先在「站点管理」中为该站点填写访问域名')
return
}
confirm(row.homeUrl)
}
async function loadFiles() {
if (!props.siteId) return
loadingFiles.value = true
try {
const res = await getDownloadableAssets(props.siteId)
files.value = res.list || []
} catch {
files.value = []
} finally {
loadingFiles.value = false
}
}
function fileUrl(row) {
return `/api/web/sites/${props.siteId}/assets/${row.id}/download`
}
function confirm(url) {
if (!url) return
emit('select', url)
emit('update:modelValue', false)
}
function confirmCustom() {
const u = (customUrl.value || '').trim()
if (!u) {
ElMessage.warning('请输入地址')
return
}
confirm(u)
}
watch(
() => props.modelValue,
(v) => {
if (v) {
customUrl.value = ''
tab.value = 'pages'
loadPages()
if (props.showOtherSites) loadOtherSites()
if (props.showDownloadableFiles) loadFiles()
}
}
)
</script>
<style scoped>
.tab-tip {
font-size: 12px;
color: #909399;
margin: 0 0 8px;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<el-form-item label="入场动画">
<el-select v-model="enter" style="width: 130px" @change="sync">
<el-option label="无" value="none" />
<el-option label="淡入" value="fadeIn" />
<el-option label="上滑" value="slideUp" />
<el-option label="左滑" value="slideLeft" />
<el-option label="缩放" value="zoomIn" />
</el-select>
<el-input-number v-model="delay" :min="0" :max="5000" style="width: 110px; margin-left: 8px" @change="sync" />
<span class="hint">延迟ms</span>
<el-input-number v-model="duration" :min="100" :max="3000" style="width: 110px; margin-left: 8px" @change="sync" />
<span class="hint">时长ms</span>
</el-form-item>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
const enter = ref('fadeIn')
const delay = ref(0)
const duration = ref(600)
function fromModel() {
const a = props.modelValue || {}
enter.value = a.enter || 'fadeIn'
delay.value = a.delay_ms ?? 0
duration.value = a.duration_ms ?? 600
}
function sync() {
emit('update:modelValue', {
enter: enter.value,
delay_ms: delay.value,
duration_ms: duration.value
})
}
watch(() => props.modelValue, fromModel, { immediate: true, deep: true })
</script>
<style scoped>
.hint {
font-size: 12px;
color: #909399;
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<div class="page-builder-blocks">
<Draggable
v-model="blocksWritable"
item-key="id"
handle=".drag-handle"
:animation="220"
ghost-class="block-ghost"
chosen-class="block-chosen"
drag-class="block-dragging"
class="blocks-drag-list"
>
<template #item="{ element: block, index: idx }">
<div class="block-card">
<div class="block-head">
<span class="block-head-left">
<span class="drag-handle" title="按住拖拽排序">
<el-icon><Rank /></el-icon>
</span>
<span class="block-type">{{ typeLabel(block.type) }}</span>
</span>
<div class="block-actions">
<el-button link type="danger" @click="remove(idx)">删除</el-button>
</div>
</div>
<template v-if="block.type === 'heading'">
<el-form label-width="88px" size="small">
<el-form-item label="标题文字">
<el-input v-model="block.props.text" placeholder="标题" />
</el-form-item>
<el-form-item label="级别 (1-6)">
<el-input-number v-model="block.props.level" :min="1" :max="6" />
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'text'">
<el-form label-width="88px" size="small">
<el-form-item label="HTML 模式">
<el-switch v-model="block.props.html" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="block.props.text" type="textarea" :rows="4" placeholder="纯文本或 HTML" />
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'link_list'">
<div class="link-items">
<div v-for="(it, j) in block.props.items" :key="j" class="link-row">
<el-input v-model="it.label" placeholder="显示文字" style="width: 110px" />
<el-input v-model="it.url" placeholder="链接" style="flex: 1; min-width: 120px" />
<el-select v-model="it.target" style="width: 95px">
<el-option label="当前页" value="_self" />
<el-option label="新窗口" value="_blank" />
</el-select>
<el-button type="primary" link @click="openPicker(it)">选择</el-button>
<el-button link type="danger" @click="block.props.items.splice(j, 1)"></el-button>
</div>
<el-button link type="primary" @click="block.props.items.push({ label: '', url: '', target: '_self' })">
+ 添加链接
</el-button>
</div>
<el-form label-width="88px" size="small" style="margin-top: 8px">
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'button'">
<el-form label-width="88px" size="small">
<el-form-item label="按钮文字">
<el-input v-model="block.props.text" />
</el-form-item>
<el-form-item label="链接">
<div class="url-row">
<el-input v-model="block.props.url" placeholder="#" />
<el-button type="primary" @click="openPicker(block.props, 'url')">选择链接</el-button>
</div>
</el-form-item>
<el-form-item label="样式">
<el-radio-group v-model="block.props.variant">
<el-radio-button label="primary">主色</el-radio-button>
<el-radio-button label="ghost">线框</el-radio-button>
</el-radio-group>
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'html'">
<el-input v-model="block.props.html" type="textarea" :rows="6" placeholder="HTML 片段" />
<el-form label-width="88px" size="small" style="margin-top: 8px">
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'spacer'">
<el-form label-width="88px" size="small">
<el-form-item label="高度(px)">
<el-input-number v-model="block.props.height" :min="0" :max="500" />
</el-form-item>
</el-form>
</template>
<template v-else-if="block.type === 'divider'">
<span class="muted">分割线</span>
</template>
<template v-else-if="block.type === 'section'">
<el-form label-width="88px" size="small">
<el-form-item label="内边距">
<el-input v-model="block.props.padding" placeholder="24px 16px" />
</el-form-item>
<el-form-item label="最大宽度">
<el-input v-model="block.props.maxWidth" placeholder="960px" />
</el-form-item>
<el-form-item label="背景色">
<el-input v-model="block.props.background" placeholder="transparent" />
</el-form-item>
</el-form>
<div class="nested-label">区块内模块可继续嵌套</div>
<PageBuilderBlocks
:blocks="block.children || []"
:site-id="siteId"
@update:blocks="(v) => patchSectionChildren(idx, v)"
/>
</template>
<template v-else>
<span class="muted">未知类型 {{ block.type }}</span>
</template>
</div>
</template>
</Draggable>
<div class="add-bar">
<el-dropdown trigger="click" @command="addBlock">
<el-button type="primary">
+ 添加模块 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="heading">标题</el-dropdown-item>
<el-dropdown-item command="text">段落文字</el-dropdown-item>
<el-dropdown-item command="link_list">链接组</el-dropdown-item>
<el-dropdown-item command="button">按钮</el-dropdown-item>
<el-dropdown-item command="html">HTML</el-dropdown-item>
<el-dropdown-item command="spacer">留白</el-dropdown-item>
<el-dropdown-item command="divider">分割线</el-dropdown-item>
<el-dropdown-item command="section">区块嵌套</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<LinkPickerDialog v-model="pickerVisible" :site-id="siteId" @select="onPicked" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ArrowDown, Rank } from '@element-plus/icons-vue'
import Draggable from 'vuedraggable'
import LinkPickerDialog from './LinkPickerDialog.vue'
import PageBuilderAnimFields from './PageBuilderAnimFields.vue'
defineOptions({ name: 'PageBuilderBlocks' })
const props = defineProps({
blocks: { type: Array, default: () => [] },
siteId: { type: String, default: '' }
})
const emit = defineEmits(['update:blocks'])
const blocksWritable = computed({
get() {
return props.blocks
},
set(val) {
emit('update:blocks', val)
}
})
function typeLabel(t) {
const m = {
heading: '标题',
text: '段落',
link_list: '链接组',
button: '按钮',
html: 'HTML',
spacer: '留白',
divider: '分割线',
section: '区块'
}
return m[t] || t
}
function newId() {
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
}
function addBlock(cmd) {
const list = [...props.blocks]
const b = {
id: newId(),
type: cmd,
props: {},
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
}
switch (cmd) {
case 'heading':
b.props = { text: '新标题', level: 2 }
break
case 'text':
b.props = { text: '段落内容', html: false }
break
case 'link_list':
b.props = { items: [{ label: '链接', url: '/', target: '_self' }] }
break
case 'button':
b.props = { text: '按钮', url: '#', variant: 'primary' }
break
case 'html':
b.props = { html: '<p>内容</p>' }
break
case 'spacer':
b.props = { height: 24 }
break
case 'divider':
b.props = {}
break
case 'section':
b.props = { padding: '24px 0', maxWidth: '960px', background: 'transparent' }
b.children = []
break
default:
b.props = {}
}
list.push(b)
emit('update:blocks', list)
}
function remove(idx) {
const list = [...props.blocks]
list.splice(idx, 1)
emit('update:blocks', list)
}
const pickerVisible = ref(false)
const pickTarget = ref(null)
function openPicker(target, key) {
if (key === 'url') pickTarget.value = { obj: target, key: 'url' }
else pickTarget.value = { item: target }
pickerVisible.value = true
}
function onPicked(url) {
const t = pickTarget.value
if (t?.item) t.item.url = url
if (t?.obj && t?.key) t.obj[t.key] = url
pickTarget.value = null
}
function patchSectionChildren(idx, children) {
const list = props.blocks.map((b, i) => (i === idx ? { ...b, children: [...children] } : b))
emit('update:blocks', list)
}
</script>
<style scoped>
.page-builder-blocks {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
background: #fafafa;
max-height: 62vh;
overflow-y: auto;
}
.block-card {
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.block-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.block-type {
font-weight: 600;
color: #409eff;
}
.link-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.url-row {
display: flex;
gap: 8px;
width: 100%;
align-items: center;
}
.url-row .el-input {
flex: 1;
}
.nested-label {
font-size: 13px;
color: #606266;
margin: 8px 0;
}
.add-bar {
margin-top: 8px;
}
.muted {
color: #909399;
font-size: 13px;
}
.blocks-drag-list {
min-height: 4px;
}
.block-head-left {
display: inline-flex;
align-items: center;
gap: 4px;
}
.drag-handle {
cursor: grab;
display: inline-flex;
align-items: center;
padding: 4px 6px;
margin-right: 4px;
color: #909399;
border-radius: 4px;
user-select: none;
touch-action: none;
}
.drag-handle:hover {
color: #409eff;
background: #ecf5ff;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.block-ghost) {
opacity: 0.55;
background: #ecf5ff !important;
border: 1px dashed #409eff !important;
}
:deep(.block-chosen) {
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.25);
}
:deep(.block-dragging) {
opacity: 0.95;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<div class="page-builder-editor">
<div class="pbe-split">
<div class="pbe-editor-col">
<PageBuilderBlocks v-model:blocks="blocks" :site-id="siteId" />
<el-collapse class="adv-collapse" accordion>
<el-collapse-item title="高级:直接编辑 JSON慎用" name="json">
<el-input
v-model="jsonDraft"
type="textarea"
:rows="10"
placeholder="修改后会覆盖上方可视化内容"
/>
<el-button type="warning" style="margin-top: 8px" @click="applyJsonDraft">应用 JSON</el-button>
</el-collapse-item>
</el-collapse>
</div>
<aside class="pbe-preview-col">
<div class="pbe-preview-head">
<span>实时预览</span>
<el-text size="small" type="info">与前台样式接近保存后线上一致</el-text>
</div>
<div class="pbe-preview-body">
<BlockRenderer v-if="blocks.length" :blocks="blocks" />
<el-empty v-else description="添加模块后此处显示效果" :image-size="80" />
</div>
</aside>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import PageBuilderBlocks from './PageBuilderBlocks.vue'
import BlockRenderer from '@yh-web/components/blocks/BlockRenderer.vue'
import '@yh-web/styles/page-animations.css'
const props = defineProps({
modelValue: { type: String, default: '' },
siteId: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const blocks = ref([])
const jsonDraft = ref('')
let syncingFromParent = false
function newBlockId() {
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
}
function normalizeBlock(b) {
if (!b.id) b.id = newBlockId()
if (!b.props) b.props = {}
if (!b.animation) b.animation = { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
if (b.type === 'link_list' && !Array.isArray(b.props.items)) b.props.items = []
if (b.type === 'section' && !Array.isArray(b.children)) b.children = []
if (Array.isArray(b.children)) b.children.forEach(normalizeBlock)
}
function parseFromString(s) {
syncingFromParent = true
try {
const j = JSON.parse(s || '{}')
const raw = Array.isArray(j.blocks) ? JSON.parse(JSON.stringify(j.blocks)) : []
raw.forEach(normalizeBlock)
blocks.value = raw
jsonDraft.value = JSON.stringify({ version: j.version || 1, blocks: blocks.value }, null, 2)
} catch {
blocks.value = []
jsonDraft.value = '{"version":1,"blocks":[]}'
}
syncingFromParent = false
}
function stringify() {
return JSON.stringify({ version: 1, blocks: blocks.value }, null, 2)
}
watch(
() => props.modelValue,
(v) => {
if (syncingFromParent) return
parseFromString(v)
},
{ immediate: true }
)
watch(
blocks,
() => {
if (syncingFromParent) return
const s = stringify()
jsonDraft.value = s
emit('update:modelValue', s)
},
{ deep: true }
)
function applyJsonDraft() {
try {
const j = JSON.parse(jsonDraft.value || '{}')
if (!Array.isArray(j.blocks)) {
ElMessage.error('JSON 须包含 blocks 数组')
return
}
syncingFromParent = true
blocks.value = JSON.parse(JSON.stringify(j.blocks))
blocks.value.forEach(normalizeBlock)
syncingFromParent = false
emit('update:modelValue', stringify())
ElMessage.success('已应用')
} catch (e) {
ElMessage.error('JSON 格式错误')
}
}
</script>
<style scoped>
.page-builder-editor {
width: 100%;
}
.pbe-split {
display: flex;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.pbe-editor-col {
flex: 1 1 420px;
min-width: 320px;
}
.pbe-preview-col {
flex: 0 1 380px;
width: 100%;
max-width: 420px;
border: 1px solid #dcdfe6;
border-radius: 8px;
background: #0a0a12;
color: #e8e8ef;
overflow: hidden;
position: sticky;
top: 8px;
}
.pbe-preview-head {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-weight: 600;
font-size: 14px;
}
.pbe-preview-body {
padding: 16px;
max-height: min(62vh, 640px);
overflow: auto;
}
.pbe-preview-body :deep(.builder-heading),
.pbe-preview-body :deep(.builder-text),
.pbe-preview-body :deep(.builder-links a),
.pbe-preview-body :deep(.builder-btn) {
color: inherit;
}
.pbe-preview-body :deep(.builder-text) {
color: rgba(255, 255, 255, 0.75);
}
.pbe-preview-body :deep(.builder-links a) {
color: #7eb8ff;
}
.pbe-preview-body :deep(hr) {
border-color: rgba(255, 255, 255, 0.15) !important;
}
.adv-collapse {
margin-top: 12px;
}
</style>

View File

@@ -9,10 +9,19 @@
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
<template v-for="item in menuItems" :key="item.index">
<el-menu-item v-if="!item.children" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
<el-sub-menu v-else :index="item.index">
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</template>
<el-menu-item v-for="sub in item.children" :key="sub.index" :index="sub.index">{{ sub.title }}</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</el-aside>
<el-container>
@@ -26,6 +35,7 @@
<el-main class="main">
<router-view />
</el-main>
<footer class="layout-footer">成都宇惠达智能科技有限公司 <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2025134957号-1</a></footer>
</el-container>
</el-container>
</template>
@@ -33,7 +43,7 @@
<script setup>
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key } from '@element-plus/icons-vue'
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera, Timer, Link } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth'
import { getMyPermissions } from '../api/admin'
@@ -59,12 +69,21 @@ const menuItems = computed(() => {
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
{ index: '/chunk-upload-cleanup', title: '分片上传清理', icon: Timer, permission: 'site:manage' },
{ index: '/yuheng-cloud-accounts', title: '宇恒云账号', icon: Link, permission: 'yuheng_cloud:manage' },
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
{ index: '/module-upload', title: '功能模块上传', icon: Upload, permission: 'module:upload' },
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
{ index: '/files', title: '文件管理', icon: Folder, permission: null },
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
]
return all.filter((item) => hasPermission(item.permission))
return all.filter((item) => {
if (item.children) {
item.children = item.children.filter((sub) => hasPermission(sub.permission))
return hasPermission(item.permission) && item.children.length > 0
}
return hasPermission(item.permission)
})
})
const handleLogout = () => {
@@ -109,4 +128,13 @@ const handleLogout = () => {
background: #f0f2f5;
padding: 20px;
}
.layout-footer {
padding: 8px 20px;
font-size: 12px;
color: #999;
text-align: center;
border-top: 1px solid #eee;
}
.layout-footer a { color: #999; text-decoration: none; }
.layout-footer a:hover { text-decoration: underline; }
</style>

View File

@@ -3,7 +3,6 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './utils/disable-debug'
const app = createApp(App)
app.use(ElementPlus)

View File

@@ -48,6 +48,18 @@ const routes = [
component: () => import('../views/settings/PaymentConfig.vue'),
meta: { title: '支付配置', permission: 'payment_config' }
},
{
path: 'chunk-upload-cleanup',
name: 'ChunkUploadCleanup',
component: () => import('../views/settings/ChunkUploadCleanup.vue'),
meta: { title: '分片上传清理', permission: 'site:manage' }
},
{
path: 'yuheng-cloud-accounts',
name: 'YuhengCloudAccountManage',
component: () => import('../views/settings/YuhengCloudAccountManage.vue'),
meta: { title: '宇恒云账号', permission: 'yuheng_cloud:manage' }
},
{
path: 'sites',
name: 'Sites',
@@ -67,10 +79,16 @@ const routes = [
meta: { title: '首页编辑', permission: 'homepage:edit' }
},
{
path: 'module-upload',
name: 'ModuleUpload',
component: () => import('../views/sites/ModuleUpload.vue'),
meta: { title: '功能模块上传', permission: 'module:upload' }
path: 'live-broadcast',
name: 'LiveBroadcast',
component: () => import('../views/sites/LiveBroadcast.vue'),
meta: { title: '视频直播开播', permission: 'homepage:edit' }
},
{
path: 'files',
name: 'FileManage',
component: () => import('../views/files/FileManage.vue'),
meta: { title: '文件管理', permission: null }
},
{
path: 'role-permissions',

View File

@@ -1,18 +0,0 @@
/**
* 禁止调试模式及右键 - 全局安全模块
* 在页面加载时立即执行
*/
// 禁止右键
document.addEventListener('contextmenu', (e) => e.preventDefault())
// 禁止 F12、Ctrl+Shift+I/J/C 等开发者工具快捷键
document.addEventListener('keydown', (e) => {
if (
e.key === 'F12' ||
(e.ctrlKey && e.shiftKey && ['I', 'J', 'C'].includes(e.key.toUpperCase())) ||
(e.ctrlKey && e.key === 'U')
) {
e.preventDefault()
}
})

View File

@@ -0,0 +1,623 @@
/**
* 管理后台 WebRTC 开播(观众端始终单路视频)
* - camera仅摄像头
* - screen_only仅共享屏幕 + 麦克风
* - screen_pip屏幕与摄像头 Canvas 合成;小窗位置由 getPipRect() 归一化矩形 (nx,ny,nw,nh) 决定,与预览拖动一致
* - 直播中可 switchMode 切换,无需结束直播
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
const LIVE_CAPTURE_QUALITY_STORAGE_KEY = 'yh_live_capture_quality'
const QUALITY_MEDIA = {
source: { video: true, audio: true },
high: {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: true
},
mid: {
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 24 }
},
audio: true
},
low: {
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 20 }
},
audio: true
}
}
// 码率上限kbps: 在实时性与并发之间取平衡
const QUALITY_VIDEO_MAX_KBPS = {
source: 1800,
high: 1400,
mid: 900,
low: 550
}
const BITRATE_PROFILE_MULTIPLIER = {
save: 0.78,
balanced: 1,
clarity: 1.2
}
function effectivePublishQualityKey() {
try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
if (v && QUALITY_MEDIA[v]) return v
} catch (_) {}
return 'source'
}
function targetVideoMaxBitrateBps(publishKey, bitrateProfile = 'balanced') {
const kbps = QUALITY_VIDEO_MAX_KBPS[publishKey] || QUALITY_VIDEO_MAX_KBPS.source
const m = BITRATE_PROFILE_MULTIPLIER[bitrateProfile] || BITRATE_PROFILE_MULTIPLIER.balanced
return Math.max(220, Math.round(kbps * m)) * 1000
}
async function applyVideoSenderPolicy(sender, publishKey, bitrateProfile) {
if (!sender) return
try {
const p = sender.getParameters ? sender.getParameters() : null
if (!p) return
if (!p.encodings || !p.encodings.length) p.encodings = [{}]
p.degradationPreference = 'maintain-framerate'
p.encodings[0].maxBitrate = targetVideoMaxBitrateBps(publishKey, bitrateProfile)
// 保留一定冗余,弱网抖动时更稳,避免一路拉满
p.encodings[0].maxFramerate =
publishKey === 'low' ? 20 : publishKey === 'mid' ? 24 : 30
await sender.setParameters(p)
} catch (_) {}
}
function liveWsURLPublish(token) {
const q = effectivePublishQualityKey()
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
if (apiBase) {
const base = apiBase.replace(/\/$/, '')
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
return `${wsOrigin}${path}`
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${path}`
}
const defaultIce = [{ urls: 'stun:stun.l.google.com:19302' }]
const MAX_SIGNAL_RECONNECT = 15
const CANVAS_W = 1280
const CANVAS_H = 720
function healthCheckUrl() {
if (apiBase) return `${apiBase}/api/health`
if (typeof window !== 'undefined') return `${window.location.origin}/api/health`
return '/api/health'
}
function buildCameraConstraints(publishKey, videoDeviceId) {
const preset = QUALITY_MEDIA[publishKey] || QUALITY_MEDIA.source
const dev = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : {}
if (preset.video === true) {
return {
audio: true,
video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true
}
}
return {
audio: true,
video: { ...preset.video, ...dev }
}
}
function clamp(v, lo, hi) {
return Math.min(hi, Math.max(lo, v))
}
/** @param {() => { nx?: number, ny?: number, nw?: number, nh?: number } | null | undefined} getPipRect */
function readPipRect(getPipRect) {
const d = typeof getPipRect === 'function' ? getPipRect() : null
const nw = clamp(Number(d?.nw) || 0.24, 0.08, 0.55)
const nh = clamp(Number(d?.nh) || 0.24, 0.08, 0.55)
const defNx = 1 - nw - 10 / CANVAS_W
const defNy = 1 - nh - 10 / CANVAS_H
const nx = clamp(Number(d?.nx) || defNx, 0, 1 - nw)
const ny = clamp(Number(d?.ny) || defNy, 0, 1 - nh)
return { nx, ny, nw, nh }
}
function humanizeGetUserMediaError(err) {
const name = err && err.name
const raw = ((err && err.message) || '').toLowerCase()
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
return '已拒绝摄像头或麦克风权限。'
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return '未检测到摄像头。'
}
if (
name === 'NotReadableError' ||
raw.includes('could not start video source') ||
raw.includes('failed to start video source') ||
raw.includes('video source')
) {
return '摄像头被占用或不可用,请关闭其它使用相机的程序后重试。'
}
if (name === 'OverconstrainedError') {
return '当前摄像头不满足画质约束,请在官网「直播」页改为「原画」或换摄像头。'
}
if (name === 'AbortError') {
return '采集被中断,请重试。'
}
return (err && err.message) || '无法打开摄像头'
}
export function startPublishing(opts = {}) {
const {
token = '',
captureMode: initialMode = 'camera',
videoDeviceId: initialDeviceId = '',
bitrateProfile = 'balanced',
onStatus = () => {},
onLocalStream = () => {},
onActiveModeChange = () => {},
getPipRect = null
} = opts
if (!token) {
onStatus('未登录,无法开播')
return { stop: () => {}, switchMode: async () => {} }
}
const publishKey = effectivePublishQualityKey()
const wsUrl = liveWsURLPublish(token)
let activeMode = initialMode
let deviceIdState = initialDeviceId
let closedByLocal = false
let stream = null
let ws = null
let pc = null
let reconnectTimer = null
let reconnectAttempt = 0
let wsGen = 0
let reconnectStopped = false
let switchBusy = false
let rafId = null
let vScreen = null
let vCam = null
let canvasEl = null
let screenShareTrack = null
function teardownComposite() {
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
if (vScreen) {
try {
const so = vScreen.srcObject
if (so) so.getTracks().forEach((t) => t.stop())
} catch (_) {}
try {
vScreen.srcObject = null
} catch (_) {}
vScreen = null
}
if (vCam) {
try {
const so = vCam.srcObject
if (so) so.getTracks().forEach((t) => t.stop())
} catch (_) {}
try {
vCam.srcObject = null
} catch (_) {}
vCam = null
}
canvasEl = null
screenShareTrack = null
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const send = (o) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o))
}
async function acquireDisplayStream() {
let display
try {
display = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
})
} catch (e) {
const name = e && e.name
throw new Error(name === 'NotAllowedError' ? '已取消或未允许屏幕共享' : (e && e.message) || '屏幕共享失败')
}
const sTr = display.getVideoTracks()[0]
if (!sTr) {
display.getTracks().forEach((t) => t.stop())
throw new Error('未获得屏幕画面')
}
screenShareTrack = sTr
sTr.addEventListener('ended', () => {
if (!closedByLocal) {
onStatus('屏幕共享已结束')
stop()
}
})
return display
}
async function buildPublishStream() {
teardownComposite()
if (activeMode === 'screen_only') {
const display = await acquireDisplayStream()
const sTr = display.getVideoTracks()[0]
let micStream
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
} catch (e) {
sTr.stop()
display.getTracks().forEach((t) => t.stop())
throw e
}
const previewVid = new MediaStream([sTr])
try {
sTr.contentHint = 'detail'
} catch (_) {}
onLocalStream({ layout: 'screen_only', main: previewVid })
return new MediaStream([sTr, ...micStream.getAudioTracks()])
}
if (activeMode === 'screen_pip') {
const display = await acquireDisplayStream()
const sTr = display.getVideoTracks()[0]
let cam
try {
cam = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
} catch (e) {
sTr.stop()
display.getTracks().forEach((t) => t.stop())
throw e
}
vScreen = document.createElement('video')
vCam = document.createElement('video')
vScreen.muted = true
vCam.muted = true
vScreen.playsInline = true
vCam.playsInline = true
vScreen.srcObject = display
vCam.srcObject = cam
await vScreen.play().catch(() => {})
await vCam.play().catch(() => {})
canvasEl = document.createElement('canvas')
canvasEl.width = CANVAS_W
canvasEl.height = CANVAS_H
const ctx = canvasEl.getContext('2d')
function tick() {
if (closedByLocal || !canvasEl) return
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
if (vScreen && vScreen.readyState >= 2) {
try {
ctx.drawImage(vScreen, 0, 0, CANVAS_W, CANVAS_H)
} catch (_) {}
}
if (vCam && vCam.readyState >= 2) {
const { nx, ny, nw, nh } = readPipRect(getPipRect)
const pw = Math.round(CANVAS_W * nw)
const ph = Math.round(CANVAS_H * nh)
const px = Math.round(CANVAS_W * nx)
const py = Math.round(CANVAS_H * ny)
ctx.strokeStyle = 'rgba(64,158,255,0.9)'
ctx.lineWidth = 3
ctx.strokeRect(px, py, pw, ph)
try {
ctx.drawImage(vCam, px, py, pw, ph)
} catch (_) {}
}
rafId = requestAnimationFrame(tick)
}
tick()
const cap = canvasEl.captureStream(30)
const outV = cap.getVideoTracks()[0]
if (!outV) {
teardownComposite()
sTr.stop()
cam.getTracks().forEach((t) => t.stop())
throw new Error('画布采集失败')
}
try {
outV.contentHint = 'detail'
} catch (_) {}
const mic = cam.getAudioTracks()
const publish = new MediaStream([outV, ...mic])
onLocalStream({
layout: 'screen_pip',
screen: display,
cam: new MediaStream([cam.getVideoTracks()[0]])
})
return publish
}
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
try {
const camV = s.getVideoTracks()[0]
if (camV) camV.contentHint = 'motion'
} catch (_) {}
onLocalStream({ layout: 'camera', main: s })
return s
}
async function ensureStreamAndAttach() {
const needNew =
!stream ||
!stream.getTracks().length ||
stream.getTracks().some((t) => t.readyState !== 'live')
if (needNew) {
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
teardownComposite()
try {
stream = await buildPublishStream()
} catch (e) {
const msg =
e && typeof e.message === 'string' && e.message
? e.message
: humanizeGetUserMediaError(e)
onStatus(msg)
throw e
}
}
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
pc = new RTCPeerConnection({ iceServers: defaultIce })
pc.onicecandidate = (e) => {
if (e.candidate) send({ type: 'ice', candidate: e.candidate.toJSON() })
}
stream.getTracks().forEach((t) => {
if (t.readyState === 'live') pc.addTrack(t, stream)
})
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
onStatus('协商中…')
}
async function switchMode(mode, camDeviceId) {
if (closedByLocal || switchBusy || !pc || pc.signalingState === 'closed') return
switchBusy = true
if (typeof camDeviceId === 'string') deviceIdState = camDeviceId
const switchingTo = mode
activeMode = mode
onStatus('切换画面中…')
try {
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
stream = null
teardownComposite()
try {
stream = await buildPublishStream()
} catch (e1) {
if (switchingTo === 'screen_pip' || switchingTo === 'screen_only') {
activeMode = 'camera'
try {
onActiveModeChange('camera')
} catch (_) {}
onStatus(
e1?.message ? `${e1.message},已切回仅摄像头` : '屏幕共享未就绪,已切回仅摄像头'
)
stream = await buildPublishStream()
} else {
throw e1
}
}
const vT = stream.getVideoTracks()[0]
const aT = stream.getAudioTracks()[0]
const vSender = pc.getSenders().find((s) => s.track?.kind === 'video')
const aSender = pc.getSenders().find((s) => s.track?.kind === 'audio')
if (vSender && vT) {
await vSender.replaceTrack(vT)
} else if (vT) {
pc.addTrack(vT, stream)
}
if (aSender && aT) {
await aSender.replaceTrack(aT)
} else if (aT) {
pc.addTrack(aT, stream)
}
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer({ iceRestart: false })
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
onStatus('已切换,协商中…')
} catch (e) {
onStatus(e?.message || humanizeGetUserMediaError(e))
} finally {
switchBusy = false
}
}
function onSocketMessage(ev) {
let msg
try {
msg = JSON.parse(ev.data)
} catch {
return
}
if (msg.type === 'answer' && msg.sdp && pc) {
pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }).then(
() => {
reconnectAttempt = 0
clearReconnectTimer()
onStatus('直播中')
},
(e) => {
onStatus(e.message || '协商失败')
}
)
}
if (msg.type === 'ice' && msg.candidate && pc) {
try {
pc.addIceCandidate(msg.candidate)
} catch (_) {}
}
if (msg.type === 'error') {
onStatus(msg.message || '服务端错误')
}
}
function scheduleReconnect() {
if (closedByLocal || reconnectStopped) return
clearReconnectTimer()
reconnectAttempt += 1
if (reconnectAttempt > MAX_SIGNAL_RECONNECT) {
reconnectStopped = true
onStatus(
`信令已重试 ${MAX_SIGNAL_RECONNECT} 次仍失败。多为 API 502 或未启动。修复后刷新本页再开播。`
)
return
}
const delay = Math.min(2000 * Math.pow(1.45, reconnectAttempt - 1), 28000)
onStatus(`信令断开,约 ${Math.round(delay / 1000)} 秒后重试(${reconnectAttempt}/${MAX_SIGNAL_RECONNECT})…`)
reconnectTimer = window.setTimeout(async () => {
reconnectTimer = null
if (closedByLocal || reconnectStopped) return
try {
const r = await fetch(healthCheckUrl(), { method: 'GET', cache: 'no-store' })
if (!r.ok) {
onStatus(`API 不可用HTTP ${r.status}`)
}
} catch (_) {
onStatus('无法访问健康检查接口')
}
if (closedByLocal || reconnectStopped) return
openSignalingSocket()
}, delay)
}
function openSignalingSocket() {
if (closedByLocal || reconnectStopped) return
const myGen = ++wsGen
clearReconnectTimer()
if (ws) {
try {
ws.close()
} catch (_) {}
ws = null
}
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
try {
ws = new WebSocket(wsUrl)
} catch (_) {
onStatus('无法连接信令')
scheduleReconnect()
return
}
ws.onopen = async () => {
if (closedByLocal || myGen !== wsGen) return
onStatus('采集中…')
try {
await ensureStreamAndAttach()
} catch (err) {
if (!closedByLocal) {
onStatus(err?.message || humanizeGetUserMediaError(err))
stop()
}
}
}
ws.onmessage = onSocketMessage
ws.onerror = () => {
if (!closedByLocal) onStatus('信令异常')
}
ws.onclose = () => {
if (myGen !== wsGen) return
ws = null
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
if (closedByLocal) return
scheduleReconnect()
}
}
function stop() {
closedByLocal = true
reconnectStopped = true
wsGen += 1
clearReconnectTimer()
teardownComposite()
if (ws) {
try {
ws.close()
} catch (_) {}
ws = null
}
if (pc) {
try {
pc.close()
} catch (_) {}
pc = null
}
stream?.getTracks().forEach((t) => {
try {
t.stop()
} catch (_) {}
})
stream = null
}
openSignalingSocket()
return {
stop,
switchMode: (mode, camDeviceId) => switchMode(mode, camDeviceId)
}
}

View File

@@ -0,0 +1,153 @@
import {
abortMultipartUpload,
completeMultipartUpload,
getMultipartUploadStatus,
initMultipartUpload,
putMultipartChunk,
uploadSiteAsset
} from '../api/admin'
const CHUNK_THRESHOLD = 8 * 1024 * 1024
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
// 串行上传分片,避免 HTTP/2 多路复用 + 大 body 在部分反代上不稳定
const UPLOAD_CONCURRENCY = 1
function fileFingerprint(file) {
return `${file.name}\t${file.size}\t${file.lastModified}`
}
function storageKey(siteId) {
return `yh_resumable_asset_${siteId}`
}
/**
* 站点资源上传:小于阈值整包 POST大于等于阈值走分片 + sessionStorage 断点续传(同文件同站点刷新后可续传)。
* @param {string} siteId
* @param {File|Blob} file
* @param {{ folder?: string, downloadable?: boolean, preserveFilename?: boolean }} opts
* @param {{ onProgress?: (p: { percent: number, loaded: number, total: number }) => void }} callbacks
*/
export async function uploadSiteAssetWithResume(siteId, file, opts = {}, callbacks = {}) {
const onProgress = typeof callbacks.onProgress === 'function' ? callbacks.onProgress : null
const total = file.size
if (total <= CHUNK_THRESHOLD) {
if (onProgress) onProgress({ percent: 0, loaded: 0, total })
const res = await uploadSiteAsset(siteId, file, opts)
if (onProgress) onProgress({ percent: 100, loaded: total, total })
return res
}
const fp = fileFingerprint(file)
const key = storageKey(siteId)
let uploadId = null
let chunkSize = DEFAULT_CHUNK_SIZE
let totalChunks = 0
const received = new Set()
let cached = null
try {
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
} catch (_) {
cached = null
}
if (cached && cached.fingerprint === fp && cached.upload_id) {
try {
const st = await getMultipartUploadStatus(siteId, cached.upload_id)
if (
st.total_size === file.size &&
st.original_filename === file.name &&
typeof st.chunk_size === 'number' &&
st.chunk_size > 0
) {
uploadId = cached.upload_id
chunkSize = st.chunk_size
totalChunks = st.total_chunks
for (const i of st.received_chunks || []) {
received.add(i)
}
}
} catch (_) {
uploadId = null
received.clear()
}
}
if (!uploadId) {
const init = await initMultipartUpload(siteId, {
filename: file.name,
total_size: file.size,
chunk_size: DEFAULT_CHUNK_SIZE,
folder: opts.folder || '',
downloadable: Boolean(opts.downloadable),
preserve_filename: Boolean(opts.preserveFilename)
})
uploadId = init.upload_id
chunkSize = init.chunk_size || DEFAULT_CHUNK_SIZE
totalChunks = init.total_chunks
sessionStorage.setItem(key, JSON.stringify({ fingerprint: fp, upload_id: uploadId, total_chunks: totalChunks }))
}
const missing = []
for (let i = 0; i < totalChunks; i++) {
if (!received.has(i)) missing.push(i)
}
const chunkByteLength = (idx) => {
const start = idx * chunkSize
const end = Math.min(start + chunkSize, file.size)
return end - start
}
let uploadedBytes = 0
for (let i = 0; i < totalChunks; i++) {
if (received.has(i)) uploadedBytes += chunkByteLength(i)
}
const reportProgress = () => {
if (!onProgress) return
const pct = total <= 0 ? 100 : Math.min(99, Math.round((uploadedBytes / total) * 100))
onProgress({ percent: pct, loaded: uploadedBytes, total })
}
reportProgress()
const queue = [...missing]
const worker = async () => {
while (queue.length) {
const idx = queue.shift()
if (idx === undefined) break
const start = idx * chunkSize
const end = Math.min(start + chunkSize, file.size)
const blob = file.slice(start, end)
await putMultipartChunk(siteId, uploadId, idx, blob)
uploadedBytes += end - start
reportProgress()
}
}
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(1, missing.length)) }, () => worker()))
const done = await completeMultipartUpload(siteId, uploadId)
try {
sessionStorage.removeItem(key)
} catch (_) {}
if (onProgress) onProgress({ percent: 100, loaded: total, total })
return done
}
/** 放弃当前站点的分片会话(可选) */
export async function abortSiteAssetResumable(siteId) {
const key = storageKey(siteId)
let cached = null
try {
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
} catch (_) {
cached = null
}
if (!cached?.upload_id) return
try {
await abortMultipartUpload(siteId, cached.upload_id)
} catch (_) {}
try {
sessionStorage.removeItem(key)
} catch (_) {}
}

View File

@@ -27,6 +27,61 @@
</el-col>
</el-row>
<el-card v-if="bandwidth" class="bw-card" style="margin-top: 20px">
<template #header>
<div class="bw-header">
<span>应用带宽观测</span>
<el-text type="info" size="small">{{ bwUpdatedAt }}</el-text>
</div>
</template>
<el-alert type="info" :closable="false" show-icon class="bw-tip">
以下为<strong> Go 进程</strong>统计的 HTTP 请求/响应字节量用于粗估负载若前面还有 Nginx/CDN<strong>公网出口带宽</strong>可能更高WebSocket如直播信令升级后的流量可能未完全计入
</el-alert>
<el-row :gutter="16" class="bw-row">
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">出站累计用户下载为主</div>
<div class="bw-value">{{ formatBytes(bandwidth.bytes_out_total) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">入站累计上传/POST</div>
<div class="bw-value">{{ formatBytes(bandwidth.bytes_in_total) }}</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label"> 60 秒出站 · Mbps</div>
<div class="bw-value accent">{{ bandwidth.recent_egress_mbps }}</div>
<div class="bw-sub">{{ formatBytes(bandwidth.bytes_out_last_60s) }} / 60s</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<div class="bw-metric">
<div class="bw-label">自启动平均出站 · Mbps</div>
<div class="bw-value accent">{{ bandwidth.avg_egress_mbps }}</div>
<div class="bw-sub">运行 {{ formatUptime(bandwidth.uptime_seconds) }}</div>
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="bw-row bw-row--second">
<el-col :xs="24" :sm="12">
<div class="bw-metric bw-metric--inline">
<span class="bw-label"> 60 秒入站约 Mbps</span>
<span class="bw-value-inline">{{ bandwidth.recent_ingress_mbps }}</span>
<span class="bw-sub">{{ formatBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
</div>
</el-col>
<el-col :xs="24" :sm="12">
<div class="bw-metric bw-metric--inline">
<span class="bw-label">自启动平均入站 · Mbps</span>
<span class="bw-value-inline">{{ bandwidth.avg_ingress_mbps }}</span>
</div>
</el-col>
</el-row>
</el-card>
<el-card style="margin-top: 20px">
<template #header>
<span>快捷入口</span>
@@ -41,7 +96,7 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
import { getStats } from '../api/admin'
const stats = reactive({
@@ -52,13 +107,51 @@ const stats = reactive({
files: 0
})
const bandwidth = ref(null)
const bwFetchAt = ref(0)
const bwUpdatedAt = computed(() => {
if (!bwFetchAt.value) return ''
const d = new Date(bwFetchAt.value)
return `更新于 ${d.toLocaleTimeString()}`
})
const loading = ref(true)
function formatBytes(n) {
if (n == null || !Number.isFinite(n) || n < 0) return '—'
if (n < 1024) return `${n} B`
const u = ['KB', 'MB', 'GB', 'TB']
let v = n
let i = -1
do {
v /= 1024
i++
} while (v >= 1024 && i < u.length - 1)
return `${v < 10 ? v.toFixed(2) : v.toFixed(1)} ${u[i]}`
}
function formatUptime(sec) {
if (sec == null || !Number.isFinite(sec) || sec < 0) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h} 小时 ${m}`
if (m > 0) return `${m}${r}`
return `${r}`
}
const fetchStats = async () => {
loading.value = true
try {
const res = await getStats()
Object.assign(stats, res)
const { bandwidth: bw, ...rest } = res
Object.assign(stats, rest)
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
} catch (e) {
console.error('获取统计失败:', e)
// 即使失败也显示 0不阻塞页面
@@ -67,7 +160,30 @@ const fetchStats = async () => {
}
}
onMounted(fetchStats)
let pollTimer = null
onMounted(() => {
fetchStats()
pollTimer = window.setInterval(() => {
getStats()
.then((res) => {
const { bandwidth: bw, ...rest } = res
Object.assign(stats, rest)
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
})
.catch(() => {})
}, 8000)
})
onUnmounted(() => {
if (pollTimer != null) {
clearInterval(pollTimer)
pollTimer = null
}
})
</script>
<style scoped>
@@ -85,4 +201,58 @@ onMounted(fetchStats)
color: #666;
font-size: 14px;
}
.bw-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bw-tip {
margin-bottom: 16px;
}
.bw-row {
margin-top: 0;
}
.bw-row--second {
margin-top: 12px;
}
.bw-metric {
padding: 12px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.bw-metric--inline {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
border-bottom: none;
padding: 8px 0;
}
.bw-label {
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.bw-metric--inline .bw-label {
margin-bottom: 0;
}
.bw-value {
font-size: 22px;
font-weight: 600;
color: #303133;
}
.bw-value.accent {
color: #409eff;
}
.bw-value-inline {
font-size: 18px;
font-weight: 600;
color: #409eff;
}
.bw-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,23 @@
<!--
文件管理可自定义功能子模块默认包含图片管理图标管理图标归在图片管理下
文件支持可下载 / 不可下载
超级管理员仅一个默认取第一个注册用户
-->
<template>
<div class="file-images">
<el-card>
<template #header>
<span>图片管理</span>
<el-tag size="small" style="margin-left:8px">含图标</el-tag>
</template>
<p class="tip">图片与图标统一在此管理支持可下载/不可下载功能开发中</p>
</el-card>
</div>
</template>
<script setup>
</script>
<style scoped>
.file-images .tip { color: #666; font-size: 14px; }
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="file-manage">
<el-card>
<template #header>
<span>文件管理</span>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="图片与图标" name="images">
<p class="tip">图片与图标统一在此管理支持可下载/不可下载功能开发中</p>
</el-tab-pane>
<el-tab-pane label="功能模块" name="module">
<div class="module-toolbar">
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="onSiteChange">
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
</el-select>
<el-button :disabled="!siteId" @click="showNewFolder = true">新建文件夹</el-button>
<el-upload :show-file-list="false" :disabled="!siteId" :before-upload="beforeUpload">
<el-button type="primary" :disabled="!siteId" :loading="uploading">上传文件</el-button>
</el-upload>
</div>
<el-alert v-if="!siteId" title="请先选择站点" type="info" style="margin: 12px 0" />
<template v-else>
<div class="breadcrumb-wrap">
<el-breadcrumb separator="/">
<el-breadcrumb-item @click="currentPath = ''"><a href="javascript:;">根目录</a></el-breadcrumb-item>
<el-breadcrumb-item v-for="(p, i) in pathParts" :key="i">
<a href="javascript:;" @click="currentPath = pathParts.slice(0, i + 1).join('/')">{{ p }}</a>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="subdirs" v-if="subDirs && subDirs.length">
<span class="label">子目录</span>
<el-button v-for="d in subDirs" :key="d" link type="primary" @click="enterDir(d)">{{ d }}/</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe style="margin-top: 12px">
<el-table-column label="文件名" prop="name" min-width="180" />
<el-table-column label="存储路径" prop="file_path" min-width="200" show-overflow-tooltip />
<el-table-column label="可下载" width="80">
<template #default="{ row }">{{ row.downloadable ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column label="上传时间" prop="created_at" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && list.length === 0 && (!subDirs || !subDirs.length)" description="当前目录为空,可上传文件或新建文件夹" />
</template>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 上传前选择是否可下载 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
<p class="upload-resume-hint">8MB 将自动分片上传中断后<strong>同一文件</strong>再次选择上传可续传勿改文件名/大小</p>
<el-form label-width="112px">
<el-form-item label="当前目录">
<span>{{ currentPath || '根目录' }}</span>
</el-form-item>
<el-form-item label="保留原文件名">
<el-switch v-model="uploadPreserveFilename" />
<span class="form-hint">开启后将按原文件名保存同名文件会被覆盖</span>
</el-form-item>
<el-form-item label="允许下载">
<el-switch v-model="uploadDownloadable" />
</el-form-item>
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
<el-progress :percentage="uploadPercent" :stroke-width="16" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="doUpload">确定上传</el-button>
</template>
</el-dialog>
<!-- 新建文件夹 -->
<el-dialog v-model="showNewFolder" title="新建文件夹" width="400px">
<el-form label-width="80px">
<el-form-item label="目录名">
<el-input v-model="newFolderName" placeholder="当前目录下新建,可填多级如 a/b" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showNewFolder = false">取消</el-button>
<el-button type="primary" @click="createFolder">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
const activeTab = ref('module')
const siteId = ref('')
const sites = ref([])
const list = ref([])
const subDirs = ref([])
const loading = ref(false)
const currentPath = ref('')
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadDialogVisible = ref(false)
const uploadDownloadable = ref(false)
const uploadPreserveFilename = ref(false)
const pendingFile = ref(null)
const showNewFolder = ref(false)
const newFolderName = ref('')
const pathParts = computed(() => {
const p = currentPath.value
if (!p) return []
return p.split('/').filter(Boolean)
})
const fetchSites = async () => {
try {
const res = await getSites()
sites.value = res.list || []
if (sites.value.length && !siteId.value) siteId.value = sites.value[0].id
} catch (e) {
ElMessage.error(e.message)
}
}
const fetchList = async () => {
if (!siteId.value) {
list.value = []
subDirs.value = []
return
}
loading.value = true
try {
const res = await getSiteAssets(siteId.value, currentPath.value || undefined)
list.value = res.list || []
subDirs.value = res.sub_dirs || []
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const onSiteChange = () => {
currentPath.value = ''
fetchList()
}
const enterDir = (name) => {
currentPath.value = currentPath.value ? currentPath.value + '/' + name : name
}
watch([siteId, currentPath], fetchList)
const beforeUpload = (file) => {
pendingFile.value = file
const p = (currentPath.value || '').replace(/^\//, '')
uploadPreserveFilename.value = p.startsWith('promotion/')
uploadDownloadable.value = !uploadPreserveFilename.value
uploadDialogVisible.value = true
return false
}
const doUpload = async () => {
if (!pendingFile.value || !siteId.value) return
uploading.value = true
uploadPercent.value = 0
try {
await uploadSiteAssetWithResume(
siteId.value,
pendingFile.value,
{
folder: currentPath.value || undefined,
downloadable: uploadDownloadable.value,
preserveFilename: uploadPreserveFilename.value
},
{
onProgress: ({ percent }) => {
uploadPercent.value = percent
}
}
)
ElMessage.success('上传成功')
uploadDialogVisible.value = false
pendingFile.value = null
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
const createFolder = async () => {
const name = (newFolderName.value || '').trim()
if (!name) {
ElMessage.warning('请输入目录名')
return
}
const fullPath = currentPath.value ? currentPath.value + '/' + name : name
try {
await createSiteFolder(siteId.value, fullPath)
ElMessage.success('创建成功')
showNewFolder.value = false
newFolderName.value = ''
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该文件?', '提示', { type: 'warning' })
try {
await deleteSiteAsset(siteId.value, row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
onMounted(() => fetchSites().then(() => fetchList()))
</script>
<style scoped>
.file-manage .tip { color: #666; font-size: 14px; }
.form-hint { display: block; margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.4; }
.form-hint code { font-size: 11px; }
.module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.breadcrumb-wrap { margin-top: 12px; }
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
.subdirs .label { margin-right: 8px; }
.upload-resume-hint {
margin: 0 0 12px;
font-size: 12px;
color: #606266;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div class="chunk-upload-cleanup">
<el-card>
<template #header>
<span>分片上传临时目录清理</span>
</template>
<p class="hint">
大文件分片上传时未完成合并的会话保存在上传目录下的 <code>.chunk-uploads</code>超过下方保留时长的目录会被定期删除未在后台保存时可使用环境变量
<code>YH_CHUNK_UPLOAD_MAX_AGE_HOURS</code><code>YH_CHUNK_UPLOAD_SWEEP_MINUTES</code>保存后台配置后优先生效
</p>
<el-form v-if="canEdit" :model="form" label-width="140px" style="max-width: 520px">
<el-form-item label="保留时长(小时)">
<el-input-number v-model="form.max_age_hours" :min="6" :max="336" :step="6" controls-position="right" />
<span class="form-tip">6336默认 72超过此时长未合并的会话视为非活动</span>
</el-form-item>
<el-form-item label="扫描间隔(分钟)">
<el-input-number v-model="form.sweep_minutes" :min="5" :max="1440" :step="5" controls-position="right" />
<span class="form-tip">51440默认 60服务端按此频率检查是否需清扫</span>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
</el-form-item>
</el-form>
<el-alert v-else type="warning" title="无权限" description="需要「站点管理」权限。" :closable="false" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { getChunkUploadCleanup, updateChunkUploadCleanup } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({
max_age_hours: 72,
sweep_minutes: 60
})
const canEdit = computed(() => authStore.hasPermission('site:manage'))
const fetchConfig = async () => {
if (!canEdit.value) return
try {
const res = await getChunkUploadCleanup()
form.max_age_hours = Number(res.max_age_hours) || 72
form.sweep_minutes = Number(res.sweep_minutes) || 60
} catch (e) {
ElMessage.error(e.message || '获取配置失败')
}
}
const handleSave = async () => {
loading.value = true
try {
await updateChunkUploadCleanup({
max_age_hours: form.max_age_hours,
sweep_minutes: form.sweep_minutes
})
ElMessage.success('保存成功,新参数在下次清扫周期生效')
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '保存失败')
} finally {
loading.value = false
}
}
onMounted(fetchConfig)
</script>
<style scoped>
.chunk-upload-cleanup {
padding: 0;
}
.hint {
margin: 0 0 20px;
font-size: 13px;
color: #606266;
line-height: 1.65;
max-width: 720px;
}
.form-tip {
display: block;
margin-top: 6px;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
</style>

View File

@@ -4,47 +4,99 @@
<template #header>
<div class="card-header">
<span>角色权限管理</span>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
<div>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
<el-button type="success" @click="showCreate = true">创建角色</el-button>
</div>
</div>
</template>
<p class="tip">超级管理员(9527)拥有全部权限且不可修改为其他角色勾选其可用的后台权限</p>
<p class="tip">
超级管理员(9527)拥有全部权限且不可改权限勾选防误操作<strong>超级用户(0)普通用户(1)</strong>可修改权限与显示名称自定义角色可删除
</p>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="role_name" label="角色" width="140" />
<el-table-column prop="role_name" label="角色" width="200">
<template #default="{ row }">
<el-input v-if="row.role_id !== 9527" v-model="row.role_name" size="small" placeholder="显示名称" style="width: 160px" />
<span v-else>{{ row.role_name }}</span>
</template>
</el-table-column>
<el-table-column prop="role_id" label="role_id" width="100" />
<el-table-column label="权限">
<el-table-column label="权限" min-width="480">
<template #default="{ row }">
<span v-if="row.role_id === 9527" class="perm-all">全部权限不可修改</span>
<div v-else class="perm-checkboxes">
<el-checkbox
v-for="p in allPermissions"
:key="p.key"
v-model="row._checked[p.key]"
style="margin-right: 16px; margin-bottom: 8px"
>
{{ p.name }}
</el-checkbox>
<div v-else class="perm-grid">
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
<el-checkbox v-model="row._checked[permKey(p)]" />
<span class="perm-text">
<span class="perm-name">{{ permLabel(p) }}</span>
<span class="perm-key">{{ permKey(p) }}</span>
</span>
</label>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="row.is_custom" link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<span v-else></span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showCreate" title="创建角色" width="560px">
<el-form label-width="90px">
<el-form-item label="角色名称" required>
<el-input v-model="createForm.role_name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="权限">
<p class="dialog-perm-hint">勾选该角色可访问的后台能力</p>
<div class="perm-grid dialog-perm-grid">
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
<el-checkbox v-model="createForm._checked[permKey(p)]" />
<span class="perm-text">
<span class="perm-name">{{ permLabel(p) }}</span>
<span class="perm-key">{{ permKey(p) }}</span>
</span>
</label>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getRolePermissionsList, updateRolePermissions } from '../../api/admin'
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getRolePermissionsList, updateRolePermissions, createRole, deleteRole } from '../../api/admin'
const list = ref([])
const allPermissions = ref([])
const loading = ref(false)
const saving = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const createForm = reactive({ role_name: '', _checked: {} })
/** 兼容旧接口大写字段 Key/Name */
function permKey(p) {
return p?.key || p?.Key || ''
}
function permLabel(p) {
const k = permKey(p)
return p?.name || p?.Name || k || '权限'
}
function buildChecked(permissions) {
const o = {}
allPermissions.value.forEach((p) => {
o[p.key] = permissions.includes(p.key)
const k = permKey(p)
if (k) o[k] = (permissions || []).includes(k)
})
return o
}
@@ -70,8 +122,11 @@ const handleSave = async () => {
try {
for (const row of list.value) {
if (row.role_id === 9527) continue
const permissions = allPermissions.value.filter((p) => row._checked[p.key]).map((p) => p.key)
await updateRolePermissions(row.role_id, { permissions })
const permissions = allPermissions.value.filter((p) => row._checked[permKey(p)]).map((p) => permKey(p))
const payload = { permissions }
const name = (row.role_name || '').trim()
if (name) payload.role_name = name
await updateRolePermissions(row.role_id, payload)
}
ElMessage.success('保存成功')
} catch (e) {
@@ -81,6 +136,51 @@ const handleSave = async () => {
}
}
const resetCreateForm = () => {
createForm.role_name = ''
createForm._checked = {}
allPermissions.value.forEach((p) => {
const k = permKey(p)
if (k) createForm._checked[k] = false
})
}
const handleCreate = async () => {
const name = (createForm.role_name || '').trim()
if (!name) {
ElMessage.warning('请输入角色名称')
return
}
creating.value = true
try {
const permissions = allPermissions.value.filter((p) => createForm._checked[permKey(p)]).map((p) => permKey(p))
await createRole({ role_name: name, permissions })
ElMessage.success('创建成功')
showCreate.value = false
resetCreateForm()
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
} finally {
creating.value = false
}
}
const handleDelete = async (row) => {
await ElMessageBox.confirm('确定删除该角色?删除后使用该角色的用户需重新分配角色。', '提示', { type: 'warning' })
try {
await deleteRole(row.role_id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message)
}
}
watch(showCreate, (v) => {
if (v) resetCreateForm()
})
onMounted(fetchList)
</script>
@@ -94,9 +194,50 @@ onMounted(fetchList)
color: #666;
font-size: 13px;
margin-bottom: 16px;
line-height: 1.6;
}
.perm-checkboxes {
.perm-all {
color: #909399;
font-size: 13px;
}
.perm-grid {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
align-items: flex-start;
}
.dialog-perm-grid {
max-height: 360px;
overflow-y: auto;
padding: 8px 0;
}
.perm-item {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
min-width: 200px;
max-width: 240px;
}
.perm-text {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.35;
}
.perm-name {
font-size: 13px;
color: #303133;
}
.perm-key {
font-size: 11px;
color: #909399;
font-family: ui-monospace, monospace;
word-break: break-all;
}
.dialog-perm-hint {
margin: 0 0 8px;
font-size: 12px;
color: #909399;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="yh-cloud-accounts">
<el-card>
<template #header>
<span>宇恒云账号管理</span>
</template>
<p class="hint">
调用云端
<code>POST /register</code>默认
<code>http://www.cloud.yuxindazhineng.com:3001/register</code>,可通过环境变量
<code>YH_CLOUD_REGISTER_URL</code> 覆盖成功后在 Mongo 集合
<code>yuheng_cloud_register_records</code> 写入一条记录<strong>仅保存账号与密码</strong>邮箱仅用于提交云端
</p>
<el-form v-if="canEdit" :model="form" :rules="rules" ref="formRef" label-width="88px" class="add-form">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="云端 username" clearable style="max-width: 360px" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="云端 password" show-password clearable style="max-width: 360px" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="云端必填 email不入库" clearable style="max-width: 360px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="submit">提交注册</el-button>
</el-form-item>
</el-form>
<el-alert v-else type="warning" title="无权限" description="需要「宇恒云账号管理」权限,请在角色权限中为当前角色勾选。" :closable="false" />
<el-divider v-if="canEdit" />
<el-table v-if="canEdit" :data="list" v-loading="loading" stripe>
<el-table-column prop="username" label="账号" min-width="140" />
<el-table-column label="密码" min-width="160">
<template #default="{ row }">
<span class="pwd-mask">{{ row._showPwd ? row.password : '••••••••' }}</span>
<el-button link type="primary" size="small" @click="row._showPwd = !row._showPwd">
{{ row._showPwd ? '隐藏' : '显示' }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="created_at" label="记录时间" width="200" />
</el-table>
<el-pagination
v-if="canEdit"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
style="margin-top: 16px"
@current-change="fetchList"
@size-change="fetchList"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { createYuhengCloudAccount, listYuhengCloudAccounts } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
const authStore = useAuthStore()
const canEdit = computed(() => authStore.hasPermission('yuheng_cloud:manage'))
const formRef = ref(null)
const submitting = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const form = reactive({
username: '',
password: '',
email: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱(提交云端)', trigger: 'blur' }]
}
const fetchList = async () => {
if (!canEdit.value) return
loading.value = true
try {
const res = await listYuhengCloudAccounts({ page: page.value, page_size: pageSize.value })
const rows = (res.list || []).map((r) => ({ ...r, _showPwd: false }))
list.value = rows
total.value = res.total ?? 0
} catch (e) {
ElMessage.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
const submit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
submitting.value = true
try {
await createYuhengCloudAccount({
username: form.username.trim(),
password: form.password,
email: form.email.trim()
})
ElMessage.success('云端注册成功,已写入本地记录')
form.username = ''
form.password = ''
form.email = ''
formRef.value?.resetFields()
fetchList()
} catch (e) {
ElMessage.error(e.response?.data?.error || e.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(() => {
if (canEdit.value) fetchList()
})
</script>
<style scoped>
.yh-cloud-accounts {
padding: 0;
}
.hint {
margin: 0 0 16px;
font-size: 13px;
color: #606266;
line-height: 1.65;
max-width: 900px;
}
.add-form {
max-width: 520px;
}
.pwd-mask {
margin-right: 8px;
font-family: monospace;
}
</style>

View File

@@ -14,57 +14,113 @@
</div>
</template>
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form">
<el-divider content-position="left">导航与标题</el-divider>
<el-form-item label="Logo 文案">
<el-input v-model="form.logo_text" placeholder="YUHENG ONE" />
<el-input v-model="form.logo_text" placeholder="宇恒一号" />
</el-form-item>
<el-form-item label="主标题">
<el-input v-model="form.title" placeholder="宇恒一号" />
</el-form-item>
<el-form-item label="副标题">
<el-input v-model="form.subtitle" placeholder="INTERSTELLAR EXPLORER EDITION" />
<el-input v-model="form.subtitle" placeholder="可选,前台大标题区已精简" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
</el-form-item>
<el-form-item label="导航链接">
<div v-for="(link, i) in form.nav_links" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
<el-input v-model="link.label" placeholder="Label" style="width: 120px" />
<el-input v-model="link.url" placeholder="URL" style="flex: 1" />
<div
v-for="(link, i) in form.nav_links"
:key="i"
style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap"
>
<el-input v-model="link.label" placeholder="显示文字" style="width: 120px" />
<el-input v-model="link.url" placeholder="路径或外链,可点「选择链接」" style="flex: 1; min-width: 160px" />
<el-button type="primary" link @click="openLinkPicker({ type: 'nav', index: i })">选择链接</el-button>
<el-link
v-if="previewReady(link.url)"
type="primary"
:href="previewHref(link.url)"
target="_blank"
rel="noopener noreferrer"
>试跳</el-link>
<el-button link type="danger" @click="form.nav_links.splice(i, 1)">删除</el-button>
</div>
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
</el-form-item>
<el-divider content-position="left">下载按钮</el-divider>
<el-form-item label="按钮文案">
<el-input v-model="form.download_text" placeholder="START EXPLORING" />
<el-divider content-position="left">侧栏下载Windows / 安卓直链</el-divider>
<p class="builder-tip" style="margin: -6px 0 12px">
填写同域可下载的静态地址需将安装包放到站点 <code>promotion/downloads/</code> 并部署前台为Windows 版下载安卓版下载直连不跳转整页
</p>
<el-form-item label="Windows 安装包">
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
<el-input
v-model="form.download_windows_url"
placeholder="/promotion/downloads/yuheng-windows.zip"
style="flex: 1; min-width: 160px"
/>
<el-button type="primary" link @click="openLinkPicker({ type: 'download_windows' })">选择链接</el-button>
</div>
</el-form-item>
<el-form-item label="按钮链接">
<el-input v-model="form.download_url" placeholder="#" />
<el-form-item label="安卓安装包">
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
<el-input
v-model="form.download_android_url"
placeholder="/promotion/downloads/yuheng-android.apk"
style="flex: 1; min-width: 160px"
/>
<el-button type="primary" link @click="openLinkPicker({ type: 'download_android' })">选择链接</el-button>
</div>
</el-form-item>
<el-divider content-position="left">平台轨道</el-divider>
<el-form-item label="平台列表">
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
<el-input v-model="p.name" placeholder="如 WINDOWS" style="width: 140px" />
<el-input v-model="p.url" placeholder="链接" style="flex: 1" />
<el-divider content-position="left">直播前台 /live</el-divider>
<p class="builder-tip" style="margin: -6px 0 12px">
<strong>本站 WebRTC 直播</strong>仅在左侧菜单视频直播开播由已登录管理员推流前台首页左上角画中画与直播页播放
<strong>外部直播间</strong>下方地址用于前台进入外部直播间跳转可留空
</p>
<el-form-item label="直播间标题">
<el-input v-model="form.live_room_title" placeholder="视频直播" style="max-width: 320px" />
</el-form-item>
<el-form-item label="直播间地址">
<el-input
v-model="form.live_room_url"
type="textarea"
:rows="2"
placeholder="https://live.example.com/xxx"
/>
</el-form-item>
<el-divider content-position="left">旧版字段前台 Vue 已不使用轨道路由</el-divider>
<el-form-item label="下载按钮文案">
<el-input v-model="form.download_text" placeholder="下载" />
</el-form-item>
<el-form-item label="下载按钮链接">
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
<el-input v-model="form.download_url" placeholder="#" style="flex: 1; min-width: 200px" />
<el-button type="primary" link @click="openLinkPicker({ type: 'download' })">选择链接</el-button>
</div>
</el-form-item>
<el-form-item label="平台轨道(可选)">
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
<el-input v-model="p.name" placeholder="名称" style="width: 140px" />
<el-input v-model="p.url" placeholder="链接" style="flex: 1; min-width: 140px" />
<el-button type="primary" link @click="openLinkPicker({ type: 'platform', index: i })">选择链接</el-button>
<el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button>
</div>
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加平台</el-button>
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加</el-button>
</el-form-item>
<el-divider content-position="left">版本与徽章</el-divider>
<el-divider content-position="left">版本与徽章前台已隐藏主视觉条可留空</el-divider>
<el-form-item label="版本">
<el-input v-model="form.version" placeholder="VERSION 3.2.1" style="width: 200px" />
<el-input v-model="form.version" placeholder="可留空" style="width: 200px" />
</el-form-item>
<el-form-item label="发射年份">
<el-input v-model="form.launch_year" placeholder="LAUNCH: 2024" style="width: 200px" />
<el-form-item label="发布说明">
<el-input v-model="form.launch_year" placeholder="发布日期:以官网为准" style="width: 200px" />
</el-form-item>
<el-form-item label="徽章文案">
<el-input v-model="form.badge_text" placeholder="FREE ACCESS" style="width: 200px" />
<el-input v-model="form.badge_text" placeholder="完全免费" style="width: 200px" />
</el-form-item>
<el-divider content-position="left">特性卡片</el-divider>
@@ -78,12 +134,26 @@
</el-form-item>
<el-form-item label="页脚文案">
<el-input v-model="form.footer_text" placeholder="© 2024 YUHENG ONE" />
<el-input v-model="form.footer_text" placeholder="© 2024 宇恒一号 · 成都宇信达智能科技有限公司" />
</el-form-item>
<el-divider content-position="left">首页下方扩展区可视化积木可拖拽排序</el-divider>
<p class="builder-tip">
网页管理 积木相同从左侧手柄拖拽调整模块顺序保存后内容显示在落地页主体模块<strong>之后</strong>页脚之前留空则不显示扩展区
</p>
<el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap">
<PageBuilderEditor v-model="form.body_builder" :site-id="siteId" />
</el-form-item>
</el-form>
<el-empty v-else description="请先选择站点" />
</el-card>
<LinkPickerDialog
v-model="linkPickerVisible"
:site-id="siteId"
@select="onLinkPicked"
/>
</div>
</template>
@@ -92,37 +162,45 @@ import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getSites, getOfficialSite, getHomepage, updateHomepage } from '../../api/admin'
import { useAuthStore } from '../../stores/auth'
import LinkPickerDialog from '../../components/LinkPickerDialog.vue'
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
const siteId = ref('')
const sites = ref([])
const saving = ref(false)
const downloading = ref(false)
const formRef = ref(null)
const linkPickerVisible = ref(false)
/** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'download_windows' | 'download_android' | 'platform'; index?: number }>} */
const linkPickTarget = ref({ type: 'download' })
const defaultForm = () => ({
logo_text: 'YUHENG ONE',
nav_links: [{ label: 'MISSION', url: '#' }, { label: 'DOWNLOAD', url: '#' }, { label: 'CONTACT', url: '#' }],
title: '宇恒一号',
subtitle: 'INTERSTELLAR EXPLORER EDITION',
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
download_text: 'START EXPLORING',
download_url: '#',
platforms: [
{ name: 'WINDOWS', url: '#' },
{ name: 'MACOS', url: '#' },
{ name: 'LINUX', url: '#' },
{ name: 'IOS', url: '#' },
{ name: 'ANDROID', url: '#' }
logo_text: '宇恒一号',
nav_links: [
{ label: '产品简介', url: '#intro' },
{ label: '产品视频', url: '#videos' },
{ label: '联系我们', url: '#contact' }
],
version: 'VERSION 3.2.1',
launch_year: 'LAUNCH: 2024',
badge_text: 'FREE ACCESS',
title: '宇恒一号',
subtitle: '',
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
download_text: '下载',
download_url: '#',
download_windows_url: '/promotion/downloads/yuheng-windows.zip',
download_android_url: '/promotion/downloads/yuheng-android.apk',
platforms: [],
version: '',
launch_year: '发布日期:以官网为准',
badge_text: '完全免费',
features: [
{ title: '星际导航', desc: '先进的AI导航系统精准定位您的需求引领探索之旅' },
{ title: '星际导航', desc: '先进的 AI 导航系统,精准定位您的需求,引领探索之旅' },
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
],
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE'
footer_text: '© 2024 宇恒一号 · 成都宇信达智能科技有限公司',
body_builder: '',
live_room_url: '',
live_room_title: '视频直播'
})
const form = reactive(defaultForm())
@@ -155,12 +233,19 @@ const fetchData = async () => {
if (!siteId.value) return
try {
const data = await getHomepage(siteId.value)
const base = defaultForm()
Object.assign(form, {
...defaultForm(),
...base,
...data,
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : defaultForm().nav_links,
platforms: Array.isArray(data.platforms) && data.platforms.length ? data.platforms : defaultForm().platforms,
features: Array.isArray(data.features) && data.features.length ? data.features : defaultForm().features
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : base.nav_links,
platforms: Array.isArray(data.platforms) ? data.platforms : base.platforms,
features: Array.isArray(data.features) && data.features.length ? data.features : base.features,
download_windows_url: data.download_windows_url || base.download_windows_url,
download_android_url: data.download_android_url || base.download_android_url,
live_room_url: typeof data.live_room_url === 'string' ? data.live_room_url : base.live_room_url,
live_room_title: (typeof data.live_room_title === 'string' && data.live_room_title.trim())
? data.live_room_title.trim()
: base.live_room_title
})
} catch (e) {
ElMessage.error(e.message)
@@ -203,6 +288,40 @@ const handleDownload = async () => {
}
}
function openLinkPicker(target) {
linkPickTarget.value = target
linkPickerVisible.value = true
}
function onLinkPicked(url) {
const t = linkPickTarget.value
if (t.type === 'nav' && typeof t.index === 'number') {
form.nav_links[t.index].url = url
} else if (t.type === 'download') {
form.download_url = url
} else if (t.type === 'download_windows') {
form.download_windows_url = url
} else if (t.type === 'download_android') {
form.download_android_url = url
} else if (t.type === 'platform' && typeof t.index === 'number') {
form.platforms[t.index].url = url
}
ElMessage.success('已填入链接')
}
function previewReady(url) {
const u = (url || '').trim()
return Boolean(u && u !== '#')
}
/** 后台试跳:相对路径用当前浏览器域名拼接 */
function previewHref(url) {
const u = (url || '').trim()
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('/')) return `${window.location.origin}${u}`
return u
}
onMounted(() => {
fetchSites().then(() => fetchData())
})
@@ -214,4 +333,25 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
.homepage-form {
max-width: 720px;
}
.builder-tip {
font-size: 13px;
color: #606266;
line-height: 1.6;
margin: 0 0 12px;
max-width: 900px;
}
.homepage-builder-wrap {
max-width: 1000px;
}
.homepage-builder-wrap :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
.builder-form-item :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
</style>

View File

@@ -0,0 +1,827 @@
<template>
<div class="live-broadcast">
<el-card class="live-broadcast-card">
<template #header>
<span>官网视频直播WebRTC</span>
</template>
<p class="status">{{ status }}</p>
<p v-if="session" class="viewer-row">
<el-tag type="info" effect="plain">当前观看人数{{ viewerCount }}</el-tag>
</p>
<div class="form-block">
<div class="field-row">
<span class="field-label">画面来源</span>
<el-radio-group v-model="captureMode" :disabled="!token || switchingCapture">
<el-radio-button value="camera">仅摄像头</el-radio-button>
<el-radio-button value="screen_only">仅共享屏幕</el-radio-button>
<el-radio-button value="screen_pip">共享屏幕 + 摄像头</el-radio-button>
</el-radio-group>
</div>
<div class="field-row">
<span class="field-label">摄像头</span>
<el-select
v-model="selectedCameraId"
placeholder="默认摄像头"
clearable
filterable
style="width: 100%; max-width: 360px"
:disabled="!token || switchingCapture"
>
<el-option label="系统默认" value="" />
<el-option
v-for="d in videoInputs"
:key="d.deviceId"
:label="d.label || `摄像头 ${d.deviceId.slice(0, 8)}…`"
:value="d.deviceId"
/>
</el-select>
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
</div>
<div class="field-row">
<span class="field-label">码率策略</span>
<el-select
v-model="bitrateProfile"
style="width: 100%; max-width: 300px"
:disabled="!token || switchingCapture"
>
<el-option label="省流优先(更多并发)" value="save" />
<el-option label="均衡(推荐)" value="balanced" />
<el-option label="清晰优先(更占带宽)" value="clarity" />
</el-select>
<el-tag effect="plain" type="info">弱网建议省流/均衡</el-tag>
</div>
<p v-if="session" class="hint-live">
直播中可切换三种模式屏幕+摄像头下可拖动小窗观众画面与预览一致画布 16:9
铺满共享整屏时尽量勿把本管理页选进画面以免套娃
</p>
<div v-if="session" class="field-row">
<el-button
type="primary"
plain
:disabled="!token || switchingCapture"
:loading="switchingCapture"
@click="applyCaptureSwitch"
>
应用切换不切断直播
</el-button>
</div>
</div>
<div class="actions">
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
<el-button v-else type="danger" @click="stop">结束直播</el-button>
</div>
<div class="live-preview-layout">
<div class="live-bw-slot">
<el-card class="live-bw-panel" shadow="never">
<template #header>
<div class="live-bw-head">
<span>带宽使用情况</span>
<el-text v-if="bwUpdatedAt" type="info" size="small">{{ bwUpdatedAt }}</el-text>
</div>
</template>
<template v-if="bandwidth">
<div class="live-bw-metrics">
<div class="live-bw-line">
<span class="live-bw-label"> 60 秒出站</span>
<strong class="live-bw-strong">{{ bandwidth.recent_egress_mbps }} Mbps</strong>
<span class="live-bw-sub">{{ formatBwBytes(bandwidth.bytes_out_last_60s) }} / 60s</span>
</div>
<div class="live-bw-line">
<span class="live-bw-label"> 60 秒入站</span>
<strong class="live-bw-strong">{{ bandwidth.recent_ingress_mbps }} Mbps</strong>
<span class="live-bw-sub">{{ formatBwBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
</div>
<div class="live-bw-line live-bw-line--split">
<span class="live-bw-mini">平均出站 {{ bandwidth.avg_egress_mbps }} Mbps</span>
<span class="live-bw-mini">运行 {{ formatBwUptime(bandwidth.uptime_seconds) }}</span>
</div>
</div>
<p class="live-bw-footnote">
为本 Go 进程 HTTP 粗估前有 Nginx/CDN 时公网带宽可能更高
</p>
</template>
<el-text v-else type="info" size="small">统计加载中或暂不可用</el-text>
</el-card>
</div>
<div ref="previewWrapRef" class="preview-wrap">
<video
v-show="previewLayout !== 'screen_pip'"
ref="previewMainRef"
class="preview-main"
playsinline
muted
autoplay
></video>
<video
v-show="previewLayout === 'screen_pip'"
ref="previewScreenRef"
class="preview-main preview-main--fill"
playsinline
muted
autoplay
></video>
<video
v-show="previewLayout === 'screen_pip'"
ref="previewCamRef"
class="preview-pip-drag"
:style="pipStyle"
playsinline
muted
autoplay
title="拖动调整小窗位置(观众端同步)"
@pointerdown.prevent="onPipPointerDown"
></video>
</div>
<aside class="live-moderation-aside" aria-label="观众与发言管控">
<el-card class="moderation-card" shadow="never">
<template #header>
<div class="moderation-head">
<span>观众与发言管控</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
</div>
</template>
<div class="moderation-actions moderation-actions--stack">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<div class="moderation-inline">
<el-input v-model.trim="manualUsername" placeholder="按用户名禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualUserMute(true)">禁言用户</el-button>
<el-button @click="setManualUserMute(false)">解禁用户</el-button>
</div>
<div class="moderation-inline">
<el-input v-model.trim="manualIP" placeholder="按 IP 禁言/解禁" style="width: 100%" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言 IP</el-button>
<el-button @click="setManualIPMute(false)">解禁 IP</el-button>
</div>
</div>
<p v-if="mutedUsernames.length" class="muted-names-line">
已禁用户归一化
<el-tag v-for="u in mutedUsernames" :key="u" size="small" type="warning" class="muted-name-tag">
{{ u }}
</el-tag>
</p>
<p class="moderation-hint">
弹幕侧登记<strong>完整用户名</strong>便于对号入座未登录弹幕连接为游客 IP
{{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送会限频
</p>
<h4 class="moderation-subtitle">在线会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe size="small" class="moderation-table">
<el-table-column label="用户名" min-width="100" show-overflow-tooltip>
<template #default="{ row }">
{{ formatSessionUsername(row) }}
</template>
</el-table-column>
<el-table-column prop="channel" label="通道" width="72" />
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column label="在线" width="72">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template #default="{ row }">
<template v-if="row.username">
<el-button link type="warning" size="small" @click="toggleUserMute(row.username, true)">
禁言
</el-button>
<el-button link type="primary" size="small" @click="toggleUserMute(row.username, false)">
解禁
</el-button>
</template>
<el-button v-if="!ipMuted(row.ip)" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁IP
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解IP</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle"> IP 聚合</h4>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe size="small" class="moderation-table">
<el-table-column prop="ip" label="IP" min-width="120" show-overflow-tooltip />
<el-table-column prop="count" label="连接" width="64" />
<el-table-column label="状态" width="72">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '禁' : '常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" size="small" @click="toggleIP(row.ip, true)">
禁言
</el-button>
<el-button v-else link type="primary" size="small" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</aside>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { ElMessage } from 'element-plus'
import {
getLiveModeration,
getStats,
setLiveMuteAll,
setLiveMuteIP,
setLiveMuteUser
} from '../../api/admin'
import { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
const base = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
return base ? `${base}/api/web/live/status` : '/api/web/live/status'
}
const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '')
const previewWrapRef = ref(null)
const previewMainRef = ref(null)
const previewScreenRef = ref(null)
const previewCamRef = ref(null)
const status = ref('就绪')
const session = ref(null)
const viewerCount = ref(0)
let viewerPollTimer = null
const captureMode = ref('camera')
const selectedCameraId = ref('')
const bitrateProfile = ref('balanced')
const videoInputs = ref([])
const switchingCapture = ref(false)
const moderationLoading = ref(false)
const muteAll = ref(false)
const onlineIPs = ref([])
const onlineUsers = ref([])
const manualIP = ref('')
const manualUsername = ref('')
const mutedUsernames = ref([])
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
let moderationTimer = null
/** @type {import('vue').Ref<Record<string, unknown> | null>} */
const bandwidth = ref(null)
const bwFetchAt = ref(0)
const bwUpdatedAt = computed(() => {
if (!bwFetchAt.value) return ''
return `更新于 ${new Date(bwFetchAt.value).toLocaleTimeString()}`
})
function formatBwBytes(n) {
if (n == null || !Number.isFinite(Number(n)) || Number(n) < 0) return '—'
const v = Number(n)
if (v < 1024) return `${v} B`
const u = ['KB', 'MB', 'GB', 'TB']
let x = v
let i = -1
do {
x /= 1024
i++
} while (x >= 1024 && i < u.length - 1)
return `${x < 10 ? x.toFixed(2) : x.toFixed(1)} ${u[i]}`
}
function formatBwUptime(sec) {
if (sec == null || !Number.isFinite(Number(sec)) || Number(sec) < 0) return '—'
const s = Math.floor(Number(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h} 小时 ${m}`
if (m > 0) return `${m}${r}`
return `${r}`
}
async function fetchBandwidth() {
try {
const res = await getStats()
const bw = res?.bandwidth
if (bw && typeof bw === 'object') {
bandwidth.value = bw
bwFetchAt.value = Date.now()
}
} catch (_) {
/* 静默,不阻塞开播 */
}
}
let bwPollTimer = null
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
const previewLayout = ref('camera')
/** 与推流画布 1280×720 一致的归一化小窗矩形(左上 + 宽高0~1 */
const pipNorm = ref(defaultPipNorm())
function defaultPipNorm() {
const nw = 0.24
const nh = 0.24
return {
nx: 1 - nw - 10 / 1280,
ny: 1 - nh - 10 / 720,
nw,
nh
}
}
const pipStyle = computed(() => ({
left: `${pipNorm.value.nx * 100}%`,
top: `${pipNorm.value.ny * 100}%`,
width: `${pipNorm.value.nw * 100}%`,
height: `${pipNorm.value.nh * 100}%`
}))
let pipDragging = false
let pipDragStart = { cx: 0, cy: 0, nx: 0, ny: 0 }
function clamp01(v, lo, hi) {
return Math.min(hi, Math.max(lo, v))
}
function onPipPointerDown(e) {
if (previewLayout.value !== 'screen_pip') return
pipDragging = true
pipDragStart.cx = e.clientX
pipDragStart.cy = e.clientY
pipDragStart.nx = pipNorm.value.nx
pipDragStart.ny = pipNorm.value.ny
try {
e.target.setPointerCapture(e.pointerId)
} catch (_) {}
window.addEventListener('pointermove', onPipPointerMove)
window.addEventListener('pointerup', onPipPointerUp, { once: true })
window.addEventListener('pointercancel', onPipPointerUp, { once: true })
}
function onPipPointerMove(e) {
if (!pipDragging || !previewWrapRef.value) return
const rect = previewWrapRef.value.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return
const dx = (e.clientX - pipDragStart.cx) / rect.width
const dy = (e.clientY - pipDragStart.cy) / rect.height
const { nw, nh } = pipNorm.value
pipNorm.value = {
...pipNorm.value,
nx: clamp01(pipDragStart.nx + dx, 0, 1 - nw),
ny: clamp01(pipDragStart.ny + dy, 0, 1 - nh)
}
}
function onPipPointerUp() {
pipDragging = false
window.removeEventListener('pointermove', onPipPointerMove)
}
async function refreshVideoDevices() {
try {
if (!navigator.mediaDevices?.enumerateDevices) return
const list = await navigator.mediaDevices.enumerateDevices()
videoInputs.value = list.filter((d) => d.kind === 'videoinput')
} catch (_) {}
}
async function loadModeration() {
moderationLoading.value = true
try {
const res = await getLiveModeration()
muteAll.value = !!res.mute_all
onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : []
onlineUsers.value = Array.isArray(res.online_users) ? res.online_users : []
mutedUsernames.value = Array.isArray(res.muted_usernames) ? res.muted_usernames : []
moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 }
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
} finally {
moderationLoading.value = false
}
}
function formatSec(s) {
const v = Math.max(0, Number(s) || 0)
if (v < 60) return `${v}s`
const m = Math.floor(v / 60)
const r = v % 60
if (m < 60) return `${m}m${r}s`
const h = Math.floor(m / 60)
const mm = m % 60
return `${h}h${mm}m`
}
async function toggleMuteAll(v) {
try {
await setLiveMuteAll(!!v)
ElMessage.success(v ? '已开启全体禁言' : '已关闭全体禁言')
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
muteAll.value = !v
}
}
async function toggleIP(ip, enabled) {
try {
await setLiveMuteIP(ip, enabled)
ElMessage.success(enabled ? `已禁言 ${ip}` : `已解禁 ${ip}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualIPMute(enabled) {
if (!manualIP.value) {
ElMessage.warning('请先输入 IP')
return
}
await toggleIP(manualIP.value, enabled)
}
async function toggleUserMute(username, enabled) {
const u = (username || '').trim()
if (!u) return
try {
await setLiveMuteUser(u, enabled)
ElMessage.success(enabled ? `已禁言用户 ${u}` : `已解禁用户 ${u}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualUserMute(enabled) {
if (!manualUsername.value.trim()) {
ElMessage.warning('请先输入用户名')
return
}
await toggleUserMute(manualUsername.value.trim(), enabled)
}
function applyPreview(payload) {
const { layout, main, screen, cam } = payload || {}
previewLayout.value = layout || 'camera'
if (previewLayout.value === 'screen_pip') {
if (previewMainRef.value) previewMainRef.value.srcObject = null
if (previewScreenRef.value) previewScreenRef.value.srcObject = screen || null
if (previewCamRef.value) previewCamRef.value.srcObject = cam || null
} else {
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
if (previewCamRef.value) previewCamRef.value.srcObject = null
if (previewMainRef.value) previewMainRef.value.srcObject = main || null
}
}
function clearPreview() {
previewLayout.value = 'camera'
pipNorm.value = defaultPipNorm()
if (previewMainRef.value) previewMainRef.value.srcObject = null
if (previewScreenRef.value) previewScreenRef.value.srcObject = null
if (previewCamRef.value) previewCamRef.value.srcObject = null
}
async function fetchViewerCount() {
try {
const r = await fetch(liveStatusUrl(), { cache: 'no-store' })
if (!r.ok) return
const j = await r.json()
if (typeof j.viewers === 'number') viewerCount.value = j.viewers
} catch (_) {}
}
function startViewerPoll() {
stopViewerPoll()
fetchViewerCount()
viewerPollTimer = window.setInterval(fetchViewerCount, 2500)
}
function stopViewerPoll() {
if (viewerPollTimer != null) {
clearInterval(viewerPollTimer)
viewerPollTimer = null
}
}
watch(session, (s) => {
if (s) {
startViewerPoll()
} else {
stopViewerPoll()
viewerCount.value = 0
}
})
async function applyCaptureSwitch() {
if (!session.value?.switchMode) return
switchingCapture.value = true
try {
await session.value.switchMode(captureMode.value, selectedCameraId.value || '')
} finally {
switchingCapture.value = false
}
}
function start() {
if (!token.value) {
status.value = '请先登录'
return
}
status.value = '正在连接…'
const { stop, switchMode } = startPublishing({
token: token.value,
captureMode: captureMode.value,
videoDeviceId: selectedCameraId.value || '',
bitrateProfile: bitrateProfile.value,
onStatus: (s) => {
status.value = s
},
onLocalStream: applyPreview,
onActiveModeChange: (m) => {
captureMode.value = m
},
getPipRect: () => ({ ...pipNorm.value })
})
session.value = { stop, switchMode }
}
function stop() {
session.value?.stop()
session.value = null
clearPreview()
status.value = '已停止'
}
function onBeforeUnload() {
session.value?.stop()
}
onMounted(() => {
document.title = '视频直播开播 - 管理后台'
window.addEventListener('beforeunload', onBeforeUnload)
try {
const v = localStorage.getItem('yh_live_bitrate_profile')
bitrateProfile.value = v === 'save' || v === 'clarity' ? v : 'balanced'
} catch (_) {}
refreshVideoDevices()
loadModeration()
moderationTimer = window.setInterval(loadModeration, 5000)
fetchBandwidth()
bwPollTimer = window.setInterval(fetchBandwidth, 8000)
})
watch(bitrateProfile, (v) => {
try {
localStorage.setItem('yh_live_bitrate_profile', v)
} catch (_) {}
})
onUnmounted(() => {
stopViewerPoll()
if (moderationTimer != null) {
clearInterval(moderationTimer)
moderationTimer = null
}
if (bwPollTimer != null) {
clearInterval(bwPollTimer)
bwPollTimer = null
}
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('pointermove', onPipPointerMove)
})
onBeforeRouteLeave(() => {
stop()
})
</script>
<style scoped>
.live-broadcast {
max-width: min(1680px, 100%);
}
/* 右侧列:上为带宽卡片、下为观众管控;左侧为预览 */
.live-preview-layout {
display: grid;
grid-template-columns: 1fr clamp(280px, 34vw, 420px);
grid-template-rows: auto auto;
gap: 12px 16px;
margin-top: 4px;
width: 100%;
align-items: start;
}
.live-bw-slot {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.live-bw-panel :deep(.el-card__header) {
padding: 10px 14px;
}
.live-bw-panel :deep(.el-card__body) {
padding: 12px 14px 14px;
}
.live-bw-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
font-size: 14px;
font-weight: 600;
}
.live-bw-metrics {
display: flex;
flex-direction: column;
gap: 8px;
}
.live-bw-line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
font-size: 13px;
}
.live-bw-line--split {
justify-content: space-between;
color: #606266;
font-size: 12px;
}
.live-bw-label {
color: #606266;
min-width: 5.5em;
}
.live-bw-strong {
color: #409eff;
font-size: 15px;
}
.live-bw-sub {
color: #909399;
font-size: 12px;
}
.live-bw-mini {
font-size: 12px;
}
.live-bw-footnote {
margin: 10px 0 0;
font-size: 11px;
line-height: 1.45;
color: #909399;
}
.preview-wrap {
grid-column: 1;
grid-row: 2;
}
.live-moderation-aside {
grid-column: 2;
grid-row: 2;
width: 100%;
min-width: 0;
max-height: calc(100vh - 180px);
overflow-y: auto;
overflow-x: hidden;
position: sticky;
top: 8px;
align-self: start;
}
/* 仅小屏/手机再上下堆叠 */
@media (max-width: 768px) {
.live-preview-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.live-bw-slot {
grid-column: 1;
grid-row: 1;
}
.preview-wrap {
grid-column: 1;
grid-row: 2;
}
.live-moderation-aside {
grid-column: 1;
grid-row: 3;
max-height: none;
position: static;
overflow-y: visible;
}
}
.status {
color: #409eff;
margin-bottom: 14px;
min-height: 1.5em;
}
.viewer-row {
margin: -6px 0 14px;
}
.form-block {
margin-bottom: 16px;
}
.field-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.field-label {
font-size: 14px;
color: #606266;
min-width: 72px;
}
.hint-live {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.5;
color: #909399;
max-width: 720px;
}
.actions {
margin-bottom: 16px;
}
.preview-wrap {
position: relative;
min-width: 0;
width: 100%;
max-width: none;
}
.preview-main {
display: block;
width: 100%;
min-height: 320px;
max-height: min(85vh, 900px);
border-radius: 8px;
background: #000;
object-fit: contain;
aspect-ratio: 16 / 9;
}
.preview-main--fill {
object-fit: fill;
}
.preview-pip-drag {
position: absolute;
box-sizing: border-box;
border-radius: 8px;
border: 3px solid #409eff;
background: #000;
object-fit: cover;
cursor: grab;
touch-action: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
z-index: 2;
}
.preview-pip-drag:active {
cursor: grabbing;
}
.moderation-card {
max-width: 100%;
}
.moderation-actions--stack {
flex-direction: column;
align-items: stretch;
}
.moderation-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.muted-names-line {
margin: 0 0 8px;
font-size: 12px;
color: #606266;
line-height: 1.6;
}
.muted-name-tag {
margin: 2px 4px 2px 0;
}
.moderation-table {
width: 100%;
margin-bottom: 8px;
}
.moderation-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.moderation-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.moderation-hint {
margin: 0 0 10px;
color: #909399;
font-size: 12px;
}
.moderation-subtitle {
margin: 16px 0 10px;
font-size: 14px;
color: #303133;
}
</style>

View File

@@ -43,7 +43,8 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
import { getSites, getSiteAssets, deleteSiteAsset } from '../../api/admin'
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
const siteId = ref('')
const sites = ref([])
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
const beforeUpload = async (file) => {
uploading.value = true
try {
await uploadSiteAsset(siteId.value, file)
await uploadSiteAssetWithResume(siteId.value, file, {})
ElMessage.success('上传成功')
fetchList()
} catch (e) {

View File

@@ -17,9 +17,24 @@
<el-table-column label="ID" width="240">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column prop="slug" label="Slug" width="120" />
<el-table-column prop="title" label="标题" width="160" />
<el-table-column prop="type" label="类型" width="100">
<el-table-column prop="slug" label="Slug" width="100" />
<el-table-column label="前台路径" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.route_path || '/' + (row.slug || '') }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" width="140" />
<el-table-column label="模式" width="90">
<template #default="{ row }">
<el-tag v-if="row.content_mode === 'builder'" type="warning" size="small">积木</el-tag>
<el-tag v-else type="info" size="small">HTML</el-tag>
</template>
</el-table-column>
<el-table-column label="发布" width="70">
<template #default="{ row }">
<el-tag v-if="row.published === false" type="danger" size="small"></el-tag>
<el-tag v-else type="success" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="90">
<template #default="{ row }">
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
<el-tag v-else size="small">页面</el-tag>
@@ -35,10 +50,35 @@
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="560px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-dialog
v-model="dialogVisible"
:title="editId ? '编辑网页' : '新增网页'"
:width="form.content_mode === 'builder' || form.content_mode === 'html' ? '1080px' : '720px'"
top="4vh"
@close="resetForm"
>
<el-alert
v-if="form.content_mode === 'builder'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
title="积木模式:左侧编辑、右侧实时预览;⋮⋮ 可拖拽排序;链接可「选择链接」。"
/>
<el-alert
v-if="form.content_mode === 'html'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
title="HTML 模式:左侧源码、右侧预览(沙箱内不执行脚本,与线上可能略有差异)。"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="Slug" prop="slug">
<el-input v-model="form.slug" placeholder="如 about、index" :disabled="!!editId" />
<el-input v-model="form.slug" placeholder="如 about、indexindex 为首页数据,一般不单独走路由)" :disabled="!!editId" />
</el-form-item>
<el-form-item label="前台路径">
<el-input v-model="form.route_path" placeholder="留空则自动为 /{slug},可填如 /download 或 /about/us" />
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="页面标题" />
@@ -49,8 +89,34 @@
<el-option label="首页" value="homepage" />
</el-select>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="HTML 或 JSON" />
<el-form-item label="内容模式">
<el-radio-group v-model="form.content_mode">
<el-radio-button value="builder">积木可视化拖拽</el-radio-button>
<el-radio-button value="html">HTML 源码</el-radio-button>
</el-radio-group>
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
</el-form-item>
<el-form-item label="发布到前台">
<el-switch v-model="form.published" />
</el-form-item>
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content" class="html-form-item">
<div class="html-split">
<div class="html-editor">
<el-input v-model="form.content" type="textarea" :rows="18" placeholder="直接编写 HTML" />
</div>
<div class="html-preview-wrap">
<div class="html-preview-title">实时预览</div>
<iframe
class="html-preview-iframe"
title="html-preview"
sandbox=""
:srcdoc="htmlPreviewSrcdoc"
/>
</div>
</div>
</el-form-item>
<el-form-item v-else label="页面积木" class="builder-form-item page-builder-wrap">
<PageBuilderEditor v-model="form.content" :site-id="siteId" />
</el-form-item>
</el-form>
<template #footer>
@@ -62,10 +128,11 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites } from '../../api/admin'
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
const siteId = ref('')
const sites = ref([])
@@ -107,7 +174,87 @@ const dialogVisible = ref(false)
const editId = ref('')
const submitting = ref(false)
const formRef = ref(null)
const form = reactive({ site_id: '', slug: '', title: '', type: 'page', content: '' })
const builderTemplate = () =>
JSON.stringify(
{
version: 1,
blocks: [
{
id: 'h1',
type: 'heading',
props: { text: '页面标题', level: 2 },
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
},
{
id: 't1',
type: 'text',
props: { text: '在此编辑说明文字,可在后台修改 JSON 调整模块与动画。' },
animation: { enter: 'slideUp', delay_ms: 100, duration_ms: 500 }
},
{
id: 'links',
type: 'link_list',
props: {
items: [
{ label: '回首页', url: '/' },
{ label: '示例外链', url: '#', target: '_blank' }
]
}
},
{
id: 'btn',
type: 'button',
props: { text: '主要按钮', url: '#', variant: 'primary' }
},
{ id: 'sp', type: 'spacer', props: { height: 24 } },
{
id: 'sec',
type: 'section',
props: { padding: '24px 0', maxWidth: '720px' },
children: [
{
id: 'sub',
type: 'text',
props: { html: '<p>区块内可嵌套子模块(<strong>section → children</strong>)。</p>' }
}
]
}
]
},
null,
2
)
const form = reactive({
site_id: '',
slug: '',
title: '',
type: 'page',
content: '',
content_mode: 'builder',
route_path: '',
published: true
})
/** HTML 预览 iframe沙箱禁用脚本避免编辑时执行恶意片段 */
const htmlPreviewSrcdoc = computed(() => {
const raw = form.content || ''
const body = raw.trim() ? raw : '<p style="color:#999">暂无内容</p>'
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:system-ui,sans-serif;padding:12px;margin:0;background:#fff;color:#222;line-height:1.5;}</style></head><body>${body}</body></html>`
})
function insertBuilderTemplate() {
form.content_mode = 'builder'
if (!form.content?.trim()) {
form.content = builderTemplate()
} else {
ElMessageBox.confirm('将用模板覆盖当前内容?', '提示', { type: 'warning' })
.then(() => {
form.content = builderTemplate()
})
.catch(() => {})
}
}
const rules = {
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
@@ -120,6 +267,9 @@ const openDialog = (row) => {
form.title = row ? row.title : ''
form.type = row ? row.type || 'page' : 'page'
form.content = row ? row.content || '' : ''
form.content_mode = row?.content_mode || 'builder'
form.route_path = row?.route_path || ''
form.published = row?.published !== false
dialogVisible.value = true
}
@@ -128,6 +278,9 @@ const resetForm = () => {
form.title = ''
form.type = 'page'
form.content = ''
form.content_mode = 'builder'
form.route_path = ''
form.published = true
editId.value = ''
}
@@ -135,11 +288,20 @@ const submitForm = async () => {
await formRef.value?.validate()
submitting.value = true
try {
const payload = {
slug: form.slug,
title: form.title,
type: form.type,
content: form.content,
content_mode: form.content_mode,
route_path: form.route_path || undefined,
published: form.published
}
if (editId.value) {
await updatePage(editId.value, { slug: form.slug, title: form.title, type: form.type, content: form.content })
await updatePage(editId.value, payload)
ElMessage.success('更新成功')
} else {
await createPage({ ...form, site_id: siteId.value })
await createPage({ ...payload, site_id: siteId.value })
ElMessage.success('创建成功')
}
dialogVisible.value = false
@@ -173,4 +335,50 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
.builder-form-item :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
.page-builder-wrap {
max-width: 100%;
}
.html-form-item :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
.html-split {
display: flex;
gap: 16px;
flex-wrap: wrap;
width: 100%;
}
.html-editor {
flex: 1 1 400px;
min-width: 280px;
}
.html-preview-wrap {
flex: 0 1 420px;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
border: 1px solid #dcdfe6;
border-radius: 8px;
overflow: hidden;
background: #fafafa;
}
.html-preview-title {
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid #ebeef5;
background: #fff;
}
.html-preview-iframe {
width: 100%;
min-height: 360px;
height: 420px;
border: none;
background: #fff;
}
</style>

View File

@@ -1,16 +1,29 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [vue()],
base: '/admin/',
resolve: {
alias: {
// 与前台共用积木渲染,避免重复维护
'@yh-web': path.resolve(__dirname, '../web/src')
}
},
server: {
port: 3000,
host: true,
// 开发时 /api 也走域名(与 .env.development 中 VITE_API_BASE 一致)
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
target: 'https://yuheng.yuxindazhineng.com',
changeOrigin: true,
secure: true,
ws: true
}
}
}

19
deploy/README.md Normal file
View File

@@ -0,0 +1,19 @@
# deploy 目录(与 api 相同:仅替换构建产物;`web` 容器除 `web/public` 外不挂源码目录)
- **deploy/web/dist**:前台构建产物,由 `pull-and-restart.sh` 生成;替换此目录内容即可更新前台。
- **deploy/admin/dist**:后台构建产物,同上。后台 Vite 通过 `@yh-web` 引用 `../web/src`(如积木 `BlockRenderer`),用 Docker 单目录挂载 `admin` 时会构建失败,须挂载**项目根**再在 `admin` 下执行 `npm run build`(见 `pull-and-restart.sh`)。
- **deploy/api/server**API 二进制,同上;替换后重启 api 容器生效。
- **deploy/web/default.conf**、**deploy/admin/default.conf**Nginx 配置,已纳入版本库。
日常更新:在服务器执行 `./pull-and-restart.sh` 会拉代码、重新构建到上述目录并重启容器。若只改静态资源,也可在服务器上手动构建后只重启对应容器。
## 后台白屏 / 控制台 “MIME type text/html” 针对 `index-*.js`
表示浏览器拿到的不是 JS而是 HTML常见`/assets/*.js` 被 SPA 回退成 `index.html`,或 404 返回了 HTML 错误页)。
1. **确认 Nginx 配置已更新**`deploy/admin/default.conf` 须含 `location ^~ /assets/``try_files $uri =404`(与仓库内 `admin/nginx.conf` 一致),挂载后重启 `admin` 容器。
2. **确认 dist 完整**`deploy/admin/dist/assets/` 下须有与 `index.html``<script type="module">` 引用**同名**的哈希文件;发版后应**整目录**替换 `dist`(勿只拷 `index.html`)。
3. **本地重建**:在项目根按 `pull-and-restart.sh` 方式在 `admin/` 执行 `npm run build``vite.config``base` 须为 `'/admin/'`
4. **勿用旧版 `nginx/admin.conf`**:若曾把仅含 `location /` 的旧配置拷到服务器,会导致 `/assets/*.js` 全部变成 `index.html`(约 640B、MIME 错)。请以 **`deploy/admin/default.conf`** 或 **`admin/nginx.conf`** 为准,并 **`docker compose restart admin nginx`**。
5. **外层 `/admin/` 反代**`yuheng.docker.conf.tpl` 使用 **`upstream yh_admin_upstream { server admin:80; }`** + **`location /admin/ { proxy_pass http://yh_admin_upstream/; }`**(尾斜杠),由 Nginx **标准规则**去掉 `/admin` 前缀;勿对 admin 使用 **变量** `proxy_pass`(会把完整 `/admin/...` 传到上游 → 内层无法匹配 `/assets/` → 白屏)。另含 **`location = /admin { return 301 /admin/; }`**,避免无尾斜杠误走前台。
6. **`restart.sh` 构建 admin** 已与 **`pull-and-restart.sh` 一致**(挂载**项目根**到容器,否则 `@yh-web` 无法解析)。发版后脚本会执行 **`scripts/verify-admin-dist.sh`**:若 `index.html` 引用的 chunk 在 `dist/assets/` 中缺失或过小(几百字节),会直接报错退出,避免白屏上线。

24
deploy/admin/default.conf Normal file
View File

@@ -0,0 +1,24 @@
# 与 admin/nginx.conf 保持内容一致Compose 挂载本文件admin 镜像内 COPY 同配置)
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# 发版后勿长期缓存入口,否则浏览器保留旧 index.html、却拉新 chunk 名 → 白屏
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
}
location ^~ /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

59
deploy/web/default.conf Normal file
View File

@@ -0,0 +1,59 @@
# 与 nginx/web.conf 保持同步compose 挂载到 web 容器
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# 域名/微信等验证文件:由外层 yh_nginx443直接 root /verify-root 提供,本容器不再挂载 verify-root
# 静态资源必须真实存在,避免错误回退成 index.html 导致白屏
location ^~ /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public, immutable";
}
# SPA 入口:勿长期缓存,否则发版后用户仍可能拿到旧 index
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
}
# web/public 挂载到 /var/www/yh-public与 dist 根目录同 URL如 /logo.png优先读挂载无则回退 dist
location ~ ^/([^/]+\.(?:png|jpe?g|gif|ico|svg|webp|webmanifest))$ {
root /var/www/yh-public;
try_files /$1 @dist_root_public;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location @dist_root_public {
root /usr/share/nginx/html;
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# web/public/social/ → 关注我们二维码等(挂载 /var/www/yh-public
location ^~ /social/ {
alias /var/www/yh-public/social/;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# 推广素材:来自构建产物 deploy/web/dist/promotionpull-and-restart 从 web/promotion rsync后台上传走 /api/web/.../promotion-media/
location ^~ /promotion/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location = / {
try_files /index.html =404;
}
# Vue SPA直接访问 /test 等路径须落到 index.html否则会 nginx 404
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,20 @@
# 与 docker-compose.yml 合并使用:宿主机 Nginx 独占 443 时,不要启动容器 yh_nginx。
# 并为 api / web / admin 绑定本机回环端口,供宿主机 Nginx 反代(见 nginx/yuheng.host.conf
#
# docker compose -f docker-compose.yml -f docker-compose.host-nginx.yml up -d
#
# 容器内 Nginx旧方案需显式启用 profile
# docker compose --profile compose-internal-nginx up -d
services:
api:
ports:
- "127.0.0.1:8088:8088"
web:
ports:
- "127.0.0.1:9080:80"
admin:
ports:
- "127.0.0.1:9081:80"
nginx:
profiles:
- compose-internal-nginx

View File

@@ -1,16 +1,24 @@
# 域名与 HTTPS 由 Nginx Proxy Manager 统一配置: https://npm.yuxindazhineng.com/nginx/proxy
# 本 compose 只暴露 9527(api)、9528(web)、9529(admin),由 NPM 反向代理到对外域名
version: "3.8"
# 对外仅暴露 443HTTPS内部 api/web/admin 不映射宿主机端口
# version 已废弃,已移除
services:
# 二进制由脚本构建到 deploy/api/server挂载 deploy/api 即可更新,无需重建镜像
api:
build:
context: .
dockerfile: server/Dockerfile
image: yh_web-api:latest
dockerfile: server/Dockerfile.run
args:
REGISTRY_MIRROR: ${REGISTRY_MIRROR:-}
image: yh_web-api-run:latest
container_name: yh_api
volumes:
- ./deploy/api:/app:ro
- ./data/uploads:/uploads
env_file:
- ./server/.env
environment:
- PORT=9527
- PORT=8088
- UPLOAD_DIR=/uploads
- MONGODB_URI=${MONGODB_URI:-mongodb://mongo:27017}
- MONGODB_DB=${MONGODB_DB:-yxd-agent-testing}
- GIN_MODE=release
@@ -19,33 +27,54 @@ services:
- mongo
networks:
- yh_net
ports:
- "9527:9527"
# 静态文件仅 deploy/web/dist与 api 一致不挂源码目录。仅额外挂载 web/publiclogo、social 二维码等)
web:
build:
context: ./web
dockerfile: Dockerfile
image: yh_web-web:latest
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
container_name: yh_web
volumes:
- ./deploy/web/dist:/usr/share/nginx/html:ro
- ./web/public:/var/www/yh-public:ro
- ./deploy/web/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- yh_net
ports:
- "9528:80"
# 静态文件由脚本构建到 deploy/admin/dist挂载后替换文件即可生效
admin:
build:
context: ./admin
dockerfile: Dockerfile
image: yh_web-admin:latest
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
container_name: yh_admin
volumes:
- ./deploy/admin/dist:/usr/share/nginx/html:ro
- ./deploy/admin/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- yh_net
nginx:
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
container_name: yh_nginx
ports:
- "9529:80"
- "443:443"
# 启动脚本:等上游 → 从 resolv.conf 注入 resolver → 生成 conf.d变量 proxy_pass避免 Podman host not found
entrypoint: ["/bin/sh", "/nginx-entrypoint-wait-dns.sh"]
volumes:
- ./scripts/nginx-entrypoint-wait-dns.sh:/nginx-entrypoint-wait-dns.sh:ro
- ./nginx/yuheng.docker.conf.tpl:/yuheng.docker.conf.tpl:ro
- ./nginx/runtime-confd:/etc/nginx/conf.d
- ./verify-root:/verify-root:ro
- /etc/ssl/yh_web/yuheng.yuxindazhineng.com:/etc/ssl/yh_web/yuheng.yuxindazhineng.com:ro
depends_on:
- api
- web
- admin
# Podman/慢盘API 首次就绪可能超过 90s避免 yh_nginx 等待超时后 Exited(1) → 全站 443 拒绝连接
environment:
- NGINX_WAIT_UPSTREAM_SEC=180
networks:
- yh_net
mongo:
image: mongo:7
# 国内默认走镜像;海外可 export REGISTRY_MIRROR= 后直连
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}mongo:7
container_name: yh_mongo
volumes:
- mongo_data:/data/db

67
docs/PAGE_BUILDER.md Normal file
View File

@@ -0,0 +1,67 @@
# 前台积木页面(动态路由)
## 概念
-**网页管理** 中创建页面,设置 **前台路径**`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
- **HTML**`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
- **积木builder**:后台使用 **可视化编辑器**(添加模块、**按住左侧手柄拖拽**调整顺序、配置动画);编辑区右侧为 **实时预览**(与前台共用 `BlockRenderer`)。链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
- **HTML 模式**:网页管理弹窗内 **左侧源码、右侧 iframe 预览**(沙箱不执行脚本)。
- 存储仍为 JSON结构如下前台按模块渲染并支持入场动画。
## 动态路由
- 前台启动时请求 `GET /api/web/routes`,按已发布页面注册 Vue Router。
- `slug``index` 的页面不参与动态路由(仍由首页 `Home.vue` + 首页数据驱动)。
- 单页数据:`GET /api/web/page?path=/your-path``site_id` 可选,默认官网站点)。
## 积木 JSON 结构
```json
{
"version": 1,
"blocks": [
{
"id": "唯一可选",
"type": "heading",
"props": { "text": "标题", "level": 2 },
"animation": { "enter": "fadeIn", "delay_ms": 0, "duration_ms": 600 }
}
]
}
```
### 模块类型 `type`
| type | props 说明 |
|------|------------|
| `heading` | `text`, `level` (16) |
| `text` | `text` 纯文本;或 `html: true` 时用 `html` / `text` 作为 HTML |
| `link_list` | `items: [{ label, url, target? }]` |
| `button` | `text`, `url`, `variant`: `primary` \| `ghost`, `target?` |
| `html` | `html` 原始 HTML 片段 |
| `spacer` | `height` 像素 |
| `divider` | 无 |
| `section` | `padding`, `maxWidth`, `background``children` 为子 `blocks` 数组 |
### 动画 `animation.enter`
- `none` | `fadeIn` | `slideUp` | `slideLeft` | `zoomIn`
- `delay_ms``duration_ms` 控制延迟与时长(毫秒)
## 扩展新模块
1.`web/src/components/blocks/BlockRenderer.vue` 增加 `type` 分支与样式。
2. 后台仍通过 JSON 配置 `props`,无需改库表结构。
## 首页编辑:下方扩展积木
- 除原有导航、主视觉、特性等表单项外,可增加 **「首页下方扩展区」**:与网页积木相同 JSON保存后由前台 `Home.vue` 在特性区之后、页脚之前用 **同一套 BlockRenderer** 动态渲染。
- 下载的静态 `index.html` 目前**不包含**该积木区(仅在线 SPA 展示)。
## 首页编辑(导航 / 下载 / 平台链接)
- **管理后台 → 首页编辑与下载**:导航链接、下载按钮链接、各平台链接均可点 **「选择链接」**,与积木编辑器共用 `LinkPickerDialog`
- **本站页面**:来自当前站点「网页管理」中的页面路径(含首页 `/`)。
- **其他站点首页**:同账号下其他站点;需在 **站点管理** 中填写 **域名**,系统会生成 `https://域名/` 形式的外链。
- **可下载文件**:与积木一致,填入 `/api/web/sites/{siteId}/assets/{id}/download`
- **试跳**:保存前可在后台用 **「试跳」** 新标签页预览;以 `/` 开头的路径会按当前浏览器域名拼接(与前台实际域名不一致时请以真实站点为准)。

96
nginx/README.md Normal file
View File

@@ -0,0 +1,96 @@
# Nginx 配置(新服务器无 NPM 时使用)
域名:**yuheng.yuxindazhineng.com**,强制 HTTPSSSL 证书按域名单独存放。
## 1. 证书目录(按域名命名)
在服务器上创建专门存放 SSL 的目录,以域名为子目录名:
```bash
sudo mkdir -p /etc/ssl/yh_web/yuheng.yuxindazhineng.com
```
将证书文件放入该目录Let's Encrypt 或自有证书均可):
- **fullchain.pem** — 证书链(或你的 `fullchain.crt`,需在配置里改扩展名)
- **privkey.pem** — 私钥(或你的 `privkey.key`
**一键脚本自动同步**:也可把证书放在项目 **`nginx/`** 下,运行 `./pull-and-restart.sh``./restart.sh` 会自动复制到系统目录。支持两种命名方式:
- **`nginx/yuheng.yuxindazhineng.com.pem`** + **`nginx/yuheng.yuxindazhineng.com.key`**(按域名命名)
- **`nginx/fullchain.pem`** + **`nginx/privkey.pem`**
示例(若用 certbot
```bash
# certbot 默认路径,可复制到统一目录或做软链接
sudo cp /etc/letsencrypt/live/yuheng.yuxindazhineng.com/fullchain.pem /etc/ssl/yh_web/yuheng.yuxindazhineng.com/
sudo cp /etc/letsencrypt/live/yuheng.yuxindazhineng.com/privkey.pem /etc/ssl/yh_web/yuheng.yuxindazhineng.com/
sudo chown -R root:root /etc/ssl/yh_web/yuheng.yuxindazhineng.com
sudo chmod 600 /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem
```
## 2. 部署 Nginx 配置
```bash
# 复制项目内配置到 Nginx 配置目录(按实际路径调整)
sudo cp /www/yh_web/nginx/yuheng.yuxindazhineng.com.conf /etc/nginx/conf.d/
# 检查配置
sudo nginx -t
# 重载
sudo systemctl reload nginx
```
若 Nginx 使用其他路径(如 `sites-enabled`),请把上述 conf 放到对应目录并 `include` 到主配置。
## 3. 两种部署方式(二选一)
**方式 A仅 compose 占 443默认**
- `docker-compose.yml` 中 nginx 映射 `443:443`,请求直接进 compose 内 Nginx再反代到 api/web/admin。
- 宿主机**不要**为本站点单独起 Nginx不要用本目录的 `yuheng.yuxindazhineng.com.conf` 占 443否则会与 compose 抢 443 或反代到已废弃的 9528/9529/8088导致 /api/、/admin/ 404。
**方式 B宿主机 Nginx 占 443反代到 compose**
- 若宿主机已有 Nginx 监听 443多站点则把 compose 中 nginx 端口改为 **8443:443**,宿主机用本目录的 `yuheng.yuxindazhineng.com.conf`(已配置为整站反代到 `127.0.0.1:8443`)。
- 复制 conf 到 `/etc/nginx/conf.d/``nginx -t && systemctl reload nginx`
**/api/health 或 /admin/ 返回 404 时**:在服务器执行 `ss -tlnp | grep 443`,看 443 是宿主机 nginx 还是 docker。若是宿主机 nginx要么停用该站点配置让 compose 独占 443方式 A要么改为方式 Bcompose 用 8443宿主机反代到 8443
**验证文件热加载**:把验证文件放到项目根目录的 `verify-root/` 即可compose 内 **`yh_nginx`** 挂载该目录并在 **443** 上直接 `root /verify-root` 提供(见 `nginx/yuheng.docker.conf.tpl`)。`reload` 后生效;若仅改文件,可 `docker compose restart nginx`
## 4. 新服务器首次安装 Nginx
```bash
# CentOS / RHEL / 阿里云
sudo dnf install -y nginx
# 或
sudo yum install -y nginx
# 开机自启并启动
sudo systemctl enable nginx
sudo systemctl start nginx
```
然后再按上面步骤创建证书目录、放入证书、复制 conf 并重载。
## 5. 前台动态路由与 404SPA
- **现象**:浏览器直接打开 `https://你的域名/some-page` 出现 **nginx**`404 Not Found`(页脚带 `nginx/x.x.x`),而不是网站自己的页面。
- **原因**:提供静态文件的 `server` 未把「不存在的路径」交给 `index.html`Nginx 在磁盘上找不到 `some-page` 文件就返回 404。
- **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf``web/Dockerfile` 内嵌配置)。若你自建 Nginx请对照修改后再 `nginx -t` 并重载。
- **应用内 404**:在 SPA 已正确回退的前提下,未在后台发布的路径会由前端路由进入 **「页面不存在」** 页(`NotFound.vue`),与上述 nginx 404 不同。
- **Compose 部署**`web` 容器实际加载的是 **`deploy/web/default.conf`**(见 `docker-compose.yml` 挂载)。若线上仍对 `/test` 等返回 **nginx 404**,请把仓库里最新的 `deploy/web/default.conf` 同步到服务器对应路径后,执行 `docker compose restart web`(或重建 `yh_web` 容器)。
## 6. 单实例:宿主机 Nginx 占 443与 `pull-and-restart.sh` / `restart.sh` 自动切换)
逻辑由 **`scripts/lib-yh-compose-deploy.sh`** 统一处理(无需单独启动脚本):
1. **启动前**`docker compose … down --remove-orphans`,只停本项目容器,**不停止**宿主机 `nginx`
2. **写入宿主机站点配置**:从 **`nginx/yuheng.host.conf`** 生成 `/etc/nginx/conf.d/<域名>.conf`,并执行 **`nginx -t`**。
3. **检测宿主机 Nginx**:若在线则跳过;若不在线则执行 `systemctl start nginx``enable`
4. **启动容器**:只起 `mongo api web admin`(不再启动容器 `yh_nginx`)。
5. **证书**:同上,`/etc/ssl/yh_web/yuheng.yuxindazhineng.com/``pull-and-restart.sh` / `restart.sh` 仍会同步仓库内证书到该目录。
**回环端口**(与 `nginx/yuheng.host.conf` 一致API `127.0.0.1:8088`,前台 `9080`,后台 `9081`
**说明**:不再使用容器 `yh_nginx` 作为入口,统一为宿主机 Nginx 单入口方案。

23
nginx/admin.conf Normal file
View File

@@ -0,0 +1,23 @@
# 与 deploy/admin/default.conf、admin/nginx.conf 保持一致(勿再使用仅含 location / 的旧版,否则 /assets/*.js 会回退成 index.html → 白屏)
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
}
location ^~ /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,11 +1,37 @@
server {
listen 80;
server_name yuheng.yuxindazhineng.com;
# 与宿主机 yuheng.host.conf 一致,避免后台大文件上传被默认 1m 拒绝
client_max_body_size 800m;
# 若使用 HTTPS取消下面注释并挂载证书到 /etc/nginx/ssl/
# listen 443 ssl;
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location /api/web/live/ws {
proxy_pass http://api:9527;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /api/web/live/danmaku/ws {
proxy_pass http://api:9527;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /api/ {
proxy_pass http://api:9527;
proxy_http_version 1.1;
@@ -13,6 +39,12 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
client_body_timeout 0;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_request_buffering off;
}
location /admin/ {
@@ -31,5 +63,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
}
}

View File

52
nginx/web.conf Normal file
View File

@@ -0,0 +1,52 @@
# 供 compose 中 web 容器使用:与 deploy/web/default.conf 同步;验证文件仅外层 yh_nginx 处理
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location ^~ /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires -1;
}
# web/public 挂载 /var/www/yh-public单段文件名同 dist 根 URL优先挂载后回退 dist
location ~ ^/([^/]+\.(?:png|jpe?g|gif|ico|svg|webp|webmanifest))$ {
root /var/www/yh-public;
try_files /$1 @dist_root_public;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location @dist_root_public {
root /usr/share/nginx/html;
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location ^~ /social/ {
alias /var/www/yh-public/social/;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location ^~ /promotion/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
location = / {
try_files /index.html =404;
}
# 前台为 Vue SPA任意路径须回退到 index.html否则直接访问 /xxx 会得到 nginx 404
location / {
try_files $uri $uri/ /index.html;
}
}

5
nginx/yuheng.docker.conf Normal file
View File

@@ -0,0 +1,5 @@
# 已迁移:实际运行使用 yuheng.docker.conf.tpl
# 启动时由 scripts/nginx-entrypoint-wait-dns.sh 将 resolv.conf 中的 nameserver 注入为 resolver
# 并配合变量 proxy_pass避免 Docker/Podman 下「upstream OK」后仍出现 host not found in upstream "api"。
#
# 修改 HTTPS 反代请编辑nginx/yuheng.docker.conf.tpl

View File

@@ -0,0 +1,101 @@
# 由 scripts/nginx-entrypoint-wait-dns.sh 在启动时 sed 替换 @@NGINX_RESOLVER@@(来自容器 /etc/resolv.conf
# 再写入 /etc/nginx/conf.d/default.conf。web/api 仍用变量 proxy_pass + resolverPodman 下动态解析)。
# admin 使用 upstream + proxy_pass …/ 可正确去掉 /admin 前缀;勿用变量 proxy_pass否则会把 /admin/assets/… 原样传到上游 → 白屏。
upstream yh_admin_upstream {
server admin:80;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
# http2 HTTP/1.1 multipart HTTP/2
server_name yuheng.yuxindazhineng.com;
client_max_body_size 800m;
resolver @@NGINX_RESOLVER@@ valid=300s ipv6=off;
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
location ~ ^/[A-Za-z0-9._-]+\.(txt|html|xml)$ {
root /verify-root;
try_files $uri =404;
default_type text/plain;
add_header Cache-Control "no-store";
}
# location / web
location = /admin {
return 301 /admin/;
}
# WebRTC WebSocket Upgrade
location /api/web/live/ws {
set $upstream_api api;
proxy_pass http://$upstream_api:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/web/live/danmaku/ws {
set $upstream_api api;
proxy_pass http://$upstream_api:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/ {
set $upstream_api api;
proxy_pass http://$upstream_api:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
client_body_timeout 0;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_request_buffering off;
}
# proxy_pass / /admin /assets//index.html
location /admin/ {
proxy_pass http://yh_admin_upstream/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
set $upstream_web web;
proxy_pass http://$upstream_web:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

109
nginx/yuheng.host.conf Normal file
View File

@@ -0,0 +1,109 @@
# 宿主机 Nginx 单实例443 终止 TLS反代到本机回环上的 Docker 服务(见 docker-compose.host-nginx.yml
# 部署:
# 1. 证书:/etc/ssl/yh_web/yuheng.yuxindazhineng.com/{fullchain.pem,privkey.pem}
# 2. 替换下方 __VERIFY_ROOT__ 为项目内 verify-root 的绝对路径(或由 pull-and-restart.sh / restart.sh 自动生成)
# 3. sudo cp yuheng.host.conf /etc/nginx/conf.d/yuheng.yuxindazhineng.com.conf
# 4. sudo nginx -t && sudo systemctl reload nginx
# HTTP → HTTPS
server {
listen 80;
listen [::]:80;
server_name yuheng.yuxindazhineng.com;
return 301 https://$server_name$request_uri;
}
upstream yh_admin_upstream {
server 127.0.0.1:9081;
keepalive 8;
}
server {
# 关闭 http2大体积分片 multipart 在部分浏览器+HTTP/2 组合下易出现 ERR_HTTP2_PROTOCOL_ERROR / 网络面板 status 0
listen 443 ssl;
listen [::]:443 ssl;
server_name yuheng.yuxindazhineng.com;
client_max_body_size 800m;
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# 域名/证书等验证文件(与 compose 内 yh_nginx 行为一致)
location ~ ^/[A-Za-z0-9._-]+\.(txt|html|xml)$ {
root __VERIFY_ROOT__;
try_files $uri =404;
default_type text/plain;
add_header Cache-Control "no-store";
}
location = /admin {
return 301 /admin/;
}
location /api/web/live/ws {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/web/live/danmaku/ws {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/ {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
# 大文件上传client_body_timeout=0 表示不按时间切断读 body见 ngx_http_core_moduleproxy_* 为反代到 Go 的读写等待上限
client_body_timeout 0;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
proxy_buffering off;
proxy_request_buffering off;
}
location /admin/ {
proxy_pass http://yh_admin_upstream/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:9080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
}
}

View File

@@ -0,0 +1,65 @@
# yh_web 宿主机 Nginx仅在做「宿主机 443 → compose 内 Nginx」时使用
# 证书路径:/etc/ssl/yh_web/yuheng.yuxindazhineng.com/
# 使用本配置时compose 中 nginx 须改为映射 8443:443避免与宿主机 443 冲突),本文件反代到 127.0.0.1:8443
# 部署:复制到 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
# HTTP → HTTPS 强制跳转
server {
listen 80;
listen [::]:80;
server_name yuheng.yuxindazhineng.com;
return 301 https://$server_name$request_uri;
}
# HTTPS整站反代到 compose 内 Nginx宿主机 443 → 127.0.0.1:8443
server {
# 与 yuheng.host.conf 一致:大文件/分片上传在 HTTP/1.1 下更稳
listen 443 ssl;
listen [::]:443 ssl;
server_name yuheng.yuxindazhineng.com;
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# 直播 WebSocket 信令(经 compose 内 Nginx 再到 api
location /api/web/live/ws {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /api/web/live/danmaku/ws {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location / {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
proxy_buffering off;
}
}

View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA188iiRdYOJhtpDOpdnvcASNh37gYBih+dxDZ1NBdWWEWvb02
9kEfwoAeBCL5vp+PQ1IroBNIc37ZpDbDzCsYjboSlD29x2gskem5tj2av5UkTLpb
3LMLfzwRBOGjGL4Eps2iLEzIKEAz5N+GY+xRHOQgSSTOia6zg4uwTANom7eiRsj+
cLlkambAhor4ZyqQ0mjgAF4LhCfutj909cvrCvWK9AgD1SpCu2TF09gQ3i6pGhzZ
YZVCydCitypQ60xBix/VszVAdHBo73l1gluF71cu4+lrCsjzw3MpoeO0pD1i0cUb
kAzF3ypSmgrv0+3adtazm6rY9PefqB4fFHDtAwIDAQABAoIBABiBivhqUDhNBtZI
j4vG0NrIO8r9yqyYWJQIs9O4vYDyx3RQUjdwebzKc54gop+E2u3YHOAWkHmdA/Xj
yiQbGLSvVoDC6hQEvlrrYY1SPYpX00FrQBc1ta6DEaOuQ6kBmuGeJDZHmcsIT1xE
Day3HxbayNfFeDamQfhEGobnNC/KWxyy5b7tHLZdBueCRjx7u0uoxREPRtzsLBIr
i19AoKgLH+RRwtLSU2b3yvLmExHkw3McUxB+tvbscBy3LJOc1Lh0Hqd1Tyyoscyz
yAQvWl2y8VIWSVNKyq2MBKPRUSuMb0G2wHknuynVVzTSE/MGSyWJbh0FRt2AJ6X1
LvW5R0ECgYEA76rU+zf5GYabFCXj3OMmYVoRfgGlQo6kvJBE8f4nB476sogONHqv
nxECpVh4TUYUr48d0Vvgk+2kTHNsc9PSn+hP94qs+SJetfy2LleoVQkIZlvIpH31
wKSZbR28j3NpdH9+/ptBH82eNxI+ta4bjNtNV8dumEAMfr4IdRGowZMCgYEA5oQU
ZseMOW7YxTIJFeq74rYwavHYOonxykAioxGmZ8LZniscTr+kdhOJEd9w5WAa5Ena
AvjHe9Ln6lk7PT7HkaFoOAYiXa5myYj2/Wpt5EpdZwIwwdFGk37wOiX4DZAgrr8L
WkJTGjc7TVXOQ73buQIYeNi6bvtDs6h9p/wIDNECgYEA2fs/gUo0dyH1dIrNx76V
zt+Tn06x12pTrOluu8bUCszheXXDrbmUeBGJnYdsy6Oc9twtW5i8Fu+CisJEdsjG
/gfWi6gGkQXQrKcvr9CsWsM/b5G1WN7zoQZUQWlVcgefd4TqpXnh7qIeb6pZfPbh
OejQXLEYBsPiWXhPyuKH4Z8CgYEAhLdel54j2Z08KKyaFohDDFAgqDH9cBajovIx
/vjWeb7xU+M2NRCZO3Ib5LJkaWtfkDgE0Nky4NOYupANT0Gp3Oq0+ixt9MnIXBgD
O/vesSUviXL1Z2F55MmcvZ3GpuhoKLPNcXXmKp3KAsh4LQBOVMIkHM+K5wK7A+Dq
F6E/cUECgYAtesYx/R0pW6n/rUVvOELfHnXpxyCq0ZA/vnGcDp10yQbcoNXUM0Sf
UMK5FRcD3q6Ghbm87aHdqKcyDY1Wxaj6aTR/QlpDY6WW/DdQ+KE4P5ts3i8GjRH6
tBVCm7wrCkRtE14zKo1B1Oy6xrMDsPVmi8ITcFzIVMX/ajovPW+80Q==
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,62 @@
-----BEGIN CERTIFICATE-----
MIIGKDCCBRCgAwIBAgIQDVgsPajfGvmIkXPM4ij1tTANBgkqhkiG9w0BAQsFADBu
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
RFYgVExTIENBIC0gRzIwHhcNMjYwMzE3MDAwMDAwWhcNMjYwNjE0MjM1OTU5WjAk
MSIwIAYDVQQDExl5dWhlbmcueXV4aW5kYXpoaW5lbmcuY29tMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA188iiRdYOJhtpDOpdnvcASNh37gYBih+dxDZ
1NBdWWEWvb029kEfwoAeBCL5vp+PQ1IroBNIc37ZpDbDzCsYjboSlD29x2gskem5
tj2av5UkTLpb3LMLfzwRBOGjGL4Eps2iLEzIKEAz5N+GY+xRHOQgSSTOia6zg4uw
TANom7eiRsj+cLlkambAhor4ZyqQ0mjgAF4LhCfutj909cvrCvWK9AgD1SpCu2TF
09gQ3i6pGhzZYZVCydCitypQ60xBix/VszVAdHBo73l1gluF71cu4+lrCsjzw3Mp
oeO0pD1i0cUbkAzF3ypSmgrv0+3adtazm6rY9PefqB4fFHDtAwIDAQABo4IDCjCC
AwYwHwYDVR0jBBgwFoAUeN+RkF/u3qz2xXXr1UxVU+8kSrYwHQYDVR0OBBYEFGdl
14ALpI+hvS6aG1IwkK3pUnGTMEMGA1UdEQQ8MDqCGXl1aGVuZy55dXhpbmRhemhp
bmVuZy5jb22CHXd3dy55dWhlbmcueXV4aW5kYXpoaW5lbmcuY29tMD4GA1UdIAQ3
MDUwMwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQu
Y29tL0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG
AQUFBwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw
LmRpZ2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNl
cnQuY29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0T
AQH/BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAZBHEbKQS7KeJHKIC
LgC8q08oB9QeNSer6v7VA8l9zfAAAAGc+poK0AAABAMARjBEAiBLVUb3SHyMsb5q
F+Q8hCDcZUQ2OZ1mgW/CAJDQhgPkrgIgRtWBs7dFvHVp2vYXogcZu7G3Nh7knysX
zviq4/3HsIkAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVOIQAAAZz6
mgqrAAAEAwBHMEUCIQC+PjQ+sLSlbAJoLu7ZlMP2RJhvhcV5KIUnwFrP0Pxw6gIg
YDXJsORch6kCTT0Ifar6x8Jz5Gvcj1Th1QFEIjWjNtgAdgBJnJtp3h187Pw23s2H
ZKa4W68Kh4AZ0VVS++nrKd34wwAAAZz6mgreAAAEAwBHMEUCICIct7bW86B0PI0l
inV8fe3awErWdf6o+WSlbDYp6VHtAiEA8/VCFN/U24dmaYOTB84SIuvrm8UWuZ5/
JGcEgMczmyswDQYJKoZIhvcNAQELBQADggEBAKkFx94P90j3xqUGpPsdzXop8cc9
nhCaJP6NgNgL0PuiZILWHaafM0S0+4rK4xYvvh3FrfuK7ZX0ppmtPCfsQF5/RatQ
b1pZS2f/0ypCCYAfGL12IXJWX69CPBSS6fzw3dTtJD/wl3ZNzE0+w61xoGA1cByQ
uo9P5CZ4bULdZon8udau2KW9pF4zjb9Uz7H+RWOIejwZGzJAMCVGZPVlGHLz8KEo
1fJhr8mYtDRdWvsrCR2rUuFQGccz7IyWsc4Kz/YA7hcEjQit4ZZ0dinLVw5XL7R4
TG4cwq95NCmhkT6cWOGU0JpebkDDGFrvh4WxtC8/7OwYgAGMYBEs1s2xPZ4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
MjAeFw0xNzExMjcxMjQ2NDBaFw0yNzExMjcxMjQ2NDBaMG4xCzAJBgNVBAYTAlVT
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
b20xLTArBgNVBAMTJEVuY3J5cHRpb24gRXZlcnl3aGVyZSBEViBUTFMgQ0EgLSBH
MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO8Uf46i/nr7pkgTDqnE
eSIfCFqvPnUq3aF1tMJ5hh9MnO6Lmt5UdHfBGwC9Si+XjK12cjZgxObsL6Rg1njv
NhAMJ4JunN0JGGRJGSevbJsA3sc68nbPQzuKp5Jc8vpryp2mts38pSCXorPR+sch
QisKA7OSQ1MjcFN0d7tbrceWFNbzgL2csJVQeogOBGSe/KZEIZw6gXLKeFe7mupn
NYJROi2iC11+HuF79iAttMc32Cv6UOxixY/3ZV+LzpLnklFq98XORgwkIJL1HuvP
ha8yvb+W6JislZJL+HLFtidoxmI7Qm3ZyIV66W533DsGFimFJkz3y0GeHWuSVMbI
lfsCAwEAAaOCAU8wggFLMB0GA1UdDgQWBBR435GQX+7erPbFdevVTFVT7yRKtjAf
BgNVHSMEGDAWgBROIlQgGJXm427mD/r6uRLtBhePOTAOBgNVHQ8BAf8EBAMCAYYw
HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8C
AQAwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
Y2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQu
Y29tL0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDBMBgNVHSAERTBDMDcGCWCGSAGG
/WwBAjAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BT
MAgGBmeBDAECATANBgkqhkiG9w0BAQsFAAOCAQEAoBs1eCLKakLtVRPFRjBIJ9LJ
L0s8ZWum8U8/1TMVkQMBn+CPb5xnCD0GSA6L/V0ZFrMNqBirrr5B241OesECvxIi
98bZ90h9+q/X5eMyOD35f8YTaEMpdnQCnawIwiHx06/0BfiTj+b/XQih+mqt3ZXe
xNCJqKexdiB2IWGSKcgahPacWkk/BAQFisKIFYEqHzV974S3FAz/8LIfD58xnsEN
GfzyIDkH3JrwYZ8caPTf6ZX9M1GrISN8HnWTtdNCH2xEajRa/h9ZBXjUyFKQrGk2
n2hcLrfZSbynEC/pSw/ET7H5nWwckjmAJ1l9fcnbqkU/pf6uMQmnfl0JQjJNSg==
-----END CERTIFICATE-----

365
pull-and-restart.sh Normal file → Executable file
View File

@@ -1,25 +1,372 @@
#!/usr/bin/env bash
# 拉取代码并重启项目(线上项目根目录:/home/yxd/project/yh_web
# 用法cd /home/yxd/project/yh_web && ./pull-and-restart.sh
# 或指定目录PROJECT_ROOT=/home/yxd/project/yh_web ./pull-and-restart.sh
# 拉取代码并重启缺什么自动安装curl、Git、Docker、Docker Compose再 git 拉取 + docker compose 构建启动
# 用法cd 项目根 && ./pull-and-restart.sh(仓库中已记录可执行权限,拉取后可直接执行)
# 行尾LF
set -e
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
cd "$ROOT"
run_sudo() { sudo "$@"; }
# ---------- 检测并安装 curl下载 Docker Compose 等需要)----------
ensure_curl() {
if command -v curl >/dev/null 2>&1; then
return 0
fi
echo "未检测到 curl正在安装..."
if command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq
run_sudo apt-get install -y curl
elif command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y curl
elif command -v yum >/dev/null 2>&1; then
run_sudo yum install -y curl
else
echo "无法自动安装 curl请先安装 curl 后重试."
exit 1
fi
echo "curl 已安装."
}
# ---------- 检测并安装 Git国内服务器用系统源----------
ensure_git() {
if command -v git >/dev/null 2>&1; then
return 0
fi
echo "未检测到 Git正在安装..."
if command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq
run_sudo apt-get install -y git
elif command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y git
elif command -v yum >/dev/null 2>&1; then
run_sudo yum install -y git
else
echo "无法自动安装 Git请先安装 Git 后重试."
exit 1
fi
echo "Git 已安装."
}
# ---------- 检测并安装 Docker用 run_sudo 检测,与后续 compose 一致;支持 Podman 兼容层)----------
ensure_docker() {
if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
if command -v docker >/dev/null 2>&1; then
echo "Docker/Podman 守护进程未连接,尝试启动..."
run_sudo systemctl start podman 2>/dev/null || true
run_sudo systemctl start docker 2>/dev/null || true
if run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
echo "错误:无法连接 Docker/Podman 守护进程,请执行: sudo systemctl start podman 或 sudo systemctl start docker" >&2
exit 1
fi
echo "未检测到 Docker 或未启动,正在安装..."
if command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq
run_sudo apt-get install -y docker.io docker-compose-plugin 2>/dev/null || run_sudo apt-get install -y docker.io docker-compose
run_sudo systemctl start docker
run_sudo systemctl enable docker
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
if command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y docker
else
run_sudo yum install -y docker
fi
run_sudo systemctl start docker
run_sudo systemctl enable docker
else
echo "无法自动安装 Docker请先安装 Docker 与 Docker Compose 后重试."
exit 1
fi
echo "Docker 安装完成."
}
# ---------- 配置 Docker Hub 镜像加速(国内拉取超时时使用 Podman 镜像)----------
ensure_registry_mirror() {
REG_CONF_D="/etc/containers/registries.conf.d"
REG_MIRROR_CONF="$REG_CONF_D/99-docker-mirror.conf"
echo "配置 Docker Hub 镜像加速Podman..."
run_sudo mkdir -p "$REG_CONF_D"
run_sudo tee "$REG_MIRROR_CONF" >/dev/null <<'REGEOF'
# 国内 Docker Hub 拉取加速,由 pull-and-restart.sh 生成(多镜像备用)
unqualified-search-registries = ["docker.io"]
[[registry]]
location = "docker.io"
[[registry.mirror]]
location = "docker.m.daocloud.io"
[[registry.mirror]]
location = "docker.1ms.run"
[[registry.mirror]]
location = "docker.xuanyuan.me"
REGEOF
echo "已写入 $REG_MIRROR_CONFdocker.io 镜像: daocloud / 1ms / xuanyuan."
}
# ---------- 检测并安装 Docker Compose优先插件 docker compose否则独立二进制----------
ensure_docker_compose() {
run_sudo docker compose version >/dev/null 2>&1 && return 0
command -v docker-compose >/dev/null 2>&1 && return 0
[ -x /usr/local/bin/docker-compose ] && return 0
# 优先尝试用包管理器安装插件,避免独立二进制架构不符导致 Bus error
echo "未检测到 Docker Compose正在尝试安装优先插件..."
if command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
if ! run_sudo docker compose version >/dev/null 2>&1; then
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
run_sudo dnf install -y dnf-plugins-core 2>/dev/null || true
run_sudo dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
run_sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
fi
elif command -v yum >/dev/null 2>&1; then
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
if ! run_sudo docker compose version >/dev/null 2>&1; then
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
run_sudo yum install -y yum-utils 2>/dev/null || true
run_sudo yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
run_sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
fi
elif command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq 2>/dev/null; run_sudo apt-get install -y docker-compose-plugin 2>/dev/null || true
fi
run_sudo docker compose version >/dev/null 2>&1 && echo "Docker Compose 插件已就绪." && return 0
# 回退:下载独立版(国内 DaoCloud 镜像)
echo "正在安装独立版 Docker Compose国内 DaoCloud 镜像)..."
COMPOSE_ARCH="$(uname -m)"
case "$COMPOSE_ARCH" in
x86_64) COMPOSE_ARCH=x86_64 ;;
aarch64|arm64) COMPOSE_ARCH=aarch64 ;;
*) COMPOSE_ARCH=x86_64 ;;
esac
COMPOSE_VER="v2.24.0"
COMPOSE_URL_CN="https://get.daocloud.io/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
if ! run_sudo curl -sfL --connect-timeout 20 --max-time 90 "$COMPOSE_URL_CN" -o /usr/local/bin/docker-compose; then
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
run_sudo curl -sfL --max-time 90 "$COMPOSE_URL" -o /usr/local/bin/docker-compose
fi
run_sudo chmod +x /usr/local/bin/docker-compose
# 若运行即崩溃(如 Bus error删除以免后续误用
run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || { run_sudo rm -f /usr/local/bin/docker-compose; echo "独立版运行失败(可能架构不符),请尝试: dnf install -y docker-compose-plugin 或 yum install -y docker-compose-plugin" >&2; return 0; }
echo "Docker Compose 已安装."
}
# ---------- 检测并安装 Nginx反代 + 强制 HTTPS证书按域名存 /etc/ssl/yh_web/<域名>/----------
ensure_nginx() {
if command -v nginx >/dev/null 2>&1; then
return 0
fi
echo "未检测到 Nginx正在安装..."
if command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq
run_sudo apt-get install -y nginx
elif command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y nginx
elif command -v yum >/dev/null 2>&1; then
run_sudo yum install -y nginx
else
echo "无法自动安装 Nginx请手动安装后重试."
exit 1
fi
run_sudo systemctl enable nginx 2>/dev/null || true
run_sudo systemctl start nginx 2>/dev/null || true
echo "Nginx 已安装."
}
ensure_curl
ensure_git
ensure_docker
ensure_docker_compose
ensure_registry_mirror
ensure_nginx
# 确定要用的 compose 命令;测试独立二进制时用 || true 避免 Bus error 导致脚本退出
resolve_compose_cmd() {
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
if [ -x /usr/local/bin/docker-compose ]; then
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
if [ "$r" -eq 0 ]; then echo "/usr/local/bin/docker-compose"; return; fi
echo "检测到 /usr/local/bin/docker-compose 无法运行(可能架构不符),正在重装..." >&2
run_sudo rm -f /usr/local/bin/docker-compose
ensure_docker_compose || true
fi
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
run_sudo docker-compose version >/dev/null 2>&1 && echo "docker-compose" && return
ensure_docker_compose || true
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
if [ -x /usr/local/bin/docker-compose ]; then
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
[ "$r" -eq 0 ] && echo "/usr/local/bin/docker-compose" || echo ""
else
echo ""
fi
}
COMPOSE_CMD=""
compose_cmd() {
if [ -z "$COMPOSE_CMD" ]; then
COMPOSE_CMD="$(resolve_compose_cmd)"
fi
if [ -z "$COMPOSE_CMD" ]; then
echo "错误:无法找到 docker compose 或 docker-compose请手动安装到 /usr/local/bin/docker-compose"
exit 1
fi
# sudo 默认不传环境变量,显式传入以便 compose 使用 REGISTRY_MIRROR / GOPROXY
run_sudo env REGISTRY_MIRROR="${REGISTRY_MIRROR}" GOPROXY="${GOPROXY}" $COMPOSE_CMD "$@"
}
echo "=========================================="
echo " yh_web 拉取并重启"
echo " 路径: $ROOT"
echo "=========================================="
# 环境配置:缺失时从 server/.env.example 复制Docker 部署用 mongo:27017
if [ ! -f server/.env ]; then
if [ -f server/.env.example ]; then
cp server/.env.example server/.env
echo "已从 server/.env.example 创建 server/.env可按需修改."
else
mkdir -p server
NGINX_DEFAULT_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
cat > server/.env <<ENVEOF
MONGODB_URI=mongodb://mongo:27017
MONGODB_DB=yxd-agent-testing
PORT=9527
GIN_MODE=release
ALLOWED_ORIGINS=https://${NGINX_DEFAULT_DOMAIN}
ENVEOF
echo "已创建默认 server/.envALLOWED_ORIGINS=https://${NGINX_DEFAULT_DOMAIN}),可按需修改."
fi
fi
[ -f server/.env ] && sed -i 's/\r$//' server/.env
[ -f server/.env ] && set -a && source server/.env && set +a
echo "[1/2] 拉取代码..."
git pull
BRANCH="${GIT_BRANCH:-master}"
echo "[1/3] 拉取代码(以 Gitea 远程为准,本地修改会被覆盖)..."
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git fetch origin --progress
git reset --hard "origin/$BRANCH"
else
echo "未检测到 Git 仓库,正在克隆..."
export GIT_TERMINAL_PROMPT=0
# 默认仓库地址(仅本地/服务器使用不提交到仓库可覆盖export GIT_REPO_URL=...
REPO_URL="${GIT_REPO_URL:-https://whm:02f8ceeee5f1aeb197ff400e4d97abbcf5550015@gitea.yuxindazhineng.com/whm/web.git}"
SELF="$(basename "$0")"
tmp_backup="/tmp/yh_web_deploy_$$"
mkdir -p "$tmp_backup"
[ -f "$SELF" ] && cp -a "$SELF" "$tmp_backup/"
[ -f server/.env ] && cp -a server/.env "$tmp_backup/" 2>/dev/null || true
git init -b "$BRANCH"
git remote add origin "$REPO_URL"
git fetch origin --progress
git reset --hard "origin/$BRANCH"
[ -f "$tmp_backup/$SELF" ] && cp -a "$tmp_backup/$SELF" "$SELF" && chmod +x "$SELF"
[ -f "$tmp_backup/.env" ] && mkdir -p server && cp -a "$tmp_backup/.env" server/.env
rm -rf "$tmp_backup"
fi
# 拉取后把 .env.example 里新增的键自动追加到 server/.env无需手改如 YH_IMPORT_PROMOTION_SITE_ID
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
[ -f server/.env ] && sed -i 's/\r$//' server/.env
[ -f server/.env ] && set -a && source server/.env && set +a
echo ""
echo "[2/2] 重新构建并启动..."
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
docker compose up -d --force-recreate 2>/dev/null || docker-compose up -d --force-recreate
echo "[2/3] 重新构建并启动..."
# 宿主机 9527 常被 sshd 占用compose 内 API 须为 8088。若宿主机 Nginx 已运行docker-compose.host-nginx.yml 会把 api/web/admin 绑到本机回环供反代。
if grep -q '9527' "$ROOT/docker-compose.yml" 2>/dev/null; then
echo "错误: 当前 docker-compose.yml 仍含 9527会与 sshd 冲突导致启动失败。请以 Gitea 为准拉取最新代码后再执行本脚本:" >&2
echo " git fetch origin && git reset --hard origin/master" >&2
echo "若 Gitea 上已为 8088 仍报错,请本地提交并 push 后再在服务器执行上述命令。" >&2
exit 1
fi
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
# 挂目录方案:构建产物到 deploy/,容器挂载这些目录,无需重建 web/admin 镜像
mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
echo "构建 web 前端 -> deploy/web/dist ..."
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
# 官网访问的是 Nginx 根目录 deploy/web/dist产品视频已放在 social/ 英文文件名,须整目录同步(含 .mov
echo "同步 web/promotion -> deploy/web/dist/promotion排除旧「视频发布」与 PPT 解压,避免重复大文件)..."
mkdir -p "$ROOT/deploy/web/dist/promotion"
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
else
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
echo "提示: 未检测到 rsync仅复制了 social/logo 等;请安装 rsync 以同步完整 promotion含视频。" >&2
fi
echo "构建 admin 前端 -> deploy/admin/dist ..."
# admin 的 vite 别名 @yh-web -> ../web/src须挂载项目根否则容器内无 web 目录会报 BlockRenderer.vue ENOENT
run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
echo "构建 api 二进制 -> deploy/api/server ..."
run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \
"${REGISTRY_MIRROR}golang:1.21-alpine" sh -c "go build -mod=vendor -o /out/server ."
# 确保容器内 nginx 可读(拉取后直接执行时权限一致)
run_sudo chmod -R a+rX "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" 2>/dev/null || true
if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dist/index.html" ]; then
echo "错误: 构建产物不完整(缺少 index.html。若仅做了 git pull 未执行本脚本,请完整执行: ./pull-and-restart.sh" >&2
exit 1
fi
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
# 仅构建 api 运行时镜像轻量无业务代码web/admin 使用官方 nginx 镜像无需构建
compose_cmd build api
# 仅当本地没有 mongo:7 时才从镜像站拉取,避免每次重复下载约 250MB
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
if ! run_sudo docker image inspect "$MONGO_IMAGE" >/dev/null 2>&1; then
echo "拉取 mongo 镜像(仅首次或镜像缺失时)..."
run_sudo docker pull "$MONGO_IMAGE" || true
else
echo "mongo 镜像已存在,跳过拉取."
fi
# 证书目录在 compose up 前就要就绪compose 内 nginx 容器会挂载)
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
run_sudo mkdir -p "$NGINX_SSL_DIR"
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.pem" "$NGINX_SSL_DIR/fullchain.pem"
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.key" "$NGINX_SSL_DIR/privkey.pem"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$NGINX_SSL_DIR/"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" "$NGINX_SSL_DIR/"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
fi
# shellcheck disable=SC1091
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
# 先停掉本项目容器(不停止宿主机 nginx→ 准备宿主机站点并确保 nginx 在线 → 启动业务容器
yh_compose_down
echo ""
echo "[3/3] 宿主机 Nginx 站点与服务..."
yh_install_host_nginx_site_conf
ensure_host_nginx_started
yh_compose_up
yh_post_deploy_healthcheck
# 可选web/promotion/视频发布 -> data/uploads + MongoDB须 server/.env 中 YH_IMPORT_PROMOTION_SITE_ID
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
echo ""
echo "完成. api:9527 web:9528 admin:9529"
echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN"

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
# 推送到 Gitea: https://gitea.yuxindazhineng.com/whm/web
set -e
cd "$(dirname "$0")"
GITEA_URL="https://gitea.yuxindazhineng.com/whm/web.git"
if [ ! -d .git ]; then
echo "初始化 Git 仓库..."
git init
git branch -M main
fi
if ! git remote get-url origin 2>/dev/null; then
echo "添加远程: $GITEA_URL"
git remote add origin "$GITEA_URL"
else
git remote set-url origin "$GITEA_URL"
fi
echo "添加并提交..."
git add .
if [ -n "$(git status --porcelain)" ]; then
git commit -m "chore: push to gitea (yh_web)"
else
echo "无新变更"
fi
echo "推送到 origin..."
git push -u origin main || git push -u origin main --force
echo "完成: https://gitea.yuxindazhineng.com/whm/web"

266
restart.sh Normal file → Executable file
View File

@@ -1,13 +1,265 @@
#!/usr/bin/env bash
# 仅重启项目(不拉代码),适用于配置/环境变更后重启
# 用法cd /home/yxd/project/yh_web && ./restart.sh
# PROJECT_ROOT=/home/yxd/project/yh_web ./restart.sh
# 仅不拉代码,其余与 pull-and-restart.sh 一致:构建到 deploy/ 并重启
# 用法cd 项目根 && ./restart.sh拉取后可直接执行无需 chmod
# 行尾LF
set -e
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
cd "$ROOT"
echo "重启 yh_web ($ROOT)..."
run_sudo() { sudo "$@"; }
ensure_curl() {
if command -v curl >/dev/null 2>&1; then return 0; fi
echo "未检测到 curl正在安装..."
if command -v apt-get >/dev/null 2>&1; then run_sudo apt-get update -qq; run_sudo apt-get install -y curl
elif command -v dnf >/dev/null 2>&1; then run_sudo dnf install -y curl
elif command -v yum >/dev/null 2>&1; then run_sudo yum install -y curl
else echo "无法自动安装 curl."; exit 1; fi
echo "curl 已安装."
}
# ---------- 检测并安装 Docker用 run_sudo 检测,与后续 compose 一致;支持 Podman----------
ensure_docker() {
if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
if command -v docker >/dev/null 2>&1; then
echo "Docker/Podman 守护进程未连接,尝试启动..."
run_sudo systemctl start podman 2>/dev/null || true
run_sudo systemctl start docker 2>/dev/null || true
if run_sudo docker info >/dev/null 2>&1; then
echo "Docker 已就绪."
return 0
fi
echo "错误:无法连接 Docker/Podman 守护进程,请执行: sudo systemctl start podman 或 sudo systemctl start docker" >&2
exit 1
fi
echo "未检测到 Docker 或未启动,正在安装..."
if command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq
run_sudo apt-get install -y docker.io docker-compose-plugin 2>/dev/null || run_sudo apt-get install -y docker.io docker-compose
run_sudo systemctl start docker
run_sudo systemctl enable docker
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
if command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y docker
else
run_sudo yum install -y docker
fi
run_sudo systemctl start docker
run_sudo systemctl enable docker
else
echo "无法自动安装 Docker请先安装 Docker 与 Docker Compose 后重试."
exit 1
fi
echo "Docker 安装完成."
}
ensure_registry_mirror() {
REG_CONF_D="/etc/containers/registries.conf.d"
REG_MIRROR_CONF="$REG_CONF_D/99-docker-mirror.conf"
echo "配置 Docker Hub 镜像加速Podman..."
run_sudo mkdir -p "$REG_CONF_D"
run_sudo tee "$REG_MIRROR_CONF" >/dev/null <<'REGEOF'
# 国内 Docker Hub 拉取加速,多镜像备用
unqualified-search-registries = ["docker.io"]
[[registry]]
location = "docker.io"
[[registry.mirror]]
location = "docker.m.daocloud.io"
[[registry.mirror]]
location = "docker.1ms.run"
[[registry.mirror]]
location = "docker.xuanyuan.me"
REGEOF
echo "已写入 $REG_MIRROR_CONF"
}
ensure_docker_compose() {
run_sudo docker compose version >/dev/null 2>&1 && return 0
command -v docker-compose >/dev/null 2>&1 && return 0
[ -x /usr/local/bin/docker-compose ] && return 0
echo "未检测到 Docker Compose正在尝试安装优先插件..."
if command -v dnf >/dev/null 2>&1; then
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
if ! run_sudo docker compose version >/dev/null 2>&1; then
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
run_sudo dnf install -y dnf-plugins-core 2>/dev/null || true
run_sudo dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
run_sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
fi
elif command -v yum >/dev/null 2>&1; then
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
if ! run_sudo docker compose version >/dev/null 2>&1; then
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
run_sudo yum install -y yum-utils 2>/dev/null || true
run_sudo yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
run_sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
fi
elif command -v apt-get >/dev/null 2>&1; then
run_sudo apt-get update -qq 2>/dev/null; run_sudo apt-get install -y docker-compose-plugin 2>/dev/null || true
fi
run_sudo docker compose version >/dev/null 2>&1 && echo "Docker Compose 插件已就绪." && return 0
echo "正在安装独立版 Docker Compose国内 DaoCloud 镜像)..."
COMPOSE_ARCH="$(uname -m)"
case "$COMPOSE_ARCH" in
x86_64) COMPOSE_ARCH=x86_64 ;;
aarch64|arm64) COMPOSE_ARCH=aarch64 ;;
*) COMPOSE_ARCH=x86_64 ;;
esac
COMPOSE_VER="v2.24.0"
COMPOSE_URL_CN="https://get.daocloud.io/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
if ! run_sudo curl -sfL --connect-timeout 20 --max-time 90 "$COMPOSE_URL_CN" -o /usr/local/bin/docker-compose; then
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
run_sudo curl -sfL --max-time 90 "$COMPOSE_URL" -o /usr/local/bin/docker-compose
fi
run_sudo chmod +x /usr/local/bin/docker-compose
run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || { run_sudo rm -f /usr/local/bin/docker-compose; echo "独立版运行失败(可能架构不符),请尝试: dnf install -y docker-compose-plugin 或 yum install -y docker-compose-plugin" >&2; return 0; }
echo "Docker Compose 已安装."
}
# ---------- 检测并安装 Nginx反代 + 强制 HTTPS----------
ensure_nginx() {
command -v nginx >/dev/null 2>&1 && return 0
echo "未检测到 Nginx正在安装..."
if command -v apt-get >/dev/null 2>&1; then run_sudo apt-get update -qq; run_sudo apt-get install -y nginx
elif command -v dnf >/dev/null 2>&1; then run_sudo dnf install -y nginx
elif command -v yum >/dev/null 2>&1; then run_sudo yum install -y nginx
else echo "无法自动安装 Nginx."; exit 1; fi
run_sudo systemctl enable nginx 2>/dev/null || true
run_sudo systemctl start nginx 2>/dev/null || true
echo "Nginx 已安装."
}
ensure_curl
ensure_docker
ensure_docker_compose
ensure_registry_mirror
ensure_nginx
resolve_compose_cmd() {
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
if [ -x /usr/local/bin/docker-compose ]; then
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
if [ "$r" -eq 0 ]; then echo "/usr/local/bin/docker-compose"; return; fi
echo "检测到 /usr/local/bin/docker-compose 无法运行(可能架构不符),正在重装..." >&2
run_sudo rm -f /usr/local/bin/docker-compose
ensure_docker_compose || true
fi
run_sudo docker-compose version >/dev/null 2>&1 && echo "docker-compose" && return
ensure_docker_compose || true
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
if [ -x /usr/local/bin/docker-compose ]; then
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
[ "$r" -eq 0 ] && echo "/usr/local/bin/docker-compose" || echo ""
else
echo ""
fi
}
COMPOSE_CMD=""
compose_cmd() {
if [ -z "$COMPOSE_CMD" ]; then COMPOSE_CMD="$(resolve_compose_cmd)"; fi
if [ -z "$COMPOSE_CMD" ]; then echo "错误:无法找到 docker compose请手动安装到 /usr/local/bin/docker-compose"; exit 1; fi
run_sudo env REGISTRY_MIRROR="${REGISTRY_MIRROR}" GOPROXY="${GOPROXY}" $COMPOSE_CMD "$@"
}
echo "重启 yh_web ($ROOT)(不拉代码,仅构建并启动)..."
# 环境配置:缺失时从 server/.env.example 复制
if [ ! -f server/.env ]; then
if [ -f server/.env.example ]; then
cp server/.env.example server/.env
echo "已从 server/.env.example 创建 server/.env"
else
mkdir -p server
ND="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
printf 'MONGODB_URI=mongodb://mongo:27017\nMONGODB_DB=yxd-agent-testing\nPORT=8088\nGIN_MODE=release\nALLOWED_ORIGINS=https://%s\n' "$ND" > server/.env
echo "已创建默认 server/.env"
fi
fi
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
[ -f server/.env ] && sed -i 's/\r$//' server/.env 2>/dev/null || true
[ -f server/.env ] && set -a && source server/.env && set +a
docker compose down 2>/dev/null || docker-compose down
docker compose up -d 2>/dev/null || docker-compose up -d
echo "完成. api:9527 web:9528 admin:9529"
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
# 与 pull-and-restart 一致:宿主机 9527 检查
if grep -q '9527' "$ROOT/docker-compose.yml" 2>/dev/null; then
echo "错误: docker-compose.yml 仍含 9527会与 sshd 冲突。请拉取最新代码后再执行。" >&2
exit 1
fi
# 构建到 deploy/(与 pull-and-restart.sh 相同,仅无 git 拉取)
mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
echo "构建 web 前端 -> deploy/web/dist ..."
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
# 与 pull-and-restart 一致:文档根是 deploy/web/dist须把 promotion含 social 视频)拷入 dist
echo "同步 web/promotion -> deploy/web/dist/promotion ..."
mkdir -p "$ROOT/deploy/web/dist/promotion"
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
else
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
fi
echo "构建 admin 前端 -> deploy/admin/dist ..."
# 与 pull-and-restart.sh 一致:须挂载项目根,@yh-web -> ../web/src仅挂 admin 会构建失败或产物异常)
run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
echo "构建 api 二进制 -> deploy/api/server ..."
run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \
"${REGISTRY_MIRROR}golang:1.21-alpine" sh -c "go build -mod=vendor -o /out/server ."
run_sudo chmod -R a+rX "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" 2>/dev/null || true
if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dist/index.html" ]; then
echo "错误: 构建产物不完整(缺少 index.html请检查上方构建日志。" >&2
exit 1
fi
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
compose_cmd build api
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
if ! run_sudo docker image inspect "$MONGO_IMAGE" >/dev/null 2>&1; then
echo "拉取 mongo 镜像(仅首次或镜像缺失时)..."
run_sudo docker pull "$MONGO_IMAGE" || true
else
echo "mongo 镜像已存在,跳过拉取."
fi
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
run_sudo mkdir -p "$NGINX_SSL_DIR"
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.pem" "$NGINX_SSL_DIR/fullchain.pem"
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.key" "$NGINX_SSL_DIR/privkey.pem"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$NGINX_SSL_DIR/"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" ]; then
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" "$NGINX_SSL_DIR/"
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
fi
# shellcheck disable=SC1091
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
yh_compose_down
echo "宿主机 Nginx 站点与服务..."
yh_install_host_nginx_site_conf
ensure_host_nginx_started
yh_compose_up
yh_post_deploy_healthcheck
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN"

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# 在 Docker 中运行项目,统一域名: https://yuheng.yuxindazhineng.com
# 端口: 9527=api, 9528=index, 9529=admin直连对外通过 nginx 80/443
set -e
cd "$(dirname "$0")"
echo "=== yh_web Docker 启动 ==="
echo "对外域名与 HTTPS 由 Nginx Proxy Manager 配置,本机端口: 9527=api, 9528=web, 9529=admin"
echo ""
# 可选:从 server/.env 加载到当前 shell供 docker-compose 使用
if [ -f server/.env ]; then
set -a
source server/.env
set +a
echo "已加载 server/.env"
fi
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
docker compose up -d 2>/dev/null || docker-compose up -d
echo ""
echo "已启动。请用 NPM 配置的域名访问(如 https://yuheng.yuxindazhineng.com"
echo "直连: api :9527, web :9528, admin :9529"

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 将 web/promotion/视频发布 导入到 data/uploads + MongoDB site_assets无需后台手动上传
# 依赖server/.env 中 MONGODB_URI、MONGODB_DB与 API 一致);本机可连 Mongo
#
# 用法:
# ./scripts/import-promotion-to-api.sh -site=你的站点MongoID
# ./scripts/import-promotion-to-api.sh -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
# ./scripts/import-promotion-to-api.sh -site=xxx -dry-run
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT/server"
exec go run -mod=vendor ./cmd/promotion-import/ "$@"

View File

@@ -0,0 +1,191 @@
# shellcheck shell=bash
# 由 pull-and-restart.sh / restart.sh 在定义好 ROOT、compose_cmd、run_sudo 之后 source。
# 统一策略:仅使用宿主机 Nginx容器 yh_nginx 不再作为入口。
YH_COMPOSE_FILES="-f docker-compose.yml -f docker-compose.host-nginx.yml"
require_cmd() {
local c="$1"
command -v "$c" >/dev/null 2>&1 || {
echo "错误: 缺少命令 $c,请先安装后重试。" >&2
exit 1
}
}
ensure_host_deploy_env() {
require_cmd systemctl
require_cmd ss
require_cmd sed
require_cmd awk
require_cmd curl
}
host_nginx_online() {
command -v systemctl >/dev/null 2>&1 || return 1
systemctl is-active nginx >/dev/null 2>&1 && return 0
systemctl is-active nginx.service >/dev/null 2>&1 && return 0
return 1
}
force_release_port() {
local p="$1"
local victims
victims="$(run_sudo ss -tlnp "sport = :$p" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | awk '!seen[$0]++')"
[ -z "$victims" ] && return 0
for pid in $victims; do
[ -z "$pid" ] && continue
local comm
comm="$(run_sudo sh -c "cat /proc/$pid/comm 2>/dev/null" || true)"
if [ "$comm" = "nginx" ]; then
continue
fi
echo "端口 $p 被非宿主机 nginx 进程占用pid=$pid, comm=${comm:-unknown}),强制停止..."
run_sudo kill -9 "$pid" 2>/dev/null || true
done
}
force_release_host_ports() {
# 先优先停掉所有发布了宿主机 80/443 的容器(最常见冲突源)
if command -v docker >/dev/null 2>&1; then
for cid in $(run_sudo docker ps --filter "publish=80" --format "{{.ID}}" 2>/dev/null); do
[ -n "$cid" ] && run_sudo docker rm -f "$cid" 2>/dev/null || true
done
for cid in $(run_sudo docker ps --filter "publish=443" --format "{{.ID}}" 2>/dev/null); do
[ -n "$cid" ] && run_sudo docker rm -f "$cid" 2>/dev/null || true
done
fi
# 再兜底杀掉非 nginx 的占用进程
force_release_port 80
force_release_port 443
}
ensure_host_nginx_started() {
ensure_host_deploy_env
force_release_host_ports
if host_nginx_online; then
echo "宿主机 Nginx 在线,跳过启动。"
return 0
fi
echo "宿主机 Nginx 未在线,尝试启动..."
run_sudo systemctl start nginx 2>/dev/null || run_sudo systemctl start nginx.service
run_sudo systemctl enable nginx 2>/dev/null || true
if host_nginx_online; then
echo "宿主机 Nginx 启动成功。"
return 0
fi
echo "错误: 无法启动宿主机 Nginx请检查 systemctl status nginx" >&2
exit 1
}
# 停止本项目 Compose 栈(包含可能残留的 yh_nginx不停止宿主机 Nginx。
yh_compose_down() {
if [ ! -f "$ROOT/docker-compose.host-nginx.yml" ]; then
compose_cmd down --remove-orphans 2>/dev/null || true
return 0
fi
compose_cmd $YH_COMPOSE_FILES down --remove-orphans 2>/dev/null || true
}
# 仅启动业务容器,不再启动容器 yh_nginx。
yh_compose_up() {
if [ ! -f "$ROOT/docker-compose.host-nginx.yml" ]; then
echo "未找到 docker-compose.host-nginx.yml使用默认 compose 启动业务容器。"
compose_cmd up -d --force-recreate mongo api web admin
return 0
fi
compose_cmd $YH_COMPOSE_FILES up -d --force-recreate mongo api web admin
}
# 从模板生成宿主机站点配置,并在配置检查通过后 reload若离线则 start
yh_install_host_nginx_site_conf() {
local domain="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
local tpl="$ROOT/nginx/yuheng.host.conf"
local out="/etc/nginx/conf.d/${domain}.conf"
local ts
ts="$(date +%Y%m%d%H%M%S)"
if [ ! -f "$tpl" ]; then
echo "未找到 $tpl,跳过宿主机站点配置生成。"
return 0
fi
ensure_host_deploy_env
# 同域名冲突兜底:将 conf.d 下其他包含相同 server_name 的配置下线,避免错误 upstream 继续生效导致 502。
run_sudo sh -c "for f in /etc/nginx/conf.d/*.conf; do
[ -f \"\$f\" ] || continue
[ \"\$f\" = \"$out\" ] && continue
if grep -Eq '^[[:space:]]*server_name[[:space:]]+[^;]*${domain}([[:space:];]|$)' \"\$f\"; then
mv -f \"\$f\" \"\${f}.disabled_by_yh_${ts}\"
fi
done"
mkdir -p "$ROOT/verify-root"
sed "s|__VERIFY_ROOT__|$ROOT/verify-root|g" "$tpl" | run_sudo tee "$out" >/dev/null
if ! run_sudo nginx -t 2>/dev/null; then
echo "错误: 宿主机 nginx -t 失败,请检查 $out" >&2
exit 1
fi
if host_nginx_online; then
run_sudo systemctl reload nginx 2>/dev/null && echo "宿主机 Nginx 已重载($out)。" || true
else
ensure_host_nginx_started
fi
}
yh_post_deploy_healthcheck() {
local domain="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
local code=""
local ok_web=0
local ok_admin=0
local ok_api=0
# 先验证上游容器端口(带重试,避免容器刚起时瞬时连接拒绝)
for _ in $(seq 1 20); do
curl -fsS --max-time 3 http://127.0.0.1:9080/ >/dev/null && ok_web=1 && break
sleep 1
done
[ "$ok_web" -eq 1 ] || {
echo "错误: 前台上游 127.0.0.1:9080 不可用" >&2
return 1
}
for _ in $(seq 1 20); do
curl -fsS --max-time 3 http://127.0.0.1:9081/ >/dev/null && ok_admin=1 && break
sleep 1
done
[ "$ok_admin" -eq 1 ] || {
echo "错误: 后台上游 127.0.0.1:9081 不可用" >&2
return 1
}
for _ in $(seq 1 30); do
curl -fsS --max-time 3 http://127.0.0.1:8088/api/health | grep -q '"status":"ok"' && ok_api=1 && break
sleep 2
done
[ "$ok_api" -eq 1 ] || {
echo "错误: API 上游 127.0.0.1:8088/api/health 不可用" >&2
return 1
}
code="$(curl -k -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://${domain}" || true)"
if [ "$code" = "502" ] || [ "$code" = "000" ]; then
echo "检测到 https://${domain} 返回 ${code},尝试自动重载宿主机 Nginx 后重试..."
run_sudo systemctl reload nginx 2>/dev/null || true
sleep 1
code="$(curl -k -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://${domain}" || true)"
fi
if [ "${code:-000}" -ge 500 ] || [ "${code:-000}" = "000" ]; then
echo "错误: https://${domain} 返回 ${code},部署后健康检查失败。" >&2
echo "==== 诊断80/443 监听 ====" >&2
run_sudo ss -tlnp | sed -n '1p;/\:80 \|:443 /p' >&2 || true
echo "==== 诊断:最近 Nginx 错误日志 ====" >&2
run_sudo sh -c 'tail -n 80 /var/log/nginx/error.log 2>/dev/null || true' >&2
return 1
fi
echo "健康检查通过https://${domain} -> ${code}"
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 将 server/.env.example 中「server/.env 尚未出现的 KEY=」追加到 .env不覆盖已有配置。
# 供 pull-and-restart / restart 调用,实现服务器零手动改 .env。
set +e
ROOT="${1:-}"
if [ -z "$ROOT" ]; then
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
fi
ENVF="$ROOT/server/.env"
EX="$ROOT/server/.env.example"
[ -f "$EX" ] || exit 0
[ -f "$ENVF" ] || exit 0
while IFS= read -r raw || [ -n "$raw" ]; do
line="${raw#"${raw%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
case "$line" in
\#*|'') continue ;;
esac
case "$line" in
[A-Za-z_][A-Za-z0-9_]*=*)
key="${line%%=*}"
if ! grep -qE "^[[:space:]]*${key}=" "$ENVF" 2>/dev/null; then
printf '\n# auto from .env.example (%s)\n%s\n' "$(date +%Y-%m-%d)" "$line" >> "$ENVF"
echo "merge-server-env: 已追加 $key -> server/.env" >&2
fi
;;
esac
done < "$EX"
exit 0

View File

@@ -0,0 +1,123 @@
#!/bin/sh
# yh_nginx1) 等 web/admin/api 就绪 2) 从 /etc/resolv.conf 取 nameserver 写入 resolver
# 3) 由 tpl 生成 default.conf变量 proxy_pass避免 Podman 在「探测已通过」后仍 host not found in upstream "api"。
set -e
MAX="${NGINX_WAIT_UPSTREAM_SEC:-120}"
DEBUG="${NGINX_WAIT_DEBUG:-0}"
log() {
if [ "$DEBUG" = "1" ] || [ "$DEBUG" = "true" ]; then
echo "yh_nginx(wait): $*" >&2
fi
}
echo "yh_nginx: waiting for upstream (web:80 admin:80 api:8088), max ${MAX}s..."
ping_one() {
host="$1"
if ping -c1 -W2 "$host" >/dev/null 2>&1; then
return 0
fi
ping -c1 -w3 "$host" >/dev/null 2>&1
}
tcp_open() {
host="$1"
port="$2"
if ! command -v nc >/dev/null 2>&1; then
return 1
fi
nc -z -w3 "$host" "$port" 2>/dev/null
}
http_ok() {
host="$1"
port="$2"
path="${3:-/}"
if ! command -v wget >/dev/null 2>&1; then
return 1
fi
if wget -q -O/dev/null -T 5 "http://${host}:${port}${path}" 2>/dev/null; then
return 0
fi
return 1
}
upstream_ok() {
host="$1"
port="$2"
path="${3:-/}"
if http_ok "$host" "$port" "$path"; then
log "http OK ${host}:${port}${path}"
return 0
fi
if tcp_open "$host" "$port"; then
log "tcp OK ${host}:${port}"
return 0
fi
if ping_one "$host"; then
log "ping OK $host (HTTP/TCP 未验证,仅 DNS/L3)"
return 0
fi
return 1
}
n=0
while [ "$n" -lt "$MAX" ]; do
if upstream_ok web 80 / && upstream_ok admin 80 / && upstream_ok api 8088 /api/health; then
break
fi
if [ "$n" -gt 0 ] && [ $((n % 15)) -eq 0 ]; then
echo "yh_nginx: still waiting... ${n}s / max ${MAX}s (web admin api)" >&2
fi
n=$((n + 1))
sleep 1
done
if [ "$n" -ge "$MAX" ]; then
echo "yh_nginx: timeout after ${MAX}s." >&2
exit 1
fi
echo "yh_nginx: upstream OK, generating nginx config..."
# 与容器内实际 DNS 一致Podman 常非 127.0.0.11);多个 nameserver 空格分隔(兼容 IPv6
NSLINE=""
while read -r line; do
case "$line" in
nameserver\ *)
ip=${line#nameserver }
ip=${ip%%#*}
# trim
ip=$(echo "$ip" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$ip" ] && continue
NSLINE="${NSLINE}${NSLINE:+ }${ip}"
;;
esac
done < /etc/resolv.conf
if [ -z "$NSLINE" ]; then
NSLINE="127.0.0.11"
echo "yh_nginx: warn: no nameserver in resolv.conf, fallback ${NSLINE}" >&2
else
echo "yh_nginx: resolver from resolv.conf: ${NSLINE}" >&2
fi
# Docker compose 服务名由网桥内置 DNS通常 127.0.0.11)解析;仅用宿主机 DNS 会间歇「could not be resolved」→502
case "$NSLINE" in
*127.0.0.11*) ;;
*) NSLINE="127.0.0.11 ${NSLINE}"; echo "yh_nginx: prepended 127.0.0.11 for compose DNS: ${NSLINE}" >&2 ;;
esac
if [ ! -r /yuheng.docker.conf.tpl ]; then
echo "yh_nginx: error: /yuheng.docker.conf.tpl not mounted" >&2
exit 1
fi
# sed 用 | 分隔,避免 IPv6 里 : 干扰(当前仍支持多 IPv4 nameserver
sed "s|@@NGINX_RESOLVER@@|${NSLINE}|g" /yuheng.docker.conf.tpl > /etc/nginx/conf.d/default.conf
nginx -t
echo "yh_nginx: starting nginx..."
exec nginx -g 'daemon off;'

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# 在部署compose up之后执行将 web/promotion/视频发布 导入 data/uploads + MongoDB site_assets。
# 触发条件:环境变量或 server/.env 中设置了 YH_IMPORT_PROMOTION_SITE_IDMongo 站点 _id
# 使用 Docker 内 golang 执行 go run与 compose 内 mongo 同网mongodb://mongo:27017宿主机无需安装 Go。
# 用法:由 pull-and-restart.sh / restart.sh 调用;勿单独在 compose 未启动时依赖 mongo 网络。
set +e
ROOT="${1:-}"
if [ -z "$ROOT" ] || [ ! -d "$ROOT/server" ]; then
echo "run-promotion-import-on-deploy.sh: 无效项目根目录" >&2
exit 0
fi
ROOT="$(cd "$ROOT" && pwd)"
SITE="${YH_IMPORT_PROMOTION_SITE_ID:-}"
if [ -z "$SITE" ] && [ -f "$ROOT/server/.env" ]; then
SITE="$(grep -E '^[[:space:]]*YH_IMPORT_PROMOTION_SITE_ID=' "$ROOT/server/.env" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r' | sed "s/^[\"']//;s/[\"']$//" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
fi
if [ -z "$SITE" ]; then
echo "跳过 promotion-import未设置 YH_IMPORT_PROMOTION_SITE_ID可在 server/.env 中配置)。"
exit 0
fi
SRC_DIR="$ROOT/web/promotion/视频发布"
if [ ! -d "$SRC_DIR" ]; then
echo "跳过 promotion-import无源目录 $SRC_DIR"
exit 0
fi
DOCKER="docker"
if ! docker info >/dev/null 2>&1; then
if sudo docker info >/dev/null 2>&1; then
DOCKER="sudo docker"
else
echo "警告: 无法使用 docker跳过 promotion-import" >&2
exit 0
fi
fi
PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT")}"
NET="${PROJECT_NAME}_yh_net"
if ! $DOCKER network inspect "$NET" >/dev/null 2>&1; then
echo "警告: 未找到 Docker 网络 $NETCOMPOSE_PROJECT_NAME 是否一致?),跳过 promotion-import" >&2
exit 0
fi
REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
GOIMAGE="${REGISTRY_MIRROR}golang:1.21-alpine"
GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
ENV_FILE_ARG=()
if [ -f "$ROOT/server/.env" ]; then
ENV_FILE_ARG=(--env-file "$ROOT/server/.env")
fi
echo ""
echo "promotion-importsite=$SITE(源=$SRC_DIR -> uploads=/uploads..."
mkdir -p "$ROOT/data/uploads"
if ! $DOCKER run --rm \
--network "$NET" \
-v "$ROOT/server:/src" \
-v "$ROOT/data/uploads:/uploads" \
-v "$SRC_DIR:/import-src:ro" \
"${ENV_FILE_ARG[@]}" \
-e MONGODB_URI=mongodb://mongo:27017 \
-e GOPROXY="$GOPROXY" \
-e YH_PI_SITE="$SITE" \
"$GOIMAGE" \
sh -c 'cd /src && go run -mod=vendor ./cmd/promotion-import/ -site="$YH_PI_SITE" -src=/import-src -upload=/uploads'; then
echo "警告: promotion-import 未成功,可手动执行: ./scripts/import-promotion-to-api.sh -site=$SITE" >&2
fi
chmod -R a+rX "$ROOT/data/uploads" 2>/dev/null || sudo chmod -R a+rX "$ROOT/data/uploads" 2>/dev/null || true
exit 0

View File

@@ -0,0 +1,45 @@
# 将 web/promotion/视频发布 中文路径素材复制到 web/promotion/social英文文件名
# 用法:在项目根 powershell 执行 .\scripts\sync-video-assets-to-social.ps1
$ErrorActionPreference = "Stop"
$Root = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$Src = Join-Path $Root "web\promotion\视频发布"
$Dst = Join-Path $Root "web\promotion\social"
New-Item -ItemType Directory -Force -Path $Dst | Out-Null
function Copy-First($toName, [string[]]$fromRels) {
$to = Join-Path $Dst $toName
foreach ($rel in $fromRels) {
$from = Join-Path $Src $rel
if (Test-Path -LiteralPath $from) {
Copy-Item -LiteralPath $from -Destination $to -Force
Write-Host "OK $toName <= $rel"
return
}
}
Write-Warning "SKIP (均未找到): -> $toName"
}
Copy-First "video-calc-demo-1-cover.jpg" @(
"宇恒一号操作计算软件实例(一)\宣传片-封面.jpg",
"宇恒一号操作计算软件实例(一)\宇恒一号操作计算软件实例(一)-封面.jpg"
)
Copy-First "video-calc-demo-1.mov" @(
"宇恒一号操作计算软件实例(一)\宣传片.mov",
"宇恒一号操作计算软件实例(一)\宇恒一号操作计算软件实例(一).mov"
)
Copy-First "video-calc-demo-2-cover.jpg" @(
"宇恒一号操作计算软件实例(二)\宇恒一号操作计算软件实例(二)-封面.jpg",
"宇恒一号操作计算软件实例(二)\宣传片-封面.jpg"
)
Copy-First "video-calc-demo-2.mov" @(
"宇恒一号操作计算软件实例(二)\宇恒一号操作计算软件实例(二).mov",
"宇恒一号操作计算软件实例(二)\宣传片.mov"
)
Copy-First "video-aiword-cover.jpg" @("宇恒一号AIWord简介\宇恒一号AIWord简介-封面.jpg")
Copy-First "video-aiword.mov" @("宇恒一号AIWord简介\宇恒一号AIWord简介.mov")
Copy-First "video-voice-office-cover.jpg" @("宇恒一号语音办公实例\宇恒一号语音办公实例-封面.jpg")
Copy-First "video-voice-office.mov" @("宇恒一号语音办公实例\宇恒一号语音办公实例.mov")
Copy-First "video-invoice-ai-cover.jpg" @("宇恒一号AI 全自动办发票\宇恒一号AI 全自动办发票-封面.jpg")
Copy-First "video-invoice-ai.mov" @("宇恒一号AI 全自动办发票\宇恒一号AI 全自动办发票.mov")
Write-Host "完成。Linux 服务器上建议在 social 目录执行: chmod -R a+rX ."

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# 将旧目录「视频发布」中含中文路径的素材复制到 web/promotion/social/,使用与 promotionVideos.js 一致的英文文件名。
# 用法:在项目根执行 ./scripts/sync-video-assets-to-social.sh
# 完成后可设置权限Linuxchmod -R a+rX web/promotion/social
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/web/promotion/视频发布"
DST="$ROOT/web/promotion/social"
mkdir -p "$DST"
# 按顺序使用第一个存在的源文件
copy_first() {
local dest="$1"
shift
for from in "$@"; do
if [[ -f "$from" ]]; then
cp -f "$from" "$dest"
echo "OK $(basename "$dest") <= $from"
return 0
fi
done
echo "SKIP (均未找到): -> $dest" >&2
return 1
}
# 操作与计算软件实例(一)
copy_first "$DST/video-calc-demo-1-cover.jpg" \
"$SRC/宇恒一号操作计算软件实例(一)/宣传片-封面.jpg" \
"$SRC/宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg" || true
copy_first "$DST/video-calc-demo-1.mov" \
"$SRC/宇恒一号操作计算软件实例(一)/宣传片.mov" \
"$SRC/宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov" || true
# 操作与计算软件实例(二)
copy_first "$DST/video-calc-demo-2-cover.jpg" \
"$SRC/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg" \
"$SRC/宇恒一号操作计算软件实例(二)/宣传片-封面.jpg" || true
copy_first "$DST/video-calc-demo-2.mov" \
"$SRC/宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov" \
"$SRC/宇恒一号操作计算软件实例(二)/宣传片.mov" || true
# AI Word
copy_first "$DST/video-aiword-cover.jpg" "$SRC/宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg" || true
copy_first "$DST/video-aiword.mov" "$SRC/宇恒一号AIWord简介/宇恒一号AIWord简介.mov" || true
# 语音办公
copy_first "$DST/video-voice-office-cover.jpg" "$SRC/宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg" || true
copy_first "$DST/video-voice-office.mov" "$SRC/宇恒一号语音办公实例/宇恒一号语音办公实例.mov" || true
# 办发票(目录名含全角逗号)
copy_first "$DST/video-invoice-ai-cover.jpg" "$SRC/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票-封面.jpg" || true
copy_first "$DST/video-invoice-ai.mov" "$SRC/宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票.mov" || true
if command -v chmod >/dev/null 2>&1; then
chmod -R a+rX "$DST" 2>/dev/null || true
echo "已执行 chmod -R a+rX $DST"
fi
echo "完成。请确认 deploy 脚本会把 web/promotion 同步到 deploy/web/dist/promotion含 social 下 .mov。"

View File

@@ -0,0 +1,40 @@
# 将 web\promotion\social 下产品视频从 .mov 转为网页通用 .mp4H.264 + AACfaststart
# 依赖:已安装 ffmpeg 并在 PATH 中Windows: https://www.gyan.dev/ffmpeg/builds/ 或 winget install ffmpeg
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$Dir = Join-Path $Root "web\promotion\social"
$ffmpeg = Get-Command ffmpeg -ErrorAction SilentlyContinue
if (-not $ffmpeg) {
Write-Host "未找到 ffmpeg。请安装并加入 PATHhttps://ffmpeg.org/download.html" -ForegroundColor Red
exit 1
}
$files = @(
"video-calc-demo-1.mov",
"video-calc-demo-2.mov",
"video-aiword.mov",
"video-voice-office.mov",
"video-invoice-ai.mov"
)
foreach ($f in $files) {
$src = Join-Path $Dir $f
$base = [System.IO.Path]::GetFileNameWithoutExtension($f)
$dst = Join-Path $Dir "$base.mp4"
if (-not (Test-Path -LiteralPath $src)) {
Write-Host "[跳过] 无源文件: $src" -ForegroundColor Yellow
continue
}
Write-Host "[转码] $src -> $dst"
& ffmpeg -y -i $src `
-c:v libx264 -profile:v high -pix_fmt yuv420p `
-c:a aac -b:a 128k `
-movflags +faststart `
$dst
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "[完成] $dst" -ForegroundColor Green
}
Write-Host "全部处理结束。请部署生成的 .mp4确认后可删除本地 .mov可选" -ForegroundColor Cyan

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 将 web/promotion/social 下产品视频从 .mov 转为网页通用 .mp4H.264 + AACfaststart
# 依赖:已安装 ffmpegmacOS: brew install ffmpegUbuntu: apt install ffmpeg
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIR="$ROOT/web/promotion/social"
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "未找到 ffmpeg请先安装https://ffmpeg.org/download.html"
exit 1
fi
# 与 web/src/data/promotionVideos.js 中 relVideo 基名一致(输出为同名 .mp4
FILES=(
"video-calc-demo-1.mov"
"video-calc-demo-2.mov"
"video-aiword.mov"
"video-voice-office.mov"
"video-invoice-ai.mov"
)
for f in "${FILES[@]}"; do
src="$DIR/$f"
base="${f%.mov}"
dst="$DIR/${base}.mp4"
if [[ ! -f "$src" ]]; then
echo "[跳过] 无源文件: $src"
continue
fi
echo "[转码] $src -> $dst"
ffmpeg -y -i "$src" \
-c:v libx264 -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$dst"
echo "[完成] $dst"
done
echo "全部处理结束。请部署生成的 .mp4确认后可删除本地 .mov 以减小体积(可选)。"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Verify admin dist: chunks referenced in index.html must exist under deploy/admin/dist and be large enough
# (avoids nginx serving index.html for missing assets -> white screen, tiny JS/CSS in Network tab)
set -e
ROOT="${1:?usage: $0 <project-root>}"
D="$ROOT/deploy/admin/dist"
H="$D/index.html"
if [ ! -f "$H" ]; then
echo "verify-admin-dist: missing $H" >&2
exit 1
fi
if ! grep -qE '/admin/assets/[^"'\''<> ]+\.js' "$H"; then
echo "verify-admin-dist: no /admin/assets/*.js in index.html (check admin/vite.config.js base: /admin/)" >&2
exit 1
fi
TMP="$(mktemp)"
grep -oE '/admin/assets/[^"'\''<> ]+' "$H" | sort -u >"$TMP"
while IFS= read -r path; do
[ -n "$path" ] || continue
rel="${path#/admin}"
f="$D$rel"
if [ ! -f "$f" ]; then
echo "verify-admin-dist: referenced but missing on disk: $path -> $f" >&2
echo " Deploy full deploy/admin/dist, not index.html alone." >&2
rm -f "$TMP"
exit 1
fi
sz=$(wc -c <"$f" 2>/dev/null | tr -d ' \r\n' || echo 0)
if [ "${sz:-0}" -lt 800 ]; then
echo "verify-admin-dist: file too small (${sz} bytes), likely wrong content: $f" >&2
echo " Admin build must mount repo root (see pull-and-restart.sh / restart.sh docker -v ROOT:/repo)." >&2
rm -f "$TMP"
exit 1
fi
done <"$TMP"
rm -f "$TMP"
echo "verify-admin-dist: OK ($D)"

View File

@@ -1,9 +1,20 @@
# 复制为 .env 或 .env.production 后修改
# Go 不会自动加载 .env需在启动前导出变量见项目根目录 .env.example 的说明)
# 复制为 .env 后按需修改(一键脚本会在缺失时自动复制)
# 本地开发:先启动 MongoDB改为 mongodb://localhost:27017
# Docker 部署:保持 mongodb://mongo:27017compose 服务名)
MONGODB_URI=mongodb://localhost:27017
MONGODB_URI=mongodb://mongo:27017
MONGODB_DB=yxd-agent-testing
PORT=8080
PORT=8088
GIN_MODE=release
SERVER_DOMAIN=https://api.example.com
ALLOWED_ORIGINS=
# 对外域名CORS、日志与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值)
# SITE_JWT_SECRET=your-long-random-secret
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
# SKIP_PROMOTION_TRANSCODE=1
# 部署时自动导入「视频发布」到 data/uploads + site_assetscompose up 后执行)
# 官网站点 MongoDB _idpull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env一般无需手改
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef

View File

@@ -1,13 +1,21 @@
# 需在项目根目录构建: docker build -f server/Dockerfile .
FROM golang:1.21-alpine AS builder
# 使用 vendor 构建,无需在构建时访问 proxy.golang.org服务器无外网时也能 build
# 国内默认走镜像;海外可 --build-arg REGISTRY_MIRROR= 直连
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}golang:1.21-alpine AS builder
WORKDIR /build
COPY server/ ./
RUN go mod download && CGO_ENABLED=0 go build -o /app/server .
# 构建参数:脚本可传 GOPROXY避免 proxy.golang.org 超时;有 vendor 时主要用 -mod=vendor 离线构建
ARG GOPROXY=https://goproxy.cn,direct
ENV GOPROXY=$GOPROXY
RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
FROM alpine:3.19
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}alpine:3.19
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
# 产品视频 .mov → .mp4 服务端转码handlers/promotion_transcode.go
RUN apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
COPY --from=builder /app/server .
EXPOSE 9527
EXPOSE 8088
ENTRYPOINT ["./server"]

9
server/Dockerfile.run Normal file
View File

@@ -0,0 +1,9 @@
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app/app/server 由宿主机构建
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}alpine:3.19
# 与编译镜像一致:挂载的二进制需在容器内调用 ffmpeg 做推广视频转码
RUN apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
WORKDIR /app
EXPOSE 8088
ENTRYPOINT ["/app/server"]

View File

@@ -22,3 +22,8 @@ go run main.go
```
默认端口 8080
## 推广视频转码promotion 目录)
上传到 `sites/{site_id}/promotion/**.mov` 后,服务会异步转 **MP4**(需本机安装 **ffmpeg**,与 Docker 镜像一致)。启动时也会扫描遗留 `.mov` 补转码。详见 `handlers/promotion_transcode.go`
关闭:`SKIP_PROMOTION_TRANSCODE=1`

View File

@@ -0,0 +1,33 @@
# promotion-import
`web/promotion/视频发布/` 下映射表中的文件复制到 **`{upload}/sites/{site_id}/promotion/social/`**,并在 **`site_assets`** 集合插入记录(与后台「保留原文件名」上传到 `promotion/social` 一致)。
对「操作与计算(一)(二)」会尝试多组路径名、半角括号、子目录内**最大** `.mov`;若仍无法按「一/二」识别文件夹,会在 `视频发布` 下找出**恰好两个**含「实例」的兄弟目录(排除 AIWord/语音/发票),排序后**第一个 → demo-1、第二个 → demo-2**(文件夹名不含「一」也能配对)。
## 参数
| 参数 | 说明 |
|------|------|
| `-site` | 必填,站点 MongoDB `_id` 字符串 |
| `-src` | 可选,`视频发布` 目录;默认 `{项目根}/web/promotion/视频发布` |
| `-upload` | 可选,上传根目录;默认 `UPLOAD_DIR` 环境变量或 `{项目根}/data/uploads` |
| `-dry-run` | 只打印计划,不写盘、不写库 |
环境变量与主程序相同:`MONGODB_URI``MONGODB_DB`(见 `server/.env`)。
## 示例
```bash
cd server
go run -mod=vendor ./cmd/promotion-import/ -site=69ba1f1f41aeb82acfd609ef
```
Docker 部署时请在**宿主机**对挂载的 `data/uploads` 执行,路径示例:
```bash
./scripts/import-promotion-to-api.sh -site=xxx \
-src=/www/yh_web/web/promotion/视频发布 \
-upload=/www/yh_web/data/uploads
```
导入后无需重启 API`promotion-media` 立即可读。

View File

@@ -0,0 +1,533 @@
// promotion-import将 web/promotion/视频发布 下中文路径素材导入到 uploads + site_assets与后台上传到 promotion/social 一致)
//
// 用法(在项目 server 目录,已配置 server/.env 中 MONGODB_URI / MONGODB_DB
//
// go run -mod=vendor ./cmd/promotion-import/ -site=站点MongoID
//
// 或指定路径:
//
// go run -mod=vendor ./cmd/promotion-import/ -site=xxx -src=/www/yh_web/web/promotion/视频发布 -upload=/www/yh_web/data/uploads
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
"yh_web/server/config"
"github.com/joho/godotenv"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func loadEnv() {
wd, _ := os.Getwd()
serverDir := wd
if !strings.HasSuffix(filepath.Clean(wd), "server") {
serverDir = filepath.Join(wd, "server")
}
envPath := filepath.Clean(filepath.Join(serverDir, ".env"))
if _, err := os.Stat(envPath); err == nil {
_ = godotenv.Load(envPath)
log.Printf("已加载: %s", envPath)
}
}
func mimeForExt(ext string) string {
switch strings.ToLower(ext) {
case ".mov":
return "video/quicktime"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
default:
return "application/octet-stream"
}
}
// importRule按顺序尝试 SrcRels再 FallbackScanDir 下智能选文件EpisodeScan 在非空时扫描 视频发布 下含「实例+一/二」的子目录(兼容半角括号、文件夹名略有差异)
type importRule struct {
SrcRels []string
Dst string
FallbackScanDir string
EpisodeScan string // "一" 或 "二"
}
// 与 sync-video-assets-to-social.sh 对齐,并增加备选路径(线上常见「实例(一)」内不叫宣传片.mov 的情况)
var mappings = []importRule{
{[]string{
"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg",
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg",
"宇恒一号操作计算软件实例(一)/宣传片-封面.jpg",
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一)-封面.jpg",
}, "video-calc-demo-1-cover.jpg", "宇恒一号操作计算软件实例(一)", "一"},
{[]string{
"宇恒一号操作计算软件实例(一)/宣传片.mov",
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov",
"宇恒一号操作计算软件实例(一)/宣传片.mov",
"宇恒一号操作计算软件实例(一)/宇恒一号操作计算软件实例(一).mov",
}, "video-calc-demo-1.mov", "宇恒一号操作计算软件实例(一)", "一"},
{[]string{
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg",
"宇恒一号操作计算软件实例(二)/宣传片-封面.jpg",
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二)-封面.jpg",
"宇恒一号操作计算软件实例(二)/宣传片-封面.jpg",
}, "video-calc-demo-2-cover.jpg", "宇恒一号操作计算软件实例(二)", "二"},
{[]string{
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov",
"宇恒一号操作计算软件实例(二)/宣传片.mov",
"宇恒一号操作计算软件实例(二)/宇恒一号操作计算软件实例(二).mov",
"宇恒一号操作计算软件实例(二)/宣传片.mov",
}, "video-calc-demo-2.mov", "宇恒一号操作计算软件实例(二)", "二"},
{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介-封面.jpg"}, "video-aiword-cover.jpg", "宇恒一号AIWord简介", ""},
{[]string{"宇恒一号AIWord简介/宇恒一号AIWord简介.mov"}, "video-aiword.mov", "宇恒一号AIWord简介", ""},
{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例-封面.jpg"}, "video-voice-office-cover.jpg", "宇恒一号语音办公实例", ""},
{[]string{"宇恒一号语音办公实例/宇恒一号语音办公实例.mov"}, "video-voice-office.mov", "宇恒一号语音办公实例", ""},
{[]string{"宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票-封面.jpg"}, "video-invoice-ai-cover.jpg", "宇恒一号AI 全自动办发票", ""},
{[]string{"宇恒一号AI 全自动办发票/宇恒一号AI 全自动办发票.mov"}, "video-invoice-ai.mov", "宇恒一号AI 全自动办发票", ""},
}
// 在 视频发布 下找名称同时含「实例」与「(一)」或「(一)」等的子目录(排除另一集)
func discoverEpisodeDir(videoPublish, episode string) (dirName string, ok bool) {
full := "" + episode + ""
half := "(" + episode + ")"
entries, err := os.ReadDir(videoPublish)
if err != nil {
return "", false
}
var hits []string
for _, e := range entries {
if !e.IsDir() {
continue
}
n := e.Name()
if !strings.Contains(n, "实例") {
continue
}
marked := strings.Contains(n, full) || strings.Contains(n, half)
if !marked {
continue
}
if episode == "一" && (strings.Contains(n, "(二)") || strings.Contains(n, "(二)")) {
continue
}
if episode == "二" && (strings.Contains(n, "(一)") || strings.Contains(n, "(一)")) {
continue
}
hits = append(hits, n)
}
if len(hits) == 0 {
return "", false
}
if len(hits) == 1 {
return hits[0], true
}
for _, h := range hits {
if strings.Contains(h, "软件") {
return h, true
}
}
return hits[0], true
}
func dirHasMov(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(e.Name()), ".mov") {
return true
}
}
return false
}
func dirHasJpeg(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext == ".jpg" || ext == ".jpeg" {
return true
}
}
return false
}
// 列出「操作与计算」类子目录:含「实例」、内有 .mov 或 .jpg避免仅封面无 mov 的(一)被漏掉),排除 AIWord/语音/发票等
func listCalcInstanceDirsForPairing(videoPublish string) []string {
entries, err := os.ReadDir(videoPublish)
if err != nil {
return nil
}
skipSubstr := []string{"AIWord", "语音", "发票", "全自动"}
var out []string
outer:
for _, e := range entries {
if !e.IsDir() {
continue
}
n := e.Name()
if !strings.Contains(n, "实例") {
continue
}
for _, s := range skipSubstr {
if strings.Contains(n, s) {
continue outer
}
}
sub := filepath.Join(videoPublish, n)
if !dirHasMov(sub) && !dirHasJpeg(sub) {
continue
}
out = append(out, n)
}
return out
}
// 将目录排序为 demo-1 在前、demo-2 在后(优先认全角/半角「一」「二」标记)
func orderCalcDirsForDemo12(dirs []string) []string {
if len(dirs) <= 1 {
return dirs
}
type scored struct {
name string
prio int
}
var xs []scored
for _, d := range dirs {
p := 100
switch {
case strings.Contains(d, "(一)") || strings.Contains(d, "(一)"):
p = 1
case strings.Contains(d, "(二)") || strings.Contains(d, "(二)"):
p = 2
case strings.Contains(d, "一") && !strings.Contains(d, "二"):
p = 5
case strings.Contains(d, "二"):
p = 6
}
xs = append(xs, scored{d, p})
}
sort.Slice(xs, func(i, j int) bool {
if xs[i].prio != xs[j].prio {
return xs[i].prio < xs[j].prio
}
return xs[i].name < xs[j].name
})
out := make([]string, len(xs))
for i, x := range xs {
out[i] = x.name
}
return out
}
func pickMediaInDir(videoPublish, dirName string, dstFile string) (absPath, relChosen string, ok bool) {
ext := strings.ToLower(filepath.Ext(dstFile))
if ext != ".mov" && ext != ".jpg" && ext != ".jpeg" {
return "", "", false
}
dir := filepath.Join(videoPublish, filepath.FromSlash(dirName))
entries, err := os.ReadDir(dir)
if err != nil {
return "", "", false
}
type cand struct {
name string
size int64
}
var movs, imgs []cand
for _, e := range entries {
if e.IsDir() {
continue
}
ne := strings.ToLower(filepath.Ext(e.Name()))
p := filepath.Join(dir, e.Name())
st, err := os.Stat(p)
if err != nil {
continue
}
sz := st.Size()
if ne == ".mov" {
movs = append(movs, cand{e.Name(), sz})
}
if ne == ".jpg" || ne == ".jpeg" {
imgs = append(imgs, cand{e.Name(), sz})
}
}
pickMov := func() (string, bool) {
if len(movs) == 0 {
return "", false
}
best := movs[0]
for _, c := range movs[1:] {
if c.size > best.size {
best = c
}
}
return best.name, true
}
pickImg := func() (string, bool) {
if len(imgs) == 0 {
return "", false
}
for _, c := range imgs {
if strings.Contains(c.name, "封面") {
return c.name, true
}
}
best := imgs[0]
for _, c := range imgs[1:] {
if c.size > best.size {
best = c
}
}
return best.name, true
}
var name string
var found bool
switch ext {
case ".mov":
name, found = pickMov()
case ".jpg", ".jpeg":
name, found = pickImg()
default:
return "", "", false
}
if !found {
return "", "", false
}
rel := filepath.ToSlash(filepath.Join(dirName, name))
return filepath.Join(videoPublish, filepath.FromSlash(rel)), rel, true
}
func resolveSourceFile(videoPublish string, rule importRule, calcPair []string) (absPath, relChosen string, ok bool) {
for _, rel := range rule.SrcRels {
p := filepath.Join(videoPublish, filepath.FromSlash(rel))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, rel, true
}
}
tryDirs := []string{}
if rule.FallbackScanDir != "" {
tryDirs = append(tryDirs, rule.FallbackScanDir)
}
if rule.EpisodeScan != "" {
if d, ok := discoverEpisodeDir(videoPublish, rule.EpisodeScan); ok {
// 避免与固定目录重复
dup := false
for _, x := range tryDirs {
if x == d {
dup = true
break
}
}
if !dup {
tryDirs = append(tryDirs, d)
}
}
}
for _, dirName := range tryDirs {
if abs, rel, ok := pickMediaInDir(videoPublish, dirName, rule.Dst); ok {
return abs, rel, true
}
}
// 恰好两个「实例」类目录且无法按名称命中时:排序后第 1 个 -> demo-1第 2 个 -> demo-2
if rule.EpisodeScan == "一" && len(calcPair) >= 2 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
return abs, rel, true
}
}
if rule.EpisodeScan == "一" && len(calcPair) == 1 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[0], rule.Dst); ok {
return abs, rel, true
}
}
if rule.EpisodeScan == "二" && len(calcPair) >= 2 {
if abs, rel, ok := pickMediaInDir(videoPublish, calcPair[1], rule.Dst); ok {
return abs, rel, true
}
}
return "", "", false
}
func main() {
loadEnv()
siteID := flag.String("site", "", "站点 MongoDB ObjectID必填与 /web/routes 的 site_id 一致)")
srcRoot := flag.String("src", "", "「视频发布」目录绝对路径;默认尝试项目 web/promotion/视频发布")
uploadRoot := flag.String("upload", "", "上传根目录(内含 sites/);默认 data/uploads 或环境变量 UPLOAD_DIR")
dryRun := flag.Bool("dry-run", false, "只打印计划,不写盘、不写库")
flag.Parse()
if strings.TrimSpace(*siteID) == "" {
log.Fatal("请指定 -site=站点ID")
}
wd, _ := os.Getwd()
projectRoot := wd
if strings.HasSuffix(filepath.Clean(wd), "server") {
projectRoot = filepath.Join(wd, "..")
}
projectRoot = filepath.Clean(projectRoot)
videoPublish := *srcRoot
if videoPublish == "" {
videoPublish = filepath.Join(projectRoot, "web", "promotion", "视频发布")
}
videoPublish = filepath.Clean(videoPublish)
calcPair := orderCalcDirsForDemo12(listCalcInstanceDirsForPairing(videoPublish))
if *dryRun && len(calcPair) > 0 {
log.Printf("[dry-run] 操作与计算类目录配对顺序(1<-[0], 2<-[1]): %v", calcPair)
}
uploadDir := *uploadRoot
if uploadDir == "" {
uploadDir = os.Getenv("UPLOAD_DIR")
}
if uploadDir == "" {
uploadDir = filepath.Join(projectRoot, "data", "uploads")
}
uploadDir = filepath.Clean(uploadDir)
mongoURI := os.Getenv("MONGODB_URI")
if mongoURI == "" {
mongoURI = "mongodb://localhost:27017"
}
if dbName := os.Getenv("MONGODB_DB"); dbName != "" {
config.DBName = dbName
}
if *dryRun {
log.Printf("[dry-run] 视频发布源: %s", videoPublish)
log.Printf("[dry-run] 上传根: %s", uploadDir)
log.Printf("[dry-run] site_id: %s", *siteID)
}
if !*dryRun {
if err := config.ConnectMongoDB(mongoURI); err != nil {
log.Fatalf("MongoDB: %v", err)
}
defer config.CloseMongoDB()
}
db := config.GetDB(config.DBName)
if db == nil && !*dryRun {
log.Fatal("数据库未连接")
}
var coll *mongo.Collection
if db != nil {
coll = db.Collection("site_assets")
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
ok, skip, fail := 0, 0, 0
for _, m := range mappings {
from, srcRelUsed, found := resolveSourceFile(videoPublish, m, calcPair)
if !found {
log.Printf("SKIP 源文件不存在(已试备选路径/扫描子目录): dst=%s episode=%s", m.Dst, m.EpisodeScan)
skip++
continue
}
destDir := filepath.Join(uploadDir, "sites", *siteID, "promotion", "social")
destPath := filepath.Join(destDir, m.Dst)
relPath := filepath.ToSlash(filepath.Join("sites", *siteID, "promotion", "social", m.Dst))
if *dryRun {
log.Printf("COPY %s -> %s | DB file_path=%s", from, destPath, relPath)
ok++
continue
}
if err := os.MkdirAll(destDir, 0755); err != nil {
log.Printf("FAIL 创建目录 %s: %v", destDir, err)
fail++
continue
}
if err := copyFile(from, destPath); err != nil {
log.Printf("FAIL 复制 %s: %v", srcRelUsed, err)
fail++
continue
}
_ = os.Chmod(destPath, 0644)
fi, _ := os.Stat(destPath)
size := int64(0)
if fi != nil {
size = fi.Size()
}
ext := strings.ToLower(filepath.Ext(m.Dst))
ct := mimeForExt(ext)
_, _ = coll.DeleteMany(ctx, bson.M{"site_id": *siteID, "file_path": relPath})
doc := bson.M{
"site_id": *siteID,
"name": m.Dst,
"file_path": relPath,
"size": size,
"content_type": ct,
"downloadable": false,
"created_at": time.Now().Format(time.RFC3339),
"import_source": "video_publish_legacy",
"source_relpath": srcRelUsed,
"promotion_alias": filepath.ToSlash(filepath.Join("promotion", "social", m.Dst)),
}
if _, err := coll.InsertOne(ctx, doc); err != nil {
log.Printf("FAIL 写库 %s: %v", relPath, err)
fail++
continue
}
log.Printf("OK %s -> %s", srcRelUsed, relPath)
ok++
}
fmt.Printf("\n完成: 成功=%d 跳过=%d 失败=%d\n", ok, skip, fail)
if fail > 0 {
os.Exit(1)
}
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}

View File

@@ -1,8 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo Starting backend with hot reload...
if not exist tmp mkdir tmp
CompileDaemon -command=tmp\main.exe -build="go build -o tmp\main.exe ."

View File

@@ -11,6 +11,7 @@ require (
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -18,6 +19,8 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // indirect
@@ -27,15 +30,34 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pion/datachannel v1.5.8 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/ice/v2 v2.3.36 // indirect
github.com/pion/interceptor v0.1.29 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.7 // indirect
github.com/pion/sctp v1.8.19 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pion/webrtc/v3 v3.3.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect

View File

@@ -30,6 +30,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -39,6 +43,9 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
@@ -50,11 +57,52 @@ 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.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -63,10 +111,15 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -85,16 +138,28 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -103,20 +168,36 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
@@ -124,6 +205,7 @@ google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cn
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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=

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -34,6 +35,37 @@ type Claims struct {
jwt.RegisteredClaims
}
// ParseClaimsFromTokenString 解析 Authorization Bearer 或裸 JWT失败返回 false
func ParseClaimsFromTokenString(tokenStr string) (*Claims, bool) {
tokenStr = strings.TrimSpace(tokenStr)
if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") {
tokenStr = strings.TrimSpace(tokenStr[7:])
}
if tokenStr == "" {
return nil, false
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
return nil, false
}
return &claims, true
}
// LivePublishAllowed 仅允许已登录后台账号发起 WebRTC 推流(与 AuthRequired 身份范围一致)
func LivePublishAllowed(tokenStr string) bool {
claims, ok := ParseClaimsFromTokenString(tokenStr)
if !ok {
return false
}
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
return false
}
return true
}
// Login 后台登录,仅 role_id=9527 超级管理员可登录
func Login(c *gin.Context) {
var input LoginInput
@@ -122,25 +154,13 @@ func AuthRequired() gin.HandlerFunc {
if tokenStr == "" {
tokenStr = c.Query("token")
}
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
tokenStr = tokenStr[7:]
}
if tokenStr == "" {
claims, ok := ParseClaimsFromTokenString(tokenStr)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort()
return
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
c.Abort()
return
}
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
@@ -164,25 +184,13 @@ func SuperUserAuthRequired() gin.HandlerFunc {
if tokenStr == "" {
tokenStr = c.Query("token")
}
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
tokenStr = tokenStr[7:]
}
if tokenStr == "" {
claims, ok := ParseClaimsFromTokenString(tokenStr)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort()
return
}
var claims Claims
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
c.Abort()
return
}
// 仅 role_id=9527 且 role=admin 可配置短信平台
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})

View File

@@ -0,0 +1,163 @@
package handlers
import (
"context"
"net/http"
"os"
"strconv"
"strings"
"time"
"yh_web/server/config"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const chunkCleanupConfigID = "chunk_upload_cleanup"
type chunkCleanupConfigDoc struct {
MaxAgeHours float64 `bson:"max_age_hours" json:"max_age_hours"`
SweepMinutes int `bson:"sweep_minutes" json:"sweep_minutes"`
}
func maxAgeFromEnv() time.Duration {
h := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_MAX_AGE_HOURS"))
if h == "" {
return 72 * time.Hour
}
v, err := strconv.ParseFloat(h, 64)
if err != nil || v < 6 {
return 72 * time.Hour
}
return time.Duration(v * float64(time.Hour))
}
func sweepFromEnv() time.Duration {
m := strings.TrimSpace(os.Getenv("YH_CHUNK_UPLOAD_SWEEP_MINUTES"))
if m == "" {
return time.Hour
}
v, err := strconv.Atoi(m)
if err != nil || v < 5 {
return time.Hour
}
return time.Duration(v) * time.Minute
}
func normalizeMaxAgeHours(h float64) time.Duration {
if h < 6 {
return 6 * time.Hour
}
if h > 336 {
return 336 * time.Hour
}
return time.Duration(h * float64(time.Hour))
}
func normalizeSweepMinutes(m int) time.Duration {
if m < 5 {
return 5 * time.Minute
}
if m > 1440 {
return 1440 * time.Minute
}
return time.Duration(m) * time.Minute
}
// loadChunkCleanupParameters 优先读 MongoDB system_config无文档时用环境变量用于定时清扫
func loadChunkCleanupParameters() (maxAge time.Duration, sweepEvery time.Duration) {
db := config.GetDB(config.DBName)
if db != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc chunkCleanupConfigDoc
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
if err == nil && doc.MaxAgeHours >= 6 && doc.SweepMinutes >= 5 {
return normalizeMaxAgeHours(doc.MaxAgeHours), normalizeSweepMinutes(doc.SweepMinutes)
}
}
return maxAgeFromEnv(), sweepFromEnv()
}
// GetChunkUploadCleanupConfig 后台读取当前保存的配置(无文档时返回默认值)
func GetChunkUploadCleanupConfig(c *gin.Context) {
db := config.GetDB(config.DBName)
if db == nil {
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
MaxAgeHours: 72,
SweepMinutes: 60,
})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc chunkCleanupConfigDoc
err := db.Collection("system_config").FindOne(ctx, bson.M{"_id": chunkCleanupConfigID}).Decode(&doc)
if err != nil {
if err == mongo.ErrNoDocuments {
c.JSON(http.StatusOK, chunkCleanupConfigDoc{
MaxAgeHours: 72,
SweepMinutes: 60,
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if doc.MaxAgeHours < 6 {
doc.MaxAgeHours = 72
}
if doc.SweepMinutes < 5 {
doc.SweepMinutes = 60
}
c.JSON(http.StatusOK, doc)
}
// ChunkUploadCleanupUpdateInput 后台保存
type ChunkUploadCleanupUpdateInput struct {
MaxAgeHours float64 `json:"max_age_hours" binding:"required"`
SweepMinutes int `json:"sweep_minutes" binding:"required"`
}
// UpdateChunkUploadCleanupConfig 保存分片临时目录保留时长与扫描间隔
func UpdateChunkUploadCleanupConfig(c *gin.Context) {
var input ChunkUploadCleanupUpdateInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if input.MaxAgeHours < 6 || input.MaxAgeHours > 336 {
c.JSON(http.StatusBadRequest, gin.H{"error": "保留时长须在 6336 小时之间"})
return
}
if input.SweepMinutes < 5 || input.SweepMinutes > 1440 {
c.JSON(http.StatusBadRequest, gin.H{"error": "扫描间隔须在 51440 分钟之间"})
return
}
db := config.GetDB(config.DBName)
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,无法保存"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
coll := db.Collection("system_config")
set := bson.M{
"_id": chunkCleanupConfigID,
"max_age_hours": input.MaxAgeHours,
"sweep_minutes": input.SweepMinutes,
"updated_at": time.Now().Format(time.RFC3339),
"updated_by_hint": "admin",
}
opts := options.UpdateOne().SetUpsert(true)
_, err := coll.UpdateOne(ctx, bson.M{"_id": chunkCleanupConfigID}, bson.M{"$set": set}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "配置已保存"})
}

View File

@@ -47,6 +47,11 @@ func GetWebHomepage(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
siteID := getOfficialSiteID(ctx)
if siteID == "" {
c.JSON(http.StatusOK, defaultHomepageData())
@@ -186,29 +191,27 @@ func DownloadHomepage(c *gin.Context) {
func defaultHomepageData() models.HomepageData {
return models.HomepageData{
LogoText: "YUHENG ONE",
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}},
LogoText: "宇恒一号",
NavLinks: []models.NavLink{{Label: "产品简介", URL: "#intro"}, {Label: "产品视频", URL: "#videos"}, {Label: "联系我们", URL: "#contact"}},
Title: "宇恒一号",
Subtitle: "INTERSTELLAR EXPLORER EDITION",
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙",
DownloadText: "START EXPLORING",
Subtitle: "",
Description: "",
DownloadText: "下载",
DownloadURL: "#",
Platforms: []models.PlatformItem{
{Name: "WINDOWS", URL: "#"},
{Name: "MACOS", URL: "#"},
{Name: "LINUX", URL: "#"},
{Name: "IOS", URL: "#"},
{Name: "ANDROID", URL: "#"},
},
Version: "VERSION 3.2.1",
LaunchYear: "LAUNCH: 2024",
BadgeText: "FREE ACCESS",
Platforms: []models.PlatformItem{},
Version: "",
LaunchYear: "发布日期:以官网为准",
BadgeText: "完全免费",
DownloadWindowsURL: "/promotion/downloads/yuheng-windows.zip",
DownloadAndroidURL: "/promotion/downloads/yuheng-android.apk",
Features: []models.FeatureItem{
{Title: "星际导航", Desc: "先进的AI导航系统精准定位您的需求引领探索之旅"},
{Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"},
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
},
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE",
FooterText: "© 2024 宇恒一号 · 成都宇信达智能科技有限公司",
LiveRoomURL: "",
LiveRoomTitle: "视频直播",
}
}

View File

@@ -5,6 +5,9 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -16,22 +19,47 @@ import (
"github.com/gin-gonic/gin"
)
const uploadDir = "uploads"
// getUploadDir 上传根目录:容器内通过 UPLOAD_DIR 挂载到独立可写路径(如 /uploads避免 /app 只读
func getUploadDir() string {
if d := os.Getenv("UPLOAD_DIR"); d != "" {
return d
}
return "uploads"
}
// ListSiteAssets 站点功能模块/上传文件列表
// pathPrefix 站点下相对路径前缀,用于多级目录
func pathPrefix(siteID string) string {
return "sites/" + siteID + "/"
}
// ListSiteAssets 站点功能模块/上传文件列表query path 为当前目录相对路径空为根downloadable=1 时返回该站点下所有可下载文件(供首页编辑选择)
func ListSiteAssets(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
onlyDownloadable := c.Query("downloadable") == "1" || c.Query("downloadable") == "true"
if onlyDownloadable {
listDownloadableAssets(c, siteID)
return
}
path := c.Query("path")
prefix := pathPrefix(siteID)
if path != "" {
prefix = prefix + path
if prefix[len(prefix)-1] != '/' {
prefix += "/"
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
filter := bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix) + "[^/]+$"}}
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -43,11 +71,179 @@ func ListSiteAssets(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
total, _ := coll.CountDocuments(ctx, filter)
subDirs := listSubDirs(c, siteID, path)
c.JSON(http.StatusOK, gin.H{"list": list, "total": total, "sub_dirs": subDirs})
}
// UploadSiteAsset 上传功能模块/文件
func listDownloadableAssets(c *gin.Context, siteID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
filter := bson.M{"site_id": siteID, "downloadable": true}
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := coll.Find(ctx, filter, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var list []models.SiteAsset
if err = cursor.All(ctx, &list); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"list": list, "total": len(list)})
}
// ListDownloadableAssets 仅返回可下载文件列表(供首页编辑选择,仅需 homepage:edit 权限)
func ListDownloadableAssets(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
listDownloadableAssets(c, siteID)
}
func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
prefix := pathPrefix(siteID)
if currentPath != "" {
prefix = prefix + currentPath
if prefix[len(prefix)-1] != '/' {
prefix += "/"
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix)}})
if err != nil {
return nil
}
defer cursor.Close(ctx)
var docs []struct {
FilePath string `bson:"file_path"`
}
_ = cursor.All(ctx, &docs)
seen := make(map[string]bool)
for _, d := range docs {
rel := strings.TrimPrefix(d.FilePath, prefix)
if rel == "" || rel == d.FilePath {
continue
}
parts := strings.SplitN(rel, "/", 2)
if len(parts) > 0 && parts[0] != "" {
seen[parts[0]] = true
}
}
// 再扫描物理目录
baseDir := filepath.Join(getUploadDir(), filepath.FromSlash(prefix))
entries, _ := os.ReadDir(baseDir)
for _, e := range entries {
if e.IsDir() {
seen[e.Name()] = true
}
}
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
return names
}
func promotionMimeType(ext string) string {
switch strings.ToLower(ext) {
case ".mov":
return "video/quicktime"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
default:
return ""
}
}
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
func ServePromotionMedia(c *gin.Context) {
siteID := c.Param("site_id")
raw := strings.TrimPrefix(c.Param("filepath"), "/")
if siteID == "" || raw == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
rel := filepath.ToSlash(filepath.Clean(raw))
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
return
}
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
relBack, err := filepath.Rel(baseDir, fullPath)
if err != nil || strings.HasPrefix(relBack, "..") {
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
return
}
fi, err := os.Stat(fullPath)
if err != nil || fi.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
ext := filepath.Ext(fullPath)
ct := promotionMimeType(ext)
if ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
c.Header("Cache-Control", "public, max-age=86400")
c.File(fullPath)
}
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
name := originalFilename
ext := filepath.Ext(name)
nameNoExt := strings.TrimSuffix(name, ext)
var saveName string
if preserve {
saveName = filepath.Base(name)
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
return "", "", "无效的文件名"
}
} else {
if len(ext) == 0 {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
} else {
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
}
}
folderClean := ""
if folder != "" {
folderClean = filepath.ToSlash(filepath.Clean(folder))
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
return "", "", "无效的目录路径"
}
}
if folderClean != "" {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
} else {
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
}
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
return relPath, destPath, ""
}
// UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false、preserve_filenametrue 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL
func UploadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
@@ -61,20 +257,30 @@ func UploadSiteAsset(c *gin.Context) {
return
}
baseDir := filepath.Join(uploadDir, "sites", siteID)
folder := strings.TrimSpace(c.PostForm("folder"))
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
if errMsg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
return
}
if preserve {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancelDel()
coll := config.GetDB(config.DBName).Collection("site_assets")
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
_ = os.Remove(destPath)
}
baseDir := filepath.Dir(destPath)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
// 避免覆盖:加时间戳
name := file.Filename
ext := filepath.Ext(name)
nameNoExt := name[:len(name)-len(ext)]
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
relPath := filepath.Join("sites", siteID, saveName)
destPath := filepath.Join(uploadDir, relPath)
if err := c.SaveUploadedFile(file, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
@@ -84,12 +290,13 @@ func UploadSiteAsset(c *gin.Context) {
defer cancel()
doc := models.SiteAsset{
SiteID: siteID,
Name: file.Filename,
FilePath: relPath,
Size: file.Size,
ContentType: file.Header.Get("Content-Type"),
CreatedAt: time.Now().Format(time.RFC3339),
SiteID: siteID,
Name: file.Filename,
FilePath: relPath,
Size: file.Size,
ContentType: file.Header.Get("Content-Type"),
Downloadable: downloadable,
CreatedAt: time.Now().Format(time.RFC3339),
}
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
"site_id": doc.SiteID,
@@ -97,6 +304,7 @@ func UploadSiteAsset(c *gin.Context) {
"file_path": doc.FilePath,
"size": doc.Size,
"content_type": doc.ContentType,
"downloadable": doc.Downloadable,
"created_at": doc.CreatedAt,
})
if err != nil {
@@ -105,6 +313,8 @@ func UploadSiteAsset(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
}
// DeleteSiteAsset 删除站点资源
@@ -133,7 +343,7 @@ func DeleteSiteAsset(c *gin.Context) {
return
}
fullPath := filepath.Join(uploadDir, asset.FilePath)
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
os.Remove(fullPath)
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
@@ -143,3 +353,71 @@ func DeleteSiteAsset(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// CreateSiteFolderInput 创建目录
type CreateSiteFolderInput struct {
Path string `json:"path" binding:"required"`
}
// CreateSiteFolder 在站点下创建多级目录
func CreateSiteFolder(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
var input CreateSiteFolderInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写目录路径"})
return
}
clean := filepath.Clean(input.Path)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
return
}
baseDir := filepath.Join(getUploadDir(), "sites", siteID, clean)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "path": filepath.ToSlash(clean)})
}
// DownloadSiteAsset 前台公开下载:仅当资源标记为可下载时返回文件(供首页等使用)
func DownloadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id")
assetIDStr := c.Param("asset_id")
if siteID == "" || assetIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
oid, err := bson.ObjectIDFromHex(assetIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
var asset models.SiteAsset
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
if !asset.Downloadable {
c.JSON(http.StatusForbidden, gin.H{"error": "该资源不可下载"})
return
}
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
if _, err := os.Stat(fullPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.Header("Content-Disposition", "attachment; filename=\""+asset.Name+"\"")
if asset.ContentType != "" {
c.Header("Content-Type", asset.ContentType)
}
c.File(fullPath)
}

View File

@@ -0,0 +1,532 @@
package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"yh_web/server/config"
"yh_web/server/pkg/logger"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
)
// 与 Nginx client_max_body_size 对齐;分片单请求仅 chunk_size 字节量级
const maxMultipartTotalSize = int64(800 << 20)
const defaultChunkSize = int64(4 << 20)
const minChunkSize = int64(1 << 20)
const maxChunkSize = int64(32 << 20)
type chunkSessionMeta struct {
SiteID string `json:"site_id"`
OriginalFilename string `json:"original_filename"`
TotalSize int64 `json:"total_size"`
ChunkSize int64 `json:"chunk_size"`
TotalChunks int `json:"total_chunks"`
Folder string `json:"folder"`
Downloadable bool `json:"downloadable"`
PreserveFilename bool `json:"preserve_filename"`
CreatedUnix int64 `json:"created_unix"`
}
func chunkSessionsRoot() string {
return filepath.Join(getUploadDir(), ".chunk-uploads")
}
func chunkSessionDir(uploadID string) string {
return filepath.Join(chunkSessionsRoot(), uploadID)
}
func metaPath(uploadID string) string {
return filepath.Join(chunkSessionDir(uploadID), "meta.json")
}
func validUploadID(uploadID string) bool {
if len(uploadID) != 24 {
return false
}
for _, c := range uploadID {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
_, err := bson.ObjectIDFromHex(uploadID)
return err == nil
}
func readChunkMeta(uploadID string) (*chunkSessionMeta, error) {
data, err := os.ReadFile(metaPath(uploadID))
if err != nil {
return nil, err
}
var m chunkSessionMeta
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
func chunkExpectedSize(meta *chunkSessionMeta, index int) int64 {
if index < 0 || index >= meta.TotalChunks {
return -1
}
start := int64(index) * meta.ChunkSize
end := start + meta.ChunkSize
if end > meta.TotalSize {
end = meta.TotalSize
}
return end - start
}
// InitMultipartUpload 创建分片会话(断点续传第一步)
func InitMultipartUpload(c *gin.Context) {
siteID := c.Param("site_id")
if siteID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
return
}
var body struct {
Filename string `json:"filename" binding:"required"`
TotalSize int64 `json:"total_size" binding:"required"`
ChunkSize int64 `json:"chunk_size"`
Folder string `json:"folder"`
Downloadable bool `json:"downloadable"`
PreserveFilename bool `json:"preserve_filename"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 filename、total_size"})
return
}
if body.TotalSize <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件大小无效"})
return
}
if body.TotalSize > maxMultipartTotalSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过当前站点允许的最大体积800MB"})
return
}
cs := body.ChunkSize
if cs <= 0 {
cs = defaultChunkSize
}
if cs < minChunkSize || cs > maxChunkSize {
c.JSON(http.StatusBadRequest, gin.H{"error": "chunk_size 须在 1MB32MB 之间"})
return
}
totalChunks := int((body.TotalSize + cs - 1) / cs)
if totalChunks <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "分片数无效"})
return
}
folder := strings.TrimSpace(body.Folder)
if folder != "" {
fc := filepath.ToSlash(filepath.Clean(folder))
if strings.HasPrefix(fc, "../") || strings.Contains(fc, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
return
}
}
uploadID := bson.NewObjectID().Hex()
dir := chunkSessionDir(uploadID)
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时目录失败"})
return
}
meta := chunkSessionMeta{
SiteID: siteID,
OriginalFilename: body.Filename,
TotalSize: body.TotalSize,
ChunkSize: cs,
TotalChunks: totalChunks,
Folder: folder,
Downloadable: body.Downloadable,
PreserveFilename: body.PreserveFilename,
CreatedUnix: time.Now().Unix(),
}
raw, _ := json.Marshal(meta)
if err := os.WriteFile(filepath.Join(dir, "meta.json"), raw, 0644); err != nil {
_ = os.RemoveAll(dir)
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入会话失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"upload_id": uploadID,
"chunk_size": cs,
"total_chunks": totalChunks,
"received_chunks": []int{},
})
}
// MultipartUploadStatus 返回已收到的分片下标(用于续传)
func MultipartUploadStatus(c *gin.Context) {
siteID := c.Param("site_id")
uploadID := c.Param("upload_id")
if siteID == "" || !validUploadID(uploadID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
meta, err := readChunkMeta(uploadID)
if err != nil || meta.SiteID != siteID {
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在或已过期"})
return
}
dir := chunkSessionDir(uploadID)
entries, err := os.ReadDir(dir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取会话失败"})
return
}
received := make([]int, 0, meta.TotalChunks)
for _, e := range entries {
if e.IsDir() || e.Name() == "meta.json" {
continue
}
idx, err := strconv.Atoi(e.Name())
if err != nil || idx < 0 || idx >= meta.TotalChunks {
continue
}
info, err := e.Info()
if err != nil {
continue
}
exp := chunkExpectedSize(meta, idx)
if exp >= 0 && info.Size() == exp {
received = append(received, idx)
}
}
sort.Ints(received)
c.JSON(http.StatusOK, gin.H{
"upload_id": uploadID,
"total_chunks": meta.TotalChunks,
"total_size": meta.TotalSize,
"chunk_size": meta.ChunkSize,
"received_chunks": received,
"original_filename": meta.OriginalFilename,
})
}
// PutMultipartChunk 上传单个分片。支持 multipart 字段 chunk推荐或 application/octet-stream 原始 body路由同时注册 POST 与 PUT。
func PutMultipartChunk(c *gin.Context) {
siteID := c.Param("site_id")
uploadID := c.Param("upload_id")
chunkStr := c.Param("chunk_index")
if siteID == "" || !validUploadID(uploadID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
chunkIndex, err := strconv.Atoi(chunkStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的分片序号"})
return
}
meta, err := readChunkMeta(uploadID)
if err != nil || meta.SiteID != siteID {
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
return
}
expected := chunkExpectedSize(meta, chunkIndex)
if expected < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "分片序号越界"})
return
}
chunkFile := filepath.Join(chunkSessionDir(uploadID), strconv.Itoa(chunkIndex))
if fi, err := os.Stat(chunkFile); err == nil && fi.Size() == expected {
c.JSON(http.StatusOK, gin.H{"message": "分片已存在", "chunk_index": chunkIndex, "size": expected})
return
}
tmp := chunkFile + ".part"
f, err := os.Create(tmp)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"})
return
}
ct := strings.ToLower(c.GetHeader("Content-Type"))
var src io.Reader
if strings.HasPrefix(ct, "multipart/form-data") {
// 与整文件上传一致走 multipart避免部分网关对 raw POST body 断连
fh, err := c.FormFile("chunk")
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
c.JSON(http.StatusBadRequest, gin.H{"error": "请使用表单字段 chunk 上传分片"})
return
}
if fh.Size > 0 && fh.Size != expected {
_ = f.Close()
_ = os.Remove(tmp)
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小不符"})
return
}
part, err := fh.Open()
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
c.JSON(http.StatusBadRequest, gin.H{"error": "打开分片失败"})
return
}
defer part.Close()
src = part
} else {
src = c.Request.Body
}
n, err := io.Copy(f, io.LimitReader(src, expected+1))
_ = f.Close()
if err != nil {
_ = os.Remove(tmp)
c.JSON(http.StatusBadRequest, gin.H{"error": "读取分片失败"})
return
}
if n != expected {
_ = os.Remove(tmp)
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小不符"})
return
}
if err := os.Rename(tmp, chunkFile); err != nil {
_ = os.Remove(tmp)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存分片失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "分片已保存", "chunk_index": chunkIndex, "size": expected})
}
// CompleteMultipartUpload 合并分片并写入 site_assets
func CompleteMultipartUpload(c *gin.Context) {
siteID := c.Param("site_id")
uploadID := c.Param("upload_id")
if siteID == "" || !validUploadID(uploadID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
meta, err := readChunkMeta(uploadID)
if err != nil || meta.SiteID != siteID {
c.JSON(http.StatusNotFound, gin.H{"error": "上传会话不存在"})
return
}
dir := chunkSessionDir(uploadID)
for i := 0; i < meta.TotalChunks; i++ {
p := filepath.Join(dir, strconv.Itoa(i))
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "分片未齐,无法合并", "missing_chunk": i})
return
}
if fi.Size() != chunkExpectedSize(meta, i) {
c.JSON(http.StatusBadRequest, gin.H{"error": "分片大小异常", "chunk_index": i})
return
}
}
relPath, destPath, errMsg := computeSiteUploadDest(siteID, meta.Folder, meta.OriginalFilename, meta.PreserveFilename)
if errMsg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
return
}
if meta.PreserveFilename {
ctxDel, cancelDel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancelDel()
coll := config.GetDB(config.DBName).Collection("site_assets")
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
_ = os.Remove(destPath)
}
baseDir := filepath.Dir(destPath)
if err := os.MkdirAll(baseDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
return
}
dst, err := os.Create(destPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目标文件失败"})
return
}
for i := 0; i < meta.TotalChunks; i++ {
srcPath := filepath.Join(dir, strconv.Itoa(i))
src, err := os.Open(srcPath)
if err != nil {
_ = dst.Close()
_ = os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": "打开分片失败"})
return
}
_, err = io.Copy(dst, src)
_ = src.Close()
if err != nil {
_ = dst.Close()
_ = os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并分片失败"})
return
}
}
_ = dst.Close()
fi, err := os.Stat(destPath)
if err != nil || fi.Size() != meta.TotalSize {
_ = os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": "合并后大小与声明不符"})
return
}
buf := make([]byte, 512)
fh, err := os.Open(destPath)
var contentType string
if err == nil {
n, _ := fh.Read(buf)
_ = fh.Close()
contentType = http.DetectContentType(buf[:n])
}
if contentType == "" || contentType == "application/octet-stream" {
if x := promotionMimeType(filepath.Ext(meta.OriginalFilename)); x != "" {
contentType = x
}
}
if contentType == "" {
contentType = "application/octet-stream"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
"site_id": siteID,
"name": meta.OriginalFilename,
"file_path": relPath,
"size": meta.TotalSize,
"content_type": contentType,
"downloadable": meta.Downloadable,
"created_at": time.Now().Format(time.RFC3339),
})
if err != nil {
_ = os.Remove(destPath)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = os.RemoveAll(dir)
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": relPath, "message": "上传成功"})
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
}
// AbortMultipartUpload 取消分片会话并删除临时文件
func AbortMultipartUpload(c *gin.Context) {
siteID := c.Param("site_id")
uploadID := c.Param("upload_id")
if siteID == "" || !validUploadID(uploadID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
meta, err := readChunkMeta(uploadID)
if err != nil || meta.SiteID != siteID {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
return
}
_ = os.RemoveAll(chunkSessionDir(uploadID))
c.JSON(http.StatusOK, gin.H{"message": "已取消"})
}
func chunkSessionCreatedAt(uploadID string) time.Time {
meta, err := readChunkMeta(uploadID)
if err == nil && meta.CreatedUnix > 0 {
return time.Unix(meta.CreatedUnix, 0)
}
fi, err := os.Stat(chunkSessionDir(uploadID))
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
// SweepStaleChunkUploadSessions 删除 {UPLOAD_DIR}/.chunk-uploads 下超过 staleChunkMaxAge 的会话目录
func SweepStaleChunkUploadSessions() (removed int, err error) {
root := chunkSessionsRoot()
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
maxAge, _ := loadChunkCleanupParameters()
now := time.Now()
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
if !validUploadID(name) {
continue
}
created := chunkSessionCreatedAt(name)
if created.IsZero() {
continue
}
if now.Sub(created) < maxAge {
continue
}
dir := filepath.Join(root, name)
if err := os.RemoveAll(dir); err != nil {
logger.Err("chunk_upload", "删除过期分片目录失败 %s: %v", dir, err)
continue
}
removed++
}
return removed, nil
}
// StartStaleChunkUploadSweep 启动后延迟执行一次,再按周期清扫非活动 .chunk-uploads
func StartStaleChunkUploadSweep(ctx context.Context) {
go func() {
const bootDelay = 2 * time.Minute
t := time.NewTimer(bootDelay)
select {
case <-t.C:
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return
}
run := func() {
n, err := SweepStaleChunkUploadSessions()
if err != nil {
logger.Err("chunk_upload", "扫描 .chunk-uploads 失败: %v", err)
return
}
if n > 0 {
logger.Log("chunk_upload", "已删除 %d 个过期分片上传临时目录(超过后台或环境变量配置的保留时长)", n)
}
}
run()
lastSweep := time.Now()
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
_, interval := loadChunkCleanupParameters()
if time.Since(lastSweep) >= interval {
run()
lastSweep = time.Now()
}
}
}
}()
}

View File

@@ -67,11 +67,14 @@ func GetPageByID(c *gin.Context) {
// CreatePageInput 创建网页
type CreatePageInput struct {
SiteID string `json:"site_id" binding:"required"`
Slug string `json:"slug" binding:"required"`
Title string `json:"title" binding:"required"`
Type string `json:"type"` // homepage, page
Content string `json:"content"`
SiteID string `json:"site_id" binding:"required"`
Slug string `json:"slug" binding:"required"`
Title string `json:"title" binding:"required"`
Type string `json:"type"` // homepage, page
Content string `json:"content"`
ContentMode string `json:"content_mode"` // html | builder
RoutePath string `json:"route_path"`
Published *bool `json:"published"`
}
// CreatePage 创建网页
@@ -98,6 +101,15 @@ func CreatePage(c *gin.Context) {
"content": input.Content,
"updated_at": now,
}
if input.ContentMode != "" {
doc["content_mode"] = input.ContentMode
}
if input.RoutePath != "" {
doc["route_path"] = input.RoutePath
}
if input.Published != nil {
doc["published"] = *input.Published
}
res, err := coll.InsertOne(ctx, doc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -108,10 +120,13 @@ func CreatePage(c *gin.Context) {
// UpdatePageInput 更新网页
type UpdatePageInput struct {
Slug *string `json:"slug"`
Title *string `json:"title"`
Type *string `json:"type"`
Content *string `json:"content"`
Slug *string `json:"slug"`
Title *string `json:"title"`
Type *string `json:"type"`
Content *string `json:"content"`
ContentMode *string `json:"content_mode"`
RoutePath *string `json:"route_path"`
Published *bool `json:"published"`
}
// UpdatePage 更新网页
@@ -142,6 +157,15 @@ func UpdatePage(c *gin.Context) {
if input.Content != nil {
set["content"] = *input.Content
}
if input.ContentMode != nil {
set["content_mode"] = *input.ContentMode
}
if input.RoutePath != nil {
set["route_path"] = *input.RoutePath
}
if input.Published != nil {
set["published"] = *input.Published
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

View File

@@ -0,0 +1,197 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"yh_web/server/config"
)
func skipPromotionTranscode() bool {
v := strings.TrimSpace(os.Getenv("SKIP_PROMOTION_TRANSCODE"))
return v == "1" || strings.EqualFold(v, "true")
}
func ffmpegAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
func isMOVUnderPromotion(relPath string, ext string) bool {
if strings.ToLower(ext) != ".mov" {
return false
}
return strings.Contains(filepath.ToSlash(relPath), "/promotion/")
}
func mp4PathForMOV(movPath string) string {
return strings.TrimSuffix(movPath, filepath.Ext(movPath)) + ".mp4"
}
func needsTranscode(movPath, mp4Path string) bool {
mi, err1 := os.Stat(movPath)
if err1 != nil || mi.IsDir() {
return false
}
pi, err2 := os.Stat(mp4Path)
if err2 != nil {
return true
}
return mi.ModTime().After(pi.ModTime())
}
// runFFmpegMOVToMP4 将 mov 转为浏览器通用 mp4与前端/脚本参数一致)
func runFFmpegMOVToMP4(ctx context.Context, movPath, mp4Path string) error {
cmd := exec.CommandContext(ctx, "ffmpeg",
"-y", "-i", movPath,
"-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
mp4Path,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, string(out))
}
return nil
}
func relPathFromUploadRoot(uploadRoot, fullPath string) (string, error) {
r, err := filepath.Rel(uploadRoot, fullPath)
if err != nil {
return "", err
}
return filepath.ToSlash(r), nil
}
// replaceMOVWithMP4InDB 将 site_assets 中对应 .mov 记录更新为 .mp4转码成功后调用
func replaceMOVWithMP4InDB(siteID, oldRelPath, mp4FullPath string, insertedID any) {
if config.MongoClient == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("site_assets")
newRel := strings.TrimSuffix(oldRelPath, filepath.Ext(oldRelPath)) + ".mp4"
fi, err := os.Stat(mp4FullPath)
if err != nil {
log.Printf("[promotion-transcode] stat mp4: %v", err)
return
}
set := bson.M{
"file_path": newRel,
"name": filepath.Base(newRel),
"size": fi.Size(),
"content_type": "video/mp4",
}
filter := bson.M{"site_id": siteID, "file_path": oldRelPath}
if oid, ok := insertedID.(bson.ObjectID); ok && !oid.IsZero() {
filter = bson.M{"_id": oid, "site_id": siteID}
}
_, err = coll.UpdateOne(ctx, filter, bson.M{"$set": set})
if err != nil {
log.Printf("[promotion-transcode] 更新数据库失败: %v", err)
}
}
// ScheduleTranscodeAfterUpload 上传保存成功后异步promotion 下 .mov -> .mp4并更新本条 site_assets
func ScheduleTranscodeAfterUpload(siteID, relPath, movFullPath string, insertedID any) {
if skipPromotionTranscode() || !isMOVUnderPromotion(relPath, filepath.Ext(movFullPath)) {
return
}
go func() {
if !ffmpegAvailable() {
log.Printf("[promotion-transcode] 已上传 .mov 但未安装 ffmpeg无法转码: %s", relPath)
return
}
mp4Full := mp4PathForMOV(movFullPath)
if !needsTranscode(movFullPath, mp4Full) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
log.Printf("[promotion-transcode] 开始转码: %s -> %s", movFullPath, mp4Full)
if err := runFFmpegMOVToMP4(ctx, movFullPath, mp4Full); err != nil {
log.Printf("[promotion-transcode] 转码失败 %s: %v", relPath, err)
return
}
if err := os.Remove(movFullPath); err != nil {
log.Printf("[promotion-transcode] 删除原 .mov 失败(可手动删): %v", err)
}
replaceMOVWithMP4InDB(siteID, relPath, mp4Full, insertedID)
log.Printf("[promotion-transcode] 完成: %s", newRelLog(relPath))
}()
}
func newRelLog(oldRel string) string {
return strings.TrimSuffix(oldRel, filepath.Ext(oldRel)) + ".mp4"
}
// SweepPromotionTranscodeOnStartup 扫描 uploads/sites/*/promotion/**.mov补转码并同步数据库已有文件
func SweepPromotionTranscodeOnStartup() {
time.Sleep(3 * time.Second)
if skipPromotionTranscode() {
log.Println("[promotion-transcode] 启动扫描已跳过 SKIP_PROMOTION_TRANSCODE=1")
return
}
if !ffmpegAvailable() {
log.Println("[promotion-transcode] 启动扫描跳过:未找到 ffmpeg安装后可重启服务")
return
}
root := getUploadDir()
sitesDir := filepath.Join(root, "sites")
fi, err := os.Stat(sitesDir)
if err != nil || !fi.IsDir() {
return
}
entries, err := os.ReadDir(sitesDir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
siteID := e.Name()
promoRoot := filepath.Join(sitesDir, siteID, "promotion")
_ = filepath.WalkDir(promoRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if strings.ToLower(filepath.Ext(path)) != ".mov" {
return nil
}
mp4Full := mp4PathForMOV(path)
if !needsTranscode(path, mp4Full) {
return nil
}
rel, err := relPathFromUploadRoot(root, path)
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
log.Printf("[promotion-transcode] [启动补转] %s", rel)
err = runFFmpegMOVToMP4(ctx, path, mp4Full)
cancel()
if err != nil {
log.Printf("[promotion-transcode] [启动补转] 失败 %s: %v", rel, err)
return nil
}
_ = os.Remove(path)
replaceMOVWithMP4InDB(siteID, rel, mp4Full, nil)
log.Printf("[promotion-transcode] [启动补转] 完成 %s", newRelLog(rel))
return nil
})
}
}

View File

@@ -179,12 +179,21 @@ func Register(c *gin.Context) {
}
}
// 超级管理员仅一个:第一个注册用户为超级管理员,后续均为普通用户
count, _ := coll.CountDocuments(ctx, bson.M{})
roleID := models.RoleIDUser
role := "user"
if count == 0 {
roleID = models.RoleIDSuperAdmin
role = "admin"
}
doc := bson.M{
"username": username,
"mobile": input.Mobile,
"password": utils.HashPassword(input.Password),
"role": "admin",
"role_id": models.RoleIDSuperAdmin,
"role": role,
"role_id": roleID,
}
if input.Email != "" {
doc["email"] = input.Email

View File

@@ -15,17 +15,9 @@ import (
"github.com/gin-gonic/gin"
)
// 定义角色(与 users.role_id 对应)
var roleMeta = []struct {
RoleID int `json:"role_id"`
RoleName string `json:"role_name"`
}{
{models.RoleIDSuperAdmin, "超级管理员"},
{models.RoleIDSuperUser, "超级用户"},
{models.RoleIDUser, "普通用户"},
}
const customRoleIDStart = 1000 // 定义角色 role_id 从此值起
// GetRolePermissionsList 返回所有角色及其权限(用于角色权限管理页
// GetRolePermissionsList 返回所有角色及其权限(含预定义与自定义
func GetRolePermissionsList(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -44,23 +36,56 @@ func GetRolePermissionsList(c *gin.Context) {
return
}
permMap := make(map[int][]string)
nameMap := make(map[int]string)
for _, d := range docs {
permMap[d.RoleID] = d.Permissions
if d.RoleName != "" {
nameMap[d.RoleID] = d.RoleName
}
}
list := make([]gin.H, 0, len(roleMeta))
for _, r := range roleMeta {
perms := permMap[r.RoleID]
allKeys := allPermissionKeys()
// 预定义角色固定在前9527, 0, 1再按 role_id 排自定义
predef := []int{models.RoleIDSuperAdmin, models.RoleIDSuperUser, models.RoleIDUser}
seen := make(map[int]bool)
list := make([]gin.H, 0)
for _, rid := range predef {
seen[rid] = true
perms := permMap[rid]
if perms == nil {
perms = []string{}
}
if r.RoleID == models.RoleIDSuperAdmin {
perms = allPermissionKeys()
if rid == models.RoleIDSuperAdmin {
perms = allKeys
}
name := nameMap[rid]
if name == "" {
name = models.DefaultRoleNames[rid]
}
list = append(list, gin.H{
"role_id": r.RoleID,
"role_name": r.RoleName,
"role_id": rid,
"role_name": name,
"permissions": perms,
"is_custom": false,
})
}
for _, d := range docs {
if seen[d.RoleID] {
continue
}
seen[d.RoleID] = true
name := d.RoleName
if name == "" {
name = "角色" + strconv.Itoa(d.RoleID)
}
perms := d.Permissions
if perms == nil {
perms = []string{}
}
list = append(list, gin.H{
"role_id": d.RoleID,
"role_name": name,
"permissions": perms,
"is_custom": true,
})
}
c.JSON(http.StatusOK, gin.H{
@@ -69,11 +94,6 @@ func GetRolePermissionsList(c *gin.Context) {
})
}
// UpdateRolePermissionsInput 更新某角色权限
type UpdateRolePermissionsInput struct {
Permissions []string `json:"permissions"`
}
// UpdateRolePermissions 更新指定角色的权限
func UpdateRolePermissions(c *gin.Context) {
roleIDStr := c.Param("role_id")
@@ -87,7 +107,10 @@ func UpdateRolePermissions(c *gin.Context) {
return
}
var input UpdateRolePermissionsInput
var input struct {
RoleName string `json:"role_name"`
Permissions []string `json:"permissions"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -98,7 +121,12 @@ func UpdateRolePermissions(c *gin.Context) {
coll := config.GetDB(config.DBName).Collection("role_permissions")
filter := bson.M{"role_id": roleID}
update := bson.M{"$set": bson.M{"role_id": roleID, "permissions": input.Permissions}}
set := bson.M{"role_id": roleID, "permissions": input.Permissions}
// 超级管理员(9527)已拦截;其余预定义(0/1)与自定义角色均可更新显示名称
if input.RoleName != "" {
set["role_name"] = input.RoleName
}
update := bson.M{"$set": set}
opts := options.UpdateOne().SetUpsert(true)
_, err = coll.UpdateOne(ctx, filter, update, opts)
if err != nil {
@@ -107,3 +135,72 @@ func UpdateRolePermissions(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions})
}
// CreateRoleInput 创建角色
type CreateRoleInput struct {
RoleName string `json:"role_name" binding:"required"`
Permissions []string `json:"permissions"`
}
// CreateRole 创建自定义角色
func CreateRole(c *gin.Context) {
var input CreateRoleInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写角色名称"})
return
}
if input.Permissions == nil {
input.Permissions = []string{}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("role_permissions")
cursor, _ := coll.Find(ctx, bson.M{"role_id": bson.M{"$gte": customRoleIDStart}}, options.Find().SetSort(bson.D{{Key: "role_id", Value: -1}}).SetLimit(1))
var docs []models.RolePermissionsDoc
_ = cursor.All(ctx, &docs)
cursor.Close(ctx)
nextID := customRoleIDStart
for _, d := range docs {
if d.RoleID >= customRoleIDStart {
nextID = d.RoleID + 1
break
}
}
doc := models.RolePermissionsDoc{
RoleID: nextID,
RoleName: input.RoleName,
Permissions: input.Permissions,
}
_, err := coll.InsertOne(ctx, bson.M{"role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions})
}
// DeleteRole 删除自定义角色(仅 role_id >= customRoleIDStart
func DeleteRole(c *gin.Context) {
roleIDStr := c.Param("role_id")
roleID, err := strconv.Atoi(roleIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 role_id"})
return
}
if roleID < customRoleIDStart {
c.JSON(http.StatusBadRequest, gin.H{"error": "预定义角色不可删除"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coll := config.GetDB(config.DBName).Collection("role_permissions")
_, err = coll.DeleteOne(ctx, bson.M{"role_id": roleID})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}

View File

@@ -0,0 +1,191 @@
package handlers
import (
"context"
"errors"
"net/http"
"os"
"strings"
"time"
"unicode/utf8"
"yh_web/server/config"
"yh_web/server/models"
"yh_web/server/utils"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
const siteJWTExpire = 30 * 24 * time.Hour
// SiteClaims 前台 JWT仅弹幕等轻量场景勿与后台 Claims 混用)
type SiteClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func siteJWTSigningKey() []byte {
s := strings.TrimSpace(os.Getenv("SITE_JWT_SECRET"))
if s == "" {
s = "yh_web_site_dm_jwt_change_in_production"
}
return []byte(s)
}
// ParseSiteClaims 解析前台 JWT失败返回 nil,false
func ParseSiteClaims(tokenStr string) (*SiteClaims, bool) {
tokenStr = strings.TrimSpace(tokenStr)
if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") {
tokenStr = strings.TrimSpace(tokenStr[7:])
}
if tokenStr == "" {
return nil, false
}
var claims SiteClaims
t, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
return siteJWTSigningKey(), nil
})
if err != nil || !t.Valid {
return nil, false
}
return &claims, true
}
// SiteDanmakuTokenValid 弹幕发送权限:有效的前台 JWT
func SiteDanmakuTokenValid(tokenStr string) bool {
_, ok := ParseSiteClaims(tokenStr)
return ok
}
// MaskSiteUsernameForDanmaku 弹幕展示半匿名1 字为「a***」2 字及以上为前两字 + ***(如 aa***、ab***
func MaskSiteUsernameForDanmaku(username string) string {
username = strings.TrimSpace(username)
if username == "" {
return "***"
}
runes := []rune(username)
if len(runes) == 1 {
return string(runes[0]) + "***"
}
return string(runes[:2]) + "***"
}
type siteRegisterInput struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type siteLoginInput struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func siteUsersColl() *mongo.Collection {
return config.GetDB(config.DBName).Collection("site_users")
}
// WebSiteRegister POST /api/web/site/register — 仅用于前台直播弹幕账号
func WebSiteRegister(c *gin.Context) {
if config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
return
}
var input siteRegisterInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
u := strings.TrimSpace(input.Username)
if utf8.RuneCountInString(u) < 2 || utf8.RuneCountInString(u) > 32 {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名为 232 个字符"})
return
}
if len(input.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "密码至少 6 位"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
coll := siteUsersColl()
var existing models.SiteUser
err := coll.FindOne(ctx, bson.M{"username": u}).Decode(&existing)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"})
return
}
if !errors.Is(err, mongo.ErrNoDocuments) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
doc := models.SiteUser{
Username: u,
PasswordHash: utils.HashPassword(input.Password),
CreatedAt: time.Now().UTC(),
}
res, err := coll.InsertOne(ctx, doc)
if err != nil {
if mongo.IsDuplicateKeyError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "该用户名已被注册"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
id, _ := res.InsertedID.(bson.ObjectID)
token, err := issueSiteToken(id.Hex(), u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "username": u})
}
// WebSiteLogin POST /api/web/site/login
func WebSiteLogin(c *gin.Context) {
if config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
return
}
var input siteLoginInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
u := strings.TrimSpace(input.Username)
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
var user models.SiteUser
err := siteUsersColl().FindOne(ctx, bson.M{"username": u}).Decode(&user)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
if utils.HashPassword(input.Password) != user.PasswordHash {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
token, err := issueSiteToken(user.ID.Hex(), user.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发登录凭证失败"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "username": user.Username})
}
func issueSiteToken(userID, username string) (string, error) {
claims := SiteClaims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(siteJWTExpire)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString(siteJWTSigningKey())
}

View File

@@ -8,6 +8,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
"yh_web/server/config"
"yh_web/server/pkg/traffic"
"github.com/gin-gonic/gin"
)
@@ -30,5 +31,6 @@ func GetStats(c *gin.Context) {
"conversations": conversations,
"messages": messages,
"files": files,
"bandwidth": traffic.Snapshot(),
})
}

View File

@@ -0,0 +1,150 @@
package handlers
import (
"context"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
)
// effectivePagePath 对外访问路径:优先 route_path否则 /{slug}index 且无 route_path 时返回空(由首页单独处理)
func effectivePagePath(p models.Page) string {
if p.RoutePath != "" {
path := strings.TrimSpace(p.RoutePath)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
if p.Slug == "" || p.Slug == homepageSlug {
return ""
}
return "/" + p.Slug
}
// GetWebRoutes 前台:获取站点已发布页面的动态路由列表(无需鉴权)
func GetWebRoutes(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
return
}
siteID := c.Query("site_id")
if siteID == "" {
siteID = getOfficialSiteID(ctx)
}
if siteID == "" {
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
return
}
coll := config.GetDB(config.DBName).Collection("pages")
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cursor.Close(ctx)
var pages []models.Page
if err = cursor.All(ctx, &pages); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
routes := make([]gin.H, 0)
for _, p := range pages {
if p.Published != nil && !*p.Published {
continue
}
path := effectivePagePath(p)
if path == "" {
continue
}
routes = append(routes, gin.H{
"path": path,
"title": p.Title,
"slug": p.Slug,
"id": p.ID.Hex(),
"mode": p.ContentMode,
})
}
c.JSON(http.StatusOK, gin.H{"site_id": siteID, "routes": routes})
}
// GetWebPageByPath 前台:按路径取单页内容(无需鉴权)
func GetWebPageByPath(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
return
}
siteID := c.Query("site_id")
if siteID == "" {
siteID = getOfficialSiteID(ctx)
}
path := strings.TrimSpace(c.Query("path"))
if siteID == "" || path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 site_id 或 path"})
return
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
coll := config.GetDB(config.DBName).Collection("pages")
var page models.Page
tryDecode := func(filter bson.M) bool {
page = models.Page{}
err := coll.FindOne(ctx, filter).Decode(&page)
return err == nil && !page.ID.IsZero()
}
if !tryDecode(bson.M{"site_id": siteID, "route_path": path}) {
alt := strings.TrimPrefix(path, "/")
if alt != "" {
tryDecode(bson.M{"site_id": siteID, "route_path": alt})
}
}
if page.ID.IsZero() {
slug := strings.TrimPrefix(path, "/")
if slug != "" {
tryDecode(bson.M{"site_id": siteID, "slug": slug})
}
}
if page.ID.IsZero() {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
if page.Published != nil && !*page.Published {
c.JSON(http.StatusNotFound, gin.H{"error": "页面未发布"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": page.ID.Hex(),
"site_id": page.SiteID,
"slug": page.Slug,
"title": page.Title,
"type": page.Type,
"content": page.Content,
"content_mode": page.ContentMode,
"route_path": page.RoutePath,
"updated_at": page.UpdatedAt,
})
}

View File

@@ -0,0 +1,182 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"yh_web/server/config"
"yh_web/server/models"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const yuhengCloudRegisterColl = "yuheng_cloud_register_records"
func cloudRegisterURL() string {
u := strings.TrimSpace(os.Getenv("YH_CLOUD_REGISTER_URL"))
if u != "" {
return strings.TrimSuffix(u, "/")
}
return "http://www.cloud.yuxindazhineng.com:3001/register"
}
// YuhengCloudRegisterInput 与云端 POST /register 一致email 仅用于调用云端,不写入 Mongo
type YuhengCloudRegisterInput struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required"`
}
type cloudRegisterPayload struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
}
func postCloudRegister(ctx context.Context, payload cloudRegisterPayload) (int, string, error) {
body, err := json.Marshal(payload)
if err != nil {
return 0, "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cloudRegisterURL(), bytes.NewReader(body))
if err != nil {
return 0, "", err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return resp.StatusCode, strings.TrimSpace(string(b)), nil
}
// CreateYuhengCloudRegister 调用云端注册接口,成功后在 Mongo 写入一条记录(仅 username、password
func CreateYuhengCloudRegister(c *gin.Context) {
var in YuhengCloudRegisterInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写用户名、密码与邮箱(邮箱仅提交云端)"})
return
}
in.Username = strings.TrimSpace(in.Username)
in.Password = strings.TrimSpace(in.Password)
in.Email = strings.TrimSpace(in.Email)
if in.Username == "" || in.Password == "" || in.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名、密码、邮箱不能为空"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Second)
defer cancel()
status, bodySnippet, err := postCloudRegister(ctx, cloudRegisterPayload{
Username: in.Username,
Password: in.Password,
Email: in.Email,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "调用云端注册失败: " + err.Error()})
return
}
if status < 200 || status >= 300 {
msg := bodySnippet
if len(msg) > 500 {
msg = msg[:500] + "…"
}
if msg == "" {
msg = http.StatusText(status)
}
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("云端返回 %d: %s", status, msg)})
return
}
db := config.GetDB(config.DBName)
if db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "数据库未连接,未写入本地记录"})
return
}
insCtx, insCancel := context.WithTimeout(context.Background(), 8*time.Second)
defer insCancel()
doc := bson.M{
"username": in.Username,
"password": in.Password,
"created_at": time.Now().UTC().Format(time.RFC3339),
}
res, err := db.Collection(yuhengCloudRegisterColl).InsertOne(insCtx, doc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "云端已成功但本地记录失败: " + err.Error()})
return
}
idHex := ""
switch v := res.InsertedID.(type) {
case bson.ObjectID:
idHex = v.Hex()
}
c.JSON(http.StatusOK, gin.H{"id": idHex, "message": "已提交云端注册并写入本地记录"})
}
// ListYuhengCloudRegisterRecords 分页列出本地留痕(便于管理页展示)
func ListYuhengCloudRegisterRecords(c *gin.Context) {
db := config.GetDB(config.DBName)
if db == nil {
c.JSON(http.StatusOK, gin.H{"list": []any{}, "total": 0})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
skip := int64((page - 1) * pageSize)
limit := int64(pageSize)
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
coll := db.Collection(yuhengCloudRegisterColl)
total, err := coll.CountDocuments(ctx, bson.M{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}).SetSkip(skip).SetLimit(limit)
cur, err := coll.Find(ctx, bson.M{}, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer cur.Close(ctx)
var list []models.YuhengCloudRegisterRecord
for cur.Next(ctx) {
var row struct {
ID bson.ObjectID `bson:"_id"`
Username string `bson:"username"`
Password string `bson:"password"`
CreatedAt string `bson:"created_at"`
}
if err := cur.Decode(&row); err != nil {
continue
}
list = append(list, models.YuhengCloudRegisterRecord{
ID: row.ID.Hex(),
Username: row.Username,
Password: row.Password,
CreatedAt: row.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
}

View File

@@ -15,6 +15,7 @@ import (
"yh_web/server/models"
"yh_web/server/pkg/logger"
"yh_web/server/pkg/schema"
"yh_web/server/pkg/weblive"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
@@ -79,7 +80,9 @@ func main() {
}
r := gin.Default()
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
r.Use(middleware.ErrorLogger())
r.Use(middleware.TrafficMeter())
// CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
@@ -96,7 +99,7 @@ func main() {
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Origin, X-Requested-With")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
@@ -142,6 +145,12 @@ func main() {
c.JSON(http.StatusOK, structure)
})
admin.GET("/stats", handlers.GetStats)
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
admin.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
// 用户管理
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
@@ -160,8 +169,17 @@ func main() {
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
// 分片路由须在 POST .../assets 整文件上传之前注册,避免被更泛的路由误匹配
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
admin.POST("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
@@ -170,10 +188,14 @@ func main() {
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
// 角色权限管理
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
admin.POST("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.CreateRole)
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
admin.DELETE("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.DeleteRole)
// 网页管理(按站点)
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
@@ -201,6 +223,12 @@ func main() {
// 官网站点首页(前台,无需鉴权)
r.GET("/api/web/homepage", handlers.GetWebHomepage)
r.GET("/api/web/routes", handlers.GetWebRoutes)
r.GET("/api/web/page", handlers.GetWebPageByPath)
// 前台直播弹幕账号(与后台 users 无关;需 MongoDB
r.POST("/api/web/site/register", handlers.WebSiteRegister)
r.POST("/api/web/site/login", handlers.WebSiteLogin)
// 前台 API 路由组
web := r.Group("/api/web")
@@ -208,11 +236,23 @@ func main() {
web.GET("/info", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "web api"})
})
// 推广/产品视频:站点 uploads 下 promotion 目录(无需鉴权,与后台上传到 promotion/… 对应)
web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia)
// 可下载资源公开下载(首页等链接指向此路径)
web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset)
// 站内 WebRTC 直播:信令 + 状态(单房间 MVP
weblive.RegisterRoutes(web)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
go handlers.SweepPromotionTranscodeOnStartup()
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads
go handlers.StartStaleChunkUploadSweep(context.Background())
r.Run(":" + port)
}

View File

@@ -0,0 +1,186 @@
package middleware
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// AdminPOSTSecurity 对 /api/admin 下 POST 校验时间戳、IP 频率、重复请求multipart 上传仅做限流不做 body 去重
func AdminPOSTSecurity() gin.HandlerFunc {
ipLimit := getIntEnv("ADMIN_POST_IP_PER_MIN", 120)
dedupeSec := getIntEnv("ADMIN_DEDUPE_SEC", 3)
tsSkew := time.Duration(getIntEnv("ADMIN_REQUEST_TS_SKEW_SEC", 300)) * time.Second
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost {
c.Next()
return
}
tsStr := c.GetHeader("X-Request-Timestamp")
if tsStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少请求头 X-Request-Timestamp毫秒时间戳"})
c.Abort()
return
}
tsMs, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Request-Timestamp 格式无效"})
c.Abort()
return
}
clientT := time.UnixMilli(tsMs)
if d := time.Since(clientT); d > tsSkew || d < -tsSkew {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求时间戳无效或时钟偏差过大"})
c.Abort()
return
}
ip := c.ClientIP()
if !ipPostLimiter.allow("ip:"+ip, ipLimit, time.Minute) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "该 IP 请求过于频繁,请稍后再试"})
c.Abort()
return
}
ct := c.GetHeader("Content-Type")
if strings.Contains(strings.ToLower(ct), "multipart/form-data") {
c.Next()
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
sig := hashSig(c.FullPath(), c.Request.URL.RawQuery, body)
key := ip + "|" + sig
if !dedupeStore.try(key, time.Duration(dedupeSec)*time.Second) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "相同请求请勿在 3 秒内重复提交"})
c.Abort()
return
}
c.Next()
}
}
// AdminPOSTUserRateLimit 需在 AuthRequired 之后:按账号限制 POST 频率
func AdminPOSTUserRateLimit() gin.HandlerFunc {
userLimit := getIntEnv("ADMIN_POST_USER_PER_MIN", 80)
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost {
c.Next()
return
}
uid, ok := c.Get("user_id")
if !ok {
c.Next()
return
}
suid, _ := uid.(string)
if suid == "" {
c.Next()
return
}
if !ipPostLimiter.allow("uid:"+suid, userLimit, time.Minute) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "该账号请求过于频繁,请稍后再试"})
c.Abort()
return
}
c.Next()
}
}
func hashSig(path, query string, body []byte) string {
h := sha256.New()
h.Write([]byte(path))
h.Write([]byte{0})
h.Write([]byte(query))
h.Write([]byte{0})
h.Write(body)
return hex.EncodeToString(h.Sum(nil))
}
type slidingLimiter struct {
mu sync.Mutex
// key -> 时间戳列表(纳秒)
m map[string][]int64
}
var ipPostLimiter = &slidingLimiter{m: make(map[string][]int64)}
func (s *slidingLimiter) allow(key string, max int, window time.Duration) bool {
now := time.Now().UnixNano()
cutoff := now - window.Nanoseconds()
s.mu.Lock()
defer s.mu.Unlock()
list := s.m[key]
out := list[:0]
for _, t := range list {
if t >= cutoff {
out = append(out, t)
}
}
if len(out) >= max {
s.m[key] = out
return false
}
out = append(out, now)
s.m[key] = out
return true
}
type deduper struct {
mu sync.Mutex
m map[string]int64 // key -> last unix nano
}
var dedupeStore = &deduper{m: make(map[string]int64)}
func (d *deduper) try(key string, minGap time.Duration) bool {
now := time.Now().UnixNano()
gap := minGap.Nanoseconds()
d.mu.Lock()
defer d.mu.Unlock()
if last, ok := d.m[key]; ok && now-last < gap {
return false
}
d.m[key] = now
if len(d.m) > 10000 {
// 简单清理过期项
cutoff := now - gap*10
for k, v := range d.m {
if v < cutoff {
delete(d.m, k)
}
}
}
return true
}
func getIntEnv(key string, def int) int {
s := strings.TrimSpace(os.Getenv(key))
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"io"
"net/http"
"yh_web/server/pkg/traffic"
"github.com/gin-gonic/gin"
)
type countReadCloser struct {
io.ReadCloser
}
func (c *countReadCloser) Read(p []byte) (int, error) {
n, err := c.ReadCloser.Read(p)
if n > 0 {
traffic.AddIn(n)
}
return n, err
}
type meterResponseWriter struct {
gin.ResponseWriter
}
func (w *meterResponseWriter) Write(p []byte) (int, error) {
n, err := w.ResponseWriter.Write(p)
if n > 0 {
traffic.AddOut(n)
}
return n, err
}
func (w *meterResponseWriter) WriteString(s string) (int, error) {
n, err := w.ResponseWriter.WriteString(s)
if n > 0 {
traffic.AddOut(n)
}
return n, err
}
// TrafficMeter 统计 HTTP 请求体与响应体字节量(进程级,非网卡级)。
func TrafficMeter() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Body != nil && c.Request.Body != http.NoBody {
c.Request.Body = &countReadCloser{ReadCloser: c.Request.Body}
}
c.Writer = &meterResponseWriter{ResponseWriter: c.Writer}
c.Next()
}
}

View File

@@ -12,27 +12,40 @@ const (
PermSMSConfig = "sms_config"
PermPaymentConfig = "payment_config"
PermRolePermission = "role:permission" // 角色权限管理
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
)
// AllPermissions 所有可配置权限(用于角色权限管理页
var AllPermissions = []struct {
Key string
Name string
}{
{PermSiteManage, "站点管理"},
{PermHomepageEdit, "首页编辑"},
{PermPageManage, "网页管理"},
{PermModuleUpload, "功能模块上传"},
{PermUserManage, "用户管理"},
{PermWorkspaceManage, "工作空间"},
{PermConversationManage, "对话管理"},
{PermSMSConfig, "短信配置"},
{PermPaymentConfig, "支付配置"},
{PermRolePermission, "角色权限管理"},
// PermissionItem 单条权限定义JSON 须用小写 key/name供前端展示与勾选
type PermissionItem struct {
Key string `json:"key"`
Name string `json:"name"`
}
// RolePermissionsDoc MongoDB 文档:角色 ID -> 权限列表
// AllPermissions 所有可配置权限(用于角色权限管理页)
var AllPermissions = []PermissionItem{
{Key: PermSiteManage, Name: "站点管理"},
{Key: PermHomepageEdit, Name: "首页编辑"},
{Key: PermPageManage, Name: "网页管理"},
{Key: PermModuleUpload, Name: "功能模块上传"},
{Key: PermUserManage, Name: "用户管理"},
{Key: PermWorkspaceManage, Name: "工作空间"},
{Key: PermConversationManage, Name: "对话管理"},
{Key: PermSMSConfig, Name: "短信配置"},
{Key: PermPaymentConfig, Name: "支付配置"},
{Key: PermRolePermission, Name: "角色权限管理"},
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
}
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
type RolePermissionsDoc struct {
RoleID int `bson:"role_id" json:"role_id"`
RoleName string `bson:"role_name,omitempty" json:"role_name"`
Permissions []string `bson:"permissions" json:"permissions"`
}
// 预定义角色 ID 的默认名称(未在 DB 中存 role_name 时使用)
var DefaultRoleNames = map[int]string{
RoleIDSuperAdmin: "超级管理员",
RoleIDSuperUser: "超级用户",
RoleIDUser: "普通用户",
}

View File

@@ -13,13 +13,16 @@ type Site struct {
// Page 网页(属于某站点)
type Page struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Slug string `bson:"slug" json:"slug"` // index, about, ...
Title string `bson:"title" json:"title"`
Type string `bson:"type" json:"type"` // homepage, page
Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串
UpdatedAt string `bson:"updated_at" json:"updated_at"`
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Slug string `bson:"slug" json:"slug"` // index, about, ...
Title string `bson:"title" json:"title"`
Type string `bson:"type" json:"type"` // homepage, page
Content string `bson:"content" json:"content"` // html 模式为 HTMLbuilder 模式为 JSON见文档
ContentMode string `bson:"content_mode,omitempty" json:"content_mode"` // html | builder空视为 html
RoutePath string `bson:"route_path,omitempty" json:"route_path"` // 自定义前台路径,如 /about空则用 /{slug}
Published *bool `bson:"published,omitempty" json:"published"` // nil 或未设视为已发布
UpdatedAt string `bson:"updated_at" json:"updated_at"`
}
// HomepageData 首页可编辑数据(与 yuheng-download-space 对应)
@@ -37,6 +40,15 @@ type HomepageData struct {
BadgeText string `json:"badge_text"` // FREE ACCESS
Features []FeatureItem `json:"features"` // 星际导航等
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
// 侧栏直链(与 web 首页 Home.vue 一致,同域静态路径)
DownloadWindowsURL string `json:"download_windows_url,omitempty"`
DownloadAndroidURL string `json:"download_android_url,omitempty"`
// BodyBuilder 首页下方扩展区:与网页积木相同 JSON 字符串 {"version":1,"blocks":[...]},空则仅展示上方模板
BodyBuilder string `json:"body_builder,omitempty"`
// 直播:前台 /live 页「进入直播间」跳转的外部地址(抖音/B 站/自建 H5 等);留空则仅提示在后台配置
LiveRoomURL string `json:"live_room_url,omitempty"`
// 直播页与首页直播模块主标题
LiveRoomTitle string `json:"live_room_title,omitempty"`
}
type NavLink struct {
@@ -56,11 +68,12 @@ type FeatureItem struct {
// SiteAsset 站点功能模块/上传文件
type SiteAsset struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Name string `bson:"name" json:"name"`
FilePath string `bson:"file_path" json:"file_path"` // 相对路径
Size int64 `bson:"size" json:"size"`
ContentType string `bson:"content_type" json:"content_type"`
CreatedAt string `bson:"created_at" json:"created_at"`
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
SiteID string `bson:"site_id" json:"site_id"`
Name string `bson:"name" json:"name"`
FilePath string `bson:"file_path" json:"file_path"` // 相对路径,可含多级目录
Size int64 `bson:"size" json:"size"`
ContentType string `bson:"content_type" json:"content_type"`
Downloadable bool `bson:"downloadable" json:"downloadable"` // 是否允许下载
CreatedAt string `bson:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,15 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SiteUser 前台用户(目前仅用于直播弹幕身份,与后台 users 集合分离)
type SiteUser struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
Username string `bson:"username" json:"username"`
PasswordHash string `bson:"password_hash" json:"-"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}

View File

@@ -0,0 +1,9 @@
package models
// YuhengCloudRegisterRecord 宇恒云注册请求在本库的留痕仅账号与密码email 仅转发云端接口不落库)
type YuhengCloudRegisterRecord struct {
ID string `bson:"_id,omitempty" json:"id"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
CreatedAt string `bson:"created_at" json:"created_at"`
}

View File

@@ -28,6 +28,8 @@ var requiredCollections = map[string][]indexSpec{
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"system_config": {},
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
}
type indexSpec struct {
@@ -48,6 +50,8 @@ var tableDDL = map[string]string{
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '密码明文留痕',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宇恒云注册请求本地留痕';",
}
// CollectionInfo 线上集合信息(名称、文档数、索引)

108
server/pkg/traffic/meter.go Normal file
View File

@@ -0,0 +1,108 @@
// Package traffic 统计经过本进程的 HTTP 流量(请求体 + 响应体),供后台评估带宽。
// 说明:前有 Nginx 时边缘出口可能更大WebSocket 升级后部分流量可能不经此计数。
package traffic
import (
"sync"
"sync/atomic"
"time"
)
var (
totalIn atomic.Uint64
totalOut atomic.Uint64
started = time.Now()
tickerOnce sync.Once
mu sync.Mutex
secIn [60]uint64
secOut [60]uint64
lastSnapIn uint64
lastSnapOut uint64
tickIndex int64
)
func ensureTicker() {
tickerOnce.Do(func() {
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
for range t.C {
tick()
}
}()
})
}
func tick() {
ti := totalIn.Load()
to := totalOut.Load()
mu.Lock()
i := int(tickIndex % 60)
secIn[i] = ti - lastSnapIn
secOut[i] = to - lastSnapOut
lastSnapIn = ti
lastSnapOut = to
tickIndex++
mu.Unlock()
}
// AddIn 记录请求体已读字节。
func AddIn(n int) {
if n > 0 {
ensureTicker()
totalIn.Add(uint64(n))
}
}
// AddOut 记录响应已写字节。
func AddOut(n int) {
if n > 0 {
ensureTicker()
totalOut.Add(uint64(n))
}
}
// Snapshot 返回当前统计(近 60 秒为滚动窗口内各秒增量之和)。
func Snapshot() map[string]any {
ensureTicker()
tin := totalIn.Load()
tout := totalOut.Load()
up := time.Since(started).Seconds()
mu.Lock()
var sumIn, sumOut uint64
for i := 0; i < 60; i++ {
sumIn += secIn[i]
sumOut += secOut[i]
}
mu.Unlock()
var avgDown, avgUp, recentDown, recentUp float64
if up > 0.5 {
avgDown = float64(tout) * 8 / (up * 1e6) // Mbps 出站(自启动平均)
avgUp = float64(tin) * 8 / (up * 1e6) // Mbps 入站
}
recentDown = float64(sumOut) * 8 / (60 * 1e6)
recentUp = float64(sumIn) * 8 / (60 * 1e6)
return map[string]any{
"bytes_in_total": tin,
"bytes_out_total": tout,
"bytes_in_last_60s": sumIn,
"bytes_out_last_60s": sumOut,
"uptime_seconds": up,
"avg_egress_mbps": round2(avgDown),
"avg_ingress_mbps": round2(avgUp),
"recent_egress_mbps": round2(recentDown),
"recent_ingress_mbps": round2(recentUp),
}
}
func round2(x float64) float64 {
if x < 0 {
return 0
}
return float64(int64(x*100+0.5)) / 100
}

View File

@@ -0,0 +1,51 @@
package weblive
import (
"encoding/json"
"os"
"strings"
"github.com/pion/webrtc/v3"
)
// MediaEngine 与 API 构建(全局复用,避免重复注册编解码器)
// 部署在 Docker/NAT 后若观众端黑屏、信令正常,请设置 LIVE_PUBLIC_IP 为本机公网 IPv4与域名解析一致可逗号分隔多个
func buildAPI() (*webrtc.API, error) {
m := &webrtc.MediaEngine{}
if err := m.RegisterDefaultCodecs(); err != nil {
return nil, err
}
se := webrtc.SettingEngine{}
if raw := strings.TrimSpace(os.Getenv("LIVE_PUBLIC_IP")); raw != "" {
parts := strings.Split(raw, ",")
var ips []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
ips = append(ips, p)
}
}
if len(ips) > 0 {
se.SetNAT1To1IPs(ips, webrtc.ICECandidateTypeHost)
}
}
api := webrtc.NewAPI(
webrtc.WithMediaEngine(m),
webrtc.WithSettingEngine(se),
)
return api, nil
}
func iceServersFromEnv() []webrtc.ICEServer {
raw := strings.TrimSpace(os.Getenv("LIVE_ICE_SERVERS"))
if raw == "" {
return []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
}
}
var servers []webrtc.ICEServer
if err := json.Unmarshal([]byte(raw), &servers); err != nil {
return []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}}
}
return servers
}

View File

@@ -0,0 +1,185 @@
package weblive
import (
"encoding/json"
"strings"
"sync"
"time"
"unicode/utf8"
"yh_web/server/handlers"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const maxDanmakuRunes = 120
var allowedGifts = map[string]struct{}{
"rocket": {},
"sports_car": {},
"plane": {},
"carnival": {},
"rose": {},
"heart": {},
"star": {},
"clap": {},
"cake": {},
"crown": {},
"fireworks": {},
"gift_box": {},
"beer": {},
"mic": {},
}
var (
danmakuClientsMu sync.Mutex
danmakuClients = make(map[*websocket.Conn]string)
)
func writeDanmakuJSON(ws *websocket.Conn, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return ws.WriteMessage(websocket.TextMessage, b)
}
func clipDanmakuText(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
if utf8.RuneCountInString(t) <= maxDanmakuRunes {
return t
}
runes := []rune(t)
return string(runes[:maxDanmakuRunes])
}
// handleDanmakuWS 弹幕 / 礼物:未带有效 token 仅可收广播。礼物 JSON {"type":"gift","gift":"rocket"};弹幕 {"text":"..."}
func handleDanmakuWS(c *gin.Context) {
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
canSend := tokenOK
fromDisplay := "***"
fullUsername := ""
if tokenOK && claims != nil {
fromDisplay = handlers.MaskSiteUsernameForDanmaku(claims.Username)
fullUsername = strings.TrimSpace(claims.Username)
}
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
ws.SetReadLimit(4096)
clientIP := c.ClientIP()
sessionID := RegisterOnlineSession("danmaku", clientIP, fullUsername)
danmakuClientsMu.Lock()
danmakuClients[ws] = clientIP
danmakuClientsMu.Unlock()
defer func() {
UnregisterOnlineSession(sessionID)
danmakuClientsMu.Lock()
delete(danmakuClients, ws)
danmakuClientsMu.Unlock()
_ = ws.Close()
}()
for {
mt, payload, err := ws.ReadMessage()
if err != nil {
return
}
if mt != websocket.TextMessage {
continue
}
TouchOnlineSession(sessionID)
if IsMutedForSend(clientIP, fullUsername) {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "muted",
"message": "您已被禁言,暂时无法发弹幕或送礼物",
})
continue
}
if !AllowSendByIP(clientIP) {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "rate_limited",
"message": "同 IP 发送过快,请稍后再试",
})
continue
}
if !canSend {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "login_required",
"message": "请先登录或注册后再发弹幕或礼物",
})
continue
}
var envelope struct {
Type string `json:"type"`
Text string `json:"text"`
Gift string `json:"gift"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
continue
}
if strings.EqualFold(strings.TrimSpace(envelope.Type), "gift") {
gid := strings.TrimSpace(envelope.Gift)
if _, ok := allowedGifts[gid]; !ok {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "bad_gift",
"message": "无效的礼物",
})
continue
}
out, err := json.Marshal(map[string]interface{}{
"type": "gift",
"gift": gid,
"from": fromDisplay,
"ts": time.Now().UnixMilli(),
})
if err != nil {
continue
}
danmakuBroadcast(out)
continue
}
text := clipDanmakuText(envelope.Text)
if text == "" {
continue
}
out, err := json.Marshal(map[string]interface{}{
"type": "dm",
"text": text,
"from": fromDisplay,
"ts": time.Now().UnixMilli(),
})
if err != nil {
continue
}
danmakuBroadcast(out)
}
}
func danmakuBroadcast(b []byte) {
danmakuClientsMu.Lock()
defer danmakuClientsMu.Unlock()
dead := make([]*websocket.Conn, 0)
for conn := range danmakuClients {
_ = conn.SetWriteDeadline(time.Now().Add(8 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
dead = append(dead, conn)
}
}
for _, conn := range dead {
delete(danmakuClients, conn)
_ = conn.Close()
}
}

188
server/pkg/weblive/hub.go Normal file
View File

@@ -0,0 +1,188 @@
package weblive
import (
"sync"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/webrtc/v3"
)
// trackForwarder 从主播轨读 RTP复制到所有观众本地轨
type trackForwarder struct {
remote *webrtc.TrackRemote
mu sync.Mutex
locals map[string]*webrtc.TrackLocalStaticRTP
stopCh chan struct{}
}
func newTrackForwarder(track *webrtc.TrackRemote) *trackForwarder {
return &trackForwarder{
remote: track,
locals: make(map[string]*webrtc.TrackLocalStaticRTP),
stopCh: make(chan struct{}),
}
}
func (tf *trackForwarder) addViewer(id string, t *webrtc.TrackLocalStaticRTP) {
tf.mu.Lock()
defer tf.mu.Unlock()
tf.locals[id] = t
}
func (tf *trackForwarder) removeViewer(id string) {
tf.mu.Lock()
defer tf.mu.Unlock()
delete(tf.locals, id)
}
func (tf *trackForwarder) close() {
select {
case <-tf.stopCh:
default:
close(tf.stopCh)
}
}
func (tf *trackForwarder) runReadLoop() {
buf := make([]byte, 1500)
for {
select {
case <-tf.stopCh:
return
default:
}
n, _, err := tf.remote.Read(buf)
if err != nil {
return
}
tf.mu.Lock()
for _, lt := range tf.locals {
cp := &rtp.Packet{}
if err := cp.Unmarshal(buf[:n]); err != nil {
continue
}
_ = lt.WriteRTP(cp)
}
tf.mu.Unlock()
}
}
// Hub 单房间:一名主播、多名观众(进程内内存态,重启清空)
type Hub struct {
mu sync.RWMutex
api *webrtc.API
cfg webrtc.Configuration
publishConn *websocket.Conn
pubPC *webrtc.PeerConnection
// 开播 WebSocket 上 quality= 参数,供 GET /live/info 只读输出
publishQuality string
forwarders []*trackForwarder
viewers map[string]*viewerSession
}
type viewerSession struct {
id string
ws *websocket.Conn
pc *webrtc.PeerConnection
pending []webrtc.ICECandidateInit
answered bool
}
func newHub(api *webrtc.API) *Hub {
return &Hub{
api: api,
cfg: webrtc.Configuration{ICEServers: iceServersFromEnv()},
viewers: make(map[string]*viewerSession),
}
}
var (
defaultHub *Hub
hubOnce sync.Once
hubInitErr error
)
func getHub() (*Hub, error) {
hubOnce.Do(func() {
var api *webrtc.API
api, hubInitErr = buildAPI()
if hubInitErr != nil {
return
}
defaultHub = newHub(api)
})
return defaultHub, hubInitErr
}
func (h *Hub) clearPublisher() {
h.mu.Lock()
defer h.mu.Unlock()
for _, tf := range h.forwarders {
tf.close()
}
h.forwarders = nil
if h.pubPC != nil {
_ = h.pubPC.Close()
h.pubPC = nil
}
h.publishConn = nil
h.publishQuality = ""
}
func (h *Hub) removeViewer(id string) {
h.mu.Lock()
vs, ok := h.viewers[id]
if ok {
delete(h.viewers, id)
}
for _, tf := range h.forwarders {
tf.removeViewer(id)
}
h.mu.Unlock()
if ok && vs != nil && vs.pc != nil {
_ = vs.pc.Close()
}
}
func (h *Hub) onPublisherTrack(track *webrtc.TrackRemote) {
if track.Kind() != webrtc.RTPCodecTypeVideo && track.Kind() != webrtc.RTPCodecTypeAudio {
return
}
tf := newTrackForwarder(track)
h.mu.Lock()
h.forwarders = append(h.forwarders, tf)
h.mu.Unlock()
goSafe("trackRead", tf.runReadLoop)
// 观众仅在「已开播」后拉流:首次协商时 attachForwardersToViewerPC 会带上当前全部轨,无需在此重协商
}
func (h *Hub) attachForwardersToViewerPC(v *viewerSession) {
h.mu.RLock()
fwd := append([]*trackForwarder(nil), h.forwarders...)
h.mu.RUnlock()
for _, tf := range fwd {
cap := tf.remote.Codec().RTPCodecCapability
lt, err := webrtc.NewTrackLocalStaticRTP(cap, tf.remote.ID()+"_"+v.id, tf.remote.StreamID())
if err != nil {
continue
}
rtpSender, err := v.pc.AddTrack(lt)
if err != nil {
continue
}
// Drain RTCP feedback to keep interceptors/senders healthy.
goSafe("viewerRTCP", func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, e := rtpSender.Read(rtcpBuf); e != nil {
return
}
}
})
tf.addViewer(v.id, lt)
}
}

View File

@@ -0,0 +1,50 @@
package weblive
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// liveQualitySet 与前端开播档位一致;非法 query 回落为 high
var liveQualitySet = map[string]struct{}{
"source": {}, "high": {}, "mid": {}, "low": {},
}
func normalizeQuality(q string) string {
q = strings.TrimSpace(strings.ToLower(q))
if _, ok := liveQualitySet[q]; ok {
return q
}
return "high"
}
func liveQualityList() []gin.H {
return []gin.H{
{"id": "source", "label": "原画(设备默认)"},
{"id": "high", "label": "高清 720p"},
{"id": "mid", "label": "标清 480p"},
{"id": "low", "label": "流畅 360p"},
}
}
// handleLiveInfo 仅 GET、无请求体、不读 query只输出直播状态与画质元数据
func handleLiveInfo(c *gin.Context) {
h, herr := getHub()
live := false
cq := ""
if herr == nil {
h.mu.RLock()
live = h.publishConn != nil && h.pubPC != nil && len(h.forwarders) > 0
cq = h.publishQuality
h.mu.RUnlock()
}
c.JSON(http.StatusOK, gin.H{
"live": live,
"qualities": liveQualityList(),
"current_quality": cq,
"ts": time.Now().UnixMilli(),
})
}

View File

@@ -0,0 +1,268 @@
package weblive
import (
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
var (
modMu sync.RWMutex
muteAll bool
mutedIP = make(map[string]bool)
mutedUsers = make(map[string]bool) // key: normMuteUsername
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
onlineMap = make(map[string]*onlineSession)
seq uint64
)
type ModerationSnapshot struct {
MuteAll bool `json:"mute_all"`
MutedIPs []string `json:"muted_ips"`
MutedUsernames []string `json:"muted_usernames"`
OnlineIPs []IPOnlineItem `json:"online_ips"`
OnlineUsers []OnlineUserItem `json:"online_users"`
RateLimit struct {
WindowMs int `json:"window_ms"`
MaxHits int `json:"max_hits"`
} `json:"rate_limit"`
}
type IPOnlineItem struct {
IP string `json:"ip"`
Count int `json:"count"`
Muted bool `json:"muted"`
}
type onlineSession struct {
ID string
IP string
Username string
Channel string
Connected time.Time
LastAt time.Time
}
type OnlineUserItem struct {
ID string `json:"id"`
IP string `json:"ip"`
Username string `json:"username"`
Channel string `json:"channel"`
ConnectedAt string `json:"connected_at"`
OnlineSec int64 `json:"online_sec"`
IdleSec int64 `json:"idle_sec"`
Muted bool `json:"muted"`
}
func SetMuteAll(enabled bool) {
modMu.Lock()
muteAll = enabled
modMu.Unlock()
}
func SetIPMuted(ip string, enabled bool) {
if ip == "" {
return
}
modMu.Lock()
if enabled {
mutedIP[ip] = true
} else {
delete(mutedIP, ip)
}
modMu.Unlock()
}
func normMuteUsername(u string) string {
return strings.ToLower(strings.TrimSpace(u))
}
// SetUserMuted 按登录用户名禁言(弹幕/礼物);与 IP 禁言、全体禁言叠加。
func SetUserMuted(username string, enabled bool) {
key := normMuteUsername(username)
if key == "" {
return
}
modMu.Lock()
if enabled {
mutedUsers[key] = true
} else {
delete(mutedUsers, key)
}
modMu.Unlock()
}
const (
ipSendWindowMs = 3000
ipSendMaxHits = 10
)
func IsIPMuted(ip string) bool {
modMu.RLock()
defer modMu.RUnlock()
return mutedIP[ip]
}
func IsMutedForIP(ip string) bool {
modMu.RLock()
defer modMu.RUnlock()
if muteAll {
return true
}
return mutedIP[ip]
}
// IsMutedForSend 发弹幕/礼物前全体禁言、IP 禁言、或已登录用户名被禁。
func IsMutedForSend(ip, username string) bool {
modMu.RLock()
defer modMu.RUnlock()
if muteAll {
return true
}
if mutedIP[ip] {
return true
}
if k := normMuteUsername(username); k != "" && mutedUsers[k] {
return true
}
return false
}
func ModerationStateSnapshot() ModerationSnapshot {
modMu.RLock()
muteAllNow := muteAll
muted := make([]string, 0, len(mutedIP))
for ip := range mutedIP {
muted = append(muted, ip)
}
mutedNames := make([]string, 0, len(mutedUsers))
for u := range mutedUsers {
mutedNames = append(mutedNames, u)
}
modMu.RUnlock()
sort.Strings(muted)
sort.Strings(mutedNames)
counts := onlineIPCountsLocked()
online := make([]IPOnlineItem, 0, len(counts))
for ip, n := range counts {
online = append(online, IPOnlineItem{IP: ip, Count: n, Muted: IsIPMuted(ip)})
}
sort.Slice(online, func(i, j int) bool {
if online[i].Count == online[j].Count {
return online[i].IP < online[j].IP
}
return online[i].Count > online[j].Count
})
users := onlineUsersLocked()
out := ModerationSnapshot{MuteAll: muteAllNow, MutedIPs: muted, OnlineIPs: online, OnlineUsers: users}
out.RateLimit.WindowMs = ipSendWindowMs
out.RateLimit.MaxHits = ipSendMaxHits
return out
}
// AllowSendByIP 本地内存限频(同 IP 先本地判定,避免刷爆)
func AllowSendByIP(ip string) bool {
if ip == "" {
return true
}
now := time.Now().UnixMilli()
cut := now - ipSendWindowMs
modMu.Lock()
defer modMu.Unlock()
arr := ipWindow[ip]
if len(arr) > 0 {
k := 0
for _, ts := range arr {
if ts >= cut {
arr[k] = ts
k++
}
}
arr = arr[:k]
}
if len(arr) >= ipSendMaxHits {
ipWindow[ip] = arr
return false
}
arr = append(arr, now)
if len(arr) > ipSendMaxHits*4 {
arr = arr[len(arr)-ipSendMaxHits*2:]
}
ipWindow[ip] = arr
return true
}
func RegisterOnlineSession(channel, ip, username string) string {
now := time.Now()
id := fmt.Sprintf("%s-%d", channel, atomic.AddUint64(&seq, 1))
modMu.Lock()
onlineMap[id] = &onlineSession{
ID: id,
IP: ip,
Username: username,
Channel: channel,
Connected: now,
LastAt: now,
}
modMu.Unlock()
return id
}
func TouchOnlineSession(id string) {
if id == "" {
return
}
modMu.Lock()
if s := onlineMap[id]; s != nil {
s.LastAt = time.Now()
}
modMu.Unlock()
}
func UnregisterOnlineSession(id string) {
if id == "" {
return
}
modMu.Lock()
delete(onlineMap, id)
modMu.Unlock()
}
func onlineIPCountsLocked() map[string]int {
out := make(map[string]int)
for _, s := range onlineMap {
if s == nil || s.IP == "" {
continue
}
out[s.IP]++
}
return out
}
func onlineUsersLocked() []OnlineUserItem {
now := time.Now()
out := make([]OnlineUserItem, 0, len(onlineMap))
for _, s := range onlineMap {
if s == nil {
continue
}
out = append(out, OnlineUserItem{
ID: s.ID,
IP: s.IP,
Username: s.Username,
Channel: s.Channel,
ConnectedAt: s.Connected.Format(time.RFC3339),
OnlineSec: int64(now.Sub(s.Connected).Seconds()),
IdleSec: int64(now.Sub(s.LastAt).Seconds()),
Muted: IsMutedForSend(s.IP, s.Username),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].OnlineSec > out[j].OnlineSec
})
return out
}

View File

@@ -0,0 +1,60 @@
package weblive
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func GetLiveModeration(c *gin.Context) {
c.JSON(http.StatusOK, ModerationStateSnapshot())
}
func PutLiveMuteAll(c *gin.Context) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
SetMuteAll(body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PutLiveMuteIP(c *gin.Context) {
var body struct {
IP string `json:"ip"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
ip := strings.TrimSpace(body.IP)
if ip == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "IP 不能为空"})
return
}
SetIPMuted(ip, body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PutLiveMuteUser(c *gin.Context) {
var body struct {
Username string `json:"username"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
u := strings.TrimSpace(body.Username)
if u == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
return
}
SetUserMuted(u, body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,15 @@
package weblive
import "log"
// goSafe 在独立 goroutine 中运行 fnpanic 只记录日志,避免拖垮整个 HTTP 进程(否则 Nginx 会看到 502
func goSafe(label string, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("weblive: panic in %s: %v", label, r)
}
}()
fn()
}()
}

Some files were not shown because too many files have changed in this diff Show More