Compare commits
81 Commits
0360ee5261
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 800adb3322 | |||
| 65574e3762 | |||
| cce3d158d5 | |||
| 0800982224 | |||
| 03f5fbb41a | |||
| f161ff0e4e | |||
| 645965b609 | |||
| 774cee0afa | |||
| 89cc1d2368 | |||
| 0980d1fa9c | |||
| 38c4c465c5 | |||
| 2f78fd0d52 | |||
| 435fbfd47e | |||
| fe8d5a34cc | |||
| 0da93fb1be | |||
| e6ac5a107a | |||
| 4112ea4447 | |||
| 0aa11575a6 | |||
| 8d800eee62 | |||
| d441fe33fd | |||
| 07ae6c02ef | |||
| 2e675bda51 | |||
| f28b80354f | |||
| 26e90c30f9 | |||
| 8c9c573a1c | |||
| 9329151976 | |||
| 8d730a2a75 | |||
| 10a842b4ef | |||
| 106e6e1f16 | |||
| 6b3210f714 | |||
| 2295410e1b | |||
| da0bcae823 | |||
| 7e24a965bc | |||
| 70e6782713 | |||
| d83a69c23a | |||
| 996dc3778d | |||
| 7811adca66 | |||
| b83ec91b1a | |||
| 65d5e425d7 | |||
| 5ea23ba657 | |||
| 3222dffc64 | |||
| f5852bc04e | |||
| 78055dbe68 | |||
| 2c0898fffd | |||
| 03878848dd | |||
| ee9394f410 | |||
| 7980c1922a | |||
| 80176ea6fc | |||
| 0a1fe41314 | |||
| 5da4941913 | |||
| ea90052e7e | |||
| d37e9a3663 | |||
| 52991d1e49 | |||
| eb6923998f | |||
| c6e5779b76 | |||
| 6f87e0c260 | |||
| 948494bca0 | |||
| 7c9649356a | |||
| 5ff300d0f7 | |||
| 66b873d0b0 | |||
| 122f5b8fba | |||
| 5830fdfba3 | |||
| 2660f8edd8 | |||
| 5bfdd04f21 | |||
| 89cd8f83bc | |||
| 77febfacc7 | |||
| d04799db5f | |||
| 6d049fe0e8 | |||
| 1710a11dad | |||
| 0896bd3bab | |||
| f4e51165a7 | |||
| c1fb5f3440 | |||
| dd05748c85 | |||
| db3a8d8cd1 | |||
| d6767c2c5c | |||
| 7336c42af0 | |||
| dfcfb477c5 | |||
| b69dde0f7e | |||
| b95fcdeb8c | |||
| 654b683067 | |||
| 5067fb6f76 |
33
.gitignore
vendored
33
.gitignore
vendored
@@ -3,6 +3,8 @@ node_modules/
|
||||
web/dist/
|
||||
admin/dist/
|
||||
server/server
|
||||
server/yh_api
|
||||
server/bin/
|
||||
*.exe
|
||||
|
||||
# 环境与密钥(服务器上单独配置)
|
||||
@@ -11,6 +13,10 @@ server/.env
|
||||
!.env.example
|
||||
!server/.env.example
|
||||
|
||||
# Nginx 证书私钥(除 yuheng 域名外不提交)
|
||||
nginx/*.key
|
||||
!nginx/yuheng.yuxindazhineng.com.key
|
||||
|
||||
# 日志
|
||||
logs/
|
||||
*.log
|
||||
@@ -23,5 +29,32 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 挂目录部署:构建产物由脚本生成,不提交
|
||||
deploy/web/dist/
|
||||
deploy/admin/dist/
|
||||
deploy/api/server
|
||||
|
||||
# 功能模块上传目录(API 写入,不提交)
|
||||
data/uploads/
|
||||
|
||||
# Docker 本地卷(不提交)
|
||||
# mongo_data 等由 compose 管理
|
||||
|
||||
# 推广素材:视频不入库(本地可保留;线上后台上传或静态部署)
|
||||
web/promotion/**/*.mov
|
||||
web/promotion/**/*.MOV
|
||||
web/promotion/**/*.mp4
|
||||
web/promotion/**/*.webm
|
||||
web/promotion/**/*.mkv
|
||||
web/promotion/**/*.avi
|
||||
web/promotion/**/*.m4v
|
||||
|
||||
# 「视频发布」封面等图片不入库(与视频配套,见该目录 README)
|
||||
web/promotion/视频发布/**/*.jpg
|
||||
web/promotion/视频发布/**/*.jpeg
|
||||
web/promotion/视频发布/**/*.png
|
||||
web/promotion/视频发布/**/*.webp
|
||||
|
||||
# PPT 解压临时目录与压缩包副本(仅保留 .pptx 源文件即可)
|
||||
web/promotion/_pptx_extract/
|
||||
web/promotion/_pptx.zip
|
||||
|
||||
@@ -81,4 +81,21 @@ if (!db.getCollectionNames().includes("role_permissions")) {
|
||||
}
|
||||
db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true });
|
||||
|
||||
// 11. site_users(前台直播弹幕账号,与后台 users 分离)
|
||||
if (!db.getCollectionNames().includes("site_users")) {
|
||||
db.createCollection("site_users");
|
||||
print("已创建集合: site_users");
|
||||
}
|
||||
db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
|
||||
|
||||
// 12. yuheng_cloud_register_records(宇恒云 POST /register 本地留痕:username、password)
|
||||
if (!db.getCollectionNames().includes("yuheng_cloud_register_records")) {
|
||||
db.createCollection("yuheng_cloud_register_records");
|
||||
print("已创建集合: yuheng_cloud_register_records");
|
||||
}
|
||||
db.yuheng_cloud_register_records.createIndex(
|
||||
{ created_at: -1 },
|
||||
{ name: "idx_created_at", background: true }
|
||||
);
|
||||
|
||||
print("集合与索引处理完成。");
|
||||
|
||||
@@ -117,6 +117,8 @@ bash pull-and-restart.sh
|
||||
若报错 `bash\r`,先执行 `sed -i 's/\r$//' pull-and-restart.sh restart.sh`。
|
||||
首次部署若目录为空,可先放入两个脚本,设置 `export GIT_REPO_URL='https://用户:Token@gitea.../web.git'` 后执行 `./pull-and-restart.sh` 完成克隆与启动。配置好 `server/.env` 后再次运行即可。
|
||||
|
||||
**产品视频自动导入**:`server/.env.example` 已含默认 `YH_IMPORT_PROMOTION_SITE_ID`;首次或拉代码后脚本会把 **`.env.example` 里尚未出现在 `server/.env` 的键自动追加**到 `server/.env`,**服务器只需执行 `./pull-and-restart.sh`**,无需手改配置。每次部署在 `compose up` 后会将 `web/promotion/视频发布/` 导入 `data/uploads` + `site_assets`(与 [官网](https://yuheng.yuxindazhineng.com/) `promotion-media` 一致)。多站点请改仓库内 `server/.env.example` 后再部署。
|
||||
|
||||
- **拉取并重启**:`cd ~/project/yh_web && ./pull-and-restart.sh`
|
||||
- **仅重启**:`cd ~/project/yh_web && ./restart.sh`
|
||||
- **对外域名**:https://yuheng.yuxindazhineng.com(所有请求均通过该域名,见下)
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN npm run build
|
||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||
FROM ${REGISTRY_MIRROR}nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,故这里用 location / 提供 SPA(base 为 /admin/ 时静态资源请求为 /assets/...)
|
||||
RUN echo 'server { listen 80; root /usr/share/nginx/html; location / { try_files $uri $uri/ /index.html; } }' > /etc/nginx/conf.d/default.conf
|
||||
# 与 deploy/admin/default.conf 同逻辑:^~ /assets/ 避免缺失 chunk 时回退到 index.html → MIME text/html 白屏
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
24
admin/nginx.conf
Normal file
24
admin/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 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 })
|
||||
|
||||
@@ -46,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
|
||||
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
||||
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
||||
|
||||
/** 分片上传临时目录清理:保留时长、扫描间隔(需 site:manage) */
|
||||
export const getChunkUploadCleanup = () => request.get('/admin/system/chunk-upload-cleanup')
|
||||
export const updateChunkUploadCleanup = (data) => request.put('/admin/system/chunk-upload-cleanup', data)
|
||||
|
||||
// 站点管理
|
||||
export const getSites = () => request.get('/admin/sites')
|
||||
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
||||
@@ -78,7 +87,43 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
||||
form.append('file', file)
|
||||
if (opts.folder != null) form.append('folder', opts.folder)
|
||||
form.append('downloadable', opts.downloadable ? 'true' : 'false')
|
||||
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
if (opts.preserveFilename) form.append('preserve_filename', 'true')
|
||||
// 大文件上传:timeout 0 = Axios 不设置请求超时(仍可能受浏览器/系统/代理断开影响)
|
||||
// 超大文件请用分片 API(见 uploadSiteAssetWithResume)
|
||||
return request.post(`/admin/sites/${siteId}/assets`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 0
|
||||
})
|
||||
}
|
||||
|
||||
/** 分片上传:创建会话(断点续传) */
|
||||
export const initMultipartUpload = (siteId, body) =>
|
||||
request.post(`/admin/sites/${siteId}/assets/init-multipart`, body, { timeout: 60000 })
|
||||
|
||||
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
||||
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
||||
|
||||
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => {
|
||||
const fd = new FormData()
|
||||
fd.append('chunk', blob, 'part.bin')
|
||||
// 不传 Content-Type,由浏览器带 boundary;与整文件 multipart 一致,减少中间层断连
|
||||
return request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, fd, {
|
||||
timeout: 180000
|
||||
})
|
||||
}
|
||||
|
||||
export const completeMultipartUpload = (siteId, uploadId) =>
|
||||
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
||||
|
||||
export const abortMultipartUpload = (siteId, uploadId) =>
|
||||
request.delete(`/admin/sites/${siteId}/assets/multipart/${uploadId}`, { timeout: 60000 })
|
||||
|
||||
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
||||
|
||||
// 直播发言管控(全体 / IP / 用户名)
|
||||
export const getLiveModeration = () => request.get('/admin/live/moderation')
|
||||
export const setLiveMuteAll = (enabled) => request.put('/admin/live/moderation/mute-all', { enabled })
|
||||
export const setLiveMuteIP = (ip, enabled) => request.put('/admin/live/moderation/mute-ip', { ip, enabled })
|
||||
export const setLiveMuteUser = (username, enabled) =>
|
||||
request.put('/admin/live/moderation/mute-user', { username, enabled })
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<template>
|
||||
<div class="page-builder-editor">
|
||||
<PageBuilderBlocks v-model:blocks="blocks" :site-id="siteId" />
|
||||
<el-collapse class="adv-collapse" accordion>
|
||||
<el-collapse-item title="高级:直接编辑 JSON(慎用)" name="json">
|
||||
<el-input
|
||||
v-model="jsonDraft"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="修改后会覆盖上方可视化内容"
|
||||
/>
|
||||
<el-button type="warning" style="margin-top: 8px" @click="applyJsonDraft">应用 JSON</el-button>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<div class="pbe-split">
|
||||
<div class="pbe-editor-col">
|
||||
<PageBuilderBlocks v-model:blocks="blocks" :site-id="siteId" />
|
||||
<el-collapse class="adv-collapse" accordion>
|
||||
<el-collapse-item title="高级:直接编辑 JSON(慎用)" name="json">
|
||||
<el-input
|
||||
v-model="jsonDraft"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="修改后会覆盖上方可视化内容"
|
||||
/>
|
||||
<el-button type="warning" style="margin-top: 8px" @click="applyJsonDraft">应用 JSON</el-button>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
<aside class="pbe-preview-col">
|
||||
<div class="pbe-preview-head">
|
||||
<span>实时预览</span>
|
||||
<el-text size="small" type="info">与前台样式接近,保存后线上一致</el-text>
|
||||
</div>
|
||||
<div class="pbe-preview-body">
|
||||
<BlockRenderer v-if="blocks.length" :blocks="blocks" />
|
||||
<el-empty v-else description="添加模块后此处显示效果" :image-size="80" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +33,8 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageBuilderBlocks from './PageBuilderBlocks.vue'
|
||||
import BlockRenderer from '@yh-web/components/blocks/BlockRenderer.vue'
|
||||
import '@yh-web/styles/page-animations.css'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -105,6 +121,57 @@ function applyJsonDraft() {
|
||||
.page-builder-editor {
|
||||
width: 100%;
|
||||
}
|
||||
.pbe-split {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pbe-editor-col {
|
||||
flex: 1 1 420px;
|
||||
min-width: 320px;
|
||||
}
|
||||
.pbe-preview-col {
|
||||
flex: 0 1 380px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8px;
|
||||
background: #0a0a12;
|
||||
color: #e8e8ef;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
}
|
||||
.pbe-preview-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pbe-preview-body {
|
||||
padding: 16px;
|
||||
max-height: min(62vh, 640px);
|
||||
overflow: auto;
|
||||
}
|
||||
.pbe-preview-body :deep(.builder-heading),
|
||||
.pbe-preview-body :deep(.builder-text),
|
||||
.pbe-preview-body :deep(.builder-links a),
|
||||
.pbe-preview-body :deep(.builder-btn) {
|
||||
color: inherit;
|
||||
}
|
||||
.pbe-preview-body :deep(.builder-text) {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.pbe-preview-body :deep(.builder-links a) {
|
||||
color: #7eb8ff;
|
||||
}
|
||||
.pbe-preview-body :deep(hr) {
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
}
|
||||
.adv-collapse {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key } from '@element-plus/icons-vue'
|
||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera, Timer, Link } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getMyPermissions } from '../api/admin'
|
||||
|
||||
@@ -69,8 +69,11 @@ const menuItems = computed(() => {
|
||||
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
||||
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
||||
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
||||
{ index: '/chunk-upload-cleanup', title: '分片上传清理', icon: Timer, permission: 'site:manage' },
|
||||
{ index: '/yuheng-cloud-accounts', title: '宇恒云账号', icon: Link, permission: 'yuheng_cloud:manage' },
|
||||
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
||||
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
||||
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
||||
{ index: '/files', title: '文件管理', icon: Folder, permission: null },
|
||||
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './utils/disable-debug'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
|
||||
@@ -48,6 +48,18 @@ const routes = [
|
||||
component: () => import('../views/settings/PaymentConfig.vue'),
|
||||
meta: { title: '支付配置', permission: 'payment_config' }
|
||||
},
|
||||
{
|
||||
path: 'chunk-upload-cleanup',
|
||||
name: 'ChunkUploadCleanup',
|
||||
component: () => import('../views/settings/ChunkUploadCleanup.vue'),
|
||||
meta: { title: '分片上传清理', permission: 'site:manage' }
|
||||
},
|
||||
{
|
||||
path: 'yuheng-cloud-accounts',
|
||||
name: 'YuhengCloudAccountManage',
|
||||
component: () => import('../views/settings/YuhengCloudAccountManage.vue'),
|
||||
meta: { title: '宇恒云账号', permission: 'yuheng_cloud:manage' }
|
||||
},
|
||||
{
|
||||
path: 'sites',
|
||||
name: 'Sites',
|
||||
@@ -66,6 +78,12 @@ const routes = [
|
||||
component: () => import('../views/sites/HomepageEdit.vue'),
|
||||
meta: { title: '首页编辑', permission: 'homepage:edit' }
|
||||
},
|
||||
{
|
||||
path: 'live-broadcast',
|
||||
name: 'LiveBroadcast',
|
||||
component: () => import('../views/sites/LiveBroadcast.vue'),
|
||||
meta: { title: '视频直播开播', permission: 'homepage:edit' }
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'FileManage',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 禁止调试模式及右键 - 全局安全模块
|
||||
* 在页面加载时立即执行
|
||||
*/
|
||||
|
||||
// 禁止右键
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault())
|
||||
|
||||
// 禁止 F12、Ctrl+Shift+I/J/C 等开发者工具快捷键
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (
|
||||
e.key === 'F12' ||
|
||||
(e.ctrlKey && e.shiftKey && ['I', 'J', 'C'].includes(e.key.toUpperCase())) ||
|
||||
(e.ctrlKey && e.key === 'U')
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
623
admin/src/utils/liveWebRTC.js
Normal file
623
admin/src/utils/liveWebRTC.js
Normal 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)
|
||||
}
|
||||
}
|
||||
153
admin/src/utils/siteAssetResumableUpload.js
Normal file
153
admin/src/utils/siteAssetResumableUpload.js
Normal 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 (_) {}
|
||||
}
|
||||
@@ -27,6 +27,61 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card v-if="bandwidth" class="bw-card" style="margin-top: 20px">
|
||||
<template #header>
|
||||
<div class="bw-header">
|
||||
<span>应用带宽观测</span>
|
||||
<el-text type="info" size="small">{{ bwUpdatedAt }}</el-text>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert type="info" :closable="false" show-icon class="bw-tip">
|
||||
以下为<strong>本 Go 进程</strong>统计的 HTTP 请求/响应字节量,用于粗估负载;若前面还有 Nginx/CDN,<strong>公网出口带宽</strong>可能更高。WebSocket(如直播信令)升级后的流量可能未完全计入。
|
||||
</el-alert>
|
||||
<el-row :gutter="16" class="bw-row">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="bw-metric">
|
||||
<div class="bw-label">出站累计(用户下载为主)</div>
|
||||
<div class="bw-value">{{ formatBytes(bandwidth.bytes_out_total) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="bw-metric">
|
||||
<div class="bw-label">入站累计(上传/POST)</div>
|
||||
<div class="bw-value">{{ formatBytes(bandwidth.bytes_in_total) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="bw-metric">
|
||||
<div class="bw-label">近 60 秒出站 · 约 Mbps</div>
|
||||
<div class="bw-value accent">{{ bandwidth.recent_egress_mbps }}</div>
|
||||
<div class="bw-sub">{{ formatBytes(bandwidth.bytes_out_last_60s) }} / 60s</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="bw-metric">
|
||||
<div class="bw-label">自启动平均出站 · Mbps</div>
|
||||
<div class="bw-value accent">{{ bandwidth.avg_egress_mbps }}</div>
|
||||
<div class="bw-sub">运行 {{ formatUptime(bandwidth.uptime_seconds) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" class="bw-row bw-row--second">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<div class="bw-metric bw-metric--inline">
|
||||
<span class="bw-label">近 60 秒入站约 Mbps</span>
|
||||
<span class="bw-value-inline">{{ bandwidth.recent_ingress_mbps }}</span>
|
||||
<span class="bw-sub">{{ formatBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<div class="bw-metric bw-metric--inline">
|
||||
<span class="bw-label">自启动平均入站 · Mbps</span>
|
||||
<span class="bw-value-inline">{{ bandwidth.avg_ingress_mbps }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card style="margin-top: 20px">
|
||||
<template #header>
|
||||
<span>快捷入口</span>
|
||||
@@ -41,7 +96,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { getStats } from '../api/admin'
|
||||
|
||||
const stats = reactive({
|
||||
@@ -52,13 +107,51 @@ const stats = reactive({
|
||||
files: 0
|
||||
})
|
||||
|
||||
const bandwidth = ref(null)
|
||||
const bwFetchAt = ref(0)
|
||||
|
||||
const bwUpdatedAt = computed(() => {
|
||||
if (!bwFetchAt.value) return ''
|
||||
const d = new Date(bwFetchAt.value)
|
||||
return `更新于 ${d.toLocaleTimeString()}`
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n == null || !Number.isFinite(n) || n < 0) return '—'
|
||||
if (n < 1024) return `${n} B`
|
||||
const u = ['KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = -1
|
||||
do {
|
||||
v /= 1024
|
||||
i++
|
||||
} while (v >= 1024 && i < u.length - 1)
|
||||
return `${v < 10 ? v.toFixed(2) : v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
|
||||
function formatUptime(sec) {
|
||||
if (sec == null || !Number.isFinite(sec) || sec < 0) return '—'
|
||||
const s = Math.floor(sec)
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
if (h > 0) return `${h} 小时 ${m} 分`
|
||||
if (m > 0) return `${m} 分 ${r} 秒`
|
||||
return `${r} 秒`
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getStats()
|
||||
Object.assign(stats, res)
|
||||
const { bandwidth: bw, ...rest } = res
|
||||
Object.assign(stats, rest)
|
||||
if (bw && typeof bw === 'object') {
|
||||
bandwidth.value = bw
|
||||
bwFetchAt.value = Date.now()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取统计失败:', e)
|
||||
// 即使失败也显示 0,不阻塞页面
|
||||
@@ -67,7 +160,30 @@ const fetchStats = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchStats)
|
||||
let pollTimer = null
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
pollTimer = window.setInterval(() => {
|
||||
getStats()
|
||||
.then((res) => {
|
||||
const { bandwidth: bw, ...rest } = res
|
||||
Object.assign(stats, rest)
|
||||
if (bw && typeof bw === 'object') {
|
||||
bandwidth.value = bw
|
||||
bwFetchAt.value = Date.now()
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, 8000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -85,4 +201,58 @@ onMounted(fetchStats)
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.bw-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bw-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bw-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bw-row--second {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.bw-metric {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.bw-metric--inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 12px;
|
||||
border-bottom: none;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.bw-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.bw-metric--inline .bw-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bw-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.bw-value.accent {
|
||||
color: #409eff;
|
||||
}
|
||||
.bw-value-inline {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
.bw-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,14 +56,22 @@
|
||||
</el-card>
|
||||
|
||||
<!-- 上传前选择是否可下载 -->
|
||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="400px" :close-on-click-modal="false">
|
||||
<el-form label-width="100px">
|
||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
|
||||
<p class="upload-resume-hint">≥8MB 将自动分片上传;中断后<strong>同一文件</strong>再次选择上传可续传(勿改文件名/大小)。</p>
|
||||
<el-form label-width="112px">
|
||||
<el-form-item label="当前目录">
|
||||
<span>{{ currentPath || '根目录' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="保留原文件名">
|
||||
<el-switch v-model="uploadPreserveFilename" />
|
||||
<span class="form-hint">开启后将按原文件名保存,同名文件会被覆盖</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许下载">
|
||||
<el-switch v-model="uploadDownloadable" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
|
||||
<el-progress :percentage="uploadPercent" :stroke-width="16" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
@@ -89,7 +97,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
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 siteId = ref('')
|
||||
@@ -99,8 +108,10 @@ const subDirs = ref([])
|
||||
const loading = ref(false)
|
||||
const currentPath = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadPercent = ref(0)
|
||||
const uploadDialogVisible = ref(false)
|
||||
const uploadDownloadable = ref(true)
|
||||
const uploadDownloadable = ref(false)
|
||||
const uploadPreserveFilename = ref(false)
|
||||
const pendingFile = ref(null)
|
||||
const showNewFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
@@ -152,7 +163,9 @@ watch([siteId, currentPath], fetchList)
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
pendingFile.value = file
|
||||
uploadDownloadable.value = true
|
||||
const p = (currentPath.value || '').replace(/^\//, '')
|
||||
uploadPreserveFilename.value = p.startsWith('promotion/')
|
||||
uploadDownloadable.value = !uploadPreserveFilename.value
|
||||
uploadDialogVisible.value = true
|
||||
return false
|
||||
}
|
||||
@@ -160,11 +173,22 @@ const beforeUpload = (file) => {
|
||||
const doUpload = async () => {
|
||||
if (!pendingFile.value || !siteId.value) return
|
||||
uploading.value = true
|
||||
uploadPercent.value = 0
|
||||
try {
|
||||
await uploadSiteAsset(siteId.value, pendingFile.value, {
|
||||
folder: currentPath.value || undefined,
|
||||
downloadable: uploadDownloadable.value
|
||||
})
|
||||
await uploadSiteAssetWithResume(
|
||||
siteId.value,
|
||||
pendingFile.value,
|
||||
{
|
||||
folder: currentPath.value || undefined,
|
||||
downloadable: uploadDownloadable.value,
|
||||
preserveFilename: uploadPreserveFilename.value
|
||||
},
|
||||
{
|
||||
onProgress: ({ percent }) => {
|
||||
uploadPercent.value = percent
|
||||
}
|
||||
}
|
||||
)
|
||||
ElMessage.success('上传成功')
|
||||
uploadDialogVisible.value = false
|
||||
pendingFile.value = null
|
||||
@@ -173,6 +197,7 @@ const doUpload = async () => {
|
||||
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadPercent.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,8 +241,16 @@ onMounted(() => fetchSites().then(() => fetchList()))
|
||||
|
||||
<style scoped>
|
||||
.file-manage .tip { color: #666; font-size: 14px; }
|
||||
.form-hint { display: block; margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.4; }
|
||||
.form-hint code { font-size: 11px; }
|
||||
.module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.breadcrumb-wrap { margin-top: 12px; }
|
||||
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
|
||||
.subdirs .label { margin-right: 8px; }
|
||||
.upload-resume-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal file
91
admin/src/views/settings/ChunkUploadCleanup.vue
Normal 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">6~336,默认 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">5~1440,默认 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>
|
||||
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal file
154
admin/src/views/settings/YuhengCloudAccountManage.vue
Normal 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>
|
||||
@@ -17,13 +17,13 @@
|
||||
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form">
|
||||
<el-divider content-position="left">导航与标题</el-divider>
|
||||
<el-form-item label="Logo 文案">
|
||||
<el-input v-model="form.logo_text" placeholder="YUHENG ONE" />
|
||||
<el-input v-model="form.logo_text" placeholder="宇恒一号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="主标题">
|
||||
<el-input v-model="form.title" placeholder="宇恒一号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="副标题">
|
||||
<el-input v-model="form.subtitle" placeholder="INTERSTELLAR EXPLORER EDITION" />
|
||||
<el-input v-model="form.subtitle" placeholder="可选,前台大标题区已精简" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
|
||||
@@ -50,51 +50,77 @@
|
||||
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">下载按钮</el-divider>
|
||||
<el-form-item label="按钮文案">
|
||||
<el-input v-model="form.download_text" placeholder="START EXPLORING" />
|
||||
<el-divider content-position="left">侧栏下载(Windows / 安卓直链)</el-divider>
|
||||
<p class="builder-tip" style="margin: -6px 0 12px">
|
||||
填写同域可下载的静态地址(需将安装包放到站点 <code>promotion/downloads/</code> 并部署)。前台为「Windows 版下载」「安卓版下载」直连,不跳转整页。
|
||||
</p>
|
||||
<el-form-item label="Windows 安装包">
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
|
||||
<el-input
|
||||
v-model="form.download_windows_url"
|
||||
placeholder="/promotion/downloads/yuheng-windows.zip"
|
||||
style="flex: 1; min-width: 160px"
|
||||
/>
|
||||
<el-button type="primary" link @click="openLinkPicker({ type: 'download_windows' })">选择链接</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="按钮链接">
|
||||
<el-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%">
|
||||
<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-link
|
||||
v-if="previewReady(form.download_url)"
|
||||
type="primary"
|
||||
:href="previewHref(form.download_url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>试跳</el-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">平台(轨道)</el-divider>
|
||||
<el-form-item label="平台列表">
|
||||
<el-form-item label="平台轨道(可选)">
|
||||
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
|
||||
<el-input v-model="p.name" placeholder="如 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-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>
|
||||
</div>
|
||||
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加平台</el-button>
|
||||
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">版本与徽章</el-divider>
|
||||
<el-divider content-position="left">版本与徽章(前台已隐藏主视觉条,可留空)</el-divider>
|
||||
<el-form-item label="版本">
|
||||
<el-input v-model="form.version" placeholder="VERSION 3.2.1" style="width: 200px" />
|
||||
<el-input v-model="form.version" placeholder="可留空" style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发射年份">
|
||||
<el-input v-model="form.launch_year" placeholder="LAUNCH: 2024" style="width: 200px" />
|
||||
<el-form-item label="发布说明">
|
||||
<el-input v-model="form.launch_year" placeholder="发布日期:以官网为准" style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="徽章文案">
|
||||
<el-input v-model="form.badge_text" placeholder="FREE ACCESS" style="width: 200px" />
|
||||
<el-input v-model="form.badge_text" placeholder="完全免费" style="width: 200px" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">特性卡片</el-divider>
|
||||
@@ -108,12 +134,12 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="页脚文案">
|
||||
<el-input v-model="form.footer_text" placeholder="© 2024 YUHENG ONE" />
|
||||
<el-input v-model="form.footer_text" placeholder="© 2024 宇恒一号 · 成都宇信达智能科技有限公司" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">首页下方扩展区(可视化积木,可拖拽排序)</el-divider>
|
||||
<p class="builder-tip">
|
||||
与「网页管理 → 积木」相同:从左侧手柄拖拽调整模块顺序。保存后内容显示在落地页主视觉与特性卡片<strong>之后</strong>、页脚之前。留空则不显示扩展区。
|
||||
与「网页管理 → 积木」相同:从左侧手柄拖拽调整模块顺序。保存后内容显示在落地页主体模块<strong>之后</strong>、页脚之前。留空则不显示扩展区。
|
||||
</p>
|
||||
<el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap">
|
||||
<PageBuilderEditor v-model="form.body_builder" :site-id="siteId" />
|
||||
@@ -145,34 +171,36 @@ const saving = ref(false)
|
||||
const downloading = ref(false)
|
||||
const formRef = ref(null)
|
||||
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 defaultForm = () => ({
|
||||
logo_text: 'YUHENG ONE',
|
||||
nav_links: [{ label: 'MISSION', url: '#' }, { label: 'DOWNLOAD', url: '#' }, { label: 'CONTACT', url: '#' }],
|
||||
title: '宇恒一号',
|
||||
subtitle: 'INTERSTELLAR EXPLORER EDITION',
|
||||
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
|
||||
download_text: 'START EXPLORING',
|
||||
download_url: '#',
|
||||
platforms: [
|
||||
{ name: 'WINDOWS', url: '#' },
|
||||
{ name: 'MACOS', url: '#' },
|
||||
{ name: 'LINUX', url: '#' },
|
||||
{ name: 'IOS', url: '#' },
|
||||
{ name: 'ANDROID', url: '#' }
|
||||
logo_text: '宇恒一号',
|
||||
nav_links: [
|
||||
{ label: '产品简介', url: '#intro' },
|
||||
{ label: '产品视频', url: '#videos' },
|
||||
{ label: '联系我们', url: '#contact' }
|
||||
],
|
||||
version: 'VERSION 3.2.1',
|
||||
launch_year: 'LAUNCH: 2024',
|
||||
badge_text: 'FREE ACCESS',
|
||||
title: '宇恒一号',
|
||||
subtitle: '',
|
||||
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
|
||||
download_text: '下载',
|
||||
download_url: '#',
|
||||
download_windows_url: '/promotion/downloads/yuheng-windows.zip',
|
||||
download_android_url: '/promotion/downloads/yuheng-android.apk',
|
||||
platforms: [],
|
||||
version: '',
|
||||
launch_year: '发布日期:以官网为准',
|
||||
badge_text: '完全免费',
|
||||
features: [
|
||||
{ title: '星际导航', desc: '先进的AI导航系统,精准定位您的需求,引领探索之旅' },
|
||||
{ title: '星际导航', desc: '先进的 AI 导航系统,精准定位您的需求,引领探索之旅' },
|
||||
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
|
||||
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
|
||||
],
|
||||
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE',
|
||||
body_builder: ''
|
||||
footer_text: '© 2024 宇恒一号 · 成都宇信达智能科技有限公司',
|
||||
body_builder: '',
|
||||
live_room_url: '',
|
||||
live_room_title: '视频直播'
|
||||
})
|
||||
|
||||
const form = reactive(defaultForm())
|
||||
@@ -205,12 +233,19 @@ const fetchData = async () => {
|
||||
if (!siteId.value) return
|
||||
try {
|
||||
const data = await getHomepage(siteId.value)
|
||||
const base = defaultForm()
|
||||
Object.assign(form, {
|
||||
...defaultForm(),
|
||||
...base,
|
||||
...data,
|
||||
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : defaultForm().nav_links,
|
||||
platforms: Array.isArray(data.platforms) && data.platforms.length ? data.platforms : defaultForm().platforms,
|
||||
features: Array.isArray(data.features) && data.features.length ? data.features : defaultForm().features
|
||||
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : base.nav_links,
|
||||
platforms: Array.isArray(data.platforms) ? data.platforms : base.platforms,
|
||||
features: Array.isArray(data.features) && data.features.length ? data.features : base.features,
|
||||
download_windows_url: data.download_windows_url || base.download_windows_url,
|
||||
download_android_url: data.download_android_url || base.download_android_url,
|
||||
live_room_url: typeof data.live_room_url === 'string' ? data.live_room_url : base.live_room_url,
|
||||
live_room_title: (typeof data.live_room_title === 'string' && data.live_room_title.trim())
|
||||
? data.live_room_title.trim()
|
||||
: base.live_room_title
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message)
|
||||
@@ -264,6 +299,10 @@ function onLinkPicked(url) {
|
||||
form.nav_links[t.index].url = url
|
||||
} else if (t.type === 'download') {
|
||||
form.download_url = url
|
||||
} else if (t.type === 'download_windows') {
|
||||
form.download_windows_url = url
|
||||
} else if (t.type === 'download_android') {
|
||||
form.download_android_url = url
|
||||
} else if (t.type === 'platform' && typeof t.index === 'number') {
|
||||
form.platforms[t.index].url = url
|
||||
}
|
||||
|
||||
827
admin/src/views/sites/LiveBroadcast.vue
Normal file
827
admin/src/views/sites/LiveBroadcast.vue
Normal 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>
|
||||
@@ -43,7 +43,8 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
|
||||
import { getSites, getSiteAssets, deleteSiteAsset } from '../../api/admin'
|
||||
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||
|
||||
const siteId = ref('')
|
||||
const sites = ref([])
|
||||
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
|
||||
const beforeUpload = async (file) => {
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadSiteAsset(siteId.value, file)
|
||||
await uploadSiteAssetWithResume(siteId.value, file, {})
|
||||
ElMessage.success('上传成功')
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editId ? '编辑网页' : '新增网页'"
|
||||
:width="form.content_mode === 'builder' ? '1080px' : '720px'"
|
||||
:width="form.content_mode === 'builder' || form.content_mode === 'html' ? '1080px' : '720px'"
|
||||
top="4vh"
|
||||
@close="resetForm"
|
||||
>
|
||||
@@ -63,7 +63,15 @@
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
title="积木模式:从模块左侧 ⋮⋮ 手柄拖拽排序;链接可点「选择链接」选站内页或文件。"
|
||||
title="积木模式:左侧编辑、右侧实时预览;⋮⋮ 可拖拽排序;链接可「选择链接」。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="form.content_mode === 'html'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
title="HTML 模式:左侧源码、右侧预览(沙箱内不执行脚本,与线上可能略有差异)。"
|
||||
/>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="Slug" prop="slug">
|
||||
@@ -91,8 +99,21 @@
|
||||
<el-form-item label="发布到前台">
|
||||
<el-switch v-model="form.published" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content">
|
||||
<el-input v-model="form.content" type="textarea" :rows="14" placeholder="直接编写 HTML" />
|
||||
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content" class="html-form-item">
|
||||
<div class="html-split">
|
||||
<div class="html-editor">
|
||||
<el-input v-model="form.content" type="textarea" :rows="18" placeholder="直接编写 HTML" />
|
||||
</div>
|
||||
<div class="html-preview-wrap">
|
||||
<div class="html-preview-title">实时预览</div>
|
||||
<iframe
|
||||
class="html-preview-iframe"
|
||||
title="html-preview"
|
||||
sandbox=""
|
||||
:srcdoc="htmlPreviewSrcdoc"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="页面积木" class="builder-form-item page-builder-wrap">
|
||||
<PageBuilderEditor v-model="form.content" :site-id="siteId" />
|
||||
@@ -107,7 +128,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getSites } from '../../api/admin'
|
||||
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
|
||||
@@ -215,6 +236,13 @@ const form = reactive({
|
||||
published: true
|
||||
})
|
||||
|
||||
/** HTML 预览 iframe(沙箱禁用脚本,避免编辑时执行恶意片段) */
|
||||
const htmlPreviewSrcdoc = computed(() => {
|
||||
const raw = form.content || ''
|
||||
const body = raw.trim() ? raw : '<p style="color:#999">暂无内容</p>'
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:system-ui,sans-serif;padding:12px;margin:0;background:#fff;color:#222;line-height:1.5;}</style></head><body>${body}</body></html>`
|
||||
})
|
||||
|
||||
function insertBuilderTemplate() {
|
||||
form.content_mode = 'builder'
|
||||
if (!form.content?.trim()) {
|
||||
@@ -314,4 +342,43 @@ onMounted(() => {
|
||||
.page-builder-wrap {
|
||||
max-width: 100%;
|
||||
}
|
||||
.html-form-item :deep(.el-form-item__content) {
|
||||
display: block;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
.html-split {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
.html-editor {
|
||||
flex: 1 1 400px;
|
||||
min-width: 280px;
|
||||
}
|
||||
.html-preview-wrap {
|
||||
flex: 0 1 420px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
.html-preview-title {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
.html-preview-iframe {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
height: 420px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/admin/',
|
||||
resolve: {
|
||||
alias: {
|
||||
// 与前台共用积木渲染,避免重复维护
|
||||
'@yh-web': path.resolve(__dirname, '../web/src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
@@ -12,7 +22,8 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'https://yuheng.yuxindazhineng.com',
|
||||
changeOrigin: true,
|
||||
secure: true
|
||||
secure: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
# deploy 目录(挂目录 + 替换文件部署)
|
||||
# deploy 目录(与 api 相同:仅替换构建产物;`web` 容器除 `web/public` 外不挂源码目录)
|
||||
|
||||
- **deploy/web/dist**:前台构建产物,由 `pull-and-restart.sh` 生成;替换此目录内容即可更新前台。
|
||||
- **deploy/admin/dist**:后台构建产物,同上。
|
||||
- **deploy/admin/dist**:后台构建产物,同上。后台 Vite 通过 `@yh-web` 引用 `../web/src`(如积木 `BlockRenderer`),用 Docker 单目录挂载 `admin` 时会构建失败,须挂载**项目根**再在 `admin` 下执行 `npm run build`(见 `pull-and-restart.sh`)。
|
||||
- **deploy/api/server**:API 二进制,同上;替换后重启 api 容器生效。
|
||||
- **deploy/web/default.conf**、**deploy/admin/default.conf**:Nginx 配置,已纳入版本库。
|
||||
|
||||
日常更新:在服务器执行 `./pull-and-restart.sh` 会拉代码、重新构建到上述目录并重启容器。若只改静态资源,也可在服务器上手动构建后只重启对应容器。
|
||||
|
||||
## 后台白屏 / 控制台 “MIME type text/html” 针对 `index-*.js`
|
||||
|
||||
表示浏览器拿到的不是 JS,而是 HTML(常见:`/assets/*.js` 被 SPA 回退成 `index.html`,或 404 返回了 HTML 错误页)。
|
||||
|
||||
1. **确认 Nginx 配置已更新**:`deploy/admin/default.conf` 须含 `location ^~ /assets/` 且 `try_files $uri =404`(与仓库内 `admin/nginx.conf` 一致),挂载后重启 `admin` 容器。
|
||||
2. **确认 dist 完整**:`deploy/admin/dist/assets/` 下须有与 `index.html` 中 `<script type="module">` 引用**同名**的哈希文件;发版后应**整目录**替换 `dist`(勿只拷 `index.html`)。
|
||||
3. **本地重建**:在项目根按 `pull-and-restart.sh` 方式在 `admin/` 执行 `npm run build`,`vite.config` 中 `base` 须为 `'/admin/'`。
|
||||
4. **勿用旧版 `nginx/admin.conf`**:若曾把仅含 `location /` 的旧配置拷到服务器,会导致 `/assets/*.js` 全部变成 `index.html`(约 640B、MIME 错)。请以 **`deploy/admin/default.conf`** 或 **`admin/nginx.conf`** 为准,并 **`docker compose restart admin nginx`**。
|
||||
5. **外层 `/admin/` 反代**:`yuheng.docker.conf.tpl` 使用 **`upstream yh_admin_upstream { server admin:80; }`** + **`location /admin/ { proxy_pass http://yh_admin_upstream/; }`**(尾斜杠),由 Nginx **标准规则**去掉 `/admin` 前缀;勿对 admin 使用 **变量** `proxy_pass`(会把完整 `/admin/...` 传到上游 → 内层无法匹配 `/assets/` → 白屏)。另含 **`location = /admin { return 301 /admin/; }`**,避免无尾斜杠误走前台。
|
||||
6. **`restart.sh` 构建 admin** 已与 **`pull-and-restart.sh` 一致**(挂载**项目根**到容器,否则 `@yh-web` 无法解析)。发版后脚本会执行 **`scripts/verify-admin-dist.sh`**:若 `index.html` 引用的 chunk 在 `dist/assets/` 中缺失或过小(几百字节),会直接报错退出,避免白屏上线。
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
# 与 admin/nginx.conf 保持内容一致(Compose 挂载本文件;admin 镜像内 COPY 同配置)
|
||||
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 发版后勿长期缓存入口,否则浏览器保留旧 index.html、却拉新 chunk 名 → 白屏
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
# 与 nginx/web.conf 保持同步;compose 挂载到 web 容器
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 根路径下的验证文件走热加载目录,替换文件即可生效
|
||||
location ~ ^/([A-Za-z0-9._-]+\.(txt|html|xml))$ {
|
||||
alias /verify-root/$1;
|
||||
# 域名/微信等验证文件:由外层 yh_nginx(443)直接 root /verify-root 提供,本容器不再挂载 verify-root
|
||||
|
||||
# 静态资源必须真实存在,避免错误回退成 index.html 导致白屏
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA 入口:勿长期缓存,否则发版后用户仍可能拿到旧 index
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
}
|
||||
|
||||
# web/public 挂载到 /var/www/yh-public:与 dist 根目录同 URL(如 /logo.png),优先读挂载,无则回退 dist
|
||||
location ~ ^/([^/]+\.(?:png|jpe?g|gif|ico|svg|webp|webmanifest))$ {
|
||||
root /var/www/yh-public;
|
||||
try_files /$1 @dist_root_public;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
location @dist_root_public {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
# web/public/social/ → 关注我们二维码等(挂载 /var/www/yh-public)
|
||||
location ^~ /social/ {
|
||||
alias /var/www/yh-public/social/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
# 推广素材:来自构建产物 deploy/web/dist/promotion(pull-and-restart 从 web/promotion rsync);后台上传走 /api/web/.../promotion-media/
|
||||
location ^~ /promotion/ {
|
||||
try_files $uri =404;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
location = / {
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
# Vue SPA:直接访问 /test 等路径须落到 index.html,否则会 nginx 404
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
20
docker-compose.host-nginx.yml
Normal file
20
docker-compose.host-nginx.yml
Normal 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
|
||||
@@ -28,14 +28,14 @@ services:
|
||||
networks:
|
||||
- yh_net
|
||||
|
||||
# 静态文件由脚本构建到 deploy/web/dist,挂载后替换文件即可生效
|
||||
# 静态文件仅 deploy/web/dist;与 api 一致不挂源码目录。仅额外挂载 web/public(logo、social 二维码等)
|
||||
web:
|
||||
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
|
||||
container_name: yh_web
|
||||
volumes:
|
||||
- ./deploy/web/dist:/usr/share/nginx/html:ro
|
||||
- ./web/public:/var/www/yh-public:ro
|
||||
- ./deploy/web/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./verify-root:/verify-root:ro
|
||||
networks:
|
||||
- yh_net
|
||||
|
||||
@@ -54,13 +54,21 @@ services:
|
||||
container_name: yh_nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
# 启动脚本:等上游 → 从 resolv.conf 注入 resolver → 生成 conf.d(变量 proxy_pass),避免 Podman host not found
|
||||
entrypoint: ["/bin/sh", "/nginx-entrypoint-wait-dns.sh"]
|
||||
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
|
||||
depends_on:
|
||||
- api
|
||||
- web
|
||||
- admin
|
||||
# Podman/慢盘:API 首次就绪可能超过 90s,避免 yh_nginx 等待超时后 Exited(1) → 全站 443 拒绝连接
|
||||
environment:
|
||||
- NGINX_WAIT_UPSTREAM_SEC=180
|
||||
networks:
|
||||
- yh_net
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
||||
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
||||
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、**按住左侧手柄拖拽**调整顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、**按住左侧手柄拖拽**调整顺序、配置动画);编辑区右侧为 **实时预览**(与前台共用 `BlockRenderer`)。链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||
- **HTML 模式**:网页管理弹窗内 **左侧源码、右侧 iframe 预览**(沙箱不执行脚本)。
|
||||
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||
|
||||
## 动态路由
|
||||
|
||||
@@ -56,7 +56,7 @@ sudo systemctl reload nginx
|
||||
|
||||
**/api/health 或 /admin/ 返回 404 时**:在服务器执行 `ss -tlnp | grep 443`,看 443 是宿主机 nginx 还是 docker。若是宿主机 nginx,要么停用该站点配置让 compose 独占 443(方式 A),要么改为方式 B(compose 用 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
|
||||
|
||||
@@ -79,3 +79,18 @@ sudo systemctl start nginx
|
||||
- **原因**:提供静态文件的 `server` 未把「不存在的路径」交给 `index.html`,Nginx 在磁盘上找不到 `some-page` 文件就返回 404。
|
||||
- **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf` 与 `web/Dockerfile` 内嵌配置)。若你自建 Nginx,请对照修改后再 `nginx -t` 并重载。
|
||||
- **应用内 404**:在 SPA 已正确回退的前提下,未在后台发布的路径会由前端路由进入 **「页面不存在」** 页(`NotFound.vue`),与上述 nginx 404 不同。
|
||||
- **Compose 部署**:`web` 容器实际加载的是 **`deploy/web/default.conf`**(见 `docker-compose.yml` 挂载)。若线上仍对 `/test` 等返回 **nginx 404**,请把仓库里最新的 `deploy/web/default.conf` 同步到服务器对应路径后,执行 `docker compose restart web`(或重建 `yh_web` 容器)。
|
||||
|
||||
## 6. 单实例:宿主机 Nginx 占 443(与 `pull-and-restart.sh` / `restart.sh` 自动切换)
|
||||
|
||||
逻辑由 **`scripts/lib-yh-compose-deploy.sh`** 统一处理(无需单独启动脚本):
|
||||
|
||||
1. **启动前**:`docker compose … down --remove-orphans`,只停本项目容器,**不停止**宿主机 `nginx`。
|
||||
2. **写入宿主机站点配置**:从 **`nginx/yuheng.host.conf`** 生成 `/etc/nginx/conf.d/<域名>.conf`,并执行 **`nginx -t`**。
|
||||
3. **检测宿主机 Nginx**:若在线则跳过;若不在线则执行 `systemctl start nginx` 并 `enable`。
|
||||
4. **启动容器**:只起 `mongo api web admin`(不再启动容器 `yh_nginx`)。
|
||||
5. **证书**:同上,`/etc/ssl/yh_web/yuheng.yuxindazhineng.com/`;`pull-and-restart.sh` / `restart.sh` 仍会同步仓库内证书到该目录。
|
||||
|
||||
**回环端口**(与 `nginx/yuheng.host.conf` 一致):API `127.0.0.1:8088`,前台 `9080`,后台 `9081`。
|
||||
|
||||
**说明**:不再使用容器 `yh_nginx` 作为入口,统一为宿主机 Nginx 单入口方案。
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
# 供 compose 中 admin 容器使用:宿主机挂载 admin/dist,SPA 回退
|
||||
# 外层 Nginx 把 /admin/ 转成 / 转发到本容器
|
||||
# 与 deploy/admin/default.conf、admin/nginx.conf 保持一致(勿再使用仅含 location / 的旧版,否则 /assets/*.js 会回退成 index.html → 白屏)
|
||||
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
}
|
||||
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name yuheng.yuxindazhineng.com;
|
||||
# 与宿主机 yuheng.host.conf 一致,避免后台大文件上传被默认 1m 拒绝
|
||||
client_max_body_size 800m;
|
||||
# 若使用 HTTPS,取消下面注释并挂载证书到 /etc/nginx/ssl/
|
||||
# listen 443 ssl;
|
||||
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
location /api/web/live/ws {
|
||||
proxy_pass http://api:9527;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/web/live/danmaku/ws {
|
||||
proxy_pass http://api:9527;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:9527;
|
||||
proxy_http_version 1.1;
|
||||
@@ -13,6 +39,12 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 75s;
|
||||
client_body_timeout 0;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
@@ -31,5 +63,8 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 75s;
|
||||
proxy_read_timeout 75s;
|
||||
}
|
||||
}
|
||||
|
||||
0
nginx/runtime-confd/.gitkeep
Normal file
0
nginx/runtime-confd/.gitkeep
Normal file
@@ -1,11 +1,45 @@
|
||||
# 供 compose 中 web 容器使用:宿主机挂载 web/dist 与 verify-root,仅提供静态与 SPA 回退
|
||||
# 供 compose 中 web 容器使用:与 deploy/web/default.conf 同步;验证文件仅外层 yh_nginx 处理
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ~ ^/([A-Za-z0-9._-]+\.(txt|html|xml))$ {
|
||||
alias /verify-root/$1;
|
||||
location ^~ /assets/ {
|
||||
try_files $uri =404;
|
||||
access_log off;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
expires -1;
|
||||
}
|
||||
|
||||
# web/public 挂载 /var/www/yh-public;单段文件名同 dist 根 URL,优先挂载后回退 dist
|
||||
location ~ ^/([^/]+\.(?:png|jpe?g|gif|ico|svg|webp|webmanifest))$ {
|
||||
root /var/www/yh-public;
|
||||
try_files /$1 @dist_root_public;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
location @dist_root_public {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri =404;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
location ^~ /social/ {
|
||||
alias /var/www/yh-public/social/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
location ^~ /promotion/ {
|
||||
try_files $uri =404;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
}
|
||||
|
||||
location = / {
|
||||
|
||||
@@ -1,43 +1,5 @@
|
||||
# 供 docker-compose 中 nginx 使用:仅监听 443,反代到 api/web/admin;证书挂载到 /etc/ssl/yh_web/yuheng.yuxindazhineng.com/
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name yuheng.yuxindazhineng.com;
|
||||
client_max_body_size 200m;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
# 已迁移:实际运行使用 yuheng.docker.conf.tpl
|
||||
# 启动时由 scripts/nginx-entrypoint-wait-dns.sh 将 resolv.conf 中的 nameserver 注入为 resolver,
|
||||
# 并配合变量 proxy_pass,避免 Docker/Podman 下「upstream OK」后仍出现 host not found in upstream "api"。
|
||||
#
|
||||
# 修改 HTTPS 反代请编辑:nginx/yuheng.docker.conf.tpl
|
||||
|
||||
101
nginx/yuheng.docker.conf.tpl
Normal file
101
nginx/yuheng.docker.conf.tpl
Normal 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 + resolver(Podman 下动态解析)。
|
||||
# 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
109
nginx/yuheng.host.conf
Normal 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_module);proxy_* 为反代到 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;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ server {
|
||||
|
||||
# HTTPS:整站反代到 compose 内 Nginx(宿主机 443 → 127.0.0.1:8443)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
# 与 yuheng.host.conf 一致:大文件/分片上传在 HTTP/1.1 下更稳
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name yuheng.yuxindazhineng.com;
|
||||
|
||||
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
|
||||
@@ -24,6 +25,31 @@ server {
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
|
||||
# 直播 WebSocket 信令(经 compose 内 Nginx 再到 api)
|
||||
location /api/web/live/ws {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/web/live/danmaku/ws {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8443;
|
||||
proxy_http_version 1.1;
|
||||
@@ -31,5 +57,9 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 75s;
|
||||
proxy_read_timeout 75s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,9 +269,14 @@ else
|
||||
rm -rf "$tmp_backup"
|
||||
fi
|
||||
|
||||
# 拉取后把 .env.example 里新增的键自动追加到 server/.env(无需手改,如 YH_IMPORT_PROMOTION_SITE_ID)
|
||||
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
|
||||
[ -f server/.env ] && sed -i 's/\r$//' server/.env
|
||||
[ -f server/.env ] && set -a && source server/.env && set +a
|
||||
|
||||
echo ""
|
||||
echo "[2/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
|
||||
echo "错误: 当前 docker-compose.yml 仍含 9527,会与 sshd 冲突导致启动失败。请以 Gitea 为准拉取最新代码后再执行本脚本:" >&2
|
||||
echo " git fetch origin && git reset --hard origin/master" >&2
|
||||
@@ -288,8 +293,23 @@ echo "构建 web 前端 -> deploy/web/dist ..."
|
||||
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
|
||||
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||
|
||||
# 官网访问的是 Nginx 根目录 deploy/web/dist;产品视频已放在 social/ 英文文件名,须整目录同步(含 .mov)
|
||||
echo "同步 web/promotion -> deploy/web/dist/promotion(排除旧「视频发布」与 PPT 解压,避免重复大文件)..."
|
||||
mkdir -p "$ROOT/deploy/web/dist/promotion"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
|
||||
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
|
||||
else
|
||||
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
|
||||
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
|
||||
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
|
||||
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
|
||||
echo "提示: 未检测到 rsync,仅复制了 social/logo 等;请安装 rsync 以同步完整 promotion(含视频)。" >&2
|
||||
fi
|
||||
|
||||
echo "构建 admin 前端 -> deploy/admin/dist ..."
|
||||
run_sudo docker run --rm -v "$ROOT/admin:/app" -v "$ROOT/deploy/admin/dist:/out" -w /app \
|
||||
# admin 的 vite 别名 @yh-web -> ../web/src,须挂载项目根,否则容器内无 web 目录会报 BlockRenderer.vue ENOENT
|
||||
run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
|
||||
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||
|
||||
echo "构建 api 二进制 -> deploy/api/server ..."
|
||||
@@ -303,6 +323,8 @@ if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dis
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
|
||||
|
||||
# 仅构建 api 运行时镜像(轻量,无业务代码);web/admin 使用官方 nginx 镜像无需构建
|
||||
compose_cmd build api
|
||||
|
||||
@@ -332,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 600 "$NGINX_SSL_DIR/privkey.pem"
|
||||
fi
|
||||
compose_cmd down 2>/dev/null || true
|
||||
compose_cmd up -d --force-recreate
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
|
||||
# 先停掉本项目容器(不停止宿主机 nginx)→ 准备宿主机站点并确保 nginx 在线 → 启动业务容器
|
||||
yh_compose_down
|
||||
echo ""
|
||||
echo "[3/3] 证书与宿主机 Nginx(可选)..."
|
||||
NGINX_CONF_NAME="${NGINX_DOMAIN}.conf"
|
||||
if [ -f "$ROOT/nginx/$NGINX_CONF_NAME" ]; then
|
||||
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 "[3/3] 宿主机 Nginx 站点与服务..."
|
||||
yh_install_host_nginx_site_conf
|
||||
ensure_host_nginx_started
|
||||
yh_compose_up
|
||||
yh_post_deploy_healthcheck
|
||||
|
||||
# 可选:web/promotion/视频发布 -> data/uploads + MongoDB(须 server/.env 中 YH_IMPORT_PROMOTION_SITE_ID)
|
||||
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
|
||||
|
||||
echo ""
|
||||
echo "完成. 对外仅 443;反代: https://$NGINX_DOMAIN"
|
||||
|
||||
36
restart.sh
36
restart.sh
@@ -180,6 +180,7 @@ if [ ! -f server/.env ]; then
|
||||
echo "已创建默认 server/.env"
|
||||
fi
|
||||
fi
|
||||
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
|
||||
[ -f server/.env ] && sed -i 's/\r$//' server/.env 2>/dev/null || true
|
||||
[ -f server/.env ] && set -a && source server/.env && set +a
|
||||
|
||||
@@ -197,8 +198,21 @@ mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
|
||||
echo "构建 web 前端 -> deploy/web/dist ..."
|
||||
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
|
||||
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||
# 与 pull-and-restart 一致:文档根是 deploy/web/dist,须把 promotion(含 social 视频)拷入 dist
|
||||
echo "同步 web/promotion -> deploy/web/dist/promotion ..."
|
||||
mkdir -p "$ROOT/deploy/web/dist/promotion"
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
|
||||
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
|
||||
else
|
||||
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
|
||||
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
|
||||
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
|
||||
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
|
||||
fi
|
||||
echo "构建 admin 前端 -> deploy/admin/dist ..."
|
||||
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/"
|
||||
echo "构建 api 二进制 -> deploy/api/server ..."
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
|
||||
|
||||
compose_cmd build api
|
||||
|
||||
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
|
||||
@@ -220,7 +236,6 @@ fi
|
||||
|
||||
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
|
||||
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
|
||||
NGINX_CONF_NAME="${NGINX_DOMAIN}.conf"
|
||||
run_sudo mkdir -p "$NGINX_SSL_DIR"
|
||||
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then
|
||||
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.pem" "$NGINX_SSL_DIR/fullchain.pem"
|
||||
@@ -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 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
|
||||
fi
|
||||
compose_cmd down 2>/dev/null || true
|
||||
compose_cmd up -d --force-recreate
|
||||
# shellcheck disable=SC1091
|
||||
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
|
||||
yh_compose_down
|
||||
echo "宿主机 Nginx 站点与服务..."
|
||||
yh_install_host_nginx_site_conf
|
||||
ensure_host_nginx_started
|
||||
yh_compose_up
|
||||
yh_post_deploy_healthcheck
|
||||
|
||||
if [ -f "$ROOT/nginx/$NGINX_CONF_NAME" ]; then
|
||||
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
|
||||
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
|
||||
|
||||
echo "完成. 对外仅 443,反代: https://$NGINX_DOMAIN"
|
||||
|
||||
12
scripts/import-promotion-to-api.sh
Normal file
12
scripts/import-promotion-to-api.sh
Normal 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/ "$@"
|
||||
191
scripts/lib-yh-compose-deploy.sh
Normal file
191
scripts/lib-yh-compose-deploy.sh
Normal 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}"
|
||||
}
|
||||
30
scripts/merge-server-env-from-example.sh
Normal file
30
scripts/merge-server-env-from-example.sh
Normal 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
|
||||
123
scripts/nginx-entrypoint-wait-dns.sh
Normal file
123
scripts/nginx-entrypoint-wait-dns.sh
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/bin/sh
|
||||
# yh_nginx:1) 等 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;'
|
||||
75
scripts/run-promotion-import-on-deploy.sh
Normal file
75
scripts/run-promotion-import-on-deploy.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在部署(compose up)之后执行:将 web/promotion/视频发布 导入 data/uploads + MongoDB site_assets。
|
||||
# 触发条件:环境变量或 server/.env 中设置了 YH_IMPORT_PROMOTION_SITE_ID(Mongo 站点 _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 网络 $NET(COMPOSE_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-import:site=$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
|
||||
45
scripts/sync-video-assets-to-social.ps1
Normal file
45
scripts/sync-video-assets-to-social.ps1
Normal 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 ."
|
||||
58
scripts/sync-video-assets-to-social.sh
Normal file
58
scripts/sync-video-assets-to-social.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将旧目录「视频发布」中含中文路径的素材复制到 web/promotion/social/,使用与 promotionVideos.js 一致的英文文件名。
|
||||
# 用法:在项目根执行 ./scripts/sync-video-assets-to-social.sh
|
||||
# 完成后可设置权限(Linux):chmod -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)。"
|
||||
40
scripts/transcode-promotion-videos.ps1
Normal file
40
scripts/transcode-promotion-videos.ps1
Normal file
@@ -0,0 +1,40 @@
|
||||
# 将 web\promotion\social 下产品视频从 .mov 转为网页通用 .mp4(H.264 + AAC,faststart)
|
||||
# 依赖:已安装 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。请安装并加入 PATH:https://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
|
||||
40
scripts/transcode-promotion-videos.sh
Normal file
40
scripts/transcode-promotion-videos.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# 将 web/promotion/social 下产品视频从 .mov 转为网页通用 .mp4(H.264 + AAC,faststart)
|
||||
# 依赖:已安装 ffmpeg(macOS: brew install ffmpeg;Ubuntu: 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 以减小体积(可选)。"
|
||||
38
scripts/verify-admin-dist.sh
Normal file
38
scripts/verify-admin-dist.sh
Normal 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)"
|
||||
@@ -6,5 +6,15 @@ MONGODB_URI=mongodb://mongo:27017
|
||||
MONGODB_DB=yxd-agent-testing
|
||||
PORT=8088
|
||||
GIN_MODE=release
|
||||
# 对外域名(CORS、日志),与 nginx 反代域名一致
|
||||
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
||||
ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com
|
||||
|
||||
# 前台直播弹幕 JWT 签名密钥(生产环境务必自定义;不设置则使用内置默认值)
|
||||
# SITE_JWT_SECRET=your-long-random-secret
|
||||
|
||||
# 设为 1 时跳过 uploads 下 promotion 视频的 .mov→.mp4 转码(无 ffmpeg 时可临时关闭)
|
||||
# SKIP_PROMOTION_TRANSCODE=1
|
||||
|
||||
# 部署时自动导入「视频发布」到 data/uploads + site_assets(compose up 后执行)
|
||||
# 官网站点 MongoDB _id;pull-and-restart 会把本文件里「.env 缺失的键」自动追加到 server/.env,一般无需手改
|
||||
YH_IMPORT_PROMOTION_SITE_ID=69ba1f1f41aeb82acfd609ef
|
||||
|
||||
@@ -13,7 +13,8 @@ RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
|
||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
# 产品视频 .mov → .mp4 服务端转码(handlers/promotion_transcode.go)
|
||||
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --from=builder /app/server .
|
||||
EXPOSE 8088
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app,/app/server 由宿主机构建
|
||||
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||
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
|
||||
WORKDIR /app
|
||||
EXPOSE 8088
|
||||
|
||||
@@ -22,3 +22,8 @@ go run main.go
|
||||
```
|
||||
|
||||
默认端口 8080
|
||||
|
||||
## 推广视频转码(promotion 目录)
|
||||
|
||||
上传到 `sites/{site_id}/promotion/**.mov` 后,服务会异步转 **MP4**(需本机安装 **ffmpeg**,与 Docker 镜像一致)。启动时也会扫描遗留 `.mov` 补转码。详见 `handlers/promotion_transcode.go`。
|
||||
关闭:`SKIP_PROMOTION_TRANSCODE=1`。
|
||||
|
||||
33
server/cmd/promotion-import/README.md
Normal file
33
server/cmd/promotion-import/README.md
Normal 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` 立即可读。
|
||||
533
server/cmd/promotion-import/main.go
Normal file
533
server/cmd/promotion-import/main.go
Normal 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()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
@@ -18,6 +19,8 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.6 // indirect
|
||||
@@ -27,15 +30,34 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/pion/datachannel v1.5.8 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/ice/v2 v2.3.36 // indirect
|
||||
github.com/pion/interceptor v0.1.29 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.12 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.14 // indirect
|
||||
github.com/pion/rtp v1.8.7 // indirect
|
||||
github.com/pion/sctp v1.8.19 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||
github.com/pion/webrtc/v3 v3.3.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
|
||||
@@ -30,6 +30,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
@@ -39,6 +43,9 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
@@ -50,11 +57,52 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
||||
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
|
||||
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
||||
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -63,10 +111,15 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
@@ -85,16 +138,28 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -103,20 +168,36 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
@@ -124,6 +205,7 @@ google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cn
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -34,6 +35,37 @@ type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ParseClaimsFromTokenString 解析 Authorization Bearer 或裸 JWT;失败返回 false
|
||||
func ParseClaimsFromTokenString(tokenStr string) (*Claims, bool) {
|
||||
tokenStr = strings.TrimSpace(tokenStr)
|
||||
if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") {
|
||||
tokenStr = strings.TrimSpace(tokenStr[7:])
|
||||
}
|
||||
if tokenStr == "" {
|
||||
return nil, false
|
||||
}
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, false
|
||||
}
|
||||
return &claims, true
|
||||
}
|
||||
|
||||
// LivePublishAllowed 仅允许已登录后台账号发起 WebRTC 推流(与 AuthRequired 身份范围一致)
|
||||
func LivePublishAllowed(tokenStr string) bool {
|
||||
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Login 后台登录,仅 role_id=9527 超级管理员可登录
|
||||
func Login(c *gin.Context) {
|
||||
var input LoginInput
|
||||
@@ -122,25 +154,13 @@ func AuthRequired() gin.HandlerFunc {
|
||||
if tokenStr == "" {
|
||||
tokenStr = c.Query("token")
|
||||
}
|
||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||
tokenStr = tokenStr[7:]
|
||||
}
|
||||
if tokenStr == "" {
|
||||
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
|
||||
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
|
||||
@@ -164,25 +184,13 @@ func SuperUserAuthRequired() gin.HandlerFunc {
|
||||
if tokenStr == "" {
|
||||
tokenStr = c.Query("token")
|
||||
}
|
||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
||||
tokenStr = tokenStr[7:]
|
||||
}
|
||||
if tokenStr == "" {
|
||||
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var claims Claims
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 仅 role_id=9527 且 role=admin 可配置短信平台
|
||||
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})
|
||||
|
||||
163
server/handlers/chunk_upload_cleanup_config.go
Normal file
163
server/handlers/chunk_upload_cleanup_config.go
Normal 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": "保留时长须在 6~336 小时之间"})
|
||||
return
|
||||
}
|
||||
if input.SweepMinutes < 5 || input.SweepMinutes > 1440 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "扫描间隔须在 5~1440 分钟之间"})
|
||||
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": "配置已保存"})
|
||||
}
|
||||
@@ -47,6 +47,11 @@ func GetWebHomepage(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if config.MongoClient == nil {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
return
|
||||
}
|
||||
|
||||
siteID := getOfficialSiteID(ctx)
|
||||
if siteID == "" {
|
||||
c.JSON(http.StatusOK, defaultHomepageData())
|
||||
@@ -186,29 +191,27 @@ func DownloadHomepage(c *gin.Context) {
|
||||
|
||||
func defaultHomepageData() models.HomepageData {
|
||||
return models.HomepageData{
|
||||
LogoText: "YUHENG ONE",
|
||||
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}},
|
||||
LogoText: "宇恒一号",
|
||||
NavLinks: []models.NavLink{{Label: "产品简介", URL: "#intro"}, {Label: "产品视频", URL: "#videos"}, {Label: "联系我们", URL: "#contact"}},
|
||||
Title: "宇恒一号",
|
||||
Subtitle: "INTERSTELLAR EXPLORER EDITION",
|
||||
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙",
|
||||
DownloadText: "START EXPLORING",
|
||||
Subtitle: "",
|
||||
Description: "",
|
||||
DownloadText: "下载",
|
||||
DownloadURL: "#",
|
||||
Platforms: []models.PlatformItem{
|
||||
{Name: "WINDOWS", URL: "#"},
|
||||
{Name: "MACOS", URL: "#"},
|
||||
{Name: "LINUX", URL: "#"},
|
||||
{Name: "IOS", URL: "#"},
|
||||
{Name: "ANDROID", URL: "#"},
|
||||
},
|
||||
Version: "VERSION 3.2.1",
|
||||
LaunchYear: "LAUNCH: 2024",
|
||||
BadgeText: "FREE ACCESS",
|
||||
Platforms: []models.PlatformItem{},
|
||||
Version: "",
|
||||
LaunchYear: "发布日期:以官网为准",
|
||||
BadgeText: "完全免费",
|
||||
DownloadWindowsURL: "/promotion/downloads/yuheng-windows.zip",
|
||||
DownloadAndroidURL: "/promotion/downloads/yuheng-android.apk",
|
||||
Features: []models.FeatureItem{
|
||||
{Title: "星际导航", Desc: "先进的AI导航系统,精准定位您的需求,引领探索之旅"},
|
||||
{Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"},
|
||||
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
|
||||
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
|
||||
},
|
||||
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE",
|
||||
FooterText: "© 2024 宇恒一号 · 成都宇信达智能科技有限公司",
|
||||
LiveRoomURL: "",
|
||||
LiveRoomTitle: "视频直播",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,97 @@ func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)
|
||||
func promotionMimeType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
|
||||
func ServePromotionMedia(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
raw := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
if siteID == "" || raw == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Clean(raw))
|
||||
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
|
||||
return
|
||||
}
|
||||
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
|
||||
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
|
||||
relBack, err := filepath.Rel(baseDir, fullPath)
|
||||
if err != nil || strings.HasPrefix(relBack, "..") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
|
||||
return
|
||||
}
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(fullPath)
|
||||
ct := promotionMimeType(ext)
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", ct)
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.File(fullPath)
|
||||
}
|
||||
|
||||
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
|
||||
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
|
||||
name := originalFilename
|
||||
ext := filepath.Ext(name)
|
||||
nameNoExt := strings.TrimSuffix(name, ext)
|
||||
var saveName string
|
||||
if preserve {
|
||||
saveName = filepath.Base(name)
|
||||
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||||
return "", "", "无效的文件名"
|
||||
}
|
||||
} else {
|
||||
if len(ext) == 0 {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||||
} else {
|
||||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||
}
|
||||
}
|
||||
|
||||
folderClean := ""
|
||||
if folder != "" {
|
||||
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||||
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||||
return "", "", "无效的目录路径"
|
||||
}
|
||||
}
|
||||
|
||||
if folderClean != "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||||
} else {
|
||||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||||
}
|
||||
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||
return relPath, destPath, ""
|
||||
}
|
||||
|
||||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
||||
func UploadSiteAsset(c *gin.Context) {
|
||||
siteID := c.Param("site_id")
|
||||
if siteID == "" {
|
||||
@@ -167,22 +257,30 @@ func UploadSiteAsset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
folder := c.PostForm("folder")
|
||||
folder := strings.TrimSpace(c.PostForm("folder"))
|
||||
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
||||
baseDir := filepath.Join(getUploadDir(), "sites", siteID, filepath.Clean(folder))
|
||||
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
||||
|
||||
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
|
||||
if errMsg != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
if preserve {
|
||||
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancelDel()
|
||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
|
||||
_ = os.Remove(destPath)
|
||||
}
|
||||
|
||||
baseDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||
return
|
||||
}
|
||||
|
||||
name := file.Filename
|
||||
ext := filepath.Ext(name)
|
||||
nameNoExt := name[:len(name)-len(ext)]
|
||||
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||
relPath := filepath.Join("sites", siteID, filepath.Clean(folder), saveName)
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||
|
||||
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||||
return
|
||||
@@ -215,6 +313,8 @@ func UploadSiteAsset(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
||||
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg)
|
||||
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||||
}
|
||||
|
||||
// DeleteSiteAsset 删除站点资源
|
||||
|
||||
532
server/handlers/multipart_upload.go
Normal file
532
server/handlers/multipart_upload.go
Normal 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 须在 1MB~32MB 之间"})
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
197
server/handlers/promotion_transcode.go
Normal file
197
server/handlers/promotion_transcode.go
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
191
server/handlers/site_auth.go
Normal file
191
server/handlers/site_auth.go
Normal 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": "用户名为 2~32 个字符"})
|
||||
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())
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
"yh_web/server/config"
|
||||
"yh_web/server/pkg/traffic"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -30,5 +31,6 @@ func GetStats(c *gin.Context) {
|
||||
"conversations": conversations,
|
||||
"messages": messages,
|
||||
"files": files,
|
||||
"bandwidth": traffic.Snapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ func GetWebRoutes(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if config.MongoClient == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
siteID := c.Query("site_id")
|
||||
if siteID == "" {
|
||||
siteID = getOfficialSiteID(ctx)
|
||||
@@ -84,6 +89,11 @@ func GetWebPageByPath(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if config.MongoClient == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
|
||||
return
|
||||
}
|
||||
|
||||
siteID := c.Query("site_id")
|
||||
if siteID == "" {
|
||||
siteID = getOfficialSiteID(ctx)
|
||||
|
||||
182
server/handlers/yuheng_cloud_register.go
Normal file
182
server/handlers/yuheng_cloud_register.go
Normal 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})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"yh_web/server/models"
|
||||
"yh_web/server/pkg/logger"
|
||||
"yh_web/server/pkg/schema"
|
||||
"yh_web/server/pkg/weblive"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
@@ -79,8 +80,9 @@ func main() {
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.MaxMultipartMemory = 200 << 20 // 200MB,与 Nginx client_max_body_size 一致,避免上传 413
|
||||
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
|
||||
r.Use(middleware.ErrorLogger())
|
||||
r.Use(middleware.TrafficMeter())
|
||||
|
||||
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
||||
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
||||
@@ -97,7 +99,7 @@ func main() {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Origin, X-Requested-With")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
@@ -143,6 +145,12 @@ func main() {
|
||||
c.JSON(http.StatusOK, structure)
|
||||
})
|
||||
admin.GET("/stats", handlers.GetStats)
|
||||
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
|
||||
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
|
||||
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
||||
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
||||
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
||||
admin.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
|
||||
|
||||
// 用户管理
|
||||
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
||||
@@ -163,6 +171,13 @@ func main() {
|
||||
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
|
||||
admin.GET("/sites/:site_id/assets/downloadable", handlers.RequirePermission(models.PermHomepageEdit), handlers.ListDownloadableAssets)
|
||||
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
||||
// 分片路由须在 POST .../assets 整文件上传之前注册,避免被更泛的路由误匹配
|
||||
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
|
||||
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
|
||||
admin.POST("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
|
||||
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
|
||||
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
||||
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
|
||||
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||
@@ -173,6 +188,8 @@ func main() {
|
||||
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
||||
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
||||
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
||||
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
|
||||
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
|
||||
|
||||
// 角色权限管理
|
||||
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
||||
@@ -209,19 +226,33 @@ func main() {
|
||||
r.GET("/api/web/routes", handlers.GetWebRoutes)
|
||||
r.GET("/api/web/page", handlers.GetWebPageByPath)
|
||||
|
||||
// 前台直播弹幕账号(与后台 users 无关;需 MongoDB)
|
||||
r.POST("/api/web/site/register", handlers.WebSiteRegister)
|
||||
r.POST("/api/web/site/login", handlers.WebSiteLogin)
|
||||
|
||||
// 前台 API 路由组
|
||||
web := r.Group("/api/web")
|
||||
{
|
||||
web.GET("/info", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "web api"})
|
||||
})
|
||||
// 推广/产品视频:站点 uploads 下 promotion 目录(无需鉴权,与后台上传到 promotion/… 对应)
|
||||
web.GET("/sites/:site_id/promotion-media/*filepath", handlers.ServePromotionMedia)
|
||||
// 可下载资源公开下载(首页等链接指向此路径)
|
||||
web.GET("/sites/:site_id/assets/:asset_id/download", handlers.DownloadSiteAsset)
|
||||
// 站内 WebRTC 直播:信令 + 状态(单房间 MVP)
|
||||
weblive.RegisterRoutes(web)
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||
go handlers.SweepPromotionTranscodeOnStartup()
|
||||
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads)
|
||||
go handlers.StartStaleChunkUploadSweep(context.Background())
|
||||
|
||||
r.Run(":" + port)
|
||||
}
|
||||
|
||||
186
server/middleware/admin_post_security.go
Normal file
186
server/middleware/admin_post_security.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminPOSTSecurity 对 /api/admin 下 POST 校验时间戳、IP 频率、重复请求;multipart 上传仅做限流不做 body 去重
|
||||
func AdminPOSTSecurity() gin.HandlerFunc {
|
||||
ipLimit := getIntEnv("ADMIN_POST_IP_PER_MIN", 120)
|
||||
dedupeSec := getIntEnv("ADMIN_DEDUPE_SEC", 3)
|
||||
tsSkew := time.Duration(getIntEnv("ADMIN_REQUEST_TS_SKEW_SEC", 300)) * time.Second
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
tsStr := c.GetHeader("X-Request-Timestamp")
|
||||
if tsStr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少请求头 X-Request-Timestamp(毫秒时间戳)"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tsMs, err := strconv.ParseInt(tsStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Request-Timestamp 格式无效"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
clientT := time.UnixMilli(tsMs)
|
||||
if d := time.Since(clientT); d > tsSkew || d < -tsSkew {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求时间戳无效或时钟偏差过大"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
if !ipPostLimiter.allow("ip:"+ip, ipLimit, time.Minute) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "该 IP 请求过于频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ct := c.GetHeader("Content-Type")
|
||||
if strings.Contains(strings.ToLower(ct), "multipart/form-data") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
sig := hashSig(c.FullPath(), c.Request.URL.RawQuery, body)
|
||||
key := ip + "|" + sig
|
||||
if !dedupeStore.try(key, time.Duration(dedupeSec)*time.Second) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "相同请求请勿在 3 秒内重复提交"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminPOSTUserRateLimit 需在 AuthRequired 之后:按账号限制 POST 频率
|
||||
func AdminPOSTUserRateLimit() gin.HandlerFunc {
|
||||
userLimit := getIntEnv("ADMIN_POST_USER_PER_MIN", 80)
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
uid, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
suid, _ := uid.(string)
|
||||
if suid == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !ipPostLimiter.allow("uid:"+suid, userLimit, time.Minute) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "该账号请求过于频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func hashSig(path, query string, body []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(path))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(query))
|
||||
h.Write([]byte{0})
|
||||
h.Write(body)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
type slidingLimiter struct {
|
||||
mu sync.Mutex
|
||||
// key -> 时间戳列表(纳秒)
|
||||
m map[string][]int64
|
||||
}
|
||||
|
||||
var ipPostLimiter = &slidingLimiter{m: make(map[string][]int64)}
|
||||
|
||||
func (s *slidingLimiter) allow(key string, max int, window time.Duration) bool {
|
||||
now := time.Now().UnixNano()
|
||||
cutoff := now - window.Nanoseconds()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[key]
|
||||
out := list[:0]
|
||||
for _, t := range list {
|
||||
if t >= cutoff {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
if len(out) >= max {
|
||||
s.m[key] = out
|
||||
return false
|
||||
}
|
||||
out = append(out, now)
|
||||
s.m[key] = out
|
||||
return true
|
||||
}
|
||||
|
||||
type deduper struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int64 // key -> last unix nano
|
||||
}
|
||||
|
||||
var dedupeStore = &deduper{m: make(map[string]int64)}
|
||||
|
||||
func (d *deduper) try(key string, minGap time.Duration) bool {
|
||||
now := time.Now().UnixNano()
|
||||
gap := minGap.Nanoseconds()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if last, ok := d.m[key]; ok && now-last < gap {
|
||||
return false
|
||||
}
|
||||
d.m[key] = now
|
||||
if len(d.m) > 10000 {
|
||||
// 简单清理过期项
|
||||
cutoff := now - gap*10
|
||||
for k, v := range d.m {
|
||||
if v < cutoff {
|
||||
delete(d.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func getIntEnv(key string, def int) int {
|
||||
s := strings.TrimSpace(os.Getenv(key))
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
53
server/middleware/traffic_meter.go
Normal file
53
server/middleware/traffic_meter.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
PermSMSConfig = "sms_config"
|
||||
PermPaymentConfig = "payment_config"
|
||||
PermRolePermission = "role:permission" // 角色权限管理
|
||||
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
|
||||
)
|
||||
|
||||
// PermissionItem 单条权限定义(JSON 须用小写 key/name,供前端展示与勾选)
|
||||
@@ -32,6 +33,7 @@ var AllPermissions = []PermissionItem{
|
||||
{Key: PermSMSConfig, Name: "短信配置"},
|
||||
{Key: PermPaymentConfig, Name: "支付配置"},
|
||||
{Key: PermRolePermission, Name: "角色权限管理"},
|
||||
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
|
||||
}
|
||||
|
||||
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
|
||||
|
||||
@@ -40,8 +40,15 @@ type HomepageData struct {
|
||||
BadgeText string `json:"badge_text"` // FREE ACCESS
|
||||
Features []FeatureItem `json:"features"` // 星际导航等
|
||||
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
|
||||
// 侧栏直链(与 web 首页 Home.vue 一致,同域静态路径)
|
||||
DownloadWindowsURL string `json:"download_windows_url,omitempty"`
|
||||
DownloadAndroidURL string `json:"download_android_url,omitempty"`
|
||||
// BodyBuilder 首页下方扩展区:与网页积木相同 JSON 字符串 {"version":1,"blocks":[...]},空则仅展示上方模板
|
||||
BodyBuilder string `json:"body_builder,omitempty"`
|
||||
// 直播:前台 /live 页「进入直播间」跳转的外部地址(抖音/B 站/自建 H5 等);留空则仅提示在后台配置
|
||||
LiveRoomURL string `json:"live_room_url,omitempty"`
|
||||
// 直播页与首页直播模块主标题
|
||||
LiveRoomTitle string `json:"live_room_title,omitempty"`
|
||||
}
|
||||
|
||||
type NavLink struct {
|
||||
|
||||
15
server/models/site_user.go
Normal file
15
server/models/site_user.go
Normal 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"`
|
||||
}
|
||||
9
server/models/yuheng_cloud_register.go
Normal file
9
server/models/yuheng_cloud_register.go
Normal 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"`
|
||||
}
|
||||
@@ -28,6 +28,8 @@ var requiredCollections = map[string][]indexSpec{
|
||||
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
||||
"system_config": {},
|
||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
||||
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
|
||||
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
|
||||
}
|
||||
|
||||
type indexSpec struct {
|
||||
@@ -48,6 +50,8 @@ var tableDDL = map[string]string{
|
||||
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
|
||||
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
||||
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
||||
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
|
||||
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '密码明文留痕',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宇恒云注册请求本地留痕';",
|
||||
}
|
||||
|
||||
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||
|
||||
108
server/pkg/traffic/meter.go
Normal file
108
server/pkg/traffic/meter.go
Normal 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
|
||||
}
|
||||
51
server/pkg/weblive/config.go
Normal file
51
server/pkg/weblive/config.go
Normal 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
|
||||
}
|
||||
185
server/pkg/weblive/danmaku.go
Normal file
185
server/pkg/weblive/danmaku.go
Normal 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
188
server/pkg/weblive/hub.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
server/pkg/weblive/info.go
Normal file
50
server/pkg/weblive/info.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
268
server/pkg/weblive/moderation.go
Normal file
268
server/pkg/weblive/moderation.go
Normal 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
|
||||
}
|
||||
60
server/pkg/weblive/moderation_api.go
Normal file
60
server/pkg/weblive/moderation_api.go
Normal 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})
|
||||
}
|
||||
15
server/pkg/weblive/safe.go
Normal file
15
server/pkg/weblive/safe.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package weblive
|
||||
|
||||
import "log"
|
||||
|
||||
// goSafe 在独立 goroutine 中运行 fn;panic 只记录日志,避免拖垮整个 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
303
server/pkg/weblive/ws.go
Normal 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
15
server/vendor/github.com/davecgh/go-spew/LICENSE
generated
vendored
Normal 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
145
server/vendor/github.com/davecgh/go-spew/spew/bypass.go
generated
vendored
Normal 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")
|
||||
}
|
||||
38
server/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go
generated
vendored
Normal file
38
server/vendor/github.com/davecgh/go-spew/spew/bypasssafe.go
generated
vendored
Normal 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
341
server/vendor/github.com/davecgh/go-spew/spew/common.go
generated
vendored
Normal 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
306
server/vendor/github.com/davecgh/go-spew/spew/config.go
generated
vendored
Normal 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
211
server/vendor/github.com/davecgh/go-spew/spew/doc.go
generated
vendored
Normal 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
509
server/vendor/github.com/davecgh/go-spew/spew/dump.go
generated
vendored
Normal 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
419
server/vendor/github.com/davecgh/go-spew/spew/format.go
generated
vendored
Normal 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
148
server/vendor/github.com/davecgh/go-spew/spew/spew.go
generated
vendored
Normal 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
10
server/vendor/github.com/google/uuid/CHANGELOG.md
generated
vendored
Normal 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
26
server/vendor/github.com/google/uuid/CONTRIBUTING.md
generated
vendored
Normal 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
9
server/vendor/github.com/google/uuid/CONTRIBUTORS
generated
vendored
Normal 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
27
server/vendor/github.com/google/uuid/LICENSE
generated
vendored
Normal 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
21
server/vendor/github.com/google/uuid/README.md
generated
vendored
Normal 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
|
||||
[](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
80
server/vendor/github.com/google/uuid/dce.go
generated
vendored
Normal 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
12
server/vendor/github.com/google/uuid/doc.go
generated
vendored
Normal 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user