Compare commits

...

78 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
1136 changed files with 155907 additions and 576 deletions

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 }); 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("集合与索引处理完成。"); print("集合与索引处理完成。");

View File

@@ -117,6 +117,8 @@ bash pull-and-restart.sh
若报错 `bash\r`,先执行 `sed -i 's/\r$//' pull-and-restart.sh 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` 后再次运行即可。 首次部署若目录为空,可先放入两个脚本,设置 `export GIT_REPO_URL='https://用户:Token@gitea.../web.git'` 后执行 `./pull-and-restart.sh` 完成克隆与启动。配置好 `server/.env` 后再次运行即可。
**产品视频自动导入**`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 && ./pull-and-restart.sh`
- **仅重启**`cd ~/project/yh_web && ./restart.sh` - **仅重启**`cd ~/project/yh_web && ./restart.sh`
- **对外域名**https://yuheng.yuxindazhineng.com所有请求均通过该域名见下 - **对外域名**https://yuheng.yuxindazhineng.com所有请求均通过该域名见下

View File

@@ -11,7 +11,7 @@ RUN npm run build
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/ ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}nginx:alpine FROM ${REGISTRY_MIRROR}nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,故这里用 location / 提供 SPAbase 为 /admin/ 时静态资源请求为 /assets/... # 与 deploy/admin/default.conf 同逻辑:^~ /assets/ 避免缺失 chunk 时回退到 index.html → MIME text/html 白屏
RUN echo 'server { listen 80; root /usr/share/nginx/html; location / { try_files $uri $uri/ /index.html; } }' > /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] 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

@@ -28,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 updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
export const deleteUser = (id) => request.delete(`/admin/users/${id}`) 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 }) export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
@@ -46,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
export const getOfficialSite = () => request.get('/admin/system/official-site') export const getOfficialSite = () => request.get('/admin/system/official-site')
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId }) 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 getSites = () => request.get('/admin/sites')
export const getSiteById = (id) => request.get(`/admin/sites/${id}`) export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
@@ -79,7 +88,42 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
if (opts.folder != null) form.append('folder', opts.folder) if (opts.folder != null) form.append('folder', opts.folder)
form.append('downloadable', opts.downloadable ? 'true' : 'false') form.append('downloadable', opts.downloadable ? 'true' : 'false')
if (opts.preserveFilename) form.append('preserve_filename', 'true') if (opts.preserveFilename) form.append('preserve_filename', 'true')
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) // 大文件上传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 createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`) 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

@@ -43,7 +43,7 @@
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' 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 { useAuthStore } from '../stores/auth'
import { getMyPermissions } from '../api/admin' import { getMyPermissions } from '../api/admin'
@@ -69,8 +69,11 @@ const menuItems = computed(() => {
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' }, { index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' }, { index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' }, { 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: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' }, { index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
{ index: '/files', title: '文件管理', icon: Folder, permission: null }, { index: '/files', title: '文件管理', icon: Folder, permission: null },
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' } { index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
] ]

View File

@@ -48,6 +48,18 @@ const routes = [
component: () => import('../views/settings/PaymentConfig.vue'), component: () => import('../views/settings/PaymentConfig.vue'),
meta: { title: '支付配置', permission: 'payment_config' } 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', path: 'sites',
name: 'Sites', name: 'Sites',
@@ -66,6 +78,12 @@ const routes = [
component: () => import('../views/sites/HomepageEdit.vue'), component: () => import('../views/sites/HomepageEdit.vue'),
meta: { title: '首页编辑', permission: 'homepage:edit' } meta: { title: '首页编辑', permission: 'homepage:edit' }
}, },
{
path: 'live-broadcast',
name: 'LiveBroadcast',
component: () => import('../views/sites/LiveBroadcast.vue'),
meta: { title: '视频直播开播', permission: 'homepage:edit' }
},
{ {
path: 'files', path: 'files',
name: 'FileManage', name: 'FileManage',

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-col>
</el-row> </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"> <el-card style="margin-top: 20px">
<template #header> <template #header>
<span>快捷入口</span> <span>快捷入口</span>
@@ -41,7 +96,7 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref, onMounted } from 'vue' import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
import { getStats } from '../api/admin' import { getStats } from '../api/admin'
const stats = reactive({ const stats = reactive({
@@ -52,13 +107,51 @@ const stats = reactive({
files: 0 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) 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 () => { const fetchStats = async () => {
loading.value = true loading.value = true
try { try {
const res = await getStats() 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) { } catch (e) {
console.error('获取统计失败:', e) console.error('获取统计失败:', e)
// 即使失败也显示 0不阻塞页面 // 即使失败也显示 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> </script>
<style scoped> <style scoped>
@@ -85,4 +201,58 @@ onMounted(fetchStats)
color: #666; color: #666;
font-size: 14px; 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> </style>

View File

@@ -57,17 +57,21 @@
<!-- 上传前选择是否可下载 --> <!-- 上传前选择是否可下载 -->
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false"> <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 label-width="112px">
<el-form-item label="当前目录"> <el-form-item label="当前目录">
<span>{{ currentPath || '根目录' }}</span> <span>{{ currentPath || '根目录' }}</span>
</el-form-item> </el-form-item>
<el-form-item label="保留原文件名"> <el-form-item label="保留原文件名">
<el-switch v-model="uploadPreserveFilename" /> <el-switch v-model="uploadPreserveFilename" />
<span class="form-hint">开启后覆盖同路径同名文件首页产品视频须上传到 <code>promotion/视频发布/</code> 并开启此项</span> <span class="form-hint">开启后将按原文件名保存同名文件会被覆盖</span>
</el-form-item> </el-form-item>
<el-form-item label="允许下载"> <el-form-item label="允许下载">
<el-switch v-model="uploadDownloadable" /> <el-switch v-model="uploadDownloadable" />
</el-form-item> </el-form-item>
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
<el-progress :percentage="uploadPercent" :stroke-width="16" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button> <el-button @click="uploadDialogVisible = false">取消</el-button>
@@ -93,7 +97,8 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset, createSiteFolder } from '../../api/admin' import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
const activeTab = ref('module') const activeTab = ref('module')
const siteId = ref('') const siteId = ref('')
@@ -103,6 +108,7 @@ const subDirs = ref([])
const loading = ref(false) const loading = ref(false)
const currentPath = ref('') const currentPath = ref('')
const uploading = ref(false) const uploading = ref(false)
const uploadPercent = ref(0)
const uploadDialogVisible = ref(false) const uploadDialogVisible = ref(false)
const uploadDownloadable = ref(false) const uploadDownloadable = ref(false)
const uploadPreserveFilename = ref(false) const uploadPreserveFilename = ref(false)
@@ -167,12 +173,22 @@ const beforeUpload = (file) => {
const doUpload = async () => { const doUpload = async () => {
if (!pendingFile.value || !siteId.value) return if (!pendingFile.value || !siteId.value) return
uploading.value = true uploading.value = true
uploadPercent.value = 0
try { try {
await uploadSiteAsset(siteId.value, pendingFile.value, { await uploadSiteAssetWithResume(
siteId.value,
pendingFile.value,
{
folder: currentPath.value || undefined, folder: currentPath.value || undefined,
downloadable: uploadDownloadable.value, downloadable: uploadDownloadable.value,
preserveFilename: uploadPreserveFilename.value preserveFilename: uploadPreserveFilename.value
}) },
{
onProgress: ({ percent }) => {
uploadPercent.value = percent
}
}
)
ElMessage.success('上传成功') ElMessage.success('上传成功')
uploadDialogVisible.value = false uploadDialogVisible.value = false
pendingFile.value = null pendingFile.value = null
@@ -181,6 +197,7 @@ const doUpload = async () => {
ElMessage.error(e.response?.data?.error || e.message || '上传失败') ElMessage.error(e.response?.data?.error || e.message || '上传失败')
} finally { } finally {
uploading.value = false uploading.value = false
uploadPercent.value = 0
} }
} }
@@ -230,4 +247,10 @@ onMounted(() => fetchSites().then(() => fetchList()))
.breadcrumb-wrap { margin-top: 12px; } .breadcrumb-wrap { margin-top: 12px; }
.subdirs { margin-top: 8px; font-size: 13px; color: #666; } .subdirs { margin-top: 8px; font-size: 13px; color: #666; }
.subdirs .label { margin-right: 8px; } .subdirs .label { margin-right: 8px; }
.upload-resume-hint {
margin: 0 0 12px;
font-size: 12px;
color: #606266;
line-height: 1.5;
}
</style> </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

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

@@ -17,13 +17,13 @@
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form"> <el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form">
<el-divider content-position="left">导航与标题</el-divider> <el-divider content-position="left">导航与标题</el-divider>
<el-form-item label="Logo 文案"> <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>
<el-form-item label="主标题"> <el-form-item label="主标题">
<el-input v-model="form.title" placeholder="宇恒一号" /> <el-input v-model="form.title" placeholder="宇恒一号" />
</el-form-item> </el-form-item>
<el-form-item label="副标题"> <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>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" /> <el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
@@ -50,51 +50,77 @@
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button> <el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
</el-form-item> </el-form-item>
<el-divider content-position="left">下载按钮</el-divider> <el-divider content-position="left">侧栏下载Windows / 安卓直链</el-divider>
<el-form-item label="按钮文案"> <p class="builder-tip" style="margin: -6px 0 12px">
<el-input v-model="form.download_text" placeholder="START EXPLORING" /> 填写同域可下载的静态地址需将安装包放到站点 <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>
<el-form-item label="按钮链接"> <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">直播前台 /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%"> <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-input v-model="form.download_url" placeholder="#" style="flex: 1; min-width: 200px" />
<el-button type="primary" link @click="openLinkPicker({ type: 'download' })">选择链接</el-button> <el-button type="primary" link @click="openLinkPicker({ type: 'download' })">选择链接</el-button>
<el-link
v-if="previewReady(form.download_url)"
type="primary"
:href="previewHref(form.download_url)"
target="_blank"
rel="noopener noreferrer"
>试跳</el-link>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="平台轨道(可选)">
<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; align-items: center"> <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="如 WINDOWS" style="width: 140px" /> <el-input v-model="p.name" placeholder="名称" style="width: 140px" />
<el-input v-model="p.url" placeholder="链接" style="flex: 1; min-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 type="primary" link @click="openLinkPicker({ type: 'platform', index: i })">选择链接</el-button>
<el-link
v-if="previewReady(p.url)"
type="primary"
:href="previewHref(p.url)"
target="_blank"
rel="noopener noreferrer"
>试跳</el-link>
<el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button> <el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button>
</div> </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-form-item>
<el-divider content-position="left">版本与徽章</el-divider> <el-divider content-position="left">版本与徽章前台已隐藏主视觉条可留空</el-divider>
<el-form-item label="版本"> <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>
<el-form-item label="发射年份"> <el-form-item label="发布说明">
<el-input v-model="form.launch_year" placeholder="LAUNCH: 2024" style="width: 200px" /> <el-input v-model="form.launch_year" placeholder="发布日期:以官网为准" style="width: 200px" />
</el-form-item> </el-form-item>
<el-form-item label="徽章文案"> <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-form-item>
<el-divider content-position="left">特性卡片</el-divider> <el-divider content-position="left">特性卡片</el-divider>
@@ -108,12 +134,12 @@
</el-form-item> </el-form-item>
<el-form-item label="页脚文案"> <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-form-item>
<el-divider content-position="left">首页下方扩展区可视化积木可拖拽排序</el-divider> <el-divider content-position="left">首页下方扩展区可视化积木可拖拽排序</el-divider>
<p class="builder-tip"> <p class="builder-tip">
网页管理 积木相同从左侧手柄拖拽调整模块顺序保存后内容显示在落地页主视觉与特性卡片<strong>之后</strong>页脚之前留空则不显示扩展区 网页管理 积木相同从左侧手柄拖拽调整模块顺序保存后内容显示在落地页主体模块<strong>之后</strong>页脚之前留空则不显示扩展区
</p> </p>
<el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap"> <el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap">
<PageBuilderEditor v-model="form.body_builder" :site-id="siteId" /> <PageBuilderEditor v-model="form.body_builder" :site-id="siteId" />
@@ -145,34 +171,36 @@ const saving = ref(false)
const downloading = ref(false) const downloading = ref(false)
const formRef = ref(null) const formRef = ref(null)
const linkPickerVisible = ref(false) const linkPickerVisible = ref(false)
/** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'platform'; index?: number }>} */ /** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'download_windows' | 'download_android' | 'platform'; index?: number }>} */
const linkPickTarget = ref({ type: 'download' }) const linkPickTarget = ref({ type: 'download' })
const defaultForm = () => ({ const defaultForm = () => ({
logo_text: 'YUHENG ONE', logo_text: '宇恒一号',
nav_links: [{ label: 'MISSION', url: '#' }, { label: 'DOWNLOAD', url: '#' }, { label: 'CONTACT', url: '#' }], nav_links: [
title: '宇恒一号', { label: '产品简介', url: '#intro' },
subtitle: 'INTERSTELLAR EXPLORER EDITION', { label: '产品视频', url: '#videos' },
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙', { label: '联系我们', url: '#contact' }
download_text: 'START EXPLORING',
download_url: '#',
platforms: [
{ name: 'WINDOWS', url: '#' },
{ name: 'MACOS', url: '#' },
{ name: 'LINUX', url: '#' },
{ name: 'IOS', url: '#' },
{ name: 'ANDROID', url: '#' }
], ],
version: 'VERSION 3.2.1', title: '宇恒一号',
launch_year: 'LAUNCH: 2024', subtitle: '',
badge_text: 'FREE ACCESS', 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: [ features: [
{ title: '星际导航', desc: '先进的 AI 导航系统,精准定位您的需求,引领探索之旅' }, { title: '星际导航', desc: '先进的 AI 导航系统,精准定位您的需求,引领探索之旅' },
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' }, { title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' } { title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
], ],
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE', footer_text: '© 2024 宇恒一号 · 成都宇信达智能科技有限公司',
body_builder: '' body_builder: '',
live_room_url: '',
live_room_title: '视频直播'
}) })
const form = reactive(defaultForm()) const form = reactive(defaultForm())
@@ -205,12 +233,19 @@ const fetchData = async () => {
if (!siteId.value) return if (!siteId.value) return
try { try {
const data = await getHomepage(siteId.value) const data = await getHomepage(siteId.value)
const base = defaultForm()
Object.assign(form, { Object.assign(form, {
...defaultForm(), ...base,
...data, ...data,
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : defaultForm().nav_links, nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : base.nav_links,
platforms: Array.isArray(data.platforms) && data.platforms.length ? data.platforms : defaultForm().platforms, platforms: Array.isArray(data.platforms) ? data.platforms : base.platforms,
features: Array.isArray(data.features) && data.features.length ? data.features : defaultForm().features 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) { } catch (e) {
ElMessage.error(e.message) ElMessage.error(e.message)
@@ -264,6 +299,10 @@ function onLinkPicked(url) {
form.nav_links[t.index].url = url form.nav_links[t.index].url = url
} else if (t.type === 'download') { } else if (t.type === 'download') {
form.download_url = url 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') { } else if (t.type === 'platform' && typeof t.index === 'number') {
form.platforms[t.index].url = url form.platforms[t.index].url = url
} }

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> <script setup>
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 siteId = ref('')
const sites = ref([]) const sites = ref([])
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
const beforeUpload = async (file) => { const beforeUpload = async (file) => {
uploading.value = true uploading.value = true
try { try {
await uploadSiteAsset(siteId.value, file) await uploadSiteAssetWithResume(siteId.value, file, {})
ElMessage.success('上传成功') ElMessage.success('上传成功')
fetchList() fetchList()
} catch (e) { } catch (e) {

View File

@@ -22,7 +22,8 @@ export default defineConfig({
'/api': { '/api': {
target: 'https://yuheng.yuxindazhineng.com', target: 'https://yuheng.yuxindazhineng.com',
changeOrigin: true, changeOrigin: true,
secure: true secure: true,
ws: true
} }
} }
} }

View File

@@ -1,4 +1,4 @@
# deploy 目录(挂目录 + 替换文件部署 # deploy 目录(与 api 相同:仅替换构建产物;`web` 容器除 `web/public` 外不挂源码目录
- **deploy/web/dist**:前台构建产物,由 `pull-and-restart.sh` 生成;替换此目录内容即可更新前台。 - **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/admin/dist**:后台构建产物,同上。后台 Vite 通过 `@yh-web` 引用 `../web/src`(如积木 `BlockRenderer`),用 Docker 单目录挂载 `admin` 时会构建失败,须挂载**项目根**再在 `admin` 下执行 `npm run build`(见 `pull-and-restart.sh`)。
@@ -6,3 +6,14 @@
- **deploy/web/default.conf**、**deploy/admin/default.conf**Nginx 配置,已纳入版本库。 - **deploy/web/default.conf**、**deploy/admin/default.conf**Nginx 配置,已纳入版本库。
日常更新:在服务器执行 `./pull-and-restart.sh` 会拉代码、重新构建到上述目录并重启容器。若只改静态资源,也可在服务器上手动构建后只重启对应容器。 日常更新:在服务器执行 `./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/` 中缺失或过小(几百字节),会直接报错退出,避免白屏上线。

View File

@@ -1,8 +1,23 @@
# 与 admin/nginx.conf 保持内容一致Compose 挂载本文件admin 镜像内 COPY 同配置)
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA # 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -4,10 +4,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# 根路径下的验证文件走热加载目录 # 域名/微信等验证文件:由外层 yh_nginx443直接 root /verify-root 提供,本容器不再挂载 verify-root
location ~ ^/([A-Za-z0-9._-]+\.(txt|html|xml))$ {
alias /verify-root/$1;
}
# 静态资源必须真实存在,避免错误回退成 index.html 导致白屏 # 静态资源必须真实存在,避免错误回退成 index.html 导致白屏
location ^~ /assets/ { location ^~ /assets/ {
@@ -17,6 +14,40 @@ server {
add_header Cache-Control "public, immutable"; 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 = / { location = / {
try_files /index.html =404; try_files /index.html =404;
} }

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

@@ -28,14 +28,14 @@ services:
networks: networks:
- yh_net - yh_net
# 静态文件由脚本构建到 deploy/web/dist,挂载后替换文件即可生效 # 静态文件 deploy/web/dist;与 api 一致不挂源码目录。仅额外挂载 web/publiclogo、social 二维码等)
web: web:
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
container_name: yh_web container_name: yh_web
volumes: volumes:
- ./deploy/web/dist:/usr/share/nginx/html:ro - ./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 - ./deploy/web/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./verify-root:/verify-root:ro
networks: networks:
- yh_net - yh_net
@@ -54,13 +54,21 @@ services:
container_name: yh_nginx container_name: yh_nginx
ports: ports:
- "443:443" - "443:443"
# 启动脚本:等上游 → 从 resolv.conf 注入 resolver → 生成 conf.d变量 proxy_pass避免 Podman host not found
entrypoint: ["/bin/sh", "/nginx-entrypoint-wait-dns.sh"]
volumes: volumes:
- ./nginx/yuheng.docker.conf:/etc/nginx/conf.d/default.conf:ro - ./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 - /etc/ssl/yh_web/yuheng.yuxindazhineng.com:/etc/ssl/yh_web/yuheng.yuxindazhineng.com:ro
depends_on: depends_on:
- api - api
- web - web
- admin - admin
# Podman/慢盘API 首次就绪可能超过 90s避免 yh_nginx 等待超时后 Exited(1) → 全站 443 拒绝连接
environment:
- NGINX_WAIT_UPSTREAM_SEC=180
networks: networks:
- yh_net - yh_net

View File

@@ -56,7 +56,7 @@ sudo systemctl reload nginx
**/api/health 或 /admin/ 返回 404 时**:在服务器执行 `ss -tlnp | grep 443`,看 443 是宿主机 nginx 还是 docker。若是宿主机 nginx要么停用该站点配置让 compose 独占 443方式 A要么改为方式 Bcompose 用 8443宿主机反代到 8443 **/api/health 或 /admin/ 返回 404 时**:在服务器执行 `ss -tlnp | grep 443`,看 443 是宿主机 nginx 还是 docker。若是宿主机 nginx要么停用该站点配置让 compose 独占 443方式 A要么改为方式 Bcompose 用 8443宿主机反代到 8443
**验证文件热加载**如果只需要把某些根目录验证文件上线,放到项目根目录的 `verify-root/` 即可`web` 容器会把它挂载为 `/verify-root`,并直接从网站根路径提供这些 `.txt/.html/.xml` 文件。修改文件后不需要重建 `web` 镜像,只要文件保存到宿主机上就会立刻生效 **验证文件热加载**验证文件放到项目根目录的 `verify-root/` 即可compose 内 **`yh_nginx`** 挂载该目录并在 **443** 上直接 `root /verify-root` 提供(见 `nginx/yuheng.docker.conf.tpl`)。`reload` 后生效;若仅改文件,可 `docker compose restart nginx`
## 4. 新服务器首次安装 Nginx ## 4. 新服务器首次安装 Nginx
@@ -80,3 +80,17 @@ sudo systemctl start nginx
- **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf``web/Dockerfile` 内嵌配置)。若你自建 Nginx请对照修改后再 `nginx -t` 并重载。 - **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf``web/Dockerfile` 内嵌配置)。若你自建 Nginx请对照修改后再 `nginx -t` 并重载。
- **应用内 404**:在 SPA 已正确回退的前提下,未在后台发布的路径会由前端路由进入 **「页面不存在」** 页(`NotFound.vue`),与上述 nginx 404 不同。 - **应用内 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` 容器)。 - **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 单入口方案。

View File

@@ -1,9 +1,22 @@
# 供 compose 中 admin 容器使用:宿主机挂载 admin/distSPA 回退 # 与 deploy/admin/default.conf、admin/nginx.conf 保持一致(勿再使用仅含 location / 的旧版,否则 /assets/*.js 会回退成 index.html → 白屏)
# 外层 Nginx 把 /admin/ 转成 / 转发到本容器 # 外层 Nginx 把 /admin/ 转成 / 转发到本容器
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

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

View File

View File

@@ -1,13 +1,9 @@
# 供 compose 中 web 容器使用:宿主机挂载 web/dist 与 verify-root仅提供静态与 SPA 回退 # 供 compose 中 web 容器使用:与 deploy/web/default.conf 同步;验证文件仅外层 yh_nginx 处理
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location ~ ^/([A-Za-z0-9._-]+\.(txt|html|xml))$ {
alias /verify-root/$1;
}
location ^~ /assets/ { location ^~ /assets/ {
try_files $uri =404; try_files $uri =404;
access_log off; access_log off;
@@ -15,6 +11,37 @@ server {
add_header Cache-Control "public, immutable"; 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 = / { location = / {
try_files /index.html =404; try_files /index.html =404;
} }

View File

@@ -1,43 +1,5 @@
# 供 docker-compose 中 nginx 使用:仅监听 443反代到 api/web/admin证书挂载到 /etc/ssl/yh_web/yuheng.yuxindazhineng.com/ # 已迁移:实际运行使用 yuheng.docker.conf.tpl
# 启动时由 scripts/nginx-entrypoint-wait-dns.sh 将 resolv.conf 中的 nameserver 注入为 resolver
server { # 并配合变量 proxy_pass避免 Docker/Podman 下「upstream OK」后仍出现 host not found in upstream "api"。
listen 443 ssl http2; #
listen [::]:443 ssl http2; # 修改 HTTPS 反代请编辑nginx/yuheng.docker.conf.tpl
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;
location / {
proxy_pass http://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;
}
location /admin/ {
proxy_pass http://admin: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;
}
# 不要用尾部斜杠,否则 /api/health 会变成 /health而后端注册的是 /api/health
location /api/ {
proxy_pass http://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;
}
}

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

@@ -13,8 +13,9 @@ server {
# HTTPS整站反代到 compose 内 Nginx宿主机 443 → 127.0.0.1:8443 # HTTPS整站反代到 compose 内 Nginx宿主机 443 → 127.0.0.1:8443
server { server {
listen 443 ssl http2; # 与 yuheng.host.conf 一致:大文件/分片上传在 HTTP/1.1 下更稳
listen [::]:443 ssl http2; listen 443 ssl;
listen [::]:443 ssl;
server_name yuheng.yuxindazhineng.com; server_name yuheng.yuxindazhineng.com;
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem; ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
@@ -24,6 +25,31 @@ server {
ssl_protocols TLSv1.2 TLSv1.3; 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; 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 / { location / {
proxy_pass http://127.0.0.1:8443; proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -31,5 +57,9 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 75s;
proxy_read_timeout 75s;
proxy_buffering off;
} }
} }

View File

@@ -269,9 +269,14 @@ else
rm -rf "$tmp_backup" rm -rf "$tmp_backup"
fi 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 ""
echo "[2/3] 重新构建并启动..." echo "[2/3] 重新构建并启动..."
# 宿主机 9527 常被 sshd 占用compose 必须使用 8088 且 api 不映射宿主机端口 # 宿主机 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 if grep -q '9527' "$ROOT/docker-compose.yml" 2>/dev/null; then
echo "错误: 当前 docker-compose.yml 仍含 9527会与 sshd 冲突导致启动失败。请以 Gitea 为准拉取最新代码后再执行本脚本:" >&2 echo "错误: 当前 docker-compose.yml 仍含 9527会与 sshd 冲突导致启动失败。请以 Gitea 为准拉取最新代码后再执行本脚本:" >&2
echo " git fetch origin && git reset --hard origin/master" >&2 echo " git fetch origin && git reset --hard origin/master" >&2
@@ -288,6 +293,20 @@ echo "构建 web 前端 -> deploy/web/dist ..."
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \ 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/" "${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 ..." echo "构建 admin 前端 -> deploy/admin/dist ..."
# admin 的 vite 别名 @yh-web -> ../web/src须挂载项目根否则容器内无 web 目录会报 BlockRenderer.vue ENOENT # 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 \ run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
@@ -304,6 +323,8 @@ if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dis
exit 1 exit 1
fi fi
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
# 仅构建 api 运行时镜像轻量无业务代码web/admin 使用官方 nginx 镜像无需构建 # 仅构建 api 运行时镜像轻量无业务代码web/admin 使用官方 nginx 镜像无需构建
compose_cmd build api compose_cmd build api
@@ -333,18 +354,19 @@ elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGIN
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem" run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
fi fi
compose_cmd down 2>/dev/null || true # shellcheck disable=SC1091
compose_cmd up -d --force-recreate . "$ROOT/scripts/lib-yh-compose-deploy.sh"
# 先停掉本项目容器(不停止宿主机 nginx→ 准备宿主机站点并确保 nginx 在线 → 启动业务容器
yh_compose_down
echo "" echo ""
echo "[3/3] 证书与宿主机 Nginx(可选)..." echo "[3/3] 宿主机 Nginx 站点与服务..."
NGINX_CONF_NAME="${NGINX_DOMAIN}.conf" yh_install_host_nginx_site_conf
if [ -f "$ROOT/nginx/$NGINX_CONF_NAME" ]; then ensure_host_nginx_started
run_sudo cp -f "$ROOT/nginx/$NGINX_CONF_NAME" /etc/nginx/conf.d/ 2>/dev/null || true yh_compose_up
if run_sudo nginx -t 2>/dev/null; then yh_post_deploy_healthcheck
run_sudo systemctl reload nginx 2>/dev/null && echo "宿主机 Nginx 已重载." || true
fi # 可选web/promotion/视频发布 -> data/uploads + MongoDB须 server/.env 中 YH_IMPORT_PROMOTION_SITE_ID
fi bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
echo "" echo ""
echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN" echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN"

View File

@@ -180,6 +180,7 @@ if [ ! -f server/.env ]; then
echo "已创建默认 server/.env" echo "已创建默认 server/.env"
fi fi
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 ] && sed -i 's/\r$//' server/.env 2>/dev/null || true
[ -f server/.env ] && set -a && source server/.env && set +a [ -f server/.env ] && set -a && source server/.env && set +a
@@ -197,8 +198,21 @@ mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
echo "构建 web 前端 -> deploy/web/dist ..." echo "构建 web 前端 -> deploy/web/dist ..."
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \ 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/" "${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 ..." echo "构建 admin 前端 -> deploy/admin/dist ..."
run_sudo docker run --rm -v "$ROOT/admin:/app" -v "$ROOT/deploy/admin/dist:/out" -w /app \ # 与 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/" "${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 ..." echo "构建 api 二进制 -> deploy/api/server ..."
run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \ run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \
@@ -208,6 +222,8 @@ if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dis
echo "错误: 构建产物不完整(缺少 index.html请检查上方构建日志。" >&2 echo "错误: 构建产物不完整(缺少 index.html请检查上方构建日志。" >&2
exit 1 exit 1
fi fi
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
compose_cmd build api compose_cmd build api
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7" MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
@@ -220,7 +236,6 @@ fi
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}" NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN" NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
NGINX_CONF_NAME="${NGINX_DOMAIN}.conf"
run_sudo mkdir -p "$NGINX_SSL_DIR" run_sudo mkdir -p "$NGINX_SSL_DIR"
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then 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.pem" "$NGINX_SSL_DIR/fullchain.pem"
@@ -236,14 +251,15 @@ elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGIN
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem" run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
fi fi
compose_cmd down 2>/dev/null || true # shellcheck disable=SC1091
compose_cmd up -d --force-recreate . "$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
if [ -f "$ROOT/nginx/$NGINX_CONF_NAME" ]; then bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
run_sudo cp -f "$ROOT/nginx/$NGINX_CONF_NAME" /etc/nginx/conf.d/ 2>/dev/null || true
if run_sudo nginx -t 2>/dev/null; then
run_sudo systemctl reload nginx 2>/dev/null && echo "Nginx 已重载." || true
fi
fi
echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN" echo "完成. 对外仅 443反代: https://$NGINX_DOMAIN"

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

@@ -6,5 +6,15 @@ MONGODB_URI=mongodb://mongo:27017
MONGODB_DB=yxd-agent-testing MONGODB_DB=yxd-agent-testing
PORT=8088 PORT=8088
GIN_MODE=release GIN_MODE=release
# 对外域名CORS、日志与 nginx 反代域名一致 # 对外域名CORS、日志与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/
ALLOWED_ORIGINS=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

@@ -13,7 +13,8 @@ RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/ ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
FROM ${REGISTRY_MIRROR}alpine:3.19 FROM ${REGISTRY_MIRROR}alpine:3.19
WORKDIR /app 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 ENV TZ=Asia/Shanghai
COPY --from=builder /app/server . COPY --from=builder /app/server .
EXPOSE 8088 EXPOSE 8088

View File

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

View File

@@ -22,3 +22,8 @@ go run main.go
``` ```
默认端口 8080 默认端口 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

@@ -11,6 +11,7 @@ require (
require ( require (
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // 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/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.6 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // 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/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.33.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/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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.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 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 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 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 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= 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/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 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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.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.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.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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 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 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= 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/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-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.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 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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.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-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-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.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 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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-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.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 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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= 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-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-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.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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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.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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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-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.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.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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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= 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= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strings"
"time" "time"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -34,6 +35,37 @@ type Claims struct {
jwt.RegisteredClaims 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 超级管理员可登录 // Login 后台登录,仅 role_id=9527 超级管理员可登录
func Login(c *gin.Context) { func Login(c *gin.Context) {
var input LoginInput var input LoginInput
@@ -122,25 +154,13 @@ func AuthRequired() gin.HandlerFunc {
if tokenStr == "" { if tokenStr == "" {
tokenStr = c.Query("token") tokenStr = c.Query("token")
} }
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { claims, ok := ParseClaimsFromTokenString(tokenStr)
tokenStr = tokenStr[7:] if !ok {
}
if tokenStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort() c.Abort()
return 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)可访问后台 // 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") { if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"}) c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
@@ -164,25 +184,13 @@ func SuperUserAuthRequired() gin.HandlerFunc {
if tokenStr == "" { if tokenStr == "" {
tokenStr = c.Query("token") tokenStr = c.Query("token")
} }
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " { claims, ok := ParseClaimsFromTokenString(tokenStr)
tokenStr = tokenStr[7:] if !ok {
}
if tokenStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
c.Abort() c.Abort()
return 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 可配置短信平台 // 仅 role_id=9527 且 role=admin 可配置短信平台
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" { if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"}) 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) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusOK, defaultHomepageData())
return
}
siteID := getOfficialSiteID(ctx) siteID := getOfficialSiteID(ctx)
if siteID == "" { if siteID == "" {
c.JSON(http.StatusOK, defaultHomepageData()) c.JSON(http.StatusOK, defaultHomepageData())
@@ -186,29 +191,27 @@ func DownloadHomepage(c *gin.Context) {
func defaultHomepageData() models.HomepageData { func defaultHomepageData() models.HomepageData {
return models.HomepageData{ return models.HomepageData{
LogoText: "YUHENG ONE", LogoText: "宇恒一号",
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}}, NavLinks: []models.NavLink{{Label: "产品简介", URL: "#intro"}, {Label: "产品视频", URL: "#videos"}, {Label: "联系我们", URL: "#contact"}},
Title: "宇恒一号", Title: "宇恒一号",
Subtitle: "INTERSTELLAR EXPLORER EDITION", Subtitle: "",
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙", Description: "",
DownloadText: "START EXPLORING", DownloadText: "下载",
DownloadURL: "#", DownloadURL: "#",
Platforms: []models.PlatformItem{ Platforms: []models.PlatformItem{},
{Name: "WINDOWS", URL: "#"}, Version: "",
{Name: "MACOS", URL: "#"}, LaunchYear: "发布日期:以官网为准",
{Name: "LINUX", URL: "#"}, BadgeText: "完全免费",
{Name: "IOS", URL: "#"}, DownloadWindowsURL: "/promotion/downloads/yuheng-windows.zip",
{Name: "ANDROID", URL: "#"}, DownloadAndroidURL: "/promotion/downloads/yuheng-android.apk",
},
Version: "VERSION 3.2.1",
LaunchYear: "LAUNCH: 2024",
BadgeText: "FREE ACCESS",
Features: []models.FeatureItem{ Features: []models.FeatureItem{
{Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"}, {Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"},
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"}, {Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"}, {Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
}, },
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE", FooterText: "© 2024 宇恒一号 · 成都宇信达智能科技有限公司",
LiveRoomURL: "",
LiveRoomTitle: "视频直播",
} }
} }

View File

@@ -207,6 +207,42 @@ func ServePromotionMedia(c *gin.Context) {
c.File(fullPath) 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 // UploadSiteAsset 上传功能模块/文件form 可选folder当前目录相对路径、downloadabletrue/false、preserve_filenametrue 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL
func UploadSiteAsset(c *gin.Context) { func UploadSiteAsset(c *gin.Context) {
siteID := c.Param("site_id") siteID := c.Param("site_id")
@@ -225,40 +261,11 @@ func UploadSiteAsset(c *gin.Context) {
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1" downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1" preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
name := file.Filename relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
ext := filepath.Ext(name) if errMsg != "" {
nameNoExt := strings.TrimSuffix(name, ext) c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
var saveName string
if preserve {
saveName = filepath.Base(name)
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
return 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, "/../") {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
return
}
}
var relPath string
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))
if preserve { if preserve {
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second) ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
@@ -306,6 +313,8 @@ func UploadSiteAsset(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"}) 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 删除站点资源 // DeleteSiteAsset 删除站点资源

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

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

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

View File

@@ -35,6 +35,11 @@ func GetWebRoutes(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
return
}
siteID := c.Query("site_id") siteID := c.Query("site_id")
if siteID == "" { if siteID == "" {
siteID = getOfficialSiteID(ctx) siteID = getOfficialSiteID(ctx)
@@ -84,6 +89,11 @@ func GetWebPageByPath(c *gin.Context) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if config.MongoClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
return
}
siteID := c.Query("site_id") siteID := c.Query("site_id")
if siteID == "" { if siteID == "" {
siteID = getOfficialSiteID(ctx) siteID = getOfficialSiteID(ctx)

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/models"
"yh_web/server/pkg/logger" "yh_web/server/pkg/logger"
"yh_web/server/pkg/schema" "yh_web/server/pkg/schema"
"yh_web/server/pkg/weblive"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@@ -81,6 +82,7 @@ func main() {
r := gin.Default() r := gin.Default()
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制 r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
r.Use(middleware.ErrorLogger()) r.Use(middleware.ErrorLogger())
r.Use(middleware.TrafficMeter())
// CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名) // CORSALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS") allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
@@ -97,7 +99,7 @@ func main() {
c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Origin", "*")
} }
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 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" { if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent) c.AbortWithStatus(http.StatusNoContent)
return return
@@ -143,6 +145,12 @@ func main() {
c.JSON(http.StatusOK, structure) c.JSON(http.StatusOK, structure)
}) })
admin.GET("/stats", handlers.GetStats) 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) admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
@@ -163,6 +171,13 @@ func main() {
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage) 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/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets) 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/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder) 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.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
@@ -173,6 +188,8 @@ func main() {
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite) admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite) admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite) 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.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
@@ -209,6 +226,10 @@ func main() {
r.GET("/api/web/routes", handlers.GetWebRoutes) r.GET("/api/web/routes", handlers.GetWebRoutes)
r.GET("/api/web/page", handlers.GetWebPageByPath) 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 路由组 // 前台 API 路由组
web := r.Group("/api/web") web := r.Group("/api/web")
{ {
@@ -219,11 +240,19 @@ func main() {
web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia) web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia)
// 可下载资源公开下载(首页等链接指向此路径) // 可下载资源公开下载(首页等链接指向此路径)
web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset) web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset)
// 站内 WebRTC 直播:信令 + 状态(单房间 MVP
weblive.RegisterRoutes(web)
} }
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "8080" port = "8080"
} }
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
go handlers.SweepPromotionTranscodeOnStartup()
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads
go handlers.StartStaleChunkUploadSweep(context.Background())
r.Run(":" + port) r.Run(":" + port)
} }

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,6 +12,7 @@ const (
PermSMSConfig = "sms_config" PermSMSConfig = "sms_config"
PermPaymentConfig = "payment_config" PermPaymentConfig = "payment_config"
PermRolePermission = "role:permission" // 角色权限管理 PermRolePermission = "role:permission" // 角色权限管理
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
) )
// PermissionItem 单条权限定义JSON 须用小写 key/name供前端展示与勾选 // PermissionItem 单条权限定义JSON 须用小写 key/name供前端展示与勾选
@@ -32,6 +33,7 @@ var AllPermissions = []PermissionItem{
{Key: PermSMSConfig, Name: "短信配置"}, {Key: PermSMSConfig, Name: "短信配置"},
{Key: PermPaymentConfig, Name: "支付配置"}, {Key: PermPaymentConfig, Name: "支付配置"},
{Key: PermRolePermission, Name: "角色权限管理"}, {Key: PermRolePermission, Name: "角色权限管理"},
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
} }
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色) // RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)

View File

@@ -40,8 +40,15 @@ type HomepageData struct {
BadgeText string `json:"badge_text"` // FREE ACCESS BadgeText string `json:"badge_text"` // FREE ACCESS
Features []FeatureItem `json:"features"` // 星际导航等 Features []FeatureItem `json:"features"` // 星际导航等
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE 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 首页下方扩展区:与网页积木相同 JSON 字符串 {"version":1,"blocks":[...]},空则仅展示上方模板
BodyBuilder string `json:"body_builder,omitempty"` 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 { type NavLink struct {

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"}}, "files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
"system_config": {}, "system_config": {},
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}}, "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 { 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='消息表';", "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='文件表';", "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='系统配置表';", "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 线上集合信息(名称、文档数、索引) // 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()
}()
}

303
server/pkg/weblive/ws.go Normal file
View File

@@ -0,0 +1,303 @@
package weblive
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"yh_web/server/handlers"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/pion/rtcp"
"github.com/pion/webrtc/v3"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type wsEnvelope struct {
Type string `json:"type"`
SDP string `json:"sdp"`
Candidate json.RawMessage `json:"candidate"`
}
func RegisterRoutes(r gin.IRoutes) {
r.GET("/live/status", handleLiveStatus)
r.GET("/live/info", handleLiveInfo)
r.GET("/live/ws", handleLiveWS)
r.GET("/live/danmaku/ws", handleDanmakuWS)
}
func handleLiveStatus(c *gin.Context) {
h, err := getHub()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "live hub unavailable"})
return
}
h.mu.RLock()
live := h.publishConn != nil && h.pubPC != nil && len(h.forwarders) > 0
viewers := len(h.viewers)
h.mu.RUnlock()
c.JSON(http.StatusOK, gin.H{"live": live, "viewers": viewers})
}
func handleLiveWS(c *gin.Context) {
role := c.Query("role")
h, err := getHub()
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
switch role {
case "publish":
if !handlers.LivePublishAllowed(c.Query("token")) {
c.JSON(http.StatusForbidden, gin.H{"error": "请使用管理后台登录后的账号开播URL 参数 token=JWT"})
return
}
handlePublisherWS(c, h)
case "view":
handleViewerWS(c, h)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be publish or view"})
}
}
func writeJSON(ws *websocket.Conn, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return ws.WriteMessage(websocket.TextMessage, b)
}
func handlePublisherWS(c *gin.Context, h *Hub) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
h.mu.Lock()
if h.publishConn != nil {
h.mu.Unlock()
_ = writeJSON(ws, map[string]string{"type": "error", "message": "已有主播在播,请稍后再试"})
_ = ws.Close()
return
}
h.publishConn = ws
h.publishQuality = normalizeQuality(c.Query("quality"))
h.mu.Unlock()
pc, err := h.api.NewPeerConnection(h.cfg)
if err != nil {
h.mu.Lock()
h.publishConn = nil
h.publishQuality = ""
h.mu.Unlock()
_ = ws.Close()
return
}
h.mu.Lock()
h.pubPC = pc
h.mu.Unlock()
var iceMu sync.Mutex
var iceQueue []webrtc.ICECandidateInit
sendICE := func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
_ = writeJSON(ws, map[string]interface{}{"type": "ice", "candidate": candidate.ToJSON()})
}
pc.OnICECandidate(func(c *webrtc.ICECandidate) { sendICE(c) })
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
log.Printf("weblive: publisher track kind=%s", track.Kind().String())
h.onPublisherTrack(track)
if track.Kind() == webrtc.RTPCodecTypeVideo {
ssrc := uint32(track.SSRC())
goSafe("publisherPLI", func() {
_ = pc.WriteRTCP([]rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: ssrc}})
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
st := pc.ConnectionState()
if st == webrtc.PeerConnectionStateClosed || st == webrtc.PeerConnectionStateFailed {
return
}
_ = pc.WriteRTCP([]rtcp.Packet{&rtcp.PictureLossIndication{MediaSSRC: ssrc}})
}
})
}
})
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateClosed {
_ = ws.Close()
}
})
defer func() {
for _, v := range h.snapshotViewers() {
_ = writeJSON(v.ws, map[string]string{"type": "ended", "message": "主播已结束直播"})
}
h.clearPublisher()
_ = ws.Close()
}()
for {
_, data, err := ws.ReadMessage()
if err != nil {
return
}
var env wsEnvelope
if err := json.Unmarshal(data, &env); err != nil {
continue
}
switch env.Type {
case "offer":
if err := pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: env.SDP}); err != nil {
_ = writeJSON(ws, map[string]string{"type": "error", "message": "SetRemoteDescription failed"})
continue
}
iceMu.Lock()
for _, cand := range iceQueue {
_ = pc.AddICECandidate(cand)
}
iceQueue = nil
iceMu.Unlock()
ans, err := pc.CreateAnswer(nil)
if err != nil {
continue
}
if err := pc.SetLocalDescription(ans); err != nil {
continue
}
_ = writeJSON(ws, map[string]string{"type": "answer", "sdp": ans.SDP})
case "ice":
var init webrtc.ICECandidateInit
if err := json.Unmarshal(env.Candidate, &init); err != nil {
continue
}
if pc.RemoteDescription() == nil {
iceMu.Lock()
iceQueue = append(iceQueue, init)
iceMu.Unlock()
continue
}
_ = pc.AddICECandidate(init)
}
}
}
func (h *Hub) snapshotViewers() []*viewerSession {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]*viewerSession, 0, len(h.viewers))
for _, v := range h.viewers {
out = append(out, v)
}
return out
}
func handleViewerWS(c *gin.Context, h *Hub) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
vid := uuid.New().String()
vs := &viewerSession{id: vid, ws: ws}
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "")
h.mu.Lock()
h.viewers[vid] = vs
h.mu.Unlock()
defer func() {
UnregisterOnlineSession(sessionID)
h.removeViewer(vid)
_ = ws.Close()
}()
pc, err := h.api.NewPeerConnection(h.cfg)
if err != nil {
return
}
vs.pc = pc
var iceMu sync.Mutex
var iceQueue []webrtc.ICECandidateInit
pc.OnICECandidate(func(cand *webrtc.ICECandidate) {
if cand == nil {
return
}
_ = writeJSON(ws, map[string]interface{}{"type": "ice", "candidate": cand.ToJSON()})
})
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
if s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateClosed {
_ = ws.Close()
}
})
for {
_, data, err := ws.ReadMessage()
if err != nil {
return
}
TouchOnlineSession(sessionID)
var env wsEnvelope
if err := json.Unmarshal(data, &env); err != nil {
continue
}
switch env.Type {
case "offer":
if err := pc.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: env.SDP}); err != nil {
_ = writeJSON(ws, map[string]string{"type": "error", "message": "SetRemoteDescription failed"})
continue
}
iceMu.Lock()
for _, cand := range iceQueue {
_ = pc.AddICECandidate(cand)
}
iceQueue = nil
iceMu.Unlock()
h.attachForwardersToViewerPC(vs)
ans, err := pc.CreateAnswer(nil)
if err != nil {
continue
}
if err := pc.SetLocalDescription(ans); err != nil {
continue
}
vs.answered = true
_ = writeJSON(ws, map[string]string{"type": "answer", "sdp": ans.SDP})
case "ice":
var init webrtc.ICECandidateInit
if err := json.Unmarshal(env.Candidate, &init); err != nil {
continue
}
if pc.RemoteDescription() == nil {
iceMu.Lock()
iceQueue = append(iceQueue, init)
iceMu.Unlock()
continue
}
_ = pc.AddICECandidate(init)
}
}
}

15
server/vendor/github.com/davecgh/go-spew/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2012-2016 Dave Collins <dave@davec.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

145
server/vendor/github.com/davecgh/go-spew/spew/bypass.go generated vendored Normal file
View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// NOTE: Due to the following build constraints, this file will only be compiled
// when the code is not running on Google App Engine, compiled by GopherJS, and
// "-tags safe" is not added to the go build command line. The "disableunsafe"
// tag is deprecated and thus should not be used.
// Go versions prior to 1.4 are disabled because they use a different layout
// for interfaces which make the implementation of unsafeReflectValue more complex.
// +build !js,!appengine,!safe,!disableunsafe,go1.4
package spew
import (
"reflect"
"unsafe"
)
const (
// UnsafeDisabled is a build-time constant which specifies whether or
// not access to the unsafe package is available.
UnsafeDisabled = false
// ptrSize is the size of a pointer on the current arch.
ptrSize = unsafe.Sizeof((*byte)(nil))
)
type flag uintptr
var (
// flagRO indicates whether the value field of a reflect.Value
// is read-only.
flagRO flag
// flagAddr indicates whether the address of the reflect.Value's
// value may be taken.
flagAddr flag
)
// flagKindMask holds the bits that make up the kind
// part of the flags field. In all the supported versions,
// it is in the lower 5 bits.
const flagKindMask = flag(0x1f)
// Different versions of Go have used different
// bit layouts for the flags type. This table
// records the known combinations.
var okFlags = []struct {
ro, addr flag
}{{
// From Go 1.4 to 1.5
ro: 1 << 5,
addr: 1 << 7,
}, {
// Up to Go tip.
ro: 1<<5 | 1<<6,
addr: 1 << 8,
}}
var flagValOffset = func() uintptr {
field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
if !ok {
panic("reflect.Value has no flag field")
}
return field.Offset
}()
// flagField returns a pointer to the flag field of a reflect.Value.
func flagField(v *reflect.Value) *flag {
return (*flag)(unsafe.Pointer(uintptr(unsafe.Pointer(v)) + flagValOffset))
}
// unsafeReflectValue converts the passed reflect.Value into a one that bypasses
// the typical safety restrictions preventing access to unaddressable and
// unexported data. It works by digging the raw pointer to the underlying
// value out of the protected value and generating a new unprotected (unsafe)
// reflect.Value to it.
//
// This allows us to check for implementations of the Stringer and error
// interfaces to be used for pretty printing ordinarily unaddressable and
// inaccessible values such as unexported struct fields.
func unsafeReflectValue(v reflect.Value) reflect.Value {
if !v.IsValid() || (v.CanInterface() && v.CanAddr()) {
return v
}
flagFieldPtr := flagField(&v)
*flagFieldPtr &^= flagRO
*flagFieldPtr |= flagAddr
return v
}
// Sanity checks against future reflect package changes
// to the type or semantics of the Value.flag field.
func init() {
field, ok := reflect.TypeOf(reflect.Value{}).FieldByName("flag")
if !ok {
panic("reflect.Value has no flag field")
}
if field.Type.Kind() != reflect.TypeOf(flag(0)).Kind() {
panic("reflect.Value flag field has changed kind")
}
type t0 int
var t struct {
A t0
// t0 will have flagEmbedRO set.
t0
// a will have flagStickyRO set
a t0
}
vA := reflect.ValueOf(t).FieldByName("A")
va := reflect.ValueOf(t).FieldByName("a")
vt0 := reflect.ValueOf(t).FieldByName("t0")
// Infer flagRO from the difference between the flags
// for the (otherwise identical) fields in t.
flagPublic := *flagField(&vA)
flagWithRO := *flagField(&va) | *flagField(&vt0)
flagRO = flagPublic ^ flagWithRO
// Infer flagAddr from the difference between a value
// taken from a pointer and not.
vPtrA := reflect.ValueOf(&t).Elem().FieldByName("A")
flagNoPtr := *flagField(&vA)
flagPtr := *flagField(&vPtrA)
flagAddr = flagNoPtr ^ flagPtr
// Check that the inferred flags tally with one of the known versions.
for _, f := range okFlags {
if flagRO == f.ro && flagAddr == f.addr {
return
}
}
panic("reflect.Value read-only flag has changed semantics")
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-2016 Dave Collins <dave@davec.name>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
// NOTE: Due to the following build constraints, this file will only be compiled
// when the code is running on Google App Engine, compiled by GopherJS, or
// "-tags safe" is added to the go build command line. The "disableunsafe"
// tag is deprecated and thus should not be used.
// +build js appengine safe disableunsafe !go1.4
package spew
import "reflect"
const (
// UnsafeDisabled is a build-time constant which specifies whether or
// not access to the unsafe package is available.
UnsafeDisabled = true
)
// unsafeReflectValue typically converts the passed reflect.Value into a one
// that bypasses the typical safety restrictions preventing access to
// unaddressable and unexported data. However, doing this relies on access to
// the unsafe package. This is a stub version which simply returns the passed
// reflect.Value when the unsafe package is not available.
func unsafeReflectValue(v reflect.Value) reflect.Value {
return v
}

341
server/vendor/github.com/davecgh/go-spew/spew/common.go generated vendored Normal file
View File

@@ -0,0 +1,341 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"io"
"reflect"
"sort"
"strconv"
)
// Some constants in the form of bytes to avoid string overhead. This mirrors
// the technique used in the fmt package.
var (
panicBytes = []byte("(PANIC=")
plusBytes = []byte("+")
iBytes = []byte("i")
trueBytes = []byte("true")
falseBytes = []byte("false")
interfaceBytes = []byte("(interface {})")
commaNewlineBytes = []byte(",\n")
newlineBytes = []byte("\n")
openBraceBytes = []byte("{")
openBraceNewlineBytes = []byte("{\n")
closeBraceBytes = []byte("}")
asteriskBytes = []byte("*")
colonBytes = []byte(":")
colonSpaceBytes = []byte(": ")
openParenBytes = []byte("(")
closeParenBytes = []byte(")")
spaceBytes = []byte(" ")
pointerChainBytes = []byte("->")
nilAngleBytes = []byte("<nil>")
maxNewlineBytes = []byte("<max depth reached>\n")
maxShortBytes = []byte("<max>")
circularBytes = []byte("<already shown>")
circularShortBytes = []byte("<shown>")
invalidAngleBytes = []byte("<invalid>")
openBracketBytes = []byte("[")
closeBracketBytes = []byte("]")
percentBytes = []byte("%")
precisionBytes = []byte(".")
openAngleBytes = []byte("<")
closeAngleBytes = []byte(">")
openMapBytes = []byte("map[")
closeMapBytes = []byte("]")
lenEqualsBytes = []byte("len=")
capEqualsBytes = []byte("cap=")
)
// hexDigits is used to map a decimal value to a hex digit.
var hexDigits = "0123456789abcdef"
// catchPanic handles any panics that might occur during the handleMethods
// calls.
func catchPanic(w io.Writer, v reflect.Value) {
if err := recover(); err != nil {
w.Write(panicBytes)
fmt.Fprintf(w, "%v", err)
w.Write(closeParenBytes)
}
}
// handleMethods attempts to call the Error and String methods on the underlying
// type the passed reflect.Value represents and outputes the result to Writer w.
//
// It handles panics in any called methods by catching and displaying the error
// as the formatted value.
func handleMethods(cs *ConfigState, w io.Writer, v reflect.Value) (handled bool) {
// We need an interface to check if the type implements the error or
// Stringer interface. However, the reflect package won't give us an
// interface on certain things like unexported struct fields in order
// to enforce visibility rules. We use unsafe, when it's available,
// to bypass these restrictions since this package does not mutate the
// values.
if !v.CanInterface() {
if UnsafeDisabled {
return false
}
v = unsafeReflectValue(v)
}
// Choose whether or not to do error and Stringer interface lookups against
// the base type or a pointer to the base type depending on settings.
// Technically calling one of these methods with a pointer receiver can
// mutate the value, however, types which choose to satisify an error or
// Stringer interface with a pointer receiver should not be mutating their
// state inside these interface methods.
if !cs.DisablePointerMethods && !UnsafeDisabled && !v.CanAddr() {
v = unsafeReflectValue(v)
}
if v.CanAddr() {
v = v.Addr()
}
// Is it an error or Stringer?
switch iface := v.Interface().(type) {
case error:
defer catchPanic(w, v)
if cs.ContinueOnMethod {
w.Write(openParenBytes)
w.Write([]byte(iface.Error()))
w.Write(closeParenBytes)
w.Write(spaceBytes)
return false
}
w.Write([]byte(iface.Error()))
return true
case fmt.Stringer:
defer catchPanic(w, v)
if cs.ContinueOnMethod {
w.Write(openParenBytes)
w.Write([]byte(iface.String()))
w.Write(closeParenBytes)
w.Write(spaceBytes)
return false
}
w.Write([]byte(iface.String()))
return true
}
return false
}
// printBool outputs a boolean value as true or false to Writer w.
func printBool(w io.Writer, val bool) {
if val {
w.Write(trueBytes)
} else {
w.Write(falseBytes)
}
}
// printInt outputs a signed integer value to Writer w.
func printInt(w io.Writer, val int64, base int) {
w.Write([]byte(strconv.FormatInt(val, base)))
}
// printUint outputs an unsigned integer value to Writer w.
func printUint(w io.Writer, val uint64, base int) {
w.Write([]byte(strconv.FormatUint(val, base)))
}
// printFloat outputs a floating point value using the specified precision,
// which is expected to be 32 or 64bit, to Writer w.
func printFloat(w io.Writer, val float64, precision int) {
w.Write([]byte(strconv.FormatFloat(val, 'g', -1, precision)))
}
// printComplex outputs a complex value using the specified float precision
// for the real and imaginary parts to Writer w.
func printComplex(w io.Writer, c complex128, floatPrecision int) {
r := real(c)
w.Write(openParenBytes)
w.Write([]byte(strconv.FormatFloat(r, 'g', -1, floatPrecision)))
i := imag(c)
if i >= 0 {
w.Write(plusBytes)
}
w.Write([]byte(strconv.FormatFloat(i, 'g', -1, floatPrecision)))
w.Write(iBytes)
w.Write(closeParenBytes)
}
// printHexPtr outputs a uintptr formatted as hexadecimal with a leading '0x'
// prefix to Writer w.
func printHexPtr(w io.Writer, p uintptr) {
// Null pointer.
num := uint64(p)
if num == 0 {
w.Write(nilAngleBytes)
return
}
// Max uint64 is 16 bytes in hex + 2 bytes for '0x' prefix
buf := make([]byte, 18)
// It's simpler to construct the hex string right to left.
base := uint64(16)
i := len(buf) - 1
for num >= base {
buf[i] = hexDigits[num%base]
num /= base
i--
}
buf[i] = hexDigits[num]
// Add '0x' prefix.
i--
buf[i] = 'x'
i--
buf[i] = '0'
// Strip unused leading bytes.
buf = buf[i:]
w.Write(buf)
}
// valuesSorter implements sort.Interface to allow a slice of reflect.Value
// elements to be sorted.
type valuesSorter struct {
values []reflect.Value
strings []string // either nil or same len and values
cs *ConfigState
}
// newValuesSorter initializes a valuesSorter instance, which holds a set of
// surrogate keys on which the data should be sorted. It uses flags in
// ConfigState to decide if and how to populate those surrogate keys.
func newValuesSorter(values []reflect.Value, cs *ConfigState) sort.Interface {
vs := &valuesSorter{values: values, cs: cs}
if canSortSimply(vs.values[0].Kind()) {
return vs
}
if !cs.DisableMethods {
vs.strings = make([]string, len(values))
for i := range vs.values {
b := bytes.Buffer{}
if !handleMethods(cs, &b, vs.values[i]) {
vs.strings = nil
break
}
vs.strings[i] = b.String()
}
}
if vs.strings == nil && cs.SpewKeys {
vs.strings = make([]string, len(values))
for i := range vs.values {
vs.strings[i] = Sprintf("%#v", vs.values[i].Interface())
}
}
return vs
}
// canSortSimply tests whether a reflect.Kind is a primitive that can be sorted
// directly, or whether it should be considered for sorting by surrogate keys
// (if the ConfigState allows it).
func canSortSimply(kind reflect.Kind) bool {
// This switch parallels valueSortLess, except for the default case.
switch kind {
case reflect.Bool:
return true
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
return true
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
return true
case reflect.Float32, reflect.Float64:
return true
case reflect.String:
return true
case reflect.Uintptr:
return true
case reflect.Array:
return true
}
return false
}
// Len returns the number of values in the slice. It is part of the
// sort.Interface implementation.
func (s *valuesSorter) Len() int {
return len(s.values)
}
// Swap swaps the values at the passed indices. It is part of the
// sort.Interface implementation.
func (s *valuesSorter) Swap(i, j int) {
s.values[i], s.values[j] = s.values[j], s.values[i]
if s.strings != nil {
s.strings[i], s.strings[j] = s.strings[j], s.strings[i]
}
}
// valueSortLess returns whether the first value should sort before the second
// value. It is used by valueSorter.Less as part of the sort.Interface
// implementation.
func valueSortLess(a, b reflect.Value) bool {
switch a.Kind() {
case reflect.Bool:
return !a.Bool() && b.Bool()
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
return a.Int() < b.Int()
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
return a.Uint() < b.Uint()
case reflect.Float32, reflect.Float64:
return a.Float() < b.Float()
case reflect.String:
return a.String() < b.String()
case reflect.Uintptr:
return a.Uint() < b.Uint()
case reflect.Array:
// Compare the contents of both arrays.
l := a.Len()
for i := 0; i < l; i++ {
av := a.Index(i)
bv := b.Index(i)
if av.Interface() == bv.Interface() {
continue
}
return valueSortLess(av, bv)
}
}
return a.String() < b.String()
}
// Less returns whether the value at index i should sort before the
// value at index j. It is part of the sort.Interface implementation.
func (s *valuesSorter) Less(i, j int) bool {
if s.strings == nil {
return valueSortLess(s.values[i], s.values[j])
}
return s.strings[i] < s.strings[j]
}
// sortValues is a sort function that handles both native types and any type that
// can be converted to error or Stringer. Other inputs are sorted according to
// their Value.String() value to ensure display stability.
func sortValues(values []reflect.Value, cs *ConfigState) {
if len(values) == 0 {
return
}
sort.Sort(newValuesSorter(values, cs))
}

306
server/vendor/github.com/davecgh/go-spew/spew/config.go generated vendored Normal file
View File

@@ -0,0 +1,306 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"io"
"os"
)
// ConfigState houses the configuration options used by spew to format and
// display values. There is a global instance, Config, that is used to control
// all top-level Formatter and Dump functionality. Each ConfigState instance
// provides methods equivalent to the top-level functions.
//
// The zero value for ConfigState provides no indentation. You would typically
// want to set it to a space or a tab.
//
// Alternatively, you can use NewDefaultConfig to get a ConfigState instance
// with default settings. See the documentation of NewDefaultConfig for default
// values.
type ConfigState struct {
// Indent specifies the string to use for each indentation level. The
// global config instance that all top-level functions use set this to a
// single space by default. If you would like more indentation, you might
// set this to a tab with "\t" or perhaps two spaces with " ".
Indent string
// MaxDepth controls the maximum number of levels to descend into nested
// data structures. The default, 0, means there is no limit.
//
// NOTE: Circular data structures are properly detected, so it is not
// necessary to set this value unless you specifically want to limit deeply
// nested data structures.
MaxDepth int
// DisableMethods specifies whether or not error and Stringer interfaces are
// invoked for types that implement them.
DisableMethods bool
// DisablePointerMethods specifies whether or not to check for and invoke
// error and Stringer interfaces on types which only accept a pointer
// receiver when the current type is not a pointer.
//
// NOTE: This might be an unsafe action since calling one of these methods
// with a pointer receiver could technically mutate the value, however,
// in practice, types which choose to satisify an error or Stringer
// interface with a pointer receiver should not be mutating their state
// inside these interface methods. As a result, this option relies on
// access to the unsafe package, so it will not have any effect when
// running in environments without access to the unsafe package such as
// Google App Engine or with the "safe" build tag specified.
DisablePointerMethods bool
// DisablePointerAddresses specifies whether to disable the printing of
// pointer addresses. This is useful when diffing data structures in tests.
DisablePointerAddresses bool
// DisableCapacities specifies whether to disable the printing of capacities
// for arrays, slices, maps and channels. This is useful when diffing
// data structures in tests.
DisableCapacities bool
// ContinueOnMethod specifies whether or not recursion should continue once
// a custom error or Stringer interface is invoked. The default, false,
// means it will print the results of invoking the custom error or Stringer
// interface and return immediately instead of continuing to recurse into
// the internals of the data type.
//
// NOTE: This flag does not have any effect if method invocation is disabled
// via the DisableMethods or DisablePointerMethods options.
ContinueOnMethod bool
// SortKeys specifies map keys should be sorted before being printed. Use
// this to have a more deterministic, diffable output. Note that only
// native types (bool, int, uint, floats, uintptr and string) and types
// that support the error or Stringer interfaces (if methods are
// enabled) are supported, with other types sorted according to the
// reflect.Value.String() output which guarantees display stability.
SortKeys bool
// SpewKeys specifies that, as a last resort attempt, map keys should
// be spewed to strings and sorted by those strings. This is only
// considered if SortKeys is true.
SpewKeys bool
}
// Config is the active configuration of the top-level functions.
// The configuration can be changed by modifying the contents of spew.Config.
var Config = ConfigState{Indent: " "}
// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the formatted string as a value that satisfies error. See NewFormatter
// for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Errorf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Errorf(format string, a ...interface{}) (err error) {
return fmt.Errorf(format, c.convertArgs(a)...)
}
// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprint(w, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprint(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprint(w, c.convertArgs(a)...)
}
// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintf(w, format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
return fmt.Fprintf(w, format, c.convertArgs(a)...)
}
// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
// passed with a Formatter interface returned by c.NewFormatter. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintln(w, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprintln(w, c.convertArgs(a)...)
}
// Print is a wrapper for fmt.Print that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Print(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Print(a ...interface{}) (n int, err error) {
return fmt.Print(c.convertArgs(a)...)
}
// Printf is a wrapper for fmt.Printf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Printf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Printf(format string, a ...interface{}) (n int, err error) {
return fmt.Printf(format, c.convertArgs(a)...)
}
// Println is a wrapper for fmt.Println that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Println(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Println(a ...interface{}) (n int, err error) {
return fmt.Println(c.convertArgs(a)...)
}
// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprint(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprint(a ...interface{}) string {
return fmt.Sprint(c.convertArgs(a)...)
}
// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
// passed with a Formatter interface returned by c.NewFormatter. It returns
// the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintf(format, c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprintf(format string, a ...interface{}) string {
return fmt.Sprintf(format, c.convertArgs(a)...)
}
// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
// were passed with a Formatter interface returned by c.NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintln(c.NewFormatter(a), c.NewFormatter(b))
func (c *ConfigState) Sprintln(a ...interface{}) string {
return fmt.Sprintln(c.convertArgs(a)...)
}
/*
NewFormatter returns a custom formatter that satisfies the fmt.Formatter
interface. As a result, it integrates cleanly with standard fmt package
printing functions. The formatter is useful for inline printing of smaller data
types similar to the standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), and %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
Typically this function shouldn't be called directly. It is much easier to make
use of the custom formatter by calling one of the convenience functions such as
c.Printf, c.Println, or c.Printf.
*/
func (c *ConfigState) NewFormatter(v interface{}) fmt.Formatter {
return newFormatter(c, v)
}
// Fdump formats and displays the passed arguments to io.Writer w. It formats
// exactly the same as Dump.
func (c *ConfigState) Fdump(w io.Writer, a ...interface{}) {
fdump(c, w, a...)
}
/*
Dump displays the passed parameters to standard out with newlines, customizable
indentation, and additional debug information such as complete types and all
pointer addresses used to indirect to the final value. It provides the
following features over the built-in printing facilities provided by the fmt
package:
* Pointers are dereferenced and followed
* Circular data structures are detected and handled properly
* Custom Stringer/error interfaces are optionally invoked, including
on unexported types
* Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
* Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output
The configuration options are controlled by modifying the public members
of c. See ConfigState for options documentation.
See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
get the formatted result as a string.
*/
func (c *ConfigState) Dump(a ...interface{}) {
fdump(c, os.Stdout, a...)
}
// Sdump returns a string with the passed arguments formatted exactly the same
// as Dump.
func (c *ConfigState) Sdump(a ...interface{}) string {
var buf bytes.Buffer
fdump(c, &buf, a...)
return buf.String()
}
// convertArgs accepts a slice of arguments and returns a slice of the same
// length with each argument converted to a spew Formatter interface using
// the ConfigState associated with s.
func (c *ConfigState) convertArgs(args []interface{}) (formatters []interface{}) {
formatters = make([]interface{}, len(args))
for index, arg := range args {
formatters[index] = newFormatter(c, arg)
}
return formatters
}
// NewDefaultConfig returns a ConfigState with the following default settings.
//
// Indent: " "
// MaxDepth: 0
// DisableMethods: false
// DisablePointerMethods: false
// ContinueOnMethod: false
// SortKeys: false
func NewDefaultConfig() *ConfigState {
return &ConfigState{Indent: " "}
}

211
server/vendor/github.com/davecgh/go-spew/spew/doc.go generated vendored Normal file
View File

@@ -0,0 +1,211 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
/*
Package spew implements a deep pretty printer for Go data structures to aid in
debugging.
A quick overview of the additional features spew provides over the built-in
printing facilities for Go data types are as follows:
* Pointers are dereferenced and followed
* Circular data structures are detected and handled properly
* Custom Stringer/error interfaces are optionally invoked, including
on unexported types
* Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
* Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output (only when using
Dump style)
There are two different approaches spew allows for dumping Go data structures:
* Dump style which prints with newlines, customizable indentation,
and additional debug information such as types and all pointer addresses
used to indirect to the final value
* A custom Formatter interface that integrates cleanly with the standard fmt
package and replaces %v, %+v, %#v, and %#+v to provide inline printing
similar to the default %v while providing the additional functionality
outlined above and passing unsupported format verbs such as %x and %q
along to fmt
Quick Start
This section demonstrates how to quickly get started with spew. See the
sections below for further details on formatting and configuration options.
To dump a variable with full newlines, indentation, type, and pointer
information use Dump, Fdump, or Sdump:
spew.Dump(myVar1, myVar2, ...)
spew.Fdump(someWriter, myVar1, myVar2, ...)
str := spew.Sdump(myVar1, myVar2, ...)
Alternatively, if you would prefer to use format strings with a compacted inline
printing style, use the convenience wrappers Printf, Fprintf, etc with
%v (most compact), %+v (adds pointer addresses), %#v (adds types), or
%#+v (adds types and pointer addresses):
spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
spew.Fprintf(someWriter, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Fprintf(someWriter, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
Configuration Options
Configuration of spew is handled by fields in the ConfigState type. For
convenience, all of the top-level functions use a global state available
via the spew.Config global.
It is also possible to create a ConfigState instance that provides methods
equivalent to the top-level functions. This allows concurrent configuration
options. See the ConfigState documentation for more details.
The following configuration options are available:
* Indent
String to use for each indentation level for Dump functions.
It is a single space by default. A popular alternative is "\t".
* MaxDepth
Maximum number of levels to descend into nested data structures.
There is no limit by default.
* DisableMethods
Disables invocation of error and Stringer interface methods.
Method invocation is enabled by default.
* DisablePointerMethods
Disables invocation of error and Stringer interface methods on types
which only accept pointer receivers from non-pointer variables.
Pointer method invocation is enabled by default.
* DisablePointerAddresses
DisablePointerAddresses specifies whether to disable the printing of
pointer addresses. This is useful when diffing data structures in tests.
* DisableCapacities
DisableCapacities specifies whether to disable the printing of
capacities for arrays, slices, maps and channels. This is useful when
diffing data structures in tests.
* ContinueOnMethod
Enables recursion into types after invoking error and Stringer interface
methods. Recursion after method invocation is disabled by default.
* SortKeys
Specifies map keys should be sorted before being printed. Use
this to have a more deterministic, diffable output. Note that
only native types (bool, int, uint, floats, uintptr and string)
and types which implement error or Stringer interfaces are
supported with other types sorted according to the
reflect.Value.String() output which guarantees display
stability. Natural map order is used by default.
* SpewKeys
Specifies that, as a last resort attempt, map keys should be
spewed to strings and sorted by those strings. This is only
considered if SortKeys is true.
Dump Usage
Simply call spew.Dump with a list of variables you want to dump:
spew.Dump(myVar1, myVar2, ...)
You may also call spew.Fdump if you would prefer to output to an arbitrary
io.Writer. For example, to dump to standard error:
spew.Fdump(os.Stderr, myVar1, myVar2, ...)
A third option is to call spew.Sdump to get the formatted output as a string:
str := spew.Sdump(myVar1, myVar2, ...)
Sample Dump Output
See the Dump example for details on the setup of the types and variables being
shown here.
(main.Foo) {
unexportedField: (*main.Bar)(0xf84002e210)({
flag: (main.Flag) flagTwo,
data: (uintptr) <nil>
}),
ExportedField: (map[interface {}]interface {}) (len=1) {
(string) (len=3) "one": (bool) true
}
}
Byte (and uint8) arrays and slices are displayed uniquely like the hexdump -C
command as shown.
([]uint8) (len=32 cap=32) {
00000000 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 |............... |
00000010 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 |!"#$%&'()*+,-./0|
00000020 31 32 |12|
}
Custom Formatter
Spew provides a custom formatter that implements the fmt.Formatter interface
so that it integrates cleanly with standard fmt package printing functions. The
formatter is useful for inline printing of smaller data types similar to the
standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
Custom Formatter Usage
The simplest way to make use of the spew custom formatter is to call one of the
convenience functions such as spew.Printf, spew.Println, or spew.Printf. The
functions have syntax you are most likely already familiar with:
spew.Printf("myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Printf("myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
spew.Println(myVar, myVar2)
spew.Fprintf(os.Stderr, "myVar1: %v -- myVar2: %+v", myVar1, myVar2)
spew.Fprintf(os.Stderr, "myVar3: %#v -- myVar4: %#+v", myVar3, myVar4)
See the Index for the full list convenience functions.
Sample Formatter Output
Double pointer to a uint8:
%v: <**>5
%+v: <**>(0xf8400420d0->0xf8400420c8)5
%#v: (**uint8)5
%#+v: (**uint8)(0xf8400420d0->0xf8400420c8)5
Pointer to circular struct with a uint8 field and a pointer to itself:
%v: <*>{1 <*><shown>}
%+v: <*>(0xf84003e260){ui8:1 c:<*>(0xf84003e260)<shown>}
%#v: (*main.circular){ui8:(uint8)1 c:(*main.circular)<shown>}
%#+v: (*main.circular)(0xf84003e260){ui8:(uint8)1 c:(*main.circular)(0xf84003e260)<shown>}
See the Printf example for details on the setup of variables being shown
here.
Errors
Since it is possible for custom Stringer/error interfaces to panic, spew
detects them and handles them internally by printing the panic information
inline with the output. Since spew is intended to provide deep pretty printing
capabilities on structures, it intentionally does not return any errors.
*/
package spew

509
server/vendor/github.com/davecgh/go-spew/spew/dump.go generated vendored Normal file
View File

@@ -0,0 +1,509 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"encoding/hex"
"fmt"
"io"
"os"
"reflect"
"regexp"
"strconv"
"strings"
)
var (
// uint8Type is a reflect.Type representing a uint8. It is used to
// convert cgo types to uint8 slices for hexdumping.
uint8Type = reflect.TypeOf(uint8(0))
// cCharRE is a regular expression that matches a cgo char.
// It is used to detect character arrays to hexdump them.
cCharRE = regexp.MustCompile(`^.*\._Ctype_char$`)
// cUnsignedCharRE is a regular expression that matches a cgo unsigned
// char. It is used to detect unsigned character arrays to hexdump
// them.
cUnsignedCharRE = regexp.MustCompile(`^.*\._Ctype_unsignedchar$`)
// cUint8tCharRE is a regular expression that matches a cgo uint8_t.
// It is used to detect uint8_t arrays to hexdump them.
cUint8tCharRE = regexp.MustCompile(`^.*\._Ctype_uint8_t$`)
)
// dumpState contains information about the state of a dump operation.
type dumpState struct {
w io.Writer
depth int
pointers map[uintptr]int
ignoreNextType bool
ignoreNextIndent bool
cs *ConfigState
}
// indent performs indentation according to the depth level and cs.Indent
// option.
func (d *dumpState) indent() {
if d.ignoreNextIndent {
d.ignoreNextIndent = false
return
}
d.w.Write(bytes.Repeat([]byte(d.cs.Indent), d.depth))
}
// unpackValue returns values inside of non-nil interfaces when possible.
// This is useful for data types like structs, arrays, slices, and maps which
// can contain varying types packed inside an interface.
func (d *dumpState) unpackValue(v reflect.Value) reflect.Value {
if v.Kind() == reflect.Interface && !v.IsNil() {
v = v.Elem()
}
return v
}
// dumpPtr handles formatting of pointers by indirecting them as necessary.
func (d *dumpState) dumpPtr(v reflect.Value) {
// Remove pointers at or below the current depth from map used to detect
// circular refs.
for k, depth := range d.pointers {
if depth >= d.depth {
delete(d.pointers, k)
}
}
// Keep list of all dereferenced pointers to show later.
pointerChain := make([]uintptr, 0)
// Figure out how many levels of indirection there are by dereferencing
// pointers and unpacking interfaces down the chain while detecting circular
// references.
nilFound := false
cycleFound := false
indirects := 0
ve := v
for ve.Kind() == reflect.Ptr {
if ve.IsNil() {
nilFound = true
break
}
indirects++
addr := ve.Pointer()
pointerChain = append(pointerChain, addr)
if pd, ok := d.pointers[addr]; ok && pd < d.depth {
cycleFound = true
indirects--
break
}
d.pointers[addr] = d.depth
ve = ve.Elem()
if ve.Kind() == reflect.Interface {
if ve.IsNil() {
nilFound = true
break
}
ve = ve.Elem()
}
}
// Display type information.
d.w.Write(openParenBytes)
d.w.Write(bytes.Repeat(asteriskBytes, indirects))
d.w.Write([]byte(ve.Type().String()))
d.w.Write(closeParenBytes)
// Display pointer information.
if !d.cs.DisablePointerAddresses && len(pointerChain) > 0 {
d.w.Write(openParenBytes)
for i, addr := range pointerChain {
if i > 0 {
d.w.Write(pointerChainBytes)
}
printHexPtr(d.w, addr)
}
d.w.Write(closeParenBytes)
}
// Display dereferenced value.
d.w.Write(openParenBytes)
switch {
case nilFound:
d.w.Write(nilAngleBytes)
case cycleFound:
d.w.Write(circularBytes)
default:
d.ignoreNextType = true
d.dump(ve)
}
d.w.Write(closeParenBytes)
}
// dumpSlice handles formatting of arrays and slices. Byte (uint8 under
// reflection) arrays and slices are dumped in hexdump -C fashion.
func (d *dumpState) dumpSlice(v reflect.Value) {
// Determine whether this type should be hex dumped or not. Also,
// for types which should be hexdumped, try to use the underlying data
// first, then fall back to trying to convert them to a uint8 slice.
var buf []uint8
doConvert := false
doHexDump := false
numEntries := v.Len()
if numEntries > 0 {
vt := v.Index(0).Type()
vts := vt.String()
switch {
// C types that need to be converted.
case cCharRE.MatchString(vts):
fallthrough
case cUnsignedCharRE.MatchString(vts):
fallthrough
case cUint8tCharRE.MatchString(vts):
doConvert = true
// Try to use existing uint8 slices and fall back to converting
// and copying if that fails.
case vt.Kind() == reflect.Uint8:
// We need an addressable interface to convert the type
// to a byte slice. However, the reflect package won't
// give us an interface on certain things like
// unexported struct fields in order to enforce
// visibility rules. We use unsafe, when available, to
// bypass these restrictions since this package does not
// mutate the values.
vs := v
if !vs.CanInterface() || !vs.CanAddr() {
vs = unsafeReflectValue(vs)
}
if !UnsafeDisabled {
vs = vs.Slice(0, numEntries)
// Use the existing uint8 slice if it can be
// type asserted.
iface := vs.Interface()
if slice, ok := iface.([]uint8); ok {
buf = slice
doHexDump = true
break
}
}
// The underlying data needs to be converted if it can't
// be type asserted to a uint8 slice.
doConvert = true
}
// Copy and convert the underlying type if needed.
if doConvert && vt.ConvertibleTo(uint8Type) {
// Convert and copy each element into a uint8 byte
// slice.
buf = make([]uint8, numEntries)
for i := 0; i < numEntries; i++ {
vv := v.Index(i)
buf[i] = uint8(vv.Convert(uint8Type).Uint())
}
doHexDump = true
}
}
// Hexdump the entire slice as needed.
if doHexDump {
indent := strings.Repeat(d.cs.Indent, d.depth)
str := indent + hex.Dump(buf)
str = strings.Replace(str, "\n", "\n"+indent, -1)
str = strings.TrimRight(str, d.cs.Indent)
d.w.Write([]byte(str))
return
}
// Recursively call dump for each item.
for i := 0; i < numEntries; i++ {
d.dump(d.unpackValue(v.Index(i)))
if i < (numEntries - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
// dump is the main workhorse for dumping a value. It uses the passed reflect
// value to figure out what kind of object we are dealing with and formats it
// appropriately. It is a recursive function, however circular data structures
// are detected and handled properly.
func (d *dumpState) dump(v reflect.Value) {
// Handle invalid reflect values immediately.
kind := v.Kind()
if kind == reflect.Invalid {
d.w.Write(invalidAngleBytes)
return
}
// Handle pointers specially.
if kind == reflect.Ptr {
d.indent()
d.dumpPtr(v)
return
}
// Print type information unless already handled elsewhere.
if !d.ignoreNextType {
d.indent()
d.w.Write(openParenBytes)
d.w.Write([]byte(v.Type().String()))
d.w.Write(closeParenBytes)
d.w.Write(spaceBytes)
}
d.ignoreNextType = false
// Display length and capacity if the built-in len and cap functions
// work with the value's kind and the len/cap itself is non-zero.
valueLen, valueCap := 0, 0
switch v.Kind() {
case reflect.Array, reflect.Slice, reflect.Chan:
valueLen, valueCap = v.Len(), v.Cap()
case reflect.Map, reflect.String:
valueLen = v.Len()
}
if valueLen != 0 || !d.cs.DisableCapacities && valueCap != 0 {
d.w.Write(openParenBytes)
if valueLen != 0 {
d.w.Write(lenEqualsBytes)
printInt(d.w, int64(valueLen), 10)
}
if !d.cs.DisableCapacities && valueCap != 0 {
if valueLen != 0 {
d.w.Write(spaceBytes)
}
d.w.Write(capEqualsBytes)
printInt(d.w, int64(valueCap), 10)
}
d.w.Write(closeParenBytes)
d.w.Write(spaceBytes)
}
// Call Stringer/error interfaces if they exist and the handle methods flag
// is enabled
if !d.cs.DisableMethods {
if (kind != reflect.Invalid) && (kind != reflect.Interface) {
if handled := handleMethods(d.cs, d.w, v); handled {
return
}
}
}
switch kind {
case reflect.Invalid:
// Do nothing. We should never get here since invalid has already
// been handled above.
case reflect.Bool:
printBool(d.w, v.Bool())
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
printInt(d.w, v.Int(), 10)
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
printUint(d.w, v.Uint(), 10)
case reflect.Float32:
printFloat(d.w, v.Float(), 32)
case reflect.Float64:
printFloat(d.w, v.Float(), 64)
case reflect.Complex64:
printComplex(d.w, v.Complex(), 32)
case reflect.Complex128:
printComplex(d.w, v.Complex(), 64)
case reflect.Slice:
if v.IsNil() {
d.w.Write(nilAngleBytes)
break
}
fallthrough
case reflect.Array:
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
d.dumpSlice(v)
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.String:
d.w.Write([]byte(strconv.Quote(v.String())))
case reflect.Interface:
// The only time we should get here is for nil interfaces due to
// unpackValue calls.
if v.IsNil() {
d.w.Write(nilAngleBytes)
}
case reflect.Ptr:
// Do nothing. We should never get here since pointers have already
// been handled above.
case reflect.Map:
// nil maps should be indicated as different than empty maps
if v.IsNil() {
d.w.Write(nilAngleBytes)
break
}
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
numEntries := v.Len()
keys := v.MapKeys()
if d.cs.SortKeys {
sortValues(keys, d.cs)
}
for i, key := range keys {
d.dump(d.unpackValue(key))
d.w.Write(colonSpaceBytes)
d.ignoreNextIndent = true
d.dump(d.unpackValue(v.MapIndex(key)))
if i < (numEntries - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.Struct:
d.w.Write(openBraceNewlineBytes)
d.depth++
if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) {
d.indent()
d.w.Write(maxNewlineBytes)
} else {
vt := v.Type()
numFields := v.NumField()
for i := 0; i < numFields; i++ {
d.indent()
vtf := vt.Field(i)
d.w.Write([]byte(vtf.Name))
d.w.Write(colonSpaceBytes)
d.ignoreNextIndent = true
d.dump(d.unpackValue(v.Field(i)))
if i < (numFields - 1) {
d.w.Write(commaNewlineBytes)
} else {
d.w.Write(newlineBytes)
}
}
}
d.depth--
d.indent()
d.w.Write(closeBraceBytes)
case reflect.Uintptr:
printHexPtr(d.w, uintptr(v.Uint()))
case reflect.UnsafePointer, reflect.Chan, reflect.Func:
printHexPtr(d.w, v.Pointer())
// There were not any other types at the time this code was written, but
// fall back to letting the default fmt package handle it in case any new
// types are added.
default:
if v.CanInterface() {
fmt.Fprintf(d.w, "%v", v.Interface())
} else {
fmt.Fprintf(d.w, "%v", v.String())
}
}
}
// fdump is a helper function to consolidate the logic from the various public
// methods which take varying writers and config states.
func fdump(cs *ConfigState, w io.Writer, a ...interface{}) {
for _, arg := range a {
if arg == nil {
w.Write(interfaceBytes)
w.Write(spaceBytes)
w.Write(nilAngleBytes)
w.Write(newlineBytes)
continue
}
d := dumpState{w: w, cs: cs}
d.pointers = make(map[uintptr]int)
d.dump(reflect.ValueOf(arg))
d.w.Write(newlineBytes)
}
}
// Fdump formats and displays the passed arguments to io.Writer w. It formats
// exactly the same as Dump.
func Fdump(w io.Writer, a ...interface{}) {
fdump(&Config, w, a...)
}
// Sdump returns a string with the passed arguments formatted exactly the same
// as Dump.
func Sdump(a ...interface{}) string {
var buf bytes.Buffer
fdump(&Config, &buf, a...)
return buf.String()
}
/*
Dump displays the passed parameters to standard out with newlines, customizable
indentation, and additional debug information such as complete types and all
pointer addresses used to indirect to the final value. It provides the
following features over the built-in printing facilities provided by the fmt
package:
* Pointers are dereferenced and followed
* Circular data structures are detected and handled properly
* Custom Stringer/error interfaces are optionally invoked, including
on unexported types
* Custom types which only implement the Stringer/error interfaces via
a pointer receiver are optionally invoked when passing non-pointer
variables
* Byte arrays and slices are dumped like the hexdump -C command which
includes offsets, byte values in hex, and ASCII output
The configuration options are controlled by an exported package global,
spew.Config. See ConfigState for options documentation.
See Fdump if you would prefer dumping to an arbitrary io.Writer or Sdump to
get the formatted result as a string.
*/
func Dump(a ...interface{}) {
fdump(&Config, os.Stdout, a...)
}

419
server/vendor/github.com/davecgh/go-spew/spew/format.go generated vendored Normal file
View File

@@ -0,0 +1,419 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"bytes"
"fmt"
"reflect"
"strconv"
"strings"
)
// supportedFlags is a list of all the character flags supported by fmt package.
const supportedFlags = "0-+# "
// formatState implements the fmt.Formatter interface and contains information
// about the state of a formatting operation. The NewFormatter function can
// be used to get a new Formatter which can be used directly as arguments
// in standard fmt package printing calls.
type formatState struct {
value interface{}
fs fmt.State
depth int
pointers map[uintptr]int
ignoreNextType bool
cs *ConfigState
}
// buildDefaultFormat recreates the original format string without precision
// and width information to pass in to fmt.Sprintf in the case of an
// unrecognized type. Unless new types are added to the language, this
// function won't ever be called.
func (f *formatState) buildDefaultFormat() (format string) {
buf := bytes.NewBuffer(percentBytes)
for _, flag := range supportedFlags {
if f.fs.Flag(int(flag)) {
buf.WriteRune(flag)
}
}
buf.WriteRune('v')
format = buf.String()
return format
}
// constructOrigFormat recreates the original format string including precision
// and width information to pass along to the standard fmt package. This allows
// automatic deferral of all format strings this package doesn't support.
func (f *formatState) constructOrigFormat(verb rune) (format string) {
buf := bytes.NewBuffer(percentBytes)
for _, flag := range supportedFlags {
if f.fs.Flag(int(flag)) {
buf.WriteRune(flag)
}
}
if width, ok := f.fs.Width(); ok {
buf.WriteString(strconv.Itoa(width))
}
if precision, ok := f.fs.Precision(); ok {
buf.Write(precisionBytes)
buf.WriteString(strconv.Itoa(precision))
}
buf.WriteRune(verb)
format = buf.String()
return format
}
// unpackValue returns values inside of non-nil interfaces when possible and
// ensures that types for values which have been unpacked from an interface
// are displayed when the show types flag is also set.
// This is useful for data types like structs, arrays, slices, and maps which
// can contain varying types packed inside an interface.
func (f *formatState) unpackValue(v reflect.Value) reflect.Value {
if v.Kind() == reflect.Interface {
f.ignoreNextType = false
if !v.IsNil() {
v = v.Elem()
}
}
return v
}
// formatPtr handles formatting of pointers by indirecting them as necessary.
func (f *formatState) formatPtr(v reflect.Value) {
// Display nil if top level pointer is nil.
showTypes := f.fs.Flag('#')
if v.IsNil() && (!showTypes || f.ignoreNextType) {
f.fs.Write(nilAngleBytes)
return
}
// Remove pointers at or below the current depth from map used to detect
// circular refs.
for k, depth := range f.pointers {
if depth >= f.depth {
delete(f.pointers, k)
}
}
// Keep list of all dereferenced pointers to possibly show later.
pointerChain := make([]uintptr, 0)
// Figure out how many levels of indirection there are by derferencing
// pointers and unpacking interfaces down the chain while detecting circular
// references.
nilFound := false
cycleFound := false
indirects := 0
ve := v
for ve.Kind() == reflect.Ptr {
if ve.IsNil() {
nilFound = true
break
}
indirects++
addr := ve.Pointer()
pointerChain = append(pointerChain, addr)
if pd, ok := f.pointers[addr]; ok && pd < f.depth {
cycleFound = true
indirects--
break
}
f.pointers[addr] = f.depth
ve = ve.Elem()
if ve.Kind() == reflect.Interface {
if ve.IsNil() {
nilFound = true
break
}
ve = ve.Elem()
}
}
// Display type or indirection level depending on flags.
if showTypes && !f.ignoreNextType {
f.fs.Write(openParenBytes)
f.fs.Write(bytes.Repeat(asteriskBytes, indirects))
f.fs.Write([]byte(ve.Type().String()))
f.fs.Write(closeParenBytes)
} else {
if nilFound || cycleFound {
indirects += strings.Count(ve.Type().String(), "*")
}
f.fs.Write(openAngleBytes)
f.fs.Write([]byte(strings.Repeat("*", indirects)))
f.fs.Write(closeAngleBytes)
}
// Display pointer information depending on flags.
if f.fs.Flag('+') && (len(pointerChain) > 0) {
f.fs.Write(openParenBytes)
for i, addr := range pointerChain {
if i > 0 {
f.fs.Write(pointerChainBytes)
}
printHexPtr(f.fs, addr)
}
f.fs.Write(closeParenBytes)
}
// Display dereferenced value.
switch {
case nilFound:
f.fs.Write(nilAngleBytes)
case cycleFound:
f.fs.Write(circularShortBytes)
default:
f.ignoreNextType = true
f.format(ve)
}
}
// format is the main workhorse for providing the Formatter interface. It
// uses the passed reflect value to figure out what kind of object we are
// dealing with and formats it appropriately. It is a recursive function,
// however circular data structures are detected and handled properly.
func (f *formatState) format(v reflect.Value) {
// Handle invalid reflect values immediately.
kind := v.Kind()
if kind == reflect.Invalid {
f.fs.Write(invalidAngleBytes)
return
}
// Handle pointers specially.
if kind == reflect.Ptr {
f.formatPtr(v)
return
}
// Print type information unless already handled elsewhere.
if !f.ignoreNextType && f.fs.Flag('#') {
f.fs.Write(openParenBytes)
f.fs.Write([]byte(v.Type().String()))
f.fs.Write(closeParenBytes)
}
f.ignoreNextType = false
// Call Stringer/error interfaces if they exist and the handle methods
// flag is enabled.
if !f.cs.DisableMethods {
if (kind != reflect.Invalid) && (kind != reflect.Interface) {
if handled := handleMethods(f.cs, f.fs, v); handled {
return
}
}
}
switch kind {
case reflect.Invalid:
// Do nothing. We should never get here since invalid has already
// been handled above.
case reflect.Bool:
printBool(f.fs, v.Bool())
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
printInt(f.fs, v.Int(), 10)
case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uint:
printUint(f.fs, v.Uint(), 10)
case reflect.Float32:
printFloat(f.fs, v.Float(), 32)
case reflect.Float64:
printFloat(f.fs, v.Float(), 64)
case reflect.Complex64:
printComplex(f.fs, v.Complex(), 32)
case reflect.Complex128:
printComplex(f.fs, v.Complex(), 64)
case reflect.Slice:
if v.IsNil() {
f.fs.Write(nilAngleBytes)
break
}
fallthrough
case reflect.Array:
f.fs.Write(openBracketBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
numEntries := v.Len()
for i := 0; i < numEntries; i++ {
if i > 0 {
f.fs.Write(spaceBytes)
}
f.ignoreNextType = true
f.format(f.unpackValue(v.Index(i)))
}
}
f.depth--
f.fs.Write(closeBracketBytes)
case reflect.String:
f.fs.Write([]byte(v.String()))
case reflect.Interface:
// The only time we should get here is for nil interfaces due to
// unpackValue calls.
if v.IsNil() {
f.fs.Write(nilAngleBytes)
}
case reflect.Ptr:
// Do nothing. We should never get here since pointers have already
// been handled above.
case reflect.Map:
// nil maps should be indicated as different than empty maps
if v.IsNil() {
f.fs.Write(nilAngleBytes)
break
}
f.fs.Write(openMapBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
keys := v.MapKeys()
if f.cs.SortKeys {
sortValues(keys, f.cs)
}
for i, key := range keys {
if i > 0 {
f.fs.Write(spaceBytes)
}
f.ignoreNextType = true
f.format(f.unpackValue(key))
f.fs.Write(colonBytes)
f.ignoreNextType = true
f.format(f.unpackValue(v.MapIndex(key)))
}
}
f.depth--
f.fs.Write(closeMapBytes)
case reflect.Struct:
numFields := v.NumField()
f.fs.Write(openBraceBytes)
f.depth++
if (f.cs.MaxDepth != 0) && (f.depth > f.cs.MaxDepth) {
f.fs.Write(maxShortBytes)
} else {
vt := v.Type()
for i := 0; i < numFields; i++ {
if i > 0 {
f.fs.Write(spaceBytes)
}
vtf := vt.Field(i)
if f.fs.Flag('+') || f.fs.Flag('#') {
f.fs.Write([]byte(vtf.Name))
f.fs.Write(colonBytes)
}
f.format(f.unpackValue(v.Field(i)))
}
}
f.depth--
f.fs.Write(closeBraceBytes)
case reflect.Uintptr:
printHexPtr(f.fs, uintptr(v.Uint()))
case reflect.UnsafePointer, reflect.Chan, reflect.Func:
printHexPtr(f.fs, v.Pointer())
// There were not any other types at the time this code was written, but
// fall back to letting the default fmt package handle it if any get added.
default:
format := f.buildDefaultFormat()
if v.CanInterface() {
fmt.Fprintf(f.fs, format, v.Interface())
} else {
fmt.Fprintf(f.fs, format, v.String())
}
}
}
// Format satisfies the fmt.Formatter interface. See NewFormatter for usage
// details.
func (f *formatState) Format(fs fmt.State, verb rune) {
f.fs = fs
// Use standard formatting for verbs that are not v.
if verb != 'v' {
format := f.constructOrigFormat(verb)
fmt.Fprintf(fs, format, f.value)
return
}
if f.value == nil {
if fs.Flag('#') {
fs.Write(interfaceBytes)
}
fs.Write(nilAngleBytes)
return
}
f.format(reflect.ValueOf(f.value))
}
// newFormatter is a helper function to consolidate the logic from the various
// public methods which take varying config states.
func newFormatter(cs *ConfigState, v interface{}) fmt.Formatter {
fs := &formatState{value: v, cs: cs}
fs.pointers = make(map[uintptr]int)
return fs
}
/*
NewFormatter returns a custom formatter that satisfies the fmt.Formatter
interface. As a result, it integrates cleanly with standard fmt package
printing functions. The formatter is useful for inline printing of smaller data
types similar to the standard %v format specifier.
The custom formatter only responds to the %v (most compact), %+v (adds pointer
addresses), %#v (adds types), or %#+v (adds types and pointer addresses) verb
combinations. Any other verbs such as %x and %q will be sent to the the
standard fmt package for formatting. In addition, the custom formatter ignores
the width and precision arguments (however they will still work on the format
specifiers not handled by the custom formatter).
Typically this function shouldn't be called directly. It is much easier to make
use of the custom formatter by calling one of the convenience functions such as
Printf, Println, or Fprintf.
*/
func NewFormatter(v interface{}) fmt.Formatter {
return newFormatter(&Config, v)
}

148
server/vendor/github.com/davecgh/go-spew/spew/spew.go generated vendored Normal file
View File

@@ -0,0 +1,148 @@
/*
* Copyright (c) 2013-2016 Dave Collins <dave@davec.name>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
package spew
import (
"fmt"
"io"
)
// Errorf is a wrapper for fmt.Errorf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the formatted string as a value that satisfies error. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Errorf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Errorf(format string, a ...interface{}) (err error) {
return fmt.Errorf(format, convertArgs(a)...)
}
// Fprint is a wrapper for fmt.Fprint that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprint(w, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprint(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprint(w, convertArgs(a)...)
}
// Fprintf is a wrapper for fmt.Fprintf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintf(w, format, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) {
return fmt.Fprintf(w, format, convertArgs(a)...)
}
// Fprintln is a wrapper for fmt.Fprintln that treats each argument as if it
// passed with a default Formatter interface returned by NewFormatter. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Fprintln(w, spew.NewFormatter(a), spew.NewFormatter(b))
func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
return fmt.Fprintln(w, convertArgs(a)...)
}
// Print is a wrapper for fmt.Print that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Print(spew.NewFormatter(a), spew.NewFormatter(b))
func Print(a ...interface{}) (n int, err error) {
return fmt.Print(convertArgs(a)...)
}
// Printf is a wrapper for fmt.Printf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Printf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Printf(format string, a ...interface{}) (n int, err error) {
return fmt.Printf(format, convertArgs(a)...)
}
// Println is a wrapper for fmt.Println that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the number of bytes written and any write error encountered. See
// NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Println(spew.NewFormatter(a), spew.NewFormatter(b))
func Println(a ...interface{}) (n int, err error) {
return fmt.Println(convertArgs(a)...)
}
// Sprint is a wrapper for fmt.Sprint that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprint(spew.NewFormatter(a), spew.NewFormatter(b))
func Sprint(a ...interface{}) string {
return fmt.Sprint(convertArgs(a)...)
}
// Sprintf is a wrapper for fmt.Sprintf that treats each argument as if it were
// passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintf(format, spew.NewFormatter(a), spew.NewFormatter(b))
func Sprintf(format string, a ...interface{}) string {
return fmt.Sprintf(format, convertArgs(a)...)
}
// Sprintln is a wrapper for fmt.Sprintln that treats each argument as if it
// were passed with a default Formatter interface returned by NewFormatter. It
// returns the resulting string. See NewFormatter for formatting details.
//
// This function is shorthand for the following syntax:
//
// fmt.Sprintln(spew.NewFormatter(a), spew.NewFormatter(b))
func Sprintln(a ...interface{}) string {
return fmt.Sprintln(convertArgs(a)...)
}
// convertArgs accepts a slice of arguments and returns a slice of the same
// length with each argument converted to a default spew Formatter interface.
func convertArgs(args []interface{}) (formatters []interface{}) {
formatters = make([]interface{}, len(args))
for index, arg := range args {
formatters[index] = NewFormatter(arg)
}
return formatters
}

10
server/vendor/github.com/google/uuid/CHANGELOG.md generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Changelog
## [1.3.1](https://github.com/google/uuid/compare/v1.3.0...v1.3.1) (2023-08-18)
### Bug Fixes
* Use .EqualFold() to parse urn prefixed UUIDs ([#118](https://github.com/google/uuid/issues/118)) ([574e687](https://github.com/google/uuid/commit/574e6874943741fb99d41764c705173ada5293f0))
## Changelog

26
server/vendor/github.com/google/uuid/CONTRIBUTING.md generated vendored Normal file
View File

@@ -0,0 +1,26 @@
# How to contribute
We definitely welcome patches and contribution to this project!
### Tips
Commits must be formatted according to the [Conventional Commits Specification](https://www.conventionalcommits.org).
Always try to include a test case! If it is not possible or not necessary,
please explain why in the pull request description.
### Releasing
Commits that would precipitate a SemVer change, as desrcibed in the Conventional
Commits Specification, will trigger [`release-please`](https://github.com/google-github-actions/release-please-action)
to create a release candidate pull request. Once submitted, `release-please`
will create a release.
For tips on how to work with `release-please`, see its documentation.
### Legal requirements
In order to protect both you and ourselves, you will need to sign the
[Contributor License Agreement](https://cla.developers.google.com/clas).
You may have already signed it for other Google projects.

9
server/vendor/github.com/google/uuid/CONTRIBUTORS generated vendored Normal file
View File

@@ -0,0 +1,9 @@
Paul Borman <borman@google.com>
bmatsuo
shawnps
theory
jboverfelt
dsymonds
cd1
wallclockbuilder
dansouza

27
server/vendor/github.com/google/uuid/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,27 @@
Copyright (c) 2009,2014 Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
server/vendor/github.com/google/uuid/README.md generated vendored Normal file
View File

@@ -0,0 +1,21 @@
# uuid
The uuid package generates and inspects UUIDs based on
[RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)
and DCE 1.1: Authentication and Security Services.
This package is based on the github.com/pborman/uuid package (previously named
code.google.com/p/go-uuid). It differs from these earlier packages in that
a UUID is a 16 byte array rather than a byte slice. One loss due to this
change is the ability to represent an invalid UUID (vs a NIL UUID).
###### Install
```sh
go get github.com/google/uuid
```
###### Documentation
[![Go Reference](https://pkg.go.dev/badge/github.com/google/uuid.svg)](https://pkg.go.dev/github.com/google/uuid)
Full `go doc` style documentation for the package can be viewed online without
installing this package by using the GoDoc site here:
http://pkg.go.dev/github.com/google/uuid

80
server/vendor/github.com/google/uuid/dce.go generated vendored Normal file
View File

@@ -0,0 +1,80 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
"fmt"
"os"
)
// A Domain represents a Version 2 domain
type Domain byte
// Domain constants for DCE Security (Version 2) UUIDs.
const (
Person = Domain(0)
Group = Domain(1)
Org = Domain(2)
)
// NewDCESecurity returns a DCE Security (Version 2) UUID.
//
// The domain should be one of Person, Group or Org.
// On a POSIX system the id should be the users UID for the Person
// domain and the users GID for the Group. The meaning of id for
// the domain Org or on non-POSIX systems is site defined.
//
// For a given domain/id pair the same token may be returned for up to
// 7 minutes and 10 seconds.
func NewDCESecurity(domain Domain, id uint32) (UUID, error) {
uuid, err := NewUUID()
if err == nil {
uuid[6] = (uuid[6] & 0x0f) | 0x20 // Version 2
uuid[9] = byte(domain)
binary.BigEndian.PutUint32(uuid[0:], id)
}
return uuid, err
}
// NewDCEPerson returns a DCE Security (Version 2) UUID in the person
// domain with the id returned by os.Getuid.
//
// NewDCESecurity(Person, uint32(os.Getuid()))
func NewDCEPerson() (UUID, error) {
return NewDCESecurity(Person, uint32(os.Getuid()))
}
// NewDCEGroup returns a DCE Security (Version 2) UUID in the group
// domain with the id returned by os.Getgid.
//
// NewDCESecurity(Group, uint32(os.Getgid()))
func NewDCEGroup() (UUID, error) {
return NewDCESecurity(Group, uint32(os.Getgid()))
}
// Domain returns the domain for a Version 2 UUID. Domains are only defined
// for Version 2 UUIDs.
func (uuid UUID) Domain() Domain {
return Domain(uuid[9])
}
// ID returns the id for a Version 2 UUID. IDs are only defined for Version 2
// UUIDs.
func (uuid UUID) ID() uint32 {
return binary.BigEndian.Uint32(uuid[0:4])
}
func (d Domain) String() string {
switch d {
case Person:
return "Person"
case Group:
return "Group"
case Org:
return "Org"
}
return fmt.Sprintf("Domain%d", int(d))
}

12
server/vendor/github.com/google/uuid/doc.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package uuid generates and inspects UUIDs.
//
// UUIDs are based on RFC 4122 and DCE 1.1: Authentication and Security
// Services.
//
// A UUID is a 16 byte (128 bit) array. UUIDs may be used as keys to
// maps or compared directly.
package uuid

53
server/vendor/github.com/google/uuid/hash.go generated vendored Normal file
View File

@@ -0,0 +1,53 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"crypto/md5"
"crypto/sha1"
"hash"
)
// Well known namespace IDs and UUIDs
var (
NameSpaceDNS = Must(Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceURL = Must(Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceOID = Must(Parse("6ba7b812-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceX500 = Must(Parse("6ba7b814-9dad-11d1-80b4-00c04fd430c8"))
Nil UUID // empty UUID, all zeros
)
// NewHash returns a new UUID derived from the hash of space concatenated with
// data generated by h. The hash should be at least 16 byte in length. The
// first 16 bytes of the hash are used to form the UUID. The version of the
// UUID will be the lower 4 bits of version. NewHash is used to implement
// NewMD5 and NewSHA1.
func NewHash(h hash.Hash, space UUID, data []byte, version int) UUID {
h.Reset()
h.Write(space[:]) //nolint:errcheck
h.Write(data) //nolint:errcheck
s := h.Sum(nil)
var uuid UUID
copy(uuid[:], s)
uuid[6] = (uuid[6] & 0x0f) | uint8((version&0xf)<<4)
uuid[8] = (uuid[8] & 0x3f) | 0x80 // RFC 4122 variant
return uuid
}
// NewMD5 returns a new MD5 (Version 3) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(md5.New(), space, data, 3)
func NewMD5(space UUID, data []byte) UUID {
return NewHash(md5.New(), space, data, 3)
}
// NewSHA1 returns a new SHA1 (Version 5) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(sha1.New(), space, data, 5)
func NewSHA1(space UUID, data []byte) UUID {
return NewHash(sha1.New(), space, data, 5)
}

38
server/vendor/github.com/google/uuid/marshal.go generated vendored Normal file
View File

@@ -0,0 +1,38 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import "fmt"
// MarshalText implements encoding.TextMarshaler.
func (uuid UUID) MarshalText() ([]byte, error) {
var js [36]byte
encodeHex(js[:], uuid)
return js[:], nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (uuid *UUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
return err
}
*uuid = id
return nil
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (uuid UUID) MarshalBinary() ([]byte, error) {
return uuid[:], nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (uuid *UUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(uuid[:], data)
return nil
}

90
server/vendor/github.com/google/uuid/node.go generated vendored Normal file
View File

@@ -0,0 +1,90 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"sync"
)
var (
nodeMu sync.Mutex
ifname string // name of interface being used
nodeID [6]byte // hardware for version 1 UUIDs
zeroID [6]byte // nodeID with only 0's
)
// NodeInterface returns the name of the interface from which the NodeID was
// derived. The interface "user" is returned if the NodeID was set by
// SetNodeID.
func NodeInterface() string {
defer nodeMu.Unlock()
nodeMu.Lock()
return ifname
}
// SetNodeInterface selects the hardware address to be used for Version 1 UUIDs.
// If name is "" then the first usable interface found will be used or a random
// Node ID will be generated. If a named interface cannot be found then false
// is returned.
//
// SetNodeInterface never fails when name is "".
func SetNodeInterface(name string) bool {
defer nodeMu.Unlock()
nodeMu.Lock()
return setNodeInterface(name)
}
func setNodeInterface(name string) bool {
iname, addr := getHardwareInterface(name) // null implementation for js
if iname != "" && addr != nil {
ifname = iname
copy(nodeID[:], addr)
return true
}
// We found no interfaces with a valid hardware address. If name
// does not specify a specific interface generate a random Node ID
// (section 4.1.6)
if name == "" {
ifname = "random"
randomBits(nodeID[:])
return true
}
return false
}
// NodeID returns a slice of a copy of the current Node ID, setting the Node ID
// if not already set.
func NodeID() []byte {
defer nodeMu.Unlock()
nodeMu.Lock()
if nodeID == zeroID {
setNodeInterface("")
}
nid := nodeID
return nid[:]
}
// SetNodeID sets the Node ID to be used for Version 1 UUIDs. The first 6 bytes
// of id are used. If id is less than 6 bytes then false is returned and the
// Node ID is not set.
func SetNodeID(id []byte) bool {
if len(id) < 6 {
return false
}
defer nodeMu.Unlock()
nodeMu.Lock()
copy(nodeID[:], id)
ifname = "user"
return true
}
// NodeID returns the 6 byte node id encoded in uuid. It returns nil if uuid is
// not valid. The NodeID is only well defined for version 1 and 2 UUIDs.
func (uuid UUID) NodeID() []byte {
var node [6]byte
copy(node[:], uuid[10:])
return node[:]
}

12
server/vendor/github.com/google/uuid/node_js.go generated vendored Normal file
View File

@@ -0,0 +1,12 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build js
package uuid
// getHardwareInterface returns nil values for the JS version of the code.
// This removes the "net" dependency, because it is not used in the browser.
// Using the "net" library inflates the size of the transpiled JS code by 673k bytes.
func getHardwareInterface(name string) (string, []byte) { return "", nil }

33
server/vendor/github.com/google/uuid/node_net.go generated vendored Normal file
View File

@@ -0,0 +1,33 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !js
package uuid
import "net"
var interfaces []net.Interface // cached list of interfaces
// getHardwareInterface returns the name and hardware address of interface name.
// If name is "" then the name and hardware address of one of the system's
// interfaces is returned. If no interfaces are found (name does not exist or
// there are no interfaces) then "", nil is returned.
//
// Only addresses of at least 6 bytes are returned.
func getHardwareInterface(name string) (string, []byte) {
if interfaces == nil {
var err error
interfaces, err = net.Interfaces()
if err != nil {
return "", nil
}
}
for _, ifs := range interfaces {
if len(ifs.HardwareAddr) >= 6 && (name == "" || name == ifs.Name) {
return ifs.Name, ifs.HardwareAddr
}
}
return "", nil
}

118
server/vendor/github.com/google/uuid/null.go generated vendored Normal file
View File

@@ -0,0 +1,118 @@
// Copyright 2021 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"bytes"
"database/sql/driver"
"encoding/json"
"fmt"
)
var jsonNull = []byte("null")
// NullUUID represents a UUID that may be null.
// NullUUID implements the SQL driver.Scanner interface so
// it can be used as a scan destination:
//
// var u uuid.NullUUID
// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&u)
// ...
// if u.Valid {
// // use u.UUID
// } else {
// // NULL value
// }
//
type NullUUID struct {
UUID UUID
Valid bool // Valid is true if UUID is not NULL
}
// Scan implements the SQL driver.Scanner interface.
func (nu *NullUUID) Scan(value interface{}) error {
if value == nil {
nu.UUID, nu.Valid = Nil, false
return nil
}
err := nu.UUID.Scan(value)
if err != nil {
nu.Valid = false
return err
}
nu.Valid = true
return nil
}
// Value implements the driver Valuer interface.
func (nu NullUUID) Value() (driver.Value, error) {
if !nu.Valid {
return nil, nil
}
// Delegate to UUID Value function
return nu.UUID.Value()
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (nu NullUUID) MarshalBinary() ([]byte, error) {
if nu.Valid {
return nu.UUID[:], nil
}
return []byte(nil), nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (nu *NullUUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(nu.UUID[:], data)
nu.Valid = true
return nil
}
// MarshalText implements encoding.TextMarshaler.
func (nu NullUUID) MarshalText() ([]byte, error) {
if nu.Valid {
return nu.UUID.MarshalText()
}
return jsonNull, nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (nu *NullUUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
nu.Valid = false
return err
}
nu.UUID = id
nu.Valid = true
return nil
}
// MarshalJSON implements json.Marshaler.
func (nu NullUUID) MarshalJSON() ([]byte, error) {
if nu.Valid {
return json.Marshal(nu.UUID)
}
return jsonNull, nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (nu *NullUUID) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, jsonNull) {
*nu = NullUUID{}
return nil // valid null UUID
}
err := json.Unmarshal(data, &nu.UUID)
nu.Valid = err == nil
return err
}

59
server/vendor/github.com/google/uuid/sql.go generated vendored Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"database/sql/driver"
"fmt"
)
// Scan implements sql.Scanner so UUIDs can be read from databases transparently.
// Currently, database types that map to string and []byte are supported. Please
// consult database-specific driver documentation for matching types.
func (uuid *UUID) Scan(src interface{}) error {
switch src := src.(type) {
case nil:
return nil
case string:
// if an empty UUID comes from a table, we return a null UUID
if src == "" {
return nil
}
// see Parse for required string format
u, err := Parse(src)
if err != nil {
return fmt.Errorf("Scan: %v", err)
}
*uuid = u
case []byte:
// if an empty UUID comes from a table, we return a null UUID
if len(src) == 0 {
return nil
}
// assumes a simple slice of bytes if 16 bytes
// otherwise attempts to parse
if len(src) != 16 {
return uuid.Scan(string(src))
}
copy((*uuid)[:], src)
default:
return fmt.Errorf("Scan: unable to scan type %T into UUID", src)
}
return nil
}
// Value implements sql.Valuer so that UUIDs can be written to databases
// transparently. Currently, UUIDs map to strings. Please consult
// database-specific driver documentation for matching types.
func (uuid UUID) Value() (driver.Value, error) {
return uuid.String(), nil
}

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