Compare commits
113 Commits
b624bac9b0
...
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 | |||
| 0360ee5261 | |||
| e1fc257435 | |||
| 88f9d42f91 | |||
| 6df5cf029d | |||
| ea163dbf8e | |||
| b17e99eb93 | |||
| c67346626a | |||
| 7a97ba8c66 | |||
| 07f55e0139 | |||
| c9d9224a68 | |||
| 5492456148 | |||
| d5bc102bd7 | |||
| 31b1f2bb4c | |||
| 81cd9dce75 | |||
| 483560bcfc | |||
| 6044786380 | |||
| 20a035a745 | |||
| bae341e1bc | |||
| eb2d5f6579 | |||
| 3993f7322e | |||
| 9babb2ac7e | |||
| ed930cbe12 | |||
| 1ece933a1e | |||
| 2851b0913c | |||
| 4373d2a0ee | |||
| 16a77ab3c8 | |||
| 022a71dfd3 | |||
| be2b5470c5 | |||
| 1022d99708 | |||
| 20e7f3a65d | |||
| 826617d737 | |||
| a0df3a8a41 |
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# 强制 shell 脚本使用 LF,避免在 Linux 上出现 bash\r 错误
|
||||||
|
*.sh text eol=lf
|
||||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -3,6 +3,8 @@ node_modules/
|
|||||||
web/dist/
|
web/dist/
|
||||||
admin/dist/
|
admin/dist/
|
||||||
server/server
|
server/server
|
||||||
|
server/yh_api
|
||||||
|
server/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# 环境与密钥(服务器上单独配置)
|
# 环境与密钥(服务器上单独配置)
|
||||||
@@ -11,6 +13,10 @@ server/.env
|
|||||||
!.env.example
|
!.env.example
|
||||||
!server/.env.example
|
!server/.env.example
|
||||||
|
|
||||||
|
# Nginx 证书私钥(除 yuheng 域名外不提交)
|
||||||
|
nginx/*.key
|
||||||
|
!nginx/yuheng.yuxindazhineng.com.key
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
@@ -23,5 +29,32 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# 挂目录部署:构建产物由脚本生成,不提交
|
||||||
|
deploy/web/dist/
|
||||||
|
deploy/admin/dist/
|
||||||
|
deploy/api/server
|
||||||
|
|
||||||
|
# 功能模块上传目录(API 写入,不提交)
|
||||||
|
data/uploads/
|
||||||
|
|
||||||
# Docker 本地卷(不提交)
|
# Docker 本地卷(不提交)
|
||||||
# mongo_data 等由 compose 管理
|
# 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 });
|
db.role_permissions.createIndex({ role_id: 1 }, { unique: true, name: "idx_role_id", background: true });
|
||||||
|
|
||||||
|
// 11. site_users(前台直播弹幕账号,与后台 users 分离)
|
||||||
|
if (!db.getCollectionNames().includes("site_users")) {
|
||||||
|
db.createCollection("site_users");
|
||||||
|
print("已创建集合: site_users");
|
||||||
|
}
|
||||||
|
db.site_users.createIndex({ username: 1 }, { unique: true, name: "idx_username", background: true });
|
||||||
|
|
||||||
|
// 12. yuheng_cloud_register_records(宇恒云 POST /register 本地留痕:username、password)
|
||||||
|
if (!db.getCollectionNames().includes("yuheng_cloud_register_records")) {
|
||||||
|
db.createCollection("yuheng_cloud_register_records");
|
||||||
|
print("已创建集合: yuheng_cloud_register_records");
|
||||||
|
}
|
||||||
|
db.yuheng_cloud_register_records.createIndex(
|
||||||
|
{ created_at: -1 },
|
||||||
|
{ name: "idx_created_at", background: true }
|
||||||
|
);
|
||||||
|
|
||||||
print("集合与索引处理完成。");
|
print("集合与索引处理完成。");
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -14,6 +14,21 @@ yh_web/
|
|||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
|
### 0. 本地开发:先启动 MongoDB
|
||||||
|
|
||||||
|
后端依赖 MongoDB,二选一即可:
|
||||||
|
|
||||||
|
**方式一:本机已安装 MongoDB**
|
||||||
|
启动 MongoDB 服务后,在 `server/.env` 中设置 `MONGODB_URI=mongodb://localhost:27017`(复制 `.env.example` 为 `.env` 后改此项即可)。
|
||||||
|
|
||||||
|
**方式二:只用 Docker 跑一个 MongoDB(推荐,无需本机安装)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name yh_mongo -p 27017:27017 mongo:7
|
||||||
|
```
|
||||||
|
|
||||||
|
同样在 `server/.env` 中保持 `MONGODB_URI=mongodb://localhost:27017`。停止:`docker stop yh_mongo`,再启:`docker start yh_mongo`。
|
||||||
|
|
||||||
### 1. 启动后端
|
### 1. 启动后端
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -44,15 +59,83 @@ npm run dev
|
|||||||
- 前台: http://localhost:3001
|
- 前台: http://localhost:3001
|
||||||
- API: http://localhost:8080
|
- API: http://localhost:8080
|
||||||
|
|
||||||
|
## 用 Docker 一键跑起来(推荐)
|
||||||
|
|
||||||
|
在项目根目录执行,用 Docker 拉镜像并启动全部服务(API、Web、Admin、MongoDB):
|
||||||
|
|
||||||
|
**1. 准备环境变量**
|
||||||
|
若还没有 `server/.env`,复制一份并可按需修改:
|
||||||
|
```bash
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 国内网络可设镜像再拉镜像(避免超时)**
|
||||||
|
```bash
|
||||||
|
# Windows PowerShell
|
||||||
|
$env:REGISTRY_MIRROR="docker.m.daocloud.io/library/"
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# Linux / Mac / Git Bash
|
||||||
|
export REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
不设 `REGISTRY_MIRROR` 时直连 Docker Hub;若拉取超时再按上面设置镜像后重试。
|
||||||
|
|
||||||
|
**3. 访问**
|
||||||
|
- 当前 compose 仅暴露 443,由 Nginx 反代;API 容器内监听 8088,通过 https 访问 /api。
|
||||||
|
|
||||||
|
停止:`docker compose down`。
|
||||||
|
|
||||||
## 线上部署(项目根目录:~/project/yh_web)
|
## 线上部署(项目根目录:~/project/yh_web)
|
||||||
|
|
||||||
线上路径为 `yxd@server1:~/project/yh_web`(即 `/home/yxd/project/yh_web`)。
|
线上路径为 `yxd@server1:~/project/yh_web`(即 `/home/yxd/project/yh_web`)。
|
||||||
|
|
||||||
首次或克隆后需加执行权限:
|
### 线上跑起来(以 Gitea 为准)
|
||||||
|
|
||||||
|
1. **本地**:提交并推送含「镜像加速」的代码(`docker-compose.yml`、各 `Dockerfile`、若已改脚本也同步到服务器)。
|
||||||
|
2. **服务器**:进入项目目录,**以 Gitea 为准**,直接执行拉取并重启脚本(脚本内会 `git fetch` + `git reset --hard origin/master`,本地修改会被覆盖)。
|
||||||
|
```bash
|
||||||
|
cd /www/yh_web # 或你的项目路径
|
||||||
|
./pull-and-restart.sh
|
||||||
|
```
|
||||||
|
**重要**:`deploy/` 下构建产物(web/admin 静态文件、api 二进制)**不在仓库里**,仅靠 `git pull` 不会生成。拉取后**必须执行** `./pull-and-restart.sh`(或 `./restart.sh`)才会构建并启动,否则访问会 403/404。脚本已记录可执行权限,拉取后可直接 `./pull-and-restart.sh`,无需 chmod。
|
||||||
|
若你已手动执行过 `git pull` 且报错 `Your local changes would be overwritten`,先以远程为准:`git fetch origin && git reset --hard origin/master`,再执行 `./pull-and-restart.sh`。
|
||||||
|
|
||||||
|
采用**挂目录 + 替换文件**部署:脚本将 web/admin 构建到 `deploy/web/dist`、`deploy/admin/dist`,api 二进制到 `deploy/api/server`,容器挂载这些目录;更新时只重新构建产物并重启,无需重建前端镜像,仅 api 使用轻量运行时镜像。详见 `deploy/README.md`。构建会从 DaoCloud 等镜像拉取 node、nginx、golang、alpine、mongo,启动后**对外仅 443**,由 compose 内 Nginx 反代到 api/web/admin,证书按脚本自动处理。
|
||||||
|
|
||||||
|
**脚本说明**:仅保留两个脚本,均会检测 Docker 并在未安装时一键安装(apt 或 yum/dnf)。
|
||||||
|
- **拉取代码并重启**:`./pull-and-restart.sh` — 若无 Git 仓库会提示设置 `GIT_REPO_URL` 后自动克隆;然后拉取并构建、启动。
|
||||||
|
- **仅重启**:`./restart.sh` — 不拉代码,仅 `docker compose down` 后 `up -d`。
|
||||||
|
|
||||||
|
**Permission denied**:拉取后脚本可能无可执行权限,任选其一即可:
|
||||||
```bash
|
```bash
|
||||||
chmod +x pull-and-restart.sh restart.sh
|
chmod +x pull-and-restart.sh restart.sh
|
||||||
|
# 或直接用 bash 执行(脚本内会自修复权限供下次使用)
|
||||||
|
bash pull-and-restart.sh
|
||||||
```
|
```
|
||||||
|
若报错 `bash\r`,先执行 `sed -i 's/\r$//' pull-and-restart.sh restart.sh`。
|
||||||
|
首次部署若目录为空,可先放入两个脚本,设置 `export GIT_REPO_URL='https://用户:Token@gitea.../web.git'` 后执行 `./pull-and-restart.sh` 完成克隆与启动。配置好 `server/.env` 后再次运行即可。
|
||||||
|
|
||||||
- **拉取代码并重启**:`cd ~/project/yh_web && ./pull-and-restart.sh`
|
**产品视频自动导入**:`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 && ./restart.sh`
|
|
||||||
- 对外域名:https://yuheng.yuxindazhineng.com
|
- **拉取并重启**:`cd ~/project/yh_web && ./pull-and-restart.sh`
|
||||||
|
- **仅重启**:`cd ~/project/yh_web && ./restart.sh`
|
||||||
|
- **对外域名**:https://yuheng.yuxindazhineng.com(所有请求均通过该域名,见下)
|
||||||
|
|
||||||
|
**所有请求通过域名**:前台/后台生产环境使用同一域名,API 也走同域 `/api`。
|
||||||
|
|
||||||
|
- **有 Nginx Proxy Manager 时**:在 NPM 中配置反代即可:
|
||||||
|
- `https://yuheng.yuxindazhineng.com/` → 本机 9528(web)
|
||||||
|
- `https://yuheng.yuxindazhineng.com/admin/` → 本机 9529(admin)
|
||||||
|
- `https://yuheng.yuxindazhineng.com/api` → 本机 8088(api)
|
||||||
|
- **新服务器无 NPM、自建 Nginx 时**:使用项目内 **`nginx/`** 配置,强制 HTTPS,SSL 证书按域名单独目录存放。详见 **`nginx/README.md`**(证书目录:`/etc/ssl/yh_web/yuheng.yuxindazhineng.com/`,复制 `nginx/yuheng.yuxindazhineng.com.conf` 到 `/etc/nginx/conf.d/` 后重载 Nginx)。
|
||||||
|
|
||||||
|
前端 `VITE_API_BASE` 留空即请求同域 `/api`;后端 `server/.env` 中 `ALLOWED_ORIGINS=https://yuheng.yuxindazhineng.com` 用于 CORS。
|
||||||
|
|
||||||
|
**服务器无法访问 Docker Hub 时**(报错 `dial tcp ... i/o timeout`):一键脚本会自动写入 Podman 镜像配置到 `/etc/containers/registries.conf.d/99-docker-mirror.conf`(DaoCloud / 1ms / 徐轩辕等镜像)。若仍拉取失败:
|
||||||
|
- **Podman**:确认主配置会加载该目录。若无效,可把 `99-docker-mirror.conf` 中的 `[[registry]]` 段合并进 `/etc/containers/registries.conf`。
|
||||||
|
- **Docker**:编辑 `/etc/docker/daemon.json` 添加 `"registry-mirrors": ["https://docker.1ms.run"]` 后 `sudo systemctl restart docker`。
|
||||||
|
然后重新运行启动脚本。
|
||||||
|
|
||||||
|
**报错 `listen tcp4 :8088: bind: address already in use`**:当前 `docker-compose.yml` 中 **api 未映射任何宿主机端口**,`PORT=8088` 仅容器内监听。该错误多为服务器上曾用旧版 compose 映射过 8088,或宿主机其他进程占用。处理:确认已 `git pull` 到最新(compose 无 api 的 `ports`),执行 `docker compose down`(或直接再次执行 `./pull-and-restart.sh`,脚本内已先 down 再 up)后重试;若仍报错,在服务器上执行 `ss -tlnp | grep 8088` 或 `lsof -i :8088` 查看占用进程并结束。
|
||||||
|
|||||||
3
admin/.env.development
Normal file
3
admin/.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 开发环境:API 请求走域名,不用 localhost
|
||||||
|
VITE_APP_DOMAIN=https://yuheng.yuxindazhineng.com
|
||||||
|
VITE_API_BASE=https://yuheng.yuxindazhineng.com
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
FROM node:20-alpine AS builder
|
# 国内默认走镜像;海外可 --build-arg REGISTRY_MIRROR= 直连
|
||||||
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
FROM ${REGISTRY_MIRROR}node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
|
RUN npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chmod -R +x node_modules/.bin 2>/dev/null || true
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
FROM ${REGISTRY_MIRROR}nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
RUN echo 'server { listen 80; location /admin/ { alias /usr/share/nginx/html/; try_files $uri $uri/ /admin/index.html; } }' > /etc/nginx/conf.d/default.conf
|
# 与 deploy/admin/default.conf 同逻辑:^~ /assets/ 避免缺失 chunk 时回退到 index.html → MIME text/html 白屏
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
admin/package-lock.json
generated
21
admin/package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
@@ -1585,6 +1586,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1689,6 +1696,18 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"element-plus": "^2.4.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"element-plus": "^2.4.4",
|
"vuedraggable": "^4.1.0"
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"axios": "^1.6.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export const getMyPermissions = () => request.get('/admin/my-permissions')
|
|||||||
|
|
||||||
// 角色权限管理
|
// 角色权限管理
|
||||||
export const getRolePermissionsList = () => request.get('/admin/role-permissions')
|
export const getRolePermissionsList = () => request.get('/admin/role-permissions')
|
||||||
|
export const createRole = (data) => request.post('/admin/role-permissions', data)
|
||||||
export const updateRolePermissions = (roleId, data) => request.put(`/admin/role-permissions/${roleId}`, data)
|
export const updateRolePermissions = (roleId, data) => request.put(`/admin/role-permissions/${roleId}`, data)
|
||||||
|
export const deleteRole = (roleId) => request.delete(`/admin/role-permissions/${roleId}`)
|
||||||
|
|
||||||
// 后台注册(手机号+验证码)
|
// 后台注册(手机号+验证码)
|
||||||
export const sendCode = (mobile) => request.post('/admin/send-code', { mobile })
|
export const sendCode = (mobile) => request.post('/admin/send-code', { mobile })
|
||||||
@@ -26,6 +28,11 @@ export const createUser = (data) => request.post('/admin/users', data)
|
|||||||
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
|
export const updateUser = (id, data) => request.put(`/admin/users/${id}`, data)
|
||||||
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
export const deleteUser = (id) => request.delete(`/admin/users/${id}`)
|
||||||
|
|
||||||
|
// 宇恒云账号(POST 云端 /register + 本地 Mongo 仅记 username/password)
|
||||||
|
export const createYuhengCloudAccount = (data) =>
|
||||||
|
request.post('/admin/yuheng-cloud-accounts', data, { timeout: 60000 })
|
||||||
|
export const listYuhengCloudAccounts = (params) => request.get('/admin/yuheng-cloud-accounts', { params })
|
||||||
|
|
||||||
// 工作空间
|
// 工作空间
|
||||||
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
|
export const getWorkspaces = (params) => request.get('/admin/workspaces', { params })
|
||||||
|
|
||||||
@@ -44,6 +51,10 @@ export const updatePaymentConfig = (data) => request.put('/admin/payment-config'
|
|||||||
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
export const getOfficialSite = () => request.get('/admin/system/official-site')
|
||||||
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
export const setOfficialSite = (siteId) => request.put('/admin/system/official-site', { site_id: siteId })
|
||||||
|
|
||||||
|
/** 分片上传临时目录清理:保留时长、扫描间隔(需 site:manage) */
|
||||||
|
export const getChunkUploadCleanup = () => request.get('/admin/system/chunk-upload-cleanup')
|
||||||
|
export const updateChunkUploadCleanup = (data) => request.put('/admin/system/chunk-upload-cleanup', data)
|
||||||
|
|
||||||
// 站点管理
|
// 站点管理
|
||||||
export const getSites = () => request.get('/admin/sites')
|
export const getSites = () => request.get('/admin/sites')
|
||||||
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
export const getSiteById = (id) => request.get(`/admin/sites/${id}`)
|
||||||
@@ -62,12 +73,57 @@ export const deletePage = (id) => request.delete(`/admin/pages/${id}`)
|
|||||||
export const getHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage`)
|
export const getHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage`)
|
||||||
export const updateHomepage = (siteId, data) => request.put(`/admin/sites/${siteId}/homepage`, data)
|
export const updateHomepage = (siteId, data) => request.put(`/admin/sites/${siteId}/homepage`, data)
|
||||||
export const downloadHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage/download`, { responseType: 'blob' })
|
export const downloadHomepage = (siteId) => request.get(`/admin/sites/${siteId}/homepage/download`, { responseType: 'blob' })
|
||||||
|
export const getDownloadableAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets/downloadable`)
|
||||||
|
|
||||||
// 功能模块上传
|
// 文件管理(功能模块:多级目录、可下载)
|
||||||
export const getSiteAssets = (siteId) => request.get(`/admin/sites/${siteId}/assets`)
|
export const getSiteAssets = (siteId, path, opts = {}) => {
|
||||||
export const uploadSiteAsset = (siteId, file) => {
|
const params = {}
|
||||||
|
if (path) params.path = path
|
||||||
|
if (opts.downloadable) params.downloadable = '1'
|
||||||
|
return request.get(`/admin/sites/${siteId}/assets`, { params })
|
||||||
|
}
|
||||||
|
export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
return request.post(`/admin/sites/${siteId}/assets`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
if (opts.folder != null) form.append('folder', opts.folder)
|
||||||
|
form.append('downloadable', opts.downloadable ? 'true' : 'false')
|
||||||
|
if (opts.preserveFilename) form.append('preserve_filename', 'true')
|
||||||
|
// 大文件上传:timeout 0 = Axios 不设置请求超时(仍可能受浏览器/系统/代理断开影响)
|
||||||
|
// 超大文件请用分片 API(见 uploadSiteAssetWithResume)
|
||||||
|
return request.post(`/admin/sites/${siteId}/assets`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分片上传:创建会话(断点续传) */
|
||||||
|
export const initMultipartUpload = (siteId, body) =>
|
||||||
|
request.post(`/admin/sites/${siteId}/assets/init-multipart`, body, { timeout: 60000 })
|
||||||
|
|
||||||
|
export const getMultipartUploadStatus = (siteId, uploadId) =>
|
||||||
|
request.get(`/admin/sites/${siteId}/assets/multipart/${uploadId}/status`, { timeout: 60000 })
|
||||||
|
|
||||||
|
export const putMultipartChunk = (siteId, uploadId, chunkIndex, blob) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('chunk', blob, 'part.bin')
|
||||||
|
// 不传 Content-Type,由浏览器带 boundary;与整文件 multipart 一致,减少中间层断连
|
||||||
|
return request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/chunk/${chunkIndex}`, fd, {
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completeMultipartUpload = (siteId, uploadId) =>
|
||||||
|
request.post(`/admin/sites/${siteId}/assets/multipart/${uploadId}/complete`, {}, { timeout: 600000 })
|
||||||
|
|
||||||
|
export const abortMultipartUpload = (siteId, uploadId) =>
|
||||||
|
request.delete(`/admin/sites/${siteId}/assets/multipart/${uploadId}`, { timeout: 60000 })
|
||||||
|
|
||||||
|
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
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 })
|
||||||
|
|||||||
226
admin/src/components/LinkPickerDialog.vue
Normal file
226
admin/src/components/LinkPickerDialog.vue
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="选择链接"
|
||||||
|
width="680px"
|
||||||
|
destroy-on-close
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-tabs v-model="tab">
|
||||||
|
<el-tab-pane label="本站页面" name="pages">
|
||||||
|
<p class="tab-tip">当前站点下已创建的网页(含首页 /)</p>
|
||||||
|
<el-table
|
||||||
|
v-loading="loadingPages"
|
||||||
|
:data="pageRows"
|
||||||
|
highlight-current-row
|
||||||
|
max-height="320"
|
||||||
|
@row-click="(row) => confirm(row.path)"
|
||||||
|
>
|
||||||
|
<el-table-column prop="title" label="标题" min-width="130" />
|
||||||
|
<el-table-column prop="path" label="前台路径" min-width="140" />
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click.stop="confirm(row.path)">选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!loadingPages && !pageRows.length" description="暂无页面,请先在网页管理中创建" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="showOtherSites" label="其他站点首页" name="sites">
|
||||||
|
<p class="tab-tip">同账号下其他站点的首页链接(需在站点管理中填写「域名」)</p>
|
||||||
|
<el-table
|
||||||
|
v-loading="loadingSites"
|
||||||
|
:data="otherSiteRows"
|
||||||
|
max-height="320"
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="站点名称" min-width="120" />
|
||||||
|
<el-table-column prop="domain" label="已配置域名" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.domain">{{ row.domain }}</span>
|
||||||
|
<el-text v-else type="warning" size="small">未填写</el-text>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="previewUrl" label="将填入的链接" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
:disabled="!row.homeUrl"
|
||||||
|
@click="confirmOtherSite(row)"
|
||||||
|
>选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!loadingSites && !otherSiteRows.length" description="没有其他站点" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane v-if="showDownloadableFiles" label="可下载文件" name="files">
|
||||||
|
<el-table
|
||||||
|
v-loading="loadingFiles"
|
||||||
|
:data="files"
|
||||||
|
max-height="320"
|
||||||
|
@row-click="(row) => confirm(fileUrl(row))"
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="文件名" min-width="160" />
|
||||||
|
<el-table-column prop="file_path" label="存储路径" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click.stop="confirm(fileUrl(row))">选择</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!loadingFiles && !files.length" description="无标记为可下载的文件,请到文件管理上传并勾选允许下载" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="自定义地址" name="custom">
|
||||||
|
<el-input v-model="customUrl" placeholder="https:// 或 /path" clearable />
|
||||||
|
<div style="margin-top: 16px">
|
||||||
|
<el-button type="primary" @click="confirmCustom">使用此地址</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getPages, getDownloadableAssets, getSites } from '../api/admin'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
siteId: { type: String, default: '' },
|
||||||
|
showDownloadableFiles: { type: Boolean, default: true },
|
||||||
|
showOtherSites: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'select'])
|
||||||
|
|
||||||
|
const tab = ref('pages')
|
||||||
|
const pageRows = ref([])
|
||||||
|
const otherSiteRows = ref([])
|
||||||
|
const files = ref([])
|
||||||
|
const loadingPages = ref(false)
|
||||||
|
const loadingSites = ref(false)
|
||||||
|
const loadingFiles = ref(false)
|
||||||
|
const customUrl = ref('')
|
||||||
|
|
||||||
|
function effectivePath(p) {
|
||||||
|
if (p.route_path) {
|
||||||
|
const r = String(p.route_path).trim()
|
||||||
|
return r.startsWith('/') ? r : '/' + r
|
||||||
|
}
|
||||||
|
if (!p.slug || p.slug === 'index') return '/'
|
||||||
|
return '/' + p.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点首页完整 URL:依赖站点 domain;无域名则无法生成外链 */
|
||||||
|
function siteHomepageUrl(site) {
|
||||||
|
let d = (site.domain || '').trim()
|
||||||
|
if (!d) return ''
|
||||||
|
if (!/^https?:\/\//i.test(d)) d = 'https://' + d
|
||||||
|
return d.replace(/\/$/, '') + '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPages() {
|
||||||
|
if (!props.siteId) return
|
||||||
|
loadingPages.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPages({ site_id: props.siteId })
|
||||||
|
const list = res.list || []
|
||||||
|
pageRows.value = list.map((p) => ({
|
||||||
|
title: (p.type === 'homepage' || p.slug === 'index' ? '【首页】' : '') + (p.title || p.slug),
|
||||||
|
path: effectivePath(p),
|
||||||
|
slug: p.slug
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
pageRows.value = []
|
||||||
|
} finally {
|
||||||
|
loadingPages.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOtherSites() {
|
||||||
|
loadingSites.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSites()
|
||||||
|
const list = (res.list || []).filter((s) => s.id !== props.siteId)
|
||||||
|
otherSiteRows.value = list.map((s) => {
|
||||||
|
const homeUrl = siteHomepageUrl(s)
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || s.id,
|
||||||
|
domain: (s.domain || '').trim(),
|
||||||
|
homeUrl,
|
||||||
|
previewUrl: homeUrl || '(未配置域名)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
otherSiteRows.value = []
|
||||||
|
} finally {
|
||||||
|
loadingSites.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOtherSite(row) {
|
||||||
|
if (!row.homeUrl) {
|
||||||
|
ElMessage.warning('请先在「站点管理」中为该站点填写访问域名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirm(row.homeUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
if (!props.siteId) return
|
||||||
|
loadingFiles.value = true
|
||||||
|
try {
|
||||||
|
const res = await getDownloadableAssets(props.siteId)
|
||||||
|
files.value = res.list || []
|
||||||
|
} catch {
|
||||||
|
files.value = []
|
||||||
|
} finally {
|
||||||
|
loadingFiles.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileUrl(row) {
|
||||||
|
return `/api/web/sites/${props.siteId}/assets/${row.id}/download`
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(url) {
|
||||||
|
if (!url) return
|
||||||
|
emit('select', url)
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCustom() {
|
||||||
|
const u = (customUrl.value || '').trim()
|
||||||
|
if (!u) {
|
||||||
|
ElMessage.warning('请输入地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirm(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
customUrl.value = ''
|
||||||
|
tab.value = 'pages'
|
||||||
|
loadPages()
|
||||||
|
if (props.showOtherSites) loadOtherSites()
|
||||||
|
if (props.showDownloadableFiles) loadFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
admin/src/components/PageBuilderAnimFields.vue
Normal file
53
admin/src/components/PageBuilderAnimFields.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<el-form-item label="入场动画">
|
||||||
|
<el-select v-model="enter" style="width: 130px" @change="sync">
|
||||||
|
<el-option label="无" value="none" />
|
||||||
|
<el-option label="淡入" value="fadeIn" />
|
||||||
|
<el-option label="上滑" value="slideUp" />
|
||||||
|
<el-option label="左滑" value="slideLeft" />
|
||||||
|
<el-option label="缩放" value="zoomIn" />
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="delay" :min="0" :max="5000" style="width: 110px; margin-left: 8px" @change="sync" />
|
||||||
|
<span class="hint">延迟ms</span>
|
||||||
|
<el-input-number v-model="duration" :min="100" :max="3000" style="width: 110px; margin-left: 8px" @change="sync" />
|
||||||
|
<span class="hint">时长ms</span>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const enter = ref('fadeIn')
|
||||||
|
const delay = ref(0)
|
||||||
|
const duration = ref(600)
|
||||||
|
|
||||||
|
function fromModel() {
|
||||||
|
const a = props.modelValue || {}
|
||||||
|
enter.value = a.enter || 'fadeIn'
|
||||||
|
delay.value = a.delay_ms ?? 0
|
||||||
|
duration.value = a.duration_ms ?? 600
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
enter: enter.value,
|
||||||
|
delay_ms: delay.value,
|
||||||
|
duration_ms: duration.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, fromModel, { immediate: true, deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
371
admin/src/components/PageBuilderBlocks.vue
Normal file
371
admin/src/components/PageBuilderBlocks.vue
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-builder-blocks">
|
||||||
|
<Draggable
|
||||||
|
v-model="blocksWritable"
|
||||||
|
item-key="id"
|
||||||
|
handle=".drag-handle"
|
||||||
|
:animation="220"
|
||||||
|
ghost-class="block-ghost"
|
||||||
|
chosen-class="block-chosen"
|
||||||
|
drag-class="block-dragging"
|
||||||
|
class="blocks-drag-list"
|
||||||
|
>
|
||||||
|
<template #item="{ element: block, index: idx }">
|
||||||
|
<div class="block-card">
|
||||||
|
<div class="block-head">
|
||||||
|
<span class="block-head-left">
|
||||||
|
<span class="drag-handle" title="按住拖拽排序">
|
||||||
|
<el-icon><Rank /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="block-type">{{ typeLabel(block.type) }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="block-actions">
|
||||||
|
<el-button link type="danger" @click="remove(idx)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="block.type === 'heading'">
|
||||||
|
<el-form label-width="88px" size="small">
|
||||||
|
<el-form-item label="标题文字">
|
||||||
|
<el-input v-model="block.props.text" placeholder="标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="级别 (1-6)">
|
||||||
|
<el-input-number v-model="block.props.level" :min="1" :max="6" />
|
||||||
|
</el-form-item>
|
||||||
|
<PageBuilderAnimFields v-model="block.animation" />
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'text'">
|
||||||
|
<el-form label-width="88px" size="small">
|
||||||
|
<el-form-item label="HTML 模式">
|
||||||
|
<el-switch v-model="block.props.html" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内容">
|
||||||
|
<el-input v-model="block.props.text" type="textarea" :rows="4" placeholder="纯文本或 HTML" />
|
||||||
|
</el-form-item>
|
||||||
|
<PageBuilderAnimFields v-model="block.animation" />
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'link_list'">
|
||||||
|
<div class="link-items">
|
||||||
|
<div v-for="(it, j) in block.props.items" :key="j" class="link-row">
|
||||||
|
<el-input v-model="it.label" placeholder="显示文字" style="width: 110px" />
|
||||||
|
<el-input v-model="it.url" placeholder="链接" style="flex: 1; min-width: 120px" />
|
||||||
|
<el-select v-model="it.target" style="width: 95px">
|
||||||
|
<el-option label="当前页" value="_self" />
|
||||||
|
<el-option label="新窗口" value="_blank" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" link @click="openPicker(it)">选择</el-button>
|
||||||
|
<el-button link type="danger" @click="block.props.items.splice(j, 1)">删</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button link type="primary" @click="block.props.items.push({ label: '', url: '', target: '_self' })">
|
||||||
|
+ 添加链接
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-form label-width="88px" size="small" style="margin-top: 8px">
|
||||||
|
<PageBuilderAnimFields v-model="block.animation" />
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'button'">
|
||||||
|
<el-form label-width="88px" size="small">
|
||||||
|
<el-form-item label="按钮文字">
|
||||||
|
<el-input v-model="block.props.text" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="链接">
|
||||||
|
<div class="url-row">
|
||||||
|
<el-input v-model="block.props.url" placeholder="#" />
|
||||||
|
<el-button type="primary" @click="openPicker(block.props, 'url')">选择链接</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="样式">
|
||||||
|
<el-radio-group v-model="block.props.variant">
|
||||||
|
<el-radio-button label="primary">主色</el-radio-button>
|
||||||
|
<el-radio-button label="ghost">线框</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<PageBuilderAnimFields v-model="block.animation" />
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'html'">
|
||||||
|
<el-input v-model="block.props.html" type="textarea" :rows="6" placeholder="HTML 片段" />
|
||||||
|
<el-form label-width="88px" size="small" style="margin-top: 8px">
|
||||||
|
<PageBuilderAnimFields v-model="block.animation" />
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'spacer'">
|
||||||
|
<el-form label-width="88px" size="small">
|
||||||
|
<el-form-item label="高度(px)">
|
||||||
|
<el-input-number v-model="block.props.height" :min="0" :max="500" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'divider'">
|
||||||
|
<span class="muted">分割线</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="block.type === 'section'">
|
||||||
|
<el-form label-width="88px" size="small">
|
||||||
|
<el-form-item label="内边距">
|
||||||
|
<el-input v-model="block.props.padding" placeholder="24px 16px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最大宽度">
|
||||||
|
<el-input v-model="block.props.maxWidth" placeholder="960px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="背景色">
|
||||||
|
<el-input v-model="block.props.background" placeholder="transparent" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="nested-label">区块内模块(可继续嵌套)</div>
|
||||||
|
<PageBuilderBlocks
|
||||||
|
:blocks="block.children || []"
|
||||||
|
:site-id="siteId"
|
||||||
|
@update:blocks="(v) => patchSectionChildren(idx, v)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<span class="muted">未知类型 {{ block.type }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
|
||||||
|
<div class="add-bar">
|
||||||
|
<el-dropdown trigger="click" @command="addBlock">
|
||||||
|
<el-button type="primary">
|
||||||
|
+ 添加模块 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="heading">标题</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="text">段落文字</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="link_list">链接组</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="button">按钮</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="html">HTML</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="spacer">留白</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="divider">分割线</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="section">区块(嵌套)</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkPickerDialog v-model="pickerVisible" :site-id="siteId" @select="onPicked" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { ArrowDown, Rank } from '@element-plus/icons-vue'
|
||||||
|
import Draggable from 'vuedraggable'
|
||||||
|
import LinkPickerDialog from './LinkPickerDialog.vue'
|
||||||
|
import PageBuilderAnimFields from './PageBuilderAnimFields.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'PageBuilderBlocks' })
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
blocks: { type: Array, default: () => [] },
|
||||||
|
siteId: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:blocks'])
|
||||||
|
|
||||||
|
const blocksWritable = computed({
|
||||||
|
get() {
|
||||||
|
return props.blocks
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
emit('update:blocks', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function typeLabel(t) {
|
||||||
|
const m = {
|
||||||
|
heading: '标题',
|
||||||
|
text: '段落',
|
||||||
|
link_list: '链接组',
|
||||||
|
button: '按钮',
|
||||||
|
html: 'HTML',
|
||||||
|
spacer: '留白',
|
||||||
|
divider: '分割线',
|
||||||
|
section: '区块'
|
||||||
|
}
|
||||||
|
return m[t] || t
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBlock(cmd) {
|
||||||
|
const list = [...props.blocks]
|
||||||
|
const b = {
|
||||||
|
id: newId(),
|
||||||
|
type: cmd,
|
||||||
|
props: {},
|
||||||
|
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
||||||
|
}
|
||||||
|
switch (cmd) {
|
||||||
|
case 'heading':
|
||||||
|
b.props = { text: '新标题', level: 2 }
|
||||||
|
break
|
||||||
|
case 'text':
|
||||||
|
b.props = { text: '段落内容', html: false }
|
||||||
|
break
|
||||||
|
case 'link_list':
|
||||||
|
b.props = { items: [{ label: '链接', url: '/', target: '_self' }] }
|
||||||
|
break
|
||||||
|
case 'button':
|
||||||
|
b.props = { text: '按钮', url: '#', variant: 'primary' }
|
||||||
|
break
|
||||||
|
case 'html':
|
||||||
|
b.props = { html: '<p>内容</p>' }
|
||||||
|
break
|
||||||
|
case 'spacer':
|
||||||
|
b.props = { height: 24 }
|
||||||
|
break
|
||||||
|
case 'divider':
|
||||||
|
b.props = {}
|
||||||
|
break
|
||||||
|
case 'section':
|
||||||
|
b.props = { padding: '24px 0', maxWidth: '960px', background: 'transparent' }
|
||||||
|
b.children = []
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
b.props = {}
|
||||||
|
}
|
||||||
|
list.push(b)
|
||||||
|
emit('update:blocks', list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(idx) {
|
||||||
|
const list = [...props.blocks]
|
||||||
|
list.splice(idx, 1)
|
||||||
|
emit('update:blocks', list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerVisible = ref(false)
|
||||||
|
const pickTarget = ref(null)
|
||||||
|
|
||||||
|
function openPicker(target, key) {
|
||||||
|
if (key === 'url') pickTarget.value = { obj: target, key: 'url' }
|
||||||
|
else pickTarget.value = { item: target }
|
||||||
|
pickerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPicked(url) {
|
||||||
|
const t = pickTarget.value
|
||||||
|
if (t?.item) t.item.url = url
|
||||||
|
if (t?.obj && t?.key) t.obj[t.key] = url
|
||||||
|
pickTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSectionChildren(idx, children) {
|
||||||
|
const list = props.blocks.map((b, i) => (i === idx ? { ...b, children: [...children] } : b))
|
||||||
|
emit('update:blocks', list)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-builder-blocks {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
max-height: 62vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.block-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.block-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.block-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.link-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.link-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.url-row .el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nested-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.add-bar {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.blocks-drag-list {
|
||||||
|
min-height: 4px;
|
||||||
|
}
|
||||||
|
.block-head-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #909399;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.drag-handle:hover {
|
||||||
|
color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
:deep(.block-ghost) {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: #ecf5ff !important;
|
||||||
|
border: 1px dashed #409eff !important;
|
||||||
|
}
|
||||||
|
:deep(.block-chosen) {
|
||||||
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.25);
|
||||||
|
}
|
||||||
|
:deep(.block-dragging) {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
178
admin/src/components/PageBuilderEditor.vue
Normal file
178
admin/src/components/PageBuilderEditor.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-builder-editor">
|
||||||
|
<div class="pbe-split">
|
||||||
|
<div class="pbe-editor-col">
|
||||||
|
<PageBuilderBlocks v-model:blocks="blocks" :site-id="siteId" />
|
||||||
|
<el-collapse class="adv-collapse" accordion>
|
||||||
|
<el-collapse-item title="高级:直接编辑 JSON(慎用)" name="json">
|
||||||
|
<el-input
|
||||||
|
v-model="jsonDraft"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
placeholder="修改后会覆盖上方可视化内容"
|
||||||
|
/>
|
||||||
|
<el-button type="warning" style="margin-top: 8px" @click="applyJsonDraft">应用 JSON</el-button>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</div>
|
||||||
|
<aside class="pbe-preview-col">
|
||||||
|
<div class="pbe-preview-head">
|
||||||
|
<span>实时预览</span>
|
||||||
|
<el-text size="small" type="info">与前台样式接近,保存后线上一致</el-text>
|
||||||
|
</div>
|
||||||
|
<div class="pbe-preview-body">
|
||||||
|
<BlockRenderer v-if="blocks.length" :blocks="blocks" />
|
||||||
|
<el-empty v-else description="添加模块后此处显示效果" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import PageBuilderBlocks from './PageBuilderBlocks.vue'
|
||||||
|
import BlockRenderer from '@yh-web/components/blocks/BlockRenderer.vue'
|
||||||
|
import '@yh-web/styles/page-animations.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
siteId: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const blocks = ref([])
|
||||||
|
const jsonDraft = ref('')
|
||||||
|
let syncingFromParent = false
|
||||||
|
|
||||||
|
function newBlockId() {
|
||||||
|
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBlock(b) {
|
||||||
|
if (!b.id) b.id = newBlockId()
|
||||||
|
if (!b.props) b.props = {}
|
||||||
|
if (!b.animation) b.animation = { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
||||||
|
if (b.type === 'link_list' && !Array.isArray(b.props.items)) b.props.items = []
|
||||||
|
if (b.type === 'section' && !Array.isArray(b.children)) b.children = []
|
||||||
|
if (Array.isArray(b.children)) b.children.forEach(normalizeBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFromString(s) {
|
||||||
|
syncingFromParent = true
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(s || '{}')
|
||||||
|
const raw = Array.isArray(j.blocks) ? JSON.parse(JSON.stringify(j.blocks)) : []
|
||||||
|
raw.forEach(normalizeBlock)
|
||||||
|
blocks.value = raw
|
||||||
|
jsonDraft.value = JSON.stringify({ version: j.version || 1, blocks: blocks.value }, null, 2)
|
||||||
|
} catch {
|
||||||
|
blocks.value = []
|
||||||
|
jsonDraft.value = '{"version":1,"blocks":[]}'
|
||||||
|
}
|
||||||
|
syncingFromParent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify() {
|
||||||
|
return JSON.stringify({ version: 1, blocks: blocks.value }, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => {
|
||||||
|
if (syncingFromParent) return
|
||||||
|
parseFromString(v)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
blocks,
|
||||||
|
() => {
|
||||||
|
if (syncingFromParent) return
|
||||||
|
const s = stringify()
|
||||||
|
jsonDraft.value = s
|
||||||
|
emit('update:modelValue', s)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function applyJsonDraft() {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(jsonDraft.value || '{}')
|
||||||
|
if (!Array.isArray(j.blocks)) {
|
||||||
|
ElMessage.error('JSON 须包含 blocks 数组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncingFromParent = true
|
||||||
|
blocks.value = JSON.parse(JSON.stringify(j.blocks))
|
||||||
|
blocks.value.forEach(normalizeBlock)
|
||||||
|
syncingFromParent = false
|
||||||
|
emit('update:modelValue', stringify())
|
||||||
|
ElMessage.success('已应用')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('JSON 格式错误')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-builder-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pbe-split {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pbe-editor-col {
|
||||||
|
flex: 1 1 420px;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
.pbe-preview-col {
|
||||||
|
flex: 0 1 380px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0a0a12;
|
||||||
|
color: #e8e8ef;
|
||||||
|
overflow: hidden;
|
||||||
|
position: sticky;
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
.pbe-preview-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.pbe-preview-body {
|
||||||
|
padding: 16px;
|
||||||
|
max-height: min(62vh, 640px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.pbe-preview-body :deep(.builder-heading),
|
||||||
|
.pbe-preview-body :deep(.builder-text),
|
||||||
|
.pbe-preview-body :deep(.builder-links a),
|
||||||
|
.pbe-preview-body :deep(.builder-btn) {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.pbe-preview-body :deep(.builder-text) {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
.pbe-preview-body :deep(.builder-links a) {
|
||||||
|
color: #7eb8ff;
|
||||||
|
}
|
||||||
|
.pbe-preview-body :deep(hr) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
}
|
||||||
|
.adv-collapse {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,10 +9,19 @@
|
|||||||
text-color="#bfcbd9"
|
text-color="#bfcbd9"
|
||||||
active-text-color="#409EFF"
|
active-text-color="#409EFF"
|
||||||
>
|
>
|
||||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
<template v-for="item in menuItems" :key="item.index">
|
||||||
<el-icon><component :is="item.icon" /></el-icon>
|
<el-menu-item v-if="!item.children" :index="item.index">
|
||||||
<span>{{ item.title }}</span>
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
</el-menu-item>
|
<span>{{ item.title }}</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-sub-menu v-else :index="item.index">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item v-for="sub in item.children" :key="sub.index" :index="sub.index">{{ sub.title }}</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</template>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
@@ -26,6 +35,7 @@
|
|||||||
<el-main class="main">
|
<el-main class="main">
|
||||||
<router-view />
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
|
<footer class="layout-footer">成都宇惠达智能科技有限公司 <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2025134957号-1</a></footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,7 +43,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key } from '@element-plus/icons-vue'
|
import { House, User, Folder, ChatDotRound, Setting, Wallet, Monitor, Document, EditPen, Upload, Key, VideoCamera, Timer, Link } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { getMyPermissions } from '../api/admin'
|
import { getMyPermissions } from '../api/admin'
|
||||||
|
|
||||||
@@ -59,12 +69,21 @@ const menuItems = computed(() => {
|
|||||||
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
{ index: '/sms-config', title: '短信配置', icon: Setting, permission: 'sms_config' },
|
||||||
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
{ index: '/payment-config', title: '支付配置', icon: Wallet, permission: 'payment_config' },
|
||||||
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
{ index: '/sites', title: '站点管理', icon: Monitor, permission: 'site:manage' },
|
||||||
|
{ index: '/chunk-upload-cleanup', title: '分片上传清理', icon: Timer, permission: 'site:manage' },
|
||||||
|
{ index: '/yuheng-cloud-accounts', title: '宇恒云账号', icon: Link, permission: 'yuheng_cloud:manage' },
|
||||||
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
{ index: '/pages', title: '网页管理', icon: Document, permission: 'page:manage' },
|
||||||
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
{ index: '/homepage-edit', title: '首页编辑', icon: EditPen, permission: 'homepage:edit' },
|
||||||
{ index: '/module-upload', title: '功能模块上传', icon: Upload, permission: 'module:upload' },
|
{ index: '/live-broadcast', title: '视频直播开播', icon: VideoCamera, permission: 'homepage:edit' },
|
||||||
|
{ index: '/files', title: '文件管理', icon: Folder, permission: null },
|
||||||
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
|
{ index: '/role-permissions', title: '角色权限管理', icon: Key, permission: 'role:permission' }
|
||||||
]
|
]
|
||||||
return all.filter((item) => hasPermission(item.permission))
|
return all.filter((item) => {
|
||||||
|
if (item.children) {
|
||||||
|
item.children = item.children.filter((sub) => hasPermission(sub.permission))
|
||||||
|
return hasPermission(item.permission) && item.children.length > 0
|
||||||
|
}
|
||||||
|
return hasPermission(item.permission)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -109,4 +128,13 @@ const handleLogout = () => {
|
|||||||
background: #f0f2f5;
|
background: #f0f2f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
.layout-footer {
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.layout-footer a { color: #999; text-decoration: none; }
|
||||||
|
.layout-footer a:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import ElementPlus from 'element-plus'
|
|||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './utils/disable-debug'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ const routes = [
|
|||||||
component: () => import('../views/settings/PaymentConfig.vue'),
|
component: () => import('../views/settings/PaymentConfig.vue'),
|
||||||
meta: { title: '支付配置', permission: 'payment_config' }
|
meta: { title: '支付配置', permission: 'payment_config' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chunk-upload-cleanup',
|
||||||
|
name: 'ChunkUploadCleanup',
|
||||||
|
component: () => import('../views/settings/ChunkUploadCleanup.vue'),
|
||||||
|
meta: { title: '分片上传清理', permission: 'site:manage' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'yuheng-cloud-accounts',
|
||||||
|
name: 'YuhengCloudAccountManage',
|
||||||
|
component: () => import('../views/settings/YuhengCloudAccountManage.vue'),
|
||||||
|
meta: { title: '宇恒云账号', permission: 'yuheng_cloud:manage' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'sites',
|
path: 'sites',
|
||||||
name: 'Sites',
|
name: 'Sites',
|
||||||
@@ -67,10 +79,16 @@ const routes = [
|
|||||||
meta: { title: '首页编辑', permission: 'homepage:edit' }
|
meta: { title: '首页编辑', permission: 'homepage:edit' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'module-upload',
|
path: 'live-broadcast',
|
||||||
name: 'ModuleUpload',
|
name: 'LiveBroadcast',
|
||||||
component: () => import('../views/sites/ModuleUpload.vue'),
|
component: () => import('../views/sites/LiveBroadcast.vue'),
|
||||||
meta: { title: '功能模块上传', permission: 'module:upload' }
|
meta: { title: '视频直播开播', permission: 'homepage:edit' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'files',
|
||||||
|
name: 'FileManage',
|
||||||
|
component: () => import('../views/files/FileManage.vue'),
|
||||||
|
meta: { title: '文件管理', permission: null }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'role-permissions',
|
path: 'role-permissions',
|
||||||
|
|||||||
@@ -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-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-card v-if="bandwidth" class="bw-card" style="margin-top: 20px">
|
||||||
|
<template #header>
|
||||||
|
<div class="bw-header">
|
||||||
|
<span>应用带宽观测</span>
|
||||||
|
<el-text type="info" size="small">{{ bwUpdatedAt }}</el-text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="bw-tip">
|
||||||
|
以下为<strong>本 Go 进程</strong>统计的 HTTP 请求/响应字节量,用于粗估负载;若前面还有 Nginx/CDN,<strong>公网出口带宽</strong>可能更高。WebSocket(如直播信令)升级后的流量可能未完全计入。
|
||||||
|
</el-alert>
|
||||||
|
<el-row :gutter="16" class="bw-row">
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">出站累计(用户下载为主)</div>
|
||||||
|
<div class="bw-value">{{ formatBytes(bandwidth.bytes_out_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">入站累计(上传/POST)</div>
|
||||||
|
<div class="bw-value">{{ formatBytes(bandwidth.bytes_in_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">近 60 秒出站 · 约 Mbps</div>
|
||||||
|
<div class="bw-value accent">{{ bandwidth.recent_egress_mbps }}</div>
|
||||||
|
<div class="bw-sub">{{ formatBytes(bandwidth.bytes_out_last_60s) }} / 60s</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<div class="bw-metric">
|
||||||
|
<div class="bw-label">自启动平均出站 · Mbps</div>
|
||||||
|
<div class="bw-value accent">{{ bandwidth.avg_egress_mbps }}</div>
|
||||||
|
<div class="bw-sub">运行 {{ formatUptime(bandwidth.uptime_seconds) }}</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="16" class="bw-row bw-row--second">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<div class="bw-metric bw-metric--inline">
|
||||||
|
<span class="bw-label">近 60 秒入站约 Mbps</span>
|
||||||
|
<span class="bw-value-inline">{{ bandwidth.recent_ingress_mbps }}</span>
|
||||||
|
<span class="bw-sub">{{ formatBytes(bandwidth.bytes_in_last_60s) }} / 60s</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<div class="bw-metric bw-metric--inline">
|
||||||
|
<span class="bw-label">自启动平均入站 · Mbps</span>
|
||||||
|
<span class="bw-value-inline">{{ bandwidth.avg_ingress_mbps }}</span>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-card style="margin-top: 20px">
|
<el-card style="margin-top: 20px">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>快捷入口</span>
|
<span>快捷入口</span>
|
||||||
@@ -41,7 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { getStats } from '../api/admin'
|
import { getStats } from '../api/admin'
|
||||||
|
|
||||||
const stats = reactive({
|
const stats = reactive({
|
||||||
@@ -52,13 +107,51 @@ const stats = reactive({
|
|||||||
files: 0
|
files: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const bandwidth = ref(null)
|
||||||
|
const bwFetchAt = ref(0)
|
||||||
|
|
||||||
|
const bwUpdatedAt = computed(() => {
|
||||||
|
if (!bwFetchAt.value) return ''
|
||||||
|
const d = new Date(bwFetchAt.value)
|
||||||
|
return `更新于 ${d.toLocaleTimeString()}`
|
||||||
|
})
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
if (n == null || !Number.isFinite(n) || n < 0) return '—'
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
const u = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = n
|
||||||
|
let i = -1
|
||||||
|
do {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
} while (v >= 1024 && i < u.length - 1)
|
||||||
|
return `${v < 10 ? v.toFixed(2) : v.toFixed(1)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(sec) {
|
||||||
|
if (sec == null || !Number.isFinite(sec) || sec < 0) return '—'
|
||||||
|
const s = Math.floor(sec)
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const r = s % 60
|
||||||
|
if (h > 0) return `${h} 小时 ${m} 分`
|
||||||
|
if (m > 0) return `${m} 分 ${r} 秒`
|
||||||
|
return `${r} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getStats()
|
const res = await getStats()
|
||||||
Object.assign(stats, res)
|
const { bandwidth: bw, ...rest } = res
|
||||||
|
Object.assign(stats, rest)
|
||||||
|
if (bw && typeof bw === 'object') {
|
||||||
|
bandwidth.value = bw
|
||||||
|
bwFetchAt.value = Date.now()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取统计失败:', e)
|
console.error('获取统计失败:', e)
|
||||||
// 即使失败也显示 0,不阻塞页面
|
// 即使失败也显示 0,不阻塞页面
|
||||||
@@ -67,7 +160,30 @@ const fetchStats = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetchStats)
|
let pollTimer = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
getStats()
|
||||||
|
.then((res) => {
|
||||||
|
const { bandwidth: bw, ...rest } = res
|
||||||
|
Object.assign(stats, rest)
|
||||||
|
if (bw && typeof bw === 'object') {
|
||||||
|
bandwidth.value = bw
|
||||||
|
bwFetchAt.value = Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 8000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer != null) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -85,4 +201,58 @@ onMounted(fetchStats)
|
|||||||
color: #666;
|
color: #666;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.bw-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bw-tip {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bw-row {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.bw-row--second {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.bw-metric {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.bw-metric--inline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 12px;
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.bw-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.bw-metric--inline .bw-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.bw-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.bw-value.accent {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.bw-value-inline {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.bw-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
23
admin/src/views/files/FileImages.vue
Normal file
23
admin/src/views/files/FileImages.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!--
|
||||||
|
文件管理:可自定义功能子模块,默认包含「图片管理」「图标管理」(图标归在图片管理下)。
|
||||||
|
文件支持:可下载 / 不可下载。
|
||||||
|
超级管理员:仅一个,默认取第一个注册用户。
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="file-images">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>图片管理</span>
|
||||||
|
<el-tag size="small" style="margin-left:8px">含图标</el-tag>
|
||||||
|
</template>
|
||||||
|
<p class="tip">图片与图标统一在此管理,支持可下载/不可下载。功能开发中。</p>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-images .tip { color: #666; font-size: 14px; }
|
||||||
|
</style>
|
||||||
256
admin/src/views/files/FileManage.vue
Normal file
256
admin/src/views/files/FileManage.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-manage">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<span>文件管理</span>
|
||||||
|
</template>
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="图片与图标" name="images">
|
||||||
|
<p class="tip">图片与图标统一在此管理,支持可下载/不可下载。功能开发中。</p>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="功能模块" name="module">
|
||||||
|
<div class="module-toolbar">
|
||||||
|
<el-select v-model="siteId" placeholder="选择站点" filterable style="width: 220px; margin-right: 12px" @change="onSiteChange">
|
||||||
|
<el-option v-for="s in sites" :key="s.id" :label="s.name" :value="s.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :disabled="!siteId" @click="showNewFolder = true">新建文件夹</el-button>
|
||||||
|
<el-upload :show-file-list="false" :disabled="!siteId" :before-upload="beforeUpload">
|
||||||
|
<el-button type="primary" :disabled="!siteId" :loading="uploading">上传文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="!siteId" title="请先选择站点" type="info" style="margin: 12px 0" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="breadcrumb-wrap">
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item @click="currentPath = ''"><a href="javascript:;">根目录</a></el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-for="(p, i) in pathParts" :key="i">
|
||||||
|
<a href="javascript:;" @click="currentPath = pathParts.slice(0, i + 1).join('/')">{{ p }}</a>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
<div class="subdirs" v-if="subDirs && subDirs.length">
|
||||||
|
<span class="label">子目录:</span>
|
||||||
|
<el-button v-for="d in subDirs" :key="d" link type="primary" @click="enterDir(d)">{{ d }}/</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" stripe style="margin-top: 12px">
|
||||||
|
<el-table-column label="文件名" prop="name" min-width="180" />
|
||||||
|
<el-table-column label="存储路径" prop="file_path" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="可下载" width="80">
|
||||||
|
<template #default="{ row }">{{ row.downloadable ? '是' : '否' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="大小" width="100">
|
||||||
|
<template #default="{ row }">{{ formatSize(row.size) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上传时间" prop="created_at" width="180" />
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!loading && list.length === 0 && (!subDirs || !subDirs.length)" description="当前目录为空,可上传文件或新建文件夹" />
|
||||||
|
</template>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 上传前选择是否可下载 -->
|
||||||
|
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="440px" :close-on-click-modal="false">
|
||||||
|
<p class="upload-resume-hint">≥8MB 将自动分片上传;中断后<strong>同一文件</strong>再次选择上传可续传(勿改文件名/大小)。</p>
|
||||||
|
<el-form label-width="112px">
|
||||||
|
<el-form-item label="当前目录">
|
||||||
|
<span>{{ currentPath || '根目录' }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="保留原文件名">
|
||||||
|
<el-switch v-model="uploadPreserveFilename" />
|
||||||
|
<span class="form-hint">开启后将按原文件名保存,同名文件会被覆盖</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="允许下载">
|
||||||
|
<el-switch v-model="uploadDownloadable" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="uploading && uploadPercent > 0" label="进度">
|
||||||
|
<el-progress :percentage="uploadPercent" :stroke-width="16" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="uploading" @click="doUpload">确定上传</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 新建文件夹 -->
|
||||||
|
<el-dialog v-model="showNewFolder" title="新建文件夹" width="400px">
|
||||||
|
<el-form label-width="80px">
|
||||||
|
<el-form-item label="目录名">
|
||||||
|
<el-input v-model="newFolderName" placeholder="当前目录下新建,可填多级如 a/b" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showNewFolder = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="createFolder">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getSites, getSiteAssets, deleteSiteAsset, createSiteFolder } from '../../api/admin'
|
||||||
|
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||||
|
|
||||||
|
const activeTab = ref('module')
|
||||||
|
const siteId = ref('')
|
||||||
|
const sites = ref([])
|
||||||
|
const list = ref([])
|
||||||
|
const subDirs = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const currentPath = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const uploadDialogVisible = ref(false)
|
||||||
|
const uploadDownloadable = ref(false)
|
||||||
|
const uploadPreserveFilename = ref(false)
|
||||||
|
const pendingFile = ref(null)
|
||||||
|
const showNewFolder = ref(false)
|
||||||
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
const pathParts = computed(() => {
|
||||||
|
const p = currentPath.value
|
||||||
|
if (!p) return []
|
||||||
|
return p.split('/').filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchSites = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getSites()
|
||||||
|
sites.value = res.list || []
|
||||||
|
if (sites.value.length && !siteId.value) siteId.value = sites.value[0].id
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchList = async () => {
|
||||||
|
if (!siteId.value) {
|
||||||
|
list.value = []
|
||||||
|
subDirs.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getSiteAssets(siteId.value, currentPath.value || undefined)
|
||||||
|
list.value = res.list || []
|
||||||
|
subDirs.value = res.sub_dirs || []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSiteChange = () => {
|
||||||
|
currentPath.value = ''
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterDir = (name) => {
|
||||||
|
currentPath.value = currentPath.value ? currentPath.value + '/' + name : name
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([siteId, currentPath], fetchList)
|
||||||
|
|
||||||
|
const beforeUpload = (file) => {
|
||||||
|
pendingFile.value = file
|
||||||
|
const p = (currentPath.value || '').replace(/^\//, '')
|
||||||
|
uploadPreserveFilename.value = p.startsWith('promotion/')
|
||||||
|
uploadDownloadable.value = !uploadPreserveFilename.value
|
||||||
|
uploadDialogVisible.value = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const doUpload = async () => {
|
||||||
|
if (!pendingFile.value || !siteId.value) return
|
||||||
|
uploading.value = true
|
||||||
|
uploadPercent.value = 0
|
||||||
|
try {
|
||||||
|
await uploadSiteAssetWithResume(
|
||||||
|
siteId.value,
|
||||||
|
pendingFile.value,
|
||||||
|
{
|
||||||
|
folder: currentPath.value || undefined,
|
||||||
|
downloadable: uploadDownloadable.value,
|
||||||
|
preserveFilename: uploadPreserveFilename.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onProgress: ({ percent }) => {
|
||||||
|
uploadPercent.value = percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
uploadDialogVisible.value = false
|
||||||
|
pendingFile.value = null
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message || '上传失败')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFolder = async () => {
|
||||||
|
const name = (newFolderName.value || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
ElMessage.warning('请输入目录名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fullPath = currentPath.value ? currentPath.value + '/' + name : name
|
||||||
|
try {
|
||||||
|
await createSiteFolder(siteId.value, fullPath)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showNewFolder.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
await ElMessageBox.confirm('确定删除该文件?', '提示', { type: 'warning' })
|
||||||
|
try {
|
||||||
|
await deleteSiteAsset(siteId.value, row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchSites().then(() => fetchList()))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-manage .tip { color: #666; font-size: 14px; }
|
||||||
|
.form-hint { display: block; margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.4; }
|
||||||
|
.form-hint code { font-size: 11px; }
|
||||||
|
.module-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.breadcrumb-wrap { margin-top: 12px; }
|
||||||
|
.subdirs { margin-top: 8px; font-size: 13px; color: #666; }
|
||||||
|
.subdirs .label { margin-right: 8px; }
|
||||||
|
.upload-resume-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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>
|
||||||
@@ -4,47 +4,99 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>角色权限管理</span>
|
<span>角色权限管理</span>
|
||||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
<div>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
<el-button type="success" @click="showCreate = true">创建角色</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p class="tip">超级管理员(9527)拥有全部权限且不可修改。为其他角色勾选其可用的后台权限。</p>
|
<p class="tip">
|
||||||
|
超级管理员(9527)拥有全部权限且不可改权限勾选(防误操作)。<strong>超级用户(0)、普通用户(1)</strong>可修改权限与显示名称;自定义角色可删除。
|
||||||
|
</p>
|
||||||
<el-table v-loading="loading" :data="list" border stripe>
|
<el-table v-loading="loading" :data="list" border stripe>
|
||||||
<el-table-column prop="role_name" label="角色" width="140" />
|
<el-table-column prop="role_name" label="角色" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-if="row.role_id !== 9527" v-model="row.role_name" size="small" placeholder="显示名称" style="width: 160px" />
|
||||||
|
<span v-else>{{ row.role_name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="role_id" label="role_id" width="100" />
|
<el-table-column prop="role_id" label="role_id" width="100" />
|
||||||
<el-table-column label="权限">
|
<el-table-column label="权限" min-width="480">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.role_id === 9527" class="perm-all">(全部权限,不可修改)</span>
|
<span v-if="row.role_id === 9527" class="perm-all">(全部权限,不可修改)</span>
|
||||||
<div v-else class="perm-checkboxes">
|
<div v-else class="perm-grid">
|
||||||
<el-checkbox
|
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
|
||||||
v-for="p in allPermissions"
|
<el-checkbox v-model="row._checked[permKey(p)]" />
|
||||||
:key="p.key"
|
<span class="perm-text">
|
||||||
v-model="row._checked[p.key]"
|
<span class="perm-name">{{ permLabel(p) }}</span>
|
||||||
style="margin-right: 16px; margin-bottom: 8px"
|
<span class="perm-key">{{ permKey(p) }}</span>
|
||||||
>
|
</span>
|
||||||
{{ p.name }}
|
</label>
|
||||||
</el-checkbox>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button v-if="row.is_custom" link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="showCreate" title="创建角色" width="560px">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="角色名称" required>
|
||||||
|
<el-input v-model="createForm.role_name" placeholder="请输入角色名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="权限">
|
||||||
|
<p class="dialog-perm-hint">勾选该角色可访问的后台能力:</p>
|
||||||
|
<div class="perm-grid dialog-perm-grid">
|
||||||
|
<label v-for="p in allPermissions" :key="permKey(p)" class="perm-item">
|
||||||
|
<el-checkbox v-model="createForm._checked[permKey(p)]" />
|
||||||
|
<span class="perm-text">
|
||||||
|
<span class="perm-name">{{ permLabel(p) }}</span>
|
||||||
|
<span class="perm-key">{{ permKey(p) }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreate = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getRolePermissionsList, updateRolePermissions } from '../../api/admin'
|
import { getRolePermissionsList, updateRolePermissions, createRole, deleteRole } from '../../api/admin'
|
||||||
|
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const allPermissions = ref([])
|
const allPermissions = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const createForm = reactive({ role_name: '', _checked: {} })
|
||||||
|
|
||||||
|
/** 兼容旧接口大写字段 Key/Name */
|
||||||
|
function permKey(p) {
|
||||||
|
return p?.key || p?.Key || ''
|
||||||
|
}
|
||||||
|
function permLabel(p) {
|
||||||
|
const k = permKey(p)
|
||||||
|
return p?.name || p?.Name || k || '权限'
|
||||||
|
}
|
||||||
|
|
||||||
function buildChecked(permissions) {
|
function buildChecked(permissions) {
|
||||||
const o = {}
|
const o = {}
|
||||||
allPermissions.value.forEach((p) => {
|
allPermissions.value.forEach((p) => {
|
||||||
o[p.key] = permissions.includes(p.key)
|
const k = permKey(p)
|
||||||
|
if (k) o[k] = (permissions || []).includes(k)
|
||||||
})
|
})
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
@@ -70,8 +122,11 @@ const handleSave = async () => {
|
|||||||
try {
|
try {
|
||||||
for (const row of list.value) {
|
for (const row of list.value) {
|
||||||
if (row.role_id === 9527) continue
|
if (row.role_id === 9527) continue
|
||||||
const permissions = allPermissions.value.filter((p) => row._checked[p.key]).map((p) => p.key)
|
const permissions = allPermissions.value.filter((p) => row._checked[permKey(p)]).map((p) => permKey(p))
|
||||||
await updateRolePermissions(row.role_id, { permissions })
|
const payload = { permissions }
|
||||||
|
const name = (row.role_name || '').trim()
|
||||||
|
if (name) payload.role_name = name
|
||||||
|
await updateRolePermissions(row.role_id, payload)
|
||||||
}
|
}
|
||||||
ElMessage.success('保存成功')
|
ElMessage.success('保存成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -81,6 +136,51 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetCreateForm = () => {
|
||||||
|
createForm.role_name = ''
|
||||||
|
createForm._checked = {}
|
||||||
|
allPermissions.value.forEach((p) => {
|
||||||
|
const k = permKey(p)
|
||||||
|
if (k) createForm._checked[k] = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = (createForm.role_name || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
ElMessage.warning('请输入角色名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const permissions = allPermissions.value.filter((p) => createForm._checked[permKey(p)]).map((p) => permKey(p))
|
||||||
|
await createRole({ role_name: name, permissions })
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreate.value = false
|
||||||
|
resetCreateForm()
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message)
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row) => {
|
||||||
|
await ElMessageBox.confirm('确定删除该角色?删除后使用该角色的用户需重新分配角色。', '提示', { type: 'warning' })
|
||||||
|
try {
|
||||||
|
await deleteRole(row.role_id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.response?.data?.error || e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showCreate, (v) => {
|
||||||
|
if (v) resetCreateForm()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(fetchList)
|
onMounted(fetchList)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -94,9 +194,50 @@ onMounted(fetchList)
|
|||||||
color: #666;
|
color: #666;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.perm-checkboxes {
|
.perm-all {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.perm-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.dialog-perm-grid {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.perm-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
.perm-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.perm-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.perm-key {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.dialog-perm-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -14,57 +14,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
|
<el-form v-if="siteId" ref="formRef" :model="form" label-width="120px" class="homepage-form">
|
||||||
<el-divider content-position="left">导航与标题</el-divider>
|
<el-divider content-position="left">导航与标题</el-divider>
|
||||||
<el-form-item label="Logo 文案">
|
<el-form-item label="Logo 文案">
|
||||||
<el-input v-model="form.logo_text" placeholder="YUHENG ONE" />
|
<el-input v-model="form.logo_text" placeholder="宇恒一号" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="主标题">
|
<el-form-item label="主标题">
|
||||||
<el-input v-model="form.title" placeholder="宇恒一号" />
|
<el-input v-model="form.title" placeholder="宇恒一号" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="副标题">
|
<el-form-item label="副标题">
|
||||||
<el-input v-model="form.subtitle" placeholder="INTERSTELLAR EXPLORER EDITION" />
|
<el-input v-model="form.subtitle" placeholder="可选,前台大标题区已精简" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item label="描述">
|
||||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
|
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="支持换行,会显示在首页" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="导航链接">
|
<el-form-item label="导航链接">
|
||||||
<div v-for="(link, i) in form.nav_links" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
|
<div
|
||||||
<el-input v-model="link.label" placeholder="Label" style="width: 120px" />
|
v-for="(link, i) in form.nav_links"
|
||||||
<el-input v-model="link.url" placeholder="URL" style="flex: 1" />
|
:key="i"
|
||||||
|
style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center; flex-wrap: wrap"
|
||||||
|
>
|
||||||
|
<el-input v-model="link.label" placeholder="显示文字" style="width: 120px" />
|
||||||
|
<el-input v-model="link.url" placeholder="路径或外链,可点「选择链接」" style="flex: 1; min-width: 160px" />
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'nav', index: i })">选择链接</el-button>
|
||||||
|
<el-link
|
||||||
|
v-if="previewReady(link.url)"
|
||||||
|
type="primary"
|
||||||
|
:href="previewHref(link.url)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>试跳</el-link>
|
||||||
<el-button link type="danger" @click="form.nav_links.splice(i, 1)">删除</el-button>
|
<el-button link type="danger" @click="form.nav_links.splice(i, 1)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
|
<el-button link type="primary" @click="form.nav_links.push({ label: '', url: '#' })">+ 添加链接</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-divider content-position="left">下载按钮</el-divider>
|
<el-divider content-position="left">侧栏下载(Windows / 安卓直链)</el-divider>
|
||||||
<el-form-item label="按钮文案">
|
<p class="builder-tip" style="margin: -6px 0 12px">
|
||||||
<el-input v-model="form.download_text" placeholder="START EXPLORING" />
|
填写同域可下载的静态地址(需将安装包放到站点 <code>promotion/downloads/</code> 并部署)。前台为「Windows 版下载」「安卓版下载」直连,不跳转整页。
|
||||||
|
</p>
|
||||||
|
<el-form-item label="Windows 安装包">
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
|
||||||
|
<el-input
|
||||||
|
v-model="form.download_windows_url"
|
||||||
|
placeholder="/promotion/downloads/yuheng-windows.zip"
|
||||||
|
style="flex: 1; min-width: 160px"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'download_windows' })">选择链接</el-button>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="按钮链接">
|
<el-form-item label="安卓安装包">
|
||||||
<el-input v-model="form.download_url" placeholder="#" />
|
<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-form-item>
|
||||||
|
|
||||||
<el-divider content-position="left">平台(轨道)</el-divider>
|
<el-divider content-position="left">直播(前台 /live)</el-divider>
|
||||||
<el-form-item label="平台列表">
|
<p class="builder-tip" style="margin: -6px 0 12px">
|
||||||
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px">
|
<strong>本站 WebRTC 直播</strong>仅在左侧菜单「视频直播开播」由已登录管理员推流,前台首页左上角画中画与「直播」页播放。
|
||||||
<el-input v-model="p.name" placeholder="如 WINDOWS" style="width: 140px" />
|
<strong>外部直播间</strong>:下方地址用于前台「进入外部直播间」跳转;可留空。
|
||||||
<el-input v-model="p.url" placeholder="链接" style="flex: 1" />
|
</p>
|
||||||
|
<el-form-item label="直播间标题">
|
||||||
|
<el-input v-model="form.live_room_title" placeholder="视频直播" style="max-width: 320px" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="直播间地址">
|
||||||
|
<el-input
|
||||||
|
v-model="form.live_room_url"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="https://live.example.com/xxx"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">旧版字段(前台 Vue 已不使用轨道路由)</el-divider>
|
||||||
|
<el-form-item label="下载按钮文案">
|
||||||
|
<el-input v-model="form.download_text" placeholder="下载" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="下载按钮链接">
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%">
|
||||||
|
<el-input v-model="form.download_url" placeholder="#" style="flex: 1; min-width: 200px" />
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'download' })">选择链接</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="平台轨道(可选)">
|
||||||
|
<div v-for="(p, i) in form.platforms" :key="i" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
|
||||||
|
<el-input v-model="p.name" placeholder="名称" style="width: 140px" />
|
||||||
|
<el-input v-model="p.url" placeholder="链接" style="flex: 1; min-width: 140px" />
|
||||||
|
<el-button type="primary" link @click="openLinkPicker({ type: 'platform', index: i })">选择链接</el-button>
|
||||||
<el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button>
|
<el-button link type="danger" @click="form.platforms.splice(i, 1)">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加平台</el-button>
|
<el-button link type="primary" @click="form.platforms.push({ name: '', url: '#' })">+ 添加</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-divider content-position="left">版本与徽章</el-divider>
|
<el-divider content-position="left">版本与徽章(前台已隐藏主视觉条,可留空)</el-divider>
|
||||||
<el-form-item label="版本">
|
<el-form-item label="版本">
|
||||||
<el-input v-model="form.version" placeholder="VERSION 3.2.1" style="width: 200px" />
|
<el-input v-model="form.version" placeholder="可留空" style="width: 200px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发射年份">
|
<el-form-item label="发布说明">
|
||||||
<el-input v-model="form.launch_year" placeholder="LAUNCH: 2024" style="width: 200px" />
|
<el-input v-model="form.launch_year" placeholder="发布日期:以官网为准" style="width: 200px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="徽章文案">
|
<el-form-item label="徽章文案">
|
||||||
<el-input v-model="form.badge_text" placeholder="FREE ACCESS" style="width: 200px" />
|
<el-input v-model="form.badge_text" placeholder="完全免费" style="width: 200px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-divider content-position="left">特性卡片</el-divider>
|
<el-divider content-position="left">特性卡片</el-divider>
|
||||||
@@ -78,12 +134,26 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="页脚文案">
|
<el-form-item label="页脚文案">
|
||||||
<el-input v-model="form.footer_text" placeholder="© 2024 YUHENG ONE" />
|
<el-input v-model="form.footer_text" placeholder="© 2024 宇恒一号 · 成都宇信达智能科技有限公司" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">首页下方扩展区(可视化积木,可拖拽排序)</el-divider>
|
||||||
|
<p class="builder-tip">
|
||||||
|
与「网页管理 → 积木」相同:从左侧手柄拖拽调整模块顺序。保存后内容显示在落地页主体模块<strong>之后</strong>、页脚之前。留空则不显示扩展区。
|
||||||
|
</p>
|
||||||
|
<el-form-item label="扩展积木" class="builder-form-item homepage-builder-wrap">
|
||||||
|
<PageBuilderEditor v-model="form.body_builder" :site-id="siteId" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-empty v-else description="请先选择站点" />
|
<el-empty v-else description="请先选择站点" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<LinkPickerDialog
|
||||||
|
v-model="linkPickerVisible"
|
||||||
|
:site-id="siteId"
|
||||||
|
@select="onLinkPicked"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,37 +162,45 @@ import { ref, reactive, onMounted, watch } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getSites, getOfficialSite, getHomepage, updateHomepage } from '../../api/admin'
|
import { getSites, getOfficialSite, getHomepage, updateHomepage } from '../../api/admin'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import LinkPickerDialog from '../../components/LinkPickerDialog.vue'
|
||||||
|
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
|
||||||
|
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
const sites = ref([])
|
const sites = ref([])
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const downloading = ref(false)
|
const downloading = ref(false)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
const linkPickerVisible = ref(false)
|
||||||
|
/** @type {import('vue').Ref<{ type: 'nav' | 'download' | 'download_windows' | 'download_android' | 'platform'; index?: number }>} */
|
||||||
|
const linkPickTarget = ref({ type: 'download' })
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
logo_text: 'YUHENG ONE',
|
logo_text: '宇恒一号',
|
||||||
nav_links: [{ label: 'MISSION', url: '#' }, { label: 'DOWNLOAD', url: '#' }, { label: 'CONTACT', url: '#' }],
|
nav_links: [
|
||||||
title: '宇恒一号',
|
{ label: '产品简介', url: '#intro' },
|
||||||
subtitle: 'INTERSTELLAR EXPLORER EDITION',
|
{ label: '产品视频', url: '#videos' },
|
||||||
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
|
{ label: '联系我们', url: '#contact' }
|
||||||
download_text: 'START EXPLORING',
|
|
||||||
download_url: '#',
|
|
||||||
platforms: [
|
|
||||||
{ name: 'WINDOWS', url: '#' },
|
|
||||||
{ name: 'MACOS', url: '#' },
|
|
||||||
{ name: 'LINUX', url: '#' },
|
|
||||||
{ name: 'IOS', url: '#' },
|
|
||||||
{ name: 'ANDROID', url: '#' }
|
|
||||||
],
|
],
|
||||||
version: 'VERSION 3.2.1',
|
title: '宇恒一号',
|
||||||
launch_year: 'LAUNCH: 2024',
|
subtitle: '',
|
||||||
badge_text: 'FREE ACCESS',
|
description: '跨越星际的智能伙伴 · 探索无限可能\n引领您进入前所未有的数字宇宙',
|
||||||
|
download_text: '下载',
|
||||||
|
download_url: '#',
|
||||||
|
download_windows_url: '/promotion/downloads/yuheng-windows.zip',
|
||||||
|
download_android_url: '/promotion/downloads/yuheng-android.apk',
|
||||||
|
platforms: [],
|
||||||
|
version: '',
|
||||||
|
launch_year: '发布日期:以官网为准',
|
||||||
|
badge_text: '完全免费',
|
||||||
features: [
|
features: [
|
||||||
{ title: '星际导航', desc: '先进的AI导航系统,精准定位您的需求,引领探索之旅' },
|
{ title: '星际导航', desc: '先进的 AI 导航系统,精准定位您的需求,引领探索之旅' },
|
||||||
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
|
{ title: '量子同步', desc: '跨维度数据同步技术,您的数据在多宇宙中保持一致' },
|
||||||
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
|
{ title: '星际防护', desc: '来自未来的安全加密协议,守护您的数字资产安全' }
|
||||||
],
|
],
|
||||||
footer_text: '© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE'
|
footer_text: '© 2024 宇恒一号 · 成都宇信达智能科技有限公司',
|
||||||
|
body_builder: '',
|
||||||
|
live_room_url: '',
|
||||||
|
live_room_title: '视频直播'
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive(defaultForm())
|
const form = reactive(defaultForm())
|
||||||
@@ -155,12 +233,19 @@ const fetchData = async () => {
|
|||||||
if (!siteId.value) return
|
if (!siteId.value) return
|
||||||
try {
|
try {
|
||||||
const data = await getHomepage(siteId.value)
|
const data = await getHomepage(siteId.value)
|
||||||
|
const base = defaultForm()
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
...defaultForm(),
|
...base,
|
||||||
...data,
|
...data,
|
||||||
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : defaultForm().nav_links,
|
nav_links: Array.isArray(data.nav_links) && data.nav_links.length ? data.nav_links : base.nav_links,
|
||||||
platforms: Array.isArray(data.platforms) && data.platforms.length ? data.platforms : defaultForm().platforms,
|
platforms: Array.isArray(data.platforms) ? data.platforms : base.platforms,
|
||||||
features: Array.isArray(data.features) && data.features.length ? data.features : defaultForm().features
|
features: Array.isArray(data.features) && data.features.length ? data.features : base.features,
|
||||||
|
download_windows_url: data.download_windows_url || base.download_windows_url,
|
||||||
|
download_android_url: data.download_android_url || base.download_android_url,
|
||||||
|
live_room_url: typeof data.live_room_url === 'string' ? data.live_room_url : base.live_room_url,
|
||||||
|
live_room_title: (typeof data.live_room_title === 'string' && data.live_room_title.trim())
|
||||||
|
? data.live_room_title.trim()
|
||||||
|
: base.live_room_title
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e.message)
|
ElMessage.error(e.message)
|
||||||
@@ -203,6 +288,40 @@ const handleDownload = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLinkPicker(target) {
|
||||||
|
linkPickTarget.value = target
|
||||||
|
linkPickerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLinkPicked(url) {
|
||||||
|
const t = linkPickTarget.value
|
||||||
|
if (t.type === 'nav' && typeof t.index === 'number') {
|
||||||
|
form.nav_links[t.index].url = url
|
||||||
|
} else if (t.type === 'download') {
|
||||||
|
form.download_url = url
|
||||||
|
} else if (t.type === 'download_windows') {
|
||||||
|
form.download_windows_url = url
|
||||||
|
} else if (t.type === 'download_android') {
|
||||||
|
form.download_android_url = url
|
||||||
|
} else if (t.type === 'platform' && typeof t.index === 'number') {
|
||||||
|
form.platforms[t.index].url = url
|
||||||
|
}
|
||||||
|
ElMessage.success('已填入链接')
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewReady(url) {
|
||||||
|
const u = (url || '').trim()
|
||||||
|
return Boolean(u && u !== '#')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台试跳:相对路径用当前浏览器域名拼接 */
|
||||||
|
function previewHref(url) {
|
||||||
|
const u = (url || '').trim()
|
||||||
|
if (/^https?:\/\//i.test(u)) return u
|
||||||
|
if (u.startsWith('/')) return `${window.location.origin}${u}`
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchSites().then(() => fetchData())
|
fetchSites().then(() => fetchData())
|
||||||
})
|
})
|
||||||
@@ -214,4 +333,25 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.homepage-form {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
.builder-tip {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
.homepage-builder-wrap {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
.homepage-builder-wrap :deep(.el-form-item__content) {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
.builder-form-item :deep(.el-form-item__content) {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getSites, getSiteAssets, uploadSiteAsset, deleteSiteAsset } from '../../api/admin'
|
import { getSites, getSiteAssets, deleteSiteAsset } from '../../api/admin'
|
||||||
|
import { uploadSiteAssetWithResume } from '../../utils/siteAssetResumableUpload'
|
||||||
|
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
const sites = ref([])
|
const sites = ref([])
|
||||||
@@ -87,7 +88,7 @@ watch(siteId, fetchList)
|
|||||||
const beforeUpload = async (file) => {
|
const beforeUpload = async (file) => {
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
await uploadSiteAsset(siteId.value, file)
|
await uploadSiteAssetWithResume(siteId.value, file, {})
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
fetchList()
|
fetchList()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -17,9 +17,24 @@
|
|||||||
<el-table-column label="ID" width="240">
|
<el-table-column label="ID" width="240">
|
||||||
<template #default="{ row }">{{ row.id }}</template>
|
<template #default="{ row }">{{ row.id }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="slug" label="Slug" width="120" />
|
<el-table-column prop="slug" label="Slug" width="100" />
|
||||||
<el-table-column prop="title" label="标题" width="160" />
|
<el-table-column label="前台路径" min-width="120" show-overflow-tooltip>
|
||||||
<el-table-column prop="type" label="类型" width="100">
|
<template #default="{ row }">{{ row.route_path || '/' + (row.slug || '') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="标题" width="140" />
|
||||||
|
<el-table-column label="模式" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.content_mode === 'builder'" type="warning" size="small">积木</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">HTML</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发布" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.published === false" type="danger" size="small">否</el-tag>
|
||||||
|
<el-tag v-else type="success" size="small">是</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" label="类型" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
|
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
|
||||||
<el-tag v-else size="small">页面</el-tag>
|
<el-tag v-else size="small">页面</el-tag>
|
||||||
@@ -35,10 +50,35 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="560px" @close="resetForm">
|
<el-dialog
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
v-model="dialogVisible"
|
||||||
|
:title="editId ? '编辑网页' : '新增网页'"
|
||||||
|
:width="form.content_mode === 'builder' || form.content_mode === 'html' ? '1080px' : '720px'"
|
||||||
|
top="4vh"
|
||||||
|
@close="resetForm"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
v-if="form.content_mode === 'builder'"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
title="积木模式:左侧编辑、右侧实时预览;⋮⋮ 可拖拽排序;链接可「选择链接」。"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="form.content_mode === 'html'"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
title="HTML 模式:左侧源码、右侧预览(沙箱内不执行脚本,与线上可能略有差异)。"
|
||||||
|
/>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="Slug" prop="slug">
|
<el-form-item label="Slug" prop="slug">
|
||||||
<el-input v-model="form.slug" placeholder="如 about、index" :disabled="!!editId" />
|
<el-input v-model="form.slug" placeholder="如 about、index(index 为首页数据,一般不单独走路由)" :disabled="!!editId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="前台路径">
|
||||||
|
<el-input v-model="form.route_path" placeholder="留空则自动为 /{slug},可填如 /download 或 /about/us" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标题" prop="title">
|
<el-form-item label="标题" prop="title">
|
||||||
<el-input v-model="form.title" placeholder="页面标题" />
|
<el-input v-model="form.title" placeholder="页面标题" />
|
||||||
@@ -49,8 +89,34 @@
|
|||||||
<el-option label="首页" value="homepage" />
|
<el-option label="首页" value="homepage" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容" prop="content">
|
<el-form-item label="内容模式">
|
||||||
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="HTML 或 JSON" />
|
<el-radio-group v-model="form.content_mode">
|
||||||
|
<el-radio-button value="builder">积木(可视化拖拽)</el-radio-button>
|
||||||
|
<el-radio-button value="html">HTML 源码</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发布到前台">
|
||||||
|
<el-switch v-model="form.published" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content" class="html-form-item">
|
||||||
|
<div class="html-split">
|
||||||
|
<div class="html-editor">
|
||||||
|
<el-input v-model="form.content" type="textarea" :rows="18" placeholder="直接编写 HTML" />
|
||||||
|
</div>
|
||||||
|
<div class="html-preview-wrap">
|
||||||
|
<div class="html-preview-title">实时预览</div>
|
||||||
|
<iframe
|
||||||
|
class="html-preview-iframe"
|
||||||
|
title="html-preview"
|
||||||
|
sandbox=""
|
||||||
|
:srcdoc="htmlPreviewSrcdoc"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="页面积木" class="builder-form-item page-builder-wrap">
|
||||||
|
<PageBuilderEditor v-model="form.content" :site-id="siteId" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -62,10 +128,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getSites } from '../../api/admin'
|
import { getSites } from '../../api/admin'
|
||||||
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
|
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
|
||||||
|
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
|
||||||
|
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
const sites = ref([])
|
const sites = ref([])
|
||||||
@@ -107,7 +174,87 @@ const dialogVisible = ref(false)
|
|||||||
const editId = ref('')
|
const editId = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const form = reactive({ site_id: '', slug: '', title: '', type: 'page', content: '' })
|
const builderTemplate = () =>
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'h1',
|
||||||
|
type: 'heading',
|
||||||
|
props: { text: '页面标题', level: 2 },
|
||||||
|
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
type: 'text',
|
||||||
|
props: { text: '在此编辑说明文字,可在后台修改 JSON 调整模块与动画。' },
|
||||||
|
animation: { enter: 'slideUp', delay_ms: 100, duration_ms: 500 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'links',
|
||||||
|
type: 'link_list',
|
||||||
|
props: {
|
||||||
|
items: [
|
||||||
|
{ label: '回首页', url: '/' },
|
||||||
|
{ label: '示例外链', url: '#', target: '_blank' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'btn',
|
||||||
|
type: 'button',
|
||||||
|
props: { text: '主要按钮', url: '#', variant: 'primary' }
|
||||||
|
},
|
||||||
|
{ id: 'sp', type: 'spacer', props: { height: 24 } },
|
||||||
|
{
|
||||||
|
id: 'sec',
|
||||||
|
type: 'section',
|
||||||
|
props: { padding: '24px 0', maxWidth: '720px' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'sub',
|
||||||
|
type: 'text',
|
||||||
|
props: { html: '<p>区块内可嵌套子模块(<strong>section → children</strong>)。</p>' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
site_id: '',
|
||||||
|
slug: '',
|
||||||
|
title: '',
|
||||||
|
type: 'page',
|
||||||
|
content: '',
|
||||||
|
content_mode: 'builder',
|
||||||
|
route_path: '',
|
||||||
|
published: true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** HTML 预览 iframe(沙箱禁用脚本,避免编辑时执行恶意片段) */
|
||||||
|
const htmlPreviewSrcdoc = computed(() => {
|
||||||
|
const raw = form.content || ''
|
||||||
|
const body = raw.trim() ? raw : '<p style="color:#999">暂无内容</p>'
|
||||||
|
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{font-family:system-ui,sans-serif;padding:12px;margin:0;background:#fff;color:#222;line-height:1.5;}</style></head><body>${body}</body></html>`
|
||||||
|
})
|
||||||
|
|
||||||
|
function insertBuilderTemplate() {
|
||||||
|
form.content_mode = 'builder'
|
||||||
|
if (!form.content?.trim()) {
|
||||||
|
form.content = builderTemplate()
|
||||||
|
} else {
|
||||||
|
ElMessageBox.confirm('将用模板覆盖当前内容?', '提示', { type: 'warning' })
|
||||||
|
.then(() => {
|
||||||
|
form.content = builderTemplate()
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
const rules = {
|
const rules = {
|
||||||
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
|
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
|
||||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||||
@@ -120,6 +267,9 @@ const openDialog = (row) => {
|
|||||||
form.title = row ? row.title : ''
|
form.title = row ? row.title : ''
|
||||||
form.type = row ? row.type || 'page' : 'page'
|
form.type = row ? row.type || 'page' : 'page'
|
||||||
form.content = row ? row.content || '' : ''
|
form.content = row ? row.content || '' : ''
|
||||||
|
form.content_mode = row?.content_mode || 'builder'
|
||||||
|
form.route_path = row?.route_path || ''
|
||||||
|
form.published = row?.published !== false
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +278,9 @@ const resetForm = () => {
|
|||||||
form.title = ''
|
form.title = ''
|
||||||
form.type = 'page'
|
form.type = 'page'
|
||||||
form.content = ''
|
form.content = ''
|
||||||
|
form.content_mode = 'builder'
|
||||||
|
form.route_path = ''
|
||||||
|
form.published = true
|
||||||
editId.value = ''
|
editId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +288,20 @@ const submitForm = async () => {
|
|||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
slug: form.slug,
|
||||||
|
title: form.title,
|
||||||
|
type: form.type,
|
||||||
|
content: form.content,
|
||||||
|
content_mode: form.content_mode,
|
||||||
|
route_path: form.route_path || undefined,
|
||||||
|
published: form.published
|
||||||
|
}
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
await updatePage(editId.value, { slug: form.slug, title: form.title, type: form.type, content: form.content })
|
await updatePage(editId.value, payload)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createPage({ ...form, site_id: siteId.value })
|
await createPage({ ...payload, site_id: siteId.value })
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
@@ -173,4 +335,50 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.builder-form-item :deep(.el-form-item__content) {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
.page-builder-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.html-form-item :deep(.el-form-item__content) {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
.html-split {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.html-editor {
|
||||||
|
flex: 1 1 400px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.html-preview-wrap {
|
||||||
|
flex: 0 1 420px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.html-preview-title {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.html-preview-iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 360px;
|
||||||
|
height: 420px;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
base: '/admin/',
|
base: '/admin/',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// 与前台共用积木渲染,避免重复维护
|
||||||
|
'@yh-web': path.resolve(__dirname, '../web/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true,
|
host: true,
|
||||||
|
// 开发时 /api 也走域名(与 .env.development 中 VITE_API_BASE 一致)
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'https://yuheng.yuxindazhineng.com',
|
||||||
changeOrigin: true
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
deploy/README.md
Normal file
19
deploy/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# deploy 目录(与 api 相同:仅替换构建产物;`web` 容器除 `web/public` 外不挂源码目录)
|
||||||
|
|
||||||
|
- **deploy/web/dist**:前台构建产物,由 `pull-and-restart.sh` 生成;替换此目录内容即可更新前台。
|
||||||
|
- **deploy/admin/dist**:后台构建产物,同上。后台 Vite 通过 `@yh-web` 引用 `../web/src`(如积木 `BlockRenderer`),用 Docker 单目录挂载 `admin` 时会构建失败,须挂载**项目根**再在 `admin` 下执行 `npm run build`(见 `pull-and-restart.sh`)。
|
||||||
|
- **deploy/api/server**:API 二进制,同上;替换后重启 api 容器生效。
|
||||||
|
- **deploy/web/default.conf**、**deploy/admin/default.conf**:Nginx 配置,已纳入版本库。
|
||||||
|
|
||||||
|
日常更新:在服务器执行 `./pull-and-restart.sh` 会拉代码、重新构建到上述目录并重启容器。若只改静态资源,也可在服务器上手动构建后只重启对应容器。
|
||||||
|
|
||||||
|
## 后台白屏 / 控制台 “MIME type text/html” 针对 `index-*.js`
|
||||||
|
|
||||||
|
表示浏览器拿到的不是 JS,而是 HTML(常见:`/assets/*.js` 被 SPA 回退成 `index.html`,或 404 返回了 HTML 错误页)。
|
||||||
|
|
||||||
|
1. **确认 Nginx 配置已更新**:`deploy/admin/default.conf` 须含 `location ^~ /assets/` 且 `try_files $uri =404`(与仓库内 `admin/nginx.conf` 一致),挂载后重启 `admin` 容器。
|
||||||
|
2. **确认 dist 完整**:`deploy/admin/dist/assets/` 下须有与 `index.html` 中 `<script type="module">` 引用**同名**的哈希文件;发版后应**整目录**替换 `dist`(勿只拷 `index.html`)。
|
||||||
|
3. **本地重建**:在项目根按 `pull-and-restart.sh` 方式在 `admin/` 执行 `npm run build`,`vite.config` 中 `base` 须为 `'/admin/'`。
|
||||||
|
4. **勿用旧版 `nginx/admin.conf`**:若曾把仅含 `location /` 的旧配置拷到服务器,会导致 `/assets/*.js` 全部变成 `index.html`(约 640B、MIME 错)。请以 **`deploy/admin/default.conf`** 或 **`admin/nginx.conf`** 为准,并 **`docker compose restart admin nginx`**。
|
||||||
|
5. **外层 `/admin/` 反代**:`yuheng.docker.conf.tpl` 使用 **`upstream yh_admin_upstream { server admin:80; }`** + **`location /admin/ { proxy_pass http://yh_admin_upstream/; }`**(尾斜杠),由 Nginx **标准规则**去掉 `/admin` 前缀;勿对 admin 使用 **变量** `proxy_pass`(会把完整 `/admin/...` 传到上游 → 内层无法匹配 `/assets/` → 白屏)。另含 **`location = /admin { return 301 /admin/; }`**,避免无尾斜杠误走前台。
|
||||||
|
6. **`restart.sh` 构建 admin** 已与 **`pull-and-restart.sh` 一致**(挂载**项目根**到容器,否则 `@yh-web` 无法解析)。发版后脚本会执行 **`scripts/verify-admin-dist.sh`**:若 `index.html` 引用的 chunk 在 `dist/assets/` 中缺失或过小(几百字节),会直接报错退出,避免白屏上线。
|
||||||
24
deploy/admin/default.conf
Normal file
24
deploy/admin/default.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 与 admin/nginx.conf 保持内容一致(Compose 挂载本文件;admin 镜像内 COPY 同配置)
|
||||||
|
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器,此处 location / 提供 SPA
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 发版后勿长期缓存入口,否则浏览器保留旧 index.html、却拉新 chunk 名 → 白屏
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
deploy/web/default.conf
Normal file
59
deploy/web/default.conf
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# 与 nginx/web.conf 保持同步;compose 挂载到 web 容器
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 域名/微信等验证文件:由外层 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
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
# 域名与 HTTPS 由 Nginx Proxy Manager 统一配置: https://npm.yuxindazhineng.com/nginx/proxy
|
# 对外仅暴露 443(HTTPS);内部 api/web/admin 不映射宿主机端口
|
||||||
# 本 compose 只暴露 9527(api)、9528(web)、9529(admin),由 NPM 反向代理到对外域名
|
# version 已废弃,已移除
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# 二进制由脚本构建到 deploy/api/server,挂载 deploy/api 即可更新,无需重建镜像
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile.run
|
||||||
image: yh_web-api:latest
|
args:
|
||||||
|
REGISTRY_MIRROR: ${REGISTRY_MIRROR:-}
|
||||||
|
image: yh_web-api-run:latest
|
||||||
container_name: yh_api
|
container_name: yh_api
|
||||||
|
volumes:
|
||||||
|
- ./deploy/api:/app:ro
|
||||||
|
- ./data/uploads:/uploads
|
||||||
|
env_file:
|
||||||
|
- ./server/.env
|
||||||
environment:
|
environment:
|
||||||
- PORT=9527
|
- PORT=8088
|
||||||
|
- UPLOAD_DIR=/uploads
|
||||||
- MONGODB_URI=${MONGODB_URI:-mongodb://mongo:27017}
|
- MONGODB_URI=${MONGODB_URI:-mongodb://mongo:27017}
|
||||||
- MONGODB_DB=${MONGODB_DB:-yxd-agent-testing}
|
- MONGODB_DB=${MONGODB_DB:-yxd-agent-testing}
|
||||||
- GIN_MODE=release
|
- GIN_MODE=release
|
||||||
@@ -19,33 +27,54 @@ services:
|
|||||||
- mongo
|
- mongo
|
||||||
networks:
|
networks:
|
||||||
- yh_net
|
- yh_net
|
||||||
ports:
|
|
||||||
- "9527:9527"
|
|
||||||
|
|
||||||
|
# 静态文件仅 deploy/web/dist;与 api 一致不挂源码目录。仅额外挂载 web/public(logo、social 二维码等)
|
||||||
web:
|
web:
|
||||||
build:
|
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
|
||||||
context: ./web
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: yh_web-web:latest
|
|
||||||
container_name: yh_web
|
container_name: yh_web
|
||||||
|
volumes:
|
||||||
|
- ./deploy/web/dist:/usr/share/nginx/html:ro
|
||||||
|
- ./web/public:/var/www/yh-public:ro
|
||||||
|
- ./deploy/web/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
networks:
|
networks:
|
||||||
- yh_net
|
- yh_net
|
||||||
ports:
|
|
||||||
- "9528:80"
|
|
||||||
|
|
||||||
|
# 静态文件由脚本构建到 deploy/admin/dist,挂载后替换文件即可生效
|
||||||
admin:
|
admin:
|
||||||
build:
|
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
|
||||||
context: ./admin
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: yh_web-admin:latest
|
|
||||||
container_name: yh_admin
|
container_name: yh_admin
|
||||||
|
volumes:
|
||||||
|
- ./deploy/admin/dist:/usr/share/nginx/html:ro
|
||||||
|
- ./deploy/admin/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
networks:
|
networks:
|
||||||
- yh_net
|
- yh_net
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}nginx:alpine
|
||||||
|
container_name: yh_nginx
|
||||||
ports:
|
ports:
|
||||||
- "9529:80"
|
- "443:443"
|
||||||
|
# 启动脚本:等上游 → 从 resolv.conf 注入 resolver → 生成 conf.d(变量 proxy_pass),避免 Podman host not found
|
||||||
|
entrypoint: ["/bin/sh", "/nginx-entrypoint-wait-dns.sh"]
|
||||||
|
volumes:
|
||||||
|
- ./scripts/nginx-entrypoint-wait-dns.sh:/nginx-entrypoint-wait-dns.sh:ro
|
||||||
|
- ./nginx/yuheng.docker.conf.tpl:/yuheng.docker.conf.tpl:ro
|
||||||
|
- ./nginx/runtime-confd:/etc/nginx/conf.d
|
||||||
|
- ./verify-root:/verify-root:ro
|
||||||
|
- /etc/ssl/yh_web/yuheng.yuxindazhineng.com:/etc/ssl/yh_web/yuheng.yuxindazhineng.com:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
- web
|
||||||
|
- admin
|
||||||
|
# Podman/慢盘:API 首次就绪可能超过 90s,避免 yh_nginx 等待超时后 Exited(1) → 全站 443 拒绝连接
|
||||||
|
environment:
|
||||||
|
- NGINX_WAIT_UPSTREAM_SEC=180
|
||||||
|
networks:
|
||||||
|
- yh_net
|
||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
# 国内默认走镜像;海外可 export REGISTRY_MIRROR= 后直连
|
||||||
|
image: ${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}mongo:7
|
||||||
container_name: yh_mongo
|
container_name: yh_mongo
|
||||||
volumes:
|
volumes:
|
||||||
- mongo_data:/data/db
|
- mongo_data:/data/db
|
||||||
|
|||||||
67
docs/PAGE_BUILDER.md
Normal file
67
docs/PAGE_BUILDER.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 前台积木页面(动态路由)
|
||||||
|
|
||||||
|
## 概念
|
||||||
|
|
||||||
|
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
||||||
|
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
||||||
|
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、**按住左侧手柄拖拽**调整顺序、配置动画);编辑区右侧为 **实时预览**(与前台共用 `BlockRenderer`)。链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||||
|
- **HTML 模式**:网页管理弹窗内 **左侧源码、右侧 iframe 预览**(沙箱不执行脚本)。
|
||||||
|
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||||
|
|
||||||
|
## 动态路由
|
||||||
|
|
||||||
|
- 前台启动时请求 `GET /api/web/routes`,按已发布页面注册 Vue Router。
|
||||||
|
- `slug` 为 `index` 的页面不参与动态路由(仍由首页 `Home.vue` + 首页数据驱动)。
|
||||||
|
- 单页数据:`GET /api/web/page?path=/your-path`(`site_id` 可选,默认官网站点)。
|
||||||
|
|
||||||
|
## 积木 JSON 结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "唯一可选",
|
||||||
|
"type": "heading",
|
||||||
|
"props": { "text": "标题", "level": 2 },
|
||||||
|
"animation": { "enter": "fadeIn", "delay_ms": 0, "duration_ms": 600 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块类型 `type`
|
||||||
|
|
||||||
|
| type | props 说明 |
|
||||||
|
|------|------------|
|
||||||
|
| `heading` | `text`, `level` (1–6) |
|
||||||
|
| `text` | `text` 纯文本;或 `html: true` 时用 `html` / `text` 作为 HTML |
|
||||||
|
| `link_list` | `items: [{ label, url, target? }]` |
|
||||||
|
| `button` | `text`, `url`, `variant`: `primary` \| `ghost`, `target?` |
|
||||||
|
| `html` | `html` 原始 HTML 片段 |
|
||||||
|
| `spacer` | `height` 像素 |
|
||||||
|
| `divider` | 无 |
|
||||||
|
| `section` | `padding`, `maxWidth`, `background`;`children` 为子 `blocks` 数组 |
|
||||||
|
|
||||||
|
### 动画 `animation.enter`
|
||||||
|
|
||||||
|
- `none` | `fadeIn` | `slideUp` | `slideLeft` | `zoomIn`
|
||||||
|
- `delay_ms`、`duration_ms` 控制延迟与时长(毫秒)
|
||||||
|
|
||||||
|
## 扩展新模块
|
||||||
|
|
||||||
|
1. 在 `web/src/components/blocks/BlockRenderer.vue` 增加 `type` 分支与样式。
|
||||||
|
2. 后台仍通过 JSON 配置 `props`,无需改库表结构。
|
||||||
|
|
||||||
|
## 首页编辑:下方扩展积木
|
||||||
|
|
||||||
|
- 除原有导航、主视觉、特性等表单项外,可增加 **「首页下方扩展区」**:与网页积木相同 JSON,保存后由前台 `Home.vue` 在特性区之后、页脚之前用 **同一套 BlockRenderer** 动态渲染。
|
||||||
|
- 下载的静态 `index.html` 目前**不包含**该积木区(仅在线 SPA 展示)。
|
||||||
|
|
||||||
|
## 首页编辑(导航 / 下载 / 平台链接)
|
||||||
|
|
||||||
|
- **管理后台 → 首页编辑与下载**:导航链接、下载按钮链接、各平台链接均可点 **「选择链接」**,与积木编辑器共用 `LinkPickerDialog`。
|
||||||
|
- **本站页面**:来自当前站点「网页管理」中的页面路径(含首页 `/`)。
|
||||||
|
- **其他站点首页**:同账号下其他站点;需在 **站点管理** 中填写 **域名**,系统会生成 `https://域名/` 形式的外链。
|
||||||
|
- **可下载文件**:与积木一致,填入 `/api/web/sites/{siteId}/assets/{id}/download`。
|
||||||
|
- **试跳**:保存前可在后台用 **「试跳」** 新标签页预览;以 `/` 开头的路径会按当前浏览器域名拼接(与前台实际域名不一致时请以真实站点为准)。
|
||||||
96
nginx/README.md
Normal file
96
nginx/README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Nginx 配置(新服务器无 NPM 时使用)
|
||||||
|
|
||||||
|
域名:**yuheng.yuxindazhineng.com**,强制 HTTPS,SSL 证书按域名单独存放。
|
||||||
|
|
||||||
|
## 1. 证书目录(按域名命名)
|
||||||
|
|
||||||
|
在服务器上创建专门存放 SSL 的目录,以域名为子目录名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/ssl/yh_web/yuheng.yuxindazhineng.com
|
||||||
|
```
|
||||||
|
|
||||||
|
将证书文件放入该目录(Let's Encrypt 或自有证书均可):
|
||||||
|
|
||||||
|
- **fullchain.pem** — 证书链(或你的 `fullchain.crt`,需在配置里改扩展名)
|
||||||
|
- **privkey.pem** — 私钥(或你的 `privkey.key`)
|
||||||
|
|
||||||
|
**一键脚本自动同步**:也可把证书放在项目 **`nginx/`** 下,运行 `./pull-and-restart.sh` 或 `./restart.sh` 会自动复制到系统目录。支持两种命名方式:
|
||||||
|
- **`nginx/yuheng.yuxindazhineng.com.pem`** + **`nginx/yuheng.yuxindazhineng.com.key`**(按域名命名)
|
||||||
|
- **`nginx/fullchain.pem`** + **`nginx/privkey.pem`**
|
||||||
|
|
||||||
|
示例(若用 certbot):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# certbot 默认路径,可复制到统一目录或做软链接
|
||||||
|
sudo cp /etc/letsencrypt/live/yuheng.yuxindazhineng.com/fullchain.pem /etc/ssl/yh_web/yuheng.yuxindazhineng.com/
|
||||||
|
sudo cp /etc/letsencrypt/live/yuheng.yuxindazhineng.com/privkey.pem /etc/ssl/yh_web/yuheng.yuxindazhineng.com/
|
||||||
|
sudo chown -R root:root /etc/ssl/yh_web/yuheng.yuxindazhineng.com
|
||||||
|
sudo chmod 600 /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 部署 Nginx 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制项目内配置到 Nginx 配置目录(按实际路径调整)
|
||||||
|
sudo cp /www/yh_web/nginx/yuheng.yuxindazhineng.com.conf /etc/nginx/conf.d/
|
||||||
|
|
||||||
|
# 检查配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 重载
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
若 Nginx 使用其他路径(如 `sites-enabled`),请把上述 conf 放到对应目录并 `include` 到主配置。
|
||||||
|
|
||||||
|
## 3. 两种部署方式(二选一)
|
||||||
|
|
||||||
|
**方式 A:仅 compose 占 443(默认)**
|
||||||
|
- `docker-compose.yml` 中 nginx 映射 `443:443`,请求直接进 compose 内 Nginx,再反代到 api/web/admin。
|
||||||
|
- 宿主机**不要**为本站点单独起 Nginx(不要用本目录的 `yuheng.yuxindazhineng.com.conf` 占 443),否则会与 compose 抢 443 或反代到已废弃的 9528/9529/8088,导致 /api/、/admin/ 404。
|
||||||
|
|
||||||
|
**方式 B:宿主机 Nginx 占 443,反代到 compose**
|
||||||
|
- 若宿主机已有 Nginx 监听 443(多站点),则把 compose 中 nginx 端口改为 **8443:443**,宿主机用本目录的 `yuheng.yuxindazhineng.com.conf`(已配置为整站反代到 `127.0.0.1:8443`)。
|
||||||
|
- 复制 conf 到 `/etc/nginx/conf.d/` 后 `nginx -t && systemctl reload nginx`。
|
||||||
|
|
||||||
|
**/api/health 或 /admin/ 返回 404 时**:在服务器执行 `ss -tlnp | grep 443`,看 443 是宿主机 nginx 还是 docker。若是宿主机 nginx,要么停用该站点配置让 compose 独占 443(方式 A),要么改为方式 B(compose 用 8443,宿主机反代到 8443)。
|
||||||
|
|
||||||
|
**验证文件热加载**:把验证文件放到项目根目录的 `verify-root/` 即可;compose 内 **`yh_nginx`** 挂载该目录并在 **443** 上直接 `root /verify-root` 提供(见 `nginx/yuheng.docker.conf.tpl`)。`reload` 后生效;若仅改文件,可 `docker compose restart nginx`。
|
||||||
|
|
||||||
|
## 4. 新服务器首次安装 Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CentOS / RHEL / 阿里云
|
||||||
|
sudo dnf install -y nginx
|
||||||
|
# 或
|
||||||
|
sudo yum install -y nginx
|
||||||
|
|
||||||
|
# 开机自启并启动
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
然后再按上面步骤创建证书目录、放入证书、复制 conf 并重载。
|
||||||
|
|
||||||
|
## 5. 前台动态路由与 404(SPA)
|
||||||
|
|
||||||
|
- **现象**:浏览器直接打开 `https://你的域名/some-page` 出现 **nginx** 的 `404 Not Found`(页脚带 `nginx/x.x.x`),而不是网站自己的页面。
|
||||||
|
- **原因**:提供静态文件的 `server` 未把「不存在的路径」交给 `index.html`,Nginx 在磁盘上找不到 `some-page` 文件就返回 404。
|
||||||
|
- **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf` 与 `web/Dockerfile` 内嵌配置)。若你自建 Nginx,请对照修改后再 `nginx -t` 并重载。
|
||||||
|
- **应用内 404**:在 SPA 已正确回退的前提下,未在后台发布的路径会由前端路由进入 **「页面不存在」** 页(`NotFound.vue`),与上述 nginx 404 不同。
|
||||||
|
- **Compose 部署**:`web` 容器实际加载的是 **`deploy/web/default.conf`**(见 `docker-compose.yml` 挂载)。若线上仍对 `/test` 等返回 **nginx 404**,请把仓库里最新的 `deploy/web/default.conf` 同步到服务器对应路径后,执行 `docker compose restart web`(或重建 `yh_web` 容器)。
|
||||||
|
|
||||||
|
## 6. 单实例:宿主机 Nginx 占 443(与 `pull-and-restart.sh` / `restart.sh` 自动切换)
|
||||||
|
|
||||||
|
逻辑由 **`scripts/lib-yh-compose-deploy.sh`** 统一处理(无需单独启动脚本):
|
||||||
|
|
||||||
|
1. **启动前**:`docker compose … down --remove-orphans`,只停本项目容器,**不停止**宿主机 `nginx`。
|
||||||
|
2. **写入宿主机站点配置**:从 **`nginx/yuheng.host.conf`** 生成 `/etc/nginx/conf.d/<域名>.conf`,并执行 **`nginx -t`**。
|
||||||
|
3. **检测宿主机 Nginx**:若在线则跳过;若不在线则执行 `systemctl start nginx` 并 `enable`。
|
||||||
|
4. **启动容器**:只起 `mongo api web admin`(不再启动容器 `yh_nginx`)。
|
||||||
|
5. **证书**:同上,`/etc/ssl/yh_web/yuheng.yuxindazhineng.com/`;`pull-and-restart.sh` / `restart.sh` 仍会同步仓库内证书到该目录。
|
||||||
|
|
||||||
|
**回环端口**(与 `nginx/yuheng.host.conf` 一致):API `127.0.0.1:8088`,前台 `9080`,后台 `9081`。
|
||||||
|
|
||||||
|
**说明**:不再使用容器 `yh_nginx` 作为入口,统一为宿主机 Nginx 单入口方案。
|
||||||
23
nginx/admin.conf
Normal file
23
nginx/admin.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 与 deploy/admin/default.conf、admin/nginx.conf 保持一致(勿再使用仅含 location / 的旧版,否则 /assets/*.js 会回退成 index.html → 白屏)
|
||||||
|
# 外层 Nginx 已把 /admin/ 转成 / 转发到本容器
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,37 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name yuheng.yuxindazhineng.com;
|
server_name yuheng.yuxindazhineng.com;
|
||||||
|
# 与宿主机 yuheng.host.conf 一致,避免后台大文件上传被默认 1m 拒绝
|
||||||
|
client_max_body_size 800m;
|
||||||
# 若使用 HTTPS,取消下面注释并挂载证书到 /etc/nginx/ssl/
|
# 若使用 HTTPS,取消下面注释并挂载证书到 /etc/nginx/ssl/
|
||||||
# listen 443 ssl;
|
# listen 443 ssl;
|
||||||
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
|
||||||
|
location /api/web/live/ws {
|
||||||
|
proxy_pass http://api:9527;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/web/live/danmaku/ws {
|
||||||
|
proxy_pass http://api:9527;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:9527;
|
proxy_pass http://api:9527;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -13,6 +39,12 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
client_body_timeout 0;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
@@ -31,5 +63,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 75s;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
nginx/runtime-confd/.gitkeep
Normal file
0
nginx/runtime-confd/.gitkeep
Normal file
52
nginx/web.conf
Normal file
52
nginx/web.conf
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 供 compose 中 web 容器使用:与 deploy/web/default.conf 同步;验证文件仅外层 yh_nginx 处理
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
expires -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
# web/public 挂载 /var/www/yh-public;单段文件名同 dist 根 URL,优先挂载后回退 dist
|
||||||
|
location ~ ^/([^/]+\.(?:png|jpe?g|gif|ico|svg|webp|webmanifest))$ {
|
||||||
|
root /var/www/yh-public;
|
||||||
|
try_files /$1 @dist_root_public;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
location @dist_root_public {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /social/ {
|
||||||
|
alias /var/www/yh-public/social/;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /promotion/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = / {
|
||||||
|
try_files /index.html =404;
|
||||||
|
}
|
||||||
|
# 前台为 Vue SPA:任意路径须回退到 index.html,否则直接访问 /xxx 会得到 nginx 404
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
nginx/yuheng.docker.conf
Normal file
5
nginx/yuheng.docker.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 已迁移:实际运行使用 yuheng.docker.conf.tpl
|
||||||
|
# 启动时由 scripts/nginx-entrypoint-wait-dns.sh 将 resolv.conf 中的 nameserver 注入为 resolver,
|
||||||
|
# 并配合变量 proxy_pass,避免 Docker/Podman 下「upstream OK」后仍出现 host not found in upstream "api"。
|
||||||
|
#
|
||||||
|
# 修改 HTTPS 反代请编辑:nginx/yuheng.docker.conf.tpl
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
nginx/yuheng.yuxindazhineng.com.conf
Normal file
65
nginx/yuheng.yuxindazhineng.com.conf
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# yh_web 宿主机 Nginx:仅在做「宿主机 443 → compose 内 Nginx」时使用
|
||||||
|
# 证书路径:/etc/ssl/yh_web/yuheng.yuxindazhineng.com/
|
||||||
|
# 使用本配置时,compose 中 nginx 须改为映射 8443:443(避免与宿主机 443 冲突),本文件反代到 127.0.0.1:8443
|
||||||
|
# 部署:复制到 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
# HTTP → HTTPS 强制跳转
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name yuheng.yuxindazhineng.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS:整站反代到 compose 内 Nginx(宿主机 443 → 127.0.0.1:8443)
|
||||||
|
server {
|
||||||
|
# 与 yuheng.host.conf 一致:大文件/分片上传在 HTTP/1.1 下更稳
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name yuheng.yuxindazhineng.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/yh_web/yuheng.yuxindazhineng.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/yh_web/yuheng.yuxindazhineng.com/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:50m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
|
||||||
|
# 直播 WebSocket 信令(经 compose 内 Nginx 再到 api)
|
||||||
|
location /api/web/live/ws {
|
||||||
|
proxy_pass http://127.0.0.1:8443;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/web/live/danmaku/ws {
|
||||||
|
proxy_pass http://127.0.0.1:8443;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8443;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_connect_timeout 75s;
|
||||||
|
proxy_send_timeout 75s;
|
||||||
|
proxy_read_timeout 75s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
nginx/yuheng.yuxindazhineng.com.key
Normal file
27
nginx/yuheng.yuxindazhineng.com.key
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpAIBAAKCAQEA188iiRdYOJhtpDOpdnvcASNh37gYBih+dxDZ1NBdWWEWvb02
|
||||||
|
9kEfwoAeBCL5vp+PQ1IroBNIc37ZpDbDzCsYjboSlD29x2gskem5tj2av5UkTLpb
|
||||||
|
3LMLfzwRBOGjGL4Eps2iLEzIKEAz5N+GY+xRHOQgSSTOia6zg4uwTANom7eiRsj+
|
||||||
|
cLlkambAhor4ZyqQ0mjgAF4LhCfutj909cvrCvWK9AgD1SpCu2TF09gQ3i6pGhzZ
|
||||||
|
YZVCydCitypQ60xBix/VszVAdHBo73l1gluF71cu4+lrCsjzw3MpoeO0pD1i0cUb
|
||||||
|
kAzF3ypSmgrv0+3adtazm6rY9PefqB4fFHDtAwIDAQABAoIBABiBivhqUDhNBtZI
|
||||||
|
j4vG0NrIO8r9yqyYWJQIs9O4vYDyx3RQUjdwebzKc54gop+E2u3YHOAWkHmdA/Xj
|
||||||
|
yiQbGLSvVoDC6hQEvlrrYY1SPYpX00FrQBc1ta6DEaOuQ6kBmuGeJDZHmcsIT1xE
|
||||||
|
Day3HxbayNfFeDamQfhEGobnNC/KWxyy5b7tHLZdBueCRjx7u0uoxREPRtzsLBIr
|
||||||
|
i19AoKgLH+RRwtLSU2b3yvLmExHkw3McUxB+tvbscBy3LJOc1Lh0Hqd1Tyyoscyz
|
||||||
|
yAQvWl2y8VIWSVNKyq2MBKPRUSuMb0G2wHknuynVVzTSE/MGSyWJbh0FRt2AJ6X1
|
||||||
|
LvW5R0ECgYEA76rU+zf5GYabFCXj3OMmYVoRfgGlQo6kvJBE8f4nB476sogONHqv
|
||||||
|
nxECpVh4TUYUr48d0Vvgk+2kTHNsc9PSn+hP94qs+SJetfy2LleoVQkIZlvIpH31
|
||||||
|
wKSZbR28j3NpdH9+/ptBH82eNxI+ta4bjNtNV8dumEAMfr4IdRGowZMCgYEA5oQU
|
||||||
|
ZseMOW7YxTIJFeq74rYwavHYOonxykAioxGmZ8LZniscTr+kdhOJEd9w5WAa5Ena
|
||||||
|
AvjHe9Ln6lk7PT7HkaFoOAYiXa5myYj2/Wpt5EpdZwIwwdFGk37wOiX4DZAgrr8L
|
||||||
|
WkJTGjc7TVXOQ73buQIYeNi6bvtDs6h9p/wIDNECgYEA2fs/gUo0dyH1dIrNx76V
|
||||||
|
zt+Tn06x12pTrOluu8bUCszheXXDrbmUeBGJnYdsy6Oc9twtW5i8Fu+CisJEdsjG
|
||||||
|
/gfWi6gGkQXQrKcvr9CsWsM/b5G1WN7zoQZUQWlVcgefd4TqpXnh7qIeb6pZfPbh
|
||||||
|
OejQXLEYBsPiWXhPyuKH4Z8CgYEAhLdel54j2Z08KKyaFohDDFAgqDH9cBajovIx
|
||||||
|
/vjWeb7xU+M2NRCZO3Ib5LJkaWtfkDgE0Nky4NOYupANT0Gp3Oq0+ixt9MnIXBgD
|
||||||
|
O/vesSUviXL1Z2F55MmcvZ3GpuhoKLPNcXXmKp3KAsh4LQBOVMIkHM+K5wK7A+Dq
|
||||||
|
F6E/cUECgYAtesYx/R0pW6n/rUVvOELfHnXpxyCq0ZA/vnGcDp10yQbcoNXUM0Sf
|
||||||
|
UMK5FRcD3q6Ghbm87aHdqKcyDY1Wxaj6aTR/QlpDY6WW/DdQ+KE4P5ts3i8GjRH6
|
||||||
|
tBVCm7wrCkRtE14zKo1B1Oy6xrMDsPVmi8ITcFzIVMX/ajovPW+80Q==
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
62
nginx/yuheng.yuxindazhineng.com.pem
Normal file
62
nginx/yuheng.yuxindazhineng.com.pem
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIGKDCCBRCgAwIBAgIQDVgsPajfGvmIkXPM4ij1tTANBgkqhkiG9w0BAQsFADBu
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||||
|
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
|
||||||
|
RFYgVExTIENBIC0gRzIwHhcNMjYwMzE3MDAwMDAwWhcNMjYwNjE0MjM1OTU5WjAk
|
||||||
|
MSIwIAYDVQQDExl5dWhlbmcueXV4aW5kYXpoaW5lbmcuY29tMIIBIjANBgkqhkiG
|
||||||
|
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA188iiRdYOJhtpDOpdnvcASNh37gYBih+dxDZ
|
||||||
|
1NBdWWEWvb029kEfwoAeBCL5vp+PQ1IroBNIc37ZpDbDzCsYjboSlD29x2gskem5
|
||||||
|
tj2av5UkTLpb3LMLfzwRBOGjGL4Eps2iLEzIKEAz5N+GY+xRHOQgSSTOia6zg4uw
|
||||||
|
TANom7eiRsj+cLlkambAhor4ZyqQ0mjgAF4LhCfutj909cvrCvWK9AgD1SpCu2TF
|
||||||
|
09gQ3i6pGhzZYZVCydCitypQ60xBix/VszVAdHBo73l1gluF71cu4+lrCsjzw3Mp
|
||||||
|
oeO0pD1i0cUbkAzF3ypSmgrv0+3adtazm6rY9PefqB4fFHDtAwIDAQABo4IDCjCC
|
||||||
|
AwYwHwYDVR0jBBgwFoAUeN+RkF/u3qz2xXXr1UxVU+8kSrYwHQYDVR0OBBYEFGdl
|
||||||
|
14ALpI+hvS6aG1IwkK3pUnGTMEMGA1UdEQQ8MDqCGXl1aGVuZy55dXhpbmRhemhp
|
||||||
|
bmVuZy5jb22CHXd3dy55dWhlbmcueXV4aW5kYXpoaW5lbmcuY29tMD4GA1UdIAQ3
|
||||||
|
MDUwMwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQu
|
||||||
|
Y29tL0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG
|
||||||
|
AQUFBwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw
|
||||||
|
LmRpZ2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNl
|
||||||
|
cnQuY29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0T
|
||||||
|
AQH/BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAZBHEbKQS7KeJHKIC
|
||||||
|
LgC8q08oB9QeNSer6v7VA8l9zfAAAAGc+poK0AAABAMARjBEAiBLVUb3SHyMsb5q
|
||||||
|
F+Q8hCDcZUQ2OZ1mgW/CAJDQhgPkrgIgRtWBs7dFvHVp2vYXogcZu7G3Nh7knysX
|
||||||
|
zviq4/3HsIkAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVOIQAAAZz6
|
||||||
|
mgqrAAAEAwBHMEUCIQC+PjQ+sLSlbAJoLu7ZlMP2RJhvhcV5KIUnwFrP0Pxw6gIg
|
||||||
|
YDXJsORch6kCTT0Ifar6x8Jz5Gvcj1Th1QFEIjWjNtgAdgBJnJtp3h187Pw23s2H
|
||||||
|
ZKa4W68Kh4AZ0VVS++nrKd34wwAAAZz6mgreAAAEAwBHMEUCICIct7bW86B0PI0l
|
||||||
|
inV8fe3awErWdf6o+WSlbDYp6VHtAiEA8/VCFN/U24dmaYOTB84SIuvrm8UWuZ5/
|
||||||
|
JGcEgMczmyswDQYJKoZIhvcNAQELBQADggEBAKkFx94P90j3xqUGpPsdzXop8cc9
|
||||||
|
nhCaJP6NgNgL0PuiZILWHaafM0S0+4rK4xYvvh3FrfuK7ZX0ppmtPCfsQF5/RatQ
|
||||||
|
b1pZS2f/0ypCCYAfGL12IXJWX69CPBSS6fzw3dTtJD/wl3ZNzE0+w61xoGA1cByQ
|
||||||
|
uo9P5CZ4bULdZon8udau2KW9pF4zjb9Uz7H+RWOIejwZGzJAMCVGZPVlGHLz8KEo
|
||||||
|
1fJhr8mYtDRdWvsrCR2rUuFQGccz7IyWsc4Kz/YA7hcEjQit4ZZ0dinLVw5XL7R4
|
||||||
|
TG4cwq95NCmhkT6cWOGU0JpebkDDGFrvh4WxtC8/7OwYgAGMYBEs1s2xPZ4=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||||
|
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||||
|
MjAeFw0xNzExMjcxMjQ2NDBaFw0yNzExMjcxMjQ2NDBaMG4xCzAJBgNVBAYTAlVT
|
||||||
|
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||||
|
b20xLTArBgNVBAMTJEVuY3J5cHRpb24gRXZlcnl3aGVyZSBEViBUTFMgQ0EgLSBH
|
||||||
|
MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO8Uf46i/nr7pkgTDqnE
|
||||||
|
eSIfCFqvPnUq3aF1tMJ5hh9MnO6Lmt5UdHfBGwC9Si+XjK12cjZgxObsL6Rg1njv
|
||||||
|
NhAMJ4JunN0JGGRJGSevbJsA3sc68nbPQzuKp5Jc8vpryp2mts38pSCXorPR+sch
|
||||||
|
QisKA7OSQ1MjcFN0d7tbrceWFNbzgL2csJVQeogOBGSe/KZEIZw6gXLKeFe7mupn
|
||||||
|
NYJROi2iC11+HuF79iAttMc32Cv6UOxixY/3ZV+LzpLnklFq98XORgwkIJL1HuvP
|
||||||
|
ha8yvb+W6JislZJL+HLFtidoxmI7Qm3ZyIV66W533DsGFimFJkz3y0GeHWuSVMbI
|
||||||
|
lfsCAwEAAaOCAU8wggFLMB0GA1UdDgQWBBR435GQX+7erPbFdevVTFVT7yRKtjAf
|
||||||
|
BgNVHSMEGDAWgBROIlQgGJXm427mD/r6uRLtBhePOTAOBgNVHQ8BAf8EBAMCAYYw
|
||||||
|
HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8C
|
||||||
|
AQAwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
|
||||||
|
Y2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQu
|
||||||
|
Y29tL0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDBMBgNVHSAERTBDMDcGCWCGSAGG
|
||||||
|
/WwBAjAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BT
|
||||||
|
MAgGBmeBDAECATANBgkqhkiG9w0BAQsFAAOCAQEAoBs1eCLKakLtVRPFRjBIJ9LJ
|
||||||
|
L0s8ZWum8U8/1TMVkQMBn+CPb5xnCD0GSA6L/V0ZFrMNqBirrr5B241OesECvxIi
|
||||||
|
98bZ90h9+q/X5eMyOD35f8YTaEMpdnQCnawIwiHx06/0BfiTj+b/XQih+mqt3ZXe
|
||||||
|
xNCJqKexdiB2IWGSKcgahPacWkk/BAQFisKIFYEqHzV974S3FAz/8LIfD58xnsEN
|
||||||
|
GfzyIDkH3JrwYZ8caPTf6ZX9M1GrISN8HnWTtdNCH2xEajRa/h9ZBXjUyFKQrGk2
|
||||||
|
n2hcLrfZSbynEC/pSw/ET7H5nWwckjmAJ1l9fcnbqkU/pf6uMQmnfl0JQjJNSg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
365
pull-and-restart.sh
Normal file → Executable file
365
pull-and-restart.sh
Normal file → Executable file
@@ -1,25 +1,372 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 拉取代码并重启项目(线上项目根目录:/home/yxd/project/yh_web)
|
# 拉取代码并重启:缺什么自动安装(curl、Git、Docker、Docker Compose),再 git 拉取 + docker compose 构建启动
|
||||||
# 用法:cd /home/yxd/project/yh_web && ./pull-and-restart.sh
|
# 用法:cd 项目根 && ./pull-and-restart.sh(仓库中已记录可执行权限,拉取后可直接执行)
|
||||||
# 或指定目录:PROJECT_ROOT=/home/yxd/project/yh_web ./pull-and-restart.sh
|
# 行尾:LF
|
||||||
set -e
|
set -e
|
||||||
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
|
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
|
run_sudo() { sudo "$@"; }
|
||||||
|
|
||||||
|
# ---------- 检测并安装 curl(下载 Docker Compose 等需要)----------
|
||||||
|
ensure_curl() {
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "未检测到 curl,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq
|
||||||
|
run_sudo apt-get install -y curl
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y curl
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
run_sudo yum install -y curl
|
||||||
|
else
|
||||||
|
echo "无法自动安装 curl,请先安装 curl 后重试."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "curl 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Git(国内服务器用系统源)----------
|
||||||
|
ensure_git() {
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "未检测到 Git,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq
|
||||||
|
run_sudo apt-get install -y git
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y git
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
run_sudo yum install -y git
|
||||||
|
else
|
||||||
|
echo "无法自动安装 Git,请先安装 Git 后重试."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Git 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Docker(用 run_sudo 检测,与后续 compose 一致;支持 Podman 兼容层)----------
|
||||||
|
ensure_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker 已就绪."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Docker/Podman 守护进程未连接,尝试启动..."
|
||||||
|
run_sudo systemctl start podman 2>/dev/null || true
|
||||||
|
run_sudo systemctl start docker 2>/dev/null || true
|
||||||
|
if run_sudo docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker 已就绪."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "错误:无法连接 Docker/Podman 守护进程,请执行: sudo systemctl start podman 或 sudo systemctl start docker" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "未检测到 Docker 或未启动,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq
|
||||||
|
run_sudo apt-get install -y docker.io docker-compose-plugin 2>/dev/null || run_sudo apt-get install -y docker.io docker-compose
|
||||||
|
run_sudo systemctl start docker
|
||||||
|
run_sudo systemctl enable docker
|
||||||
|
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y docker
|
||||||
|
else
|
||||||
|
run_sudo yum install -y docker
|
||||||
|
fi
|
||||||
|
run_sudo systemctl start docker
|
||||||
|
run_sudo systemctl enable docker
|
||||||
|
else
|
||||||
|
echo "无法自动安装 Docker,请先安装 Docker 与 Docker Compose 后重试."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Docker 安装完成."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 配置 Docker Hub 镜像加速(国内拉取超时时使用 Podman 镜像)----------
|
||||||
|
ensure_registry_mirror() {
|
||||||
|
REG_CONF_D="/etc/containers/registries.conf.d"
|
||||||
|
REG_MIRROR_CONF="$REG_CONF_D/99-docker-mirror.conf"
|
||||||
|
echo "配置 Docker Hub 镜像加速(Podman)..."
|
||||||
|
run_sudo mkdir -p "$REG_CONF_D"
|
||||||
|
run_sudo tee "$REG_MIRROR_CONF" >/dev/null <<'REGEOF'
|
||||||
|
# 国内 Docker Hub 拉取加速,由 pull-and-restart.sh 生成(多镜像备用)
|
||||||
|
unqualified-search-registries = ["docker.io"]
|
||||||
|
[[registry]]
|
||||||
|
location = "docker.io"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.m.daocloud.io"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.1ms.run"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.xuanyuan.me"
|
||||||
|
REGEOF
|
||||||
|
echo "已写入 $REG_MIRROR_CONF(docker.io 镜像: daocloud / 1ms / xuanyuan)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Docker Compose(优先插件 docker compose,否则独立二进制)----------
|
||||||
|
ensure_docker_compose() {
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && return 0
|
||||||
|
command -v docker-compose >/dev/null 2>&1 && return 0
|
||||||
|
[ -x /usr/local/bin/docker-compose ] && return 0
|
||||||
|
# 优先尝试用包管理器安装插件,避免独立二进制架构不符导致 Bus error
|
||||||
|
echo "未检测到 Docker Compose,正在尝试安装(优先插件)..."
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
if ! run_sudo docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
|
||||||
|
run_sudo dnf install -y dnf-plugins-core 2>/dev/null || true
|
||||||
|
run_sudo dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
|
||||||
|
run_sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
|
||||||
|
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
if ! run_sudo docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
|
||||||
|
run_sudo yum install -y yum-utils 2>/dev/null || true
|
||||||
|
run_sudo yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
|
||||||
|
run_sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
|
||||||
|
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq 2>/dev/null; run_sudo apt-get install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "Docker Compose 插件已就绪." && return 0
|
||||||
|
# 回退:下载独立版(国内 DaoCloud 镜像)
|
||||||
|
echo "正在安装独立版 Docker Compose(国内 DaoCloud 镜像)..."
|
||||||
|
COMPOSE_ARCH="$(uname -m)"
|
||||||
|
case "$COMPOSE_ARCH" in
|
||||||
|
x86_64) COMPOSE_ARCH=x86_64 ;;
|
||||||
|
aarch64|arm64) COMPOSE_ARCH=aarch64 ;;
|
||||||
|
*) COMPOSE_ARCH=x86_64 ;;
|
||||||
|
esac
|
||||||
|
COMPOSE_VER="v2.24.0"
|
||||||
|
COMPOSE_URL_CN="https://get.daocloud.io/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
|
||||||
|
if ! run_sudo curl -sfL --connect-timeout 20 --max-time 90 "$COMPOSE_URL_CN" -o /usr/local/bin/docker-compose; then
|
||||||
|
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
|
||||||
|
run_sudo curl -sfL --max-time 90 "$COMPOSE_URL" -o /usr/local/bin/docker-compose
|
||||||
|
fi
|
||||||
|
run_sudo chmod +x /usr/local/bin/docker-compose
|
||||||
|
# 若运行即崩溃(如 Bus error),删除以免后续误用
|
||||||
|
run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || { run_sudo rm -f /usr/local/bin/docker-compose; echo "独立版运行失败(可能架构不符),请尝试: dnf install -y docker-compose-plugin 或 yum install -y docker-compose-plugin" >&2; return 0; }
|
||||||
|
echo "Docker Compose 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Nginx(反代 + 强制 HTTPS,证书按域名存 /etc/ssl/yh_web/<域名>/)----------
|
||||||
|
ensure_nginx() {
|
||||||
|
if command -v nginx >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "未检测到 Nginx,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq
|
||||||
|
run_sudo apt-get install -y nginx
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y nginx
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
run_sudo yum install -y nginx
|
||||||
|
else
|
||||||
|
echo "无法自动安装 Nginx,请手动安装后重试."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
run_sudo systemctl enable nginx 2>/dev/null || true
|
||||||
|
run_sudo systemctl start nginx 2>/dev/null || true
|
||||||
|
echo "Nginx 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_curl
|
||||||
|
ensure_git
|
||||||
|
ensure_docker
|
||||||
|
ensure_docker_compose
|
||||||
|
ensure_registry_mirror
|
||||||
|
ensure_nginx
|
||||||
|
|
||||||
|
# 确定要用的 compose 命令;测试独立二进制时用 || true 避免 Bus error 导致脚本退出
|
||||||
|
resolve_compose_cmd() {
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
|
||||||
|
if [ -x /usr/local/bin/docker-compose ]; then
|
||||||
|
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
|
||||||
|
if [ "$r" -eq 0 ]; then echo "/usr/local/bin/docker-compose"; return; fi
|
||||||
|
echo "检测到 /usr/local/bin/docker-compose 无法运行(可能架构不符),正在重装..." >&2
|
||||||
|
run_sudo rm -f /usr/local/bin/docker-compose
|
||||||
|
ensure_docker_compose || true
|
||||||
|
fi
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
|
||||||
|
run_sudo docker-compose version >/dev/null 2>&1 && echo "docker-compose" && return
|
||||||
|
ensure_docker_compose || true
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
|
||||||
|
if [ -x /usr/local/bin/docker-compose ]; then
|
||||||
|
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
|
||||||
|
[ "$r" -eq 0 ] && echo "/usr/local/bin/docker-compose" || echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
COMPOSE_CMD=""
|
||||||
|
compose_cmd() {
|
||||||
|
if [ -z "$COMPOSE_CMD" ]; then
|
||||||
|
COMPOSE_CMD="$(resolve_compose_cmd)"
|
||||||
|
fi
|
||||||
|
if [ -z "$COMPOSE_CMD" ]; then
|
||||||
|
echo "错误:无法找到 docker compose 或 docker-compose,请手动安装到 /usr/local/bin/docker-compose"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# sudo 默认不传环境变量,显式传入以便 compose 使用 REGISTRY_MIRROR / GOPROXY
|
||||||
|
run_sudo env REGISTRY_MIRROR="${REGISTRY_MIRROR}" GOPROXY="${GOPROXY}" $COMPOSE_CMD "$@"
|
||||||
|
}
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " yh_web 拉取并重启"
|
echo " yh_web 拉取并重启"
|
||||||
echo " 路径: $ROOT"
|
echo " 路径: $ROOT"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 环境配置:缺失时从 server/.env.example 复制(Docker 部署用 mongo:27017)
|
||||||
|
if [ ! -f server/.env ]; then
|
||||||
|
if [ -f server/.env.example ]; then
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
echo "已从 server/.env.example 创建 server/.env(可按需修改)."
|
||||||
|
else
|
||||||
|
mkdir -p server
|
||||||
|
NGINX_DEFAULT_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
|
||||||
|
cat > server/.env <<ENVEOF
|
||||||
|
MONGODB_URI=mongodb://mongo:27017
|
||||||
|
MONGODB_DB=yxd-agent-testing
|
||||||
|
PORT=9527
|
||||||
|
GIN_MODE=release
|
||||||
|
ALLOWED_ORIGINS=https://${NGINX_DEFAULT_DOMAIN}
|
||||||
|
ENVEOF
|
||||||
|
echo "已创建默认 server/.env(ALLOWED_ORIGINS=https://${NGINX_DEFAULT_DOMAIN}),可按需修改."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -f server/.env ] && sed -i 's/\r$//' server/.env
|
||||||
[ -f server/.env ] && set -a && source server/.env && set +a
|
[ -f server/.env ] && set -a && source server/.env && set +a
|
||||||
|
|
||||||
echo "[1/2] 拉取代码..."
|
BRANCH="${GIT_BRANCH:-master}"
|
||||||
git pull
|
echo "[1/3] 拉取代码(以 Gitea 远程为准,本地修改会被覆盖)..."
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git fetch origin --progress
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
else
|
||||||
|
echo "未检测到 Git 仓库,正在克隆..."
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
# 默认仓库地址(仅本地/服务器使用,不提交到仓库;可覆盖:export GIT_REPO_URL=...)
|
||||||
|
REPO_URL="${GIT_REPO_URL:-https://whm:02f8ceeee5f1aeb197ff400e4d97abbcf5550015@gitea.yuxindazhineng.com/whm/web.git}"
|
||||||
|
SELF="$(basename "$0")"
|
||||||
|
tmp_backup="/tmp/yh_web_deploy_$$"
|
||||||
|
mkdir -p "$tmp_backup"
|
||||||
|
[ -f "$SELF" ] && cp -a "$SELF" "$tmp_backup/"
|
||||||
|
[ -f server/.env ] && cp -a server/.env "$tmp_backup/" 2>/dev/null || true
|
||||||
|
git init -b "$BRANCH"
|
||||||
|
git remote add origin "$REPO_URL"
|
||||||
|
git fetch origin --progress
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
[ -f "$tmp_backup/$SELF" ] && cp -a "$tmp_backup/$SELF" "$SELF" && chmod +x "$SELF"
|
||||||
|
[ -f "$tmp_backup/.env" ] && mkdir -p server && cp -a "$tmp_backup/.env" server/.env
|
||||||
|
rm -rf "$tmp_backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 拉取后把 .env.example 里新增的键自动追加到 server/.env(无需手改,如 YH_IMPORT_PROMOTION_SITE_ID)
|
||||||
|
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
|
||||||
|
[ -f server/.env ] && sed -i 's/\r$//' server/.env
|
||||||
|
[ -f server/.env ] && set -a && source server/.env && set +a
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "[2/2] 重新构建并启动..."
|
echo "[2/3] 重新构建并启动..."
|
||||||
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
|
# 宿主机 9527 常被 sshd 占用,compose 内 API 须为 8088。若宿主机 Nginx 已运行,docker-compose.host-nginx.yml 会把 api/web/admin 绑到本机回环供反代。
|
||||||
docker compose up -d --force-recreate 2>/dev/null || docker-compose up -d --force-recreate
|
if grep -q '9527' "$ROOT/docker-compose.yml" 2>/dev/null; then
|
||||||
|
echo "错误: 当前 docker-compose.yml 仍含 9527,会与 sshd 冲突导致启动失败。请以 Gitea 为准拉取最新代码后再执行本脚本:" >&2
|
||||||
|
echo " git fetch origin && git reset --hard origin/master" >&2
|
||||||
|
echo "若 Gitea 上已为 8088 仍报错,请本地提交并 push 后再在服务器执行上述命令。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||||
|
export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
|
||||||
|
|
||||||
|
# 挂目录方案:构建产物到 deploy/,容器挂载这些目录,无需重建 web/admin 镜像
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
|
||||||
|
|
||||||
|
echo "构建 web 前端 -> deploy/web/dist ..."
|
||||||
|
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
|
||||||
|
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||||
|
|
||||||
|
# 官网访问的是 Nginx 根目录 deploy/web/dist;产品视频已放在 social/ 英文文件名,须整目录同步(含 .mov)
|
||||||
|
echo "同步 web/promotion -> deploy/web/dist/promotion(排除旧「视频发布」与 PPT 解压,避免重复大文件)..."
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist/promotion"
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
|
||||||
|
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
|
||||||
|
else
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
|
||||||
|
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
|
||||||
|
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
|
||||||
|
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
|
||||||
|
echo "提示: 未检测到 rsync,仅复制了 social/logo 等;请安装 rsync 以同步完整 promotion(含视频)。" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "构建 admin 前端 -> deploy/admin/dist ..."
|
||||||
|
# admin 的 vite 别名 @yh-web -> ../web/src,须挂载项目根,否则容器内无 web 目录会报 BlockRenderer.vue ENOENT
|
||||||
|
run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
|
||||||
|
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||||
|
|
||||||
|
echo "构建 api 二进制 -> deploy/api/server ..."
|
||||||
|
run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \
|
||||||
|
"${REGISTRY_MIRROR}golang:1.21-alpine" sh -c "go build -mod=vendor -o /out/server ."
|
||||||
|
|
||||||
|
# 确保容器内 nginx 可读(拉取后直接执行时权限一致)
|
||||||
|
run_sudo chmod -R a+rX "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" 2>/dev/null || true
|
||||||
|
if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dist/index.html" ]; then
|
||||||
|
echo "错误: 构建产物不完整(缺少 index.html)。若仅做了 git pull 未执行本脚本,请完整执行: ./pull-and-restart.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
|
||||||
|
|
||||||
|
# 仅构建 api 运行时镜像(轻量,无业务代码);web/admin 使用官方 nginx 镜像无需构建
|
||||||
|
compose_cmd build api
|
||||||
|
|
||||||
|
# 仅当本地没有 mongo:7 时才从镜像站拉取,避免每次重复下载约 250MB
|
||||||
|
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
|
||||||
|
if ! run_sudo docker image inspect "$MONGO_IMAGE" >/dev/null 2>&1; then
|
||||||
|
echo "拉取 mongo 镜像(仅首次或镜像缺失时)..."
|
||||||
|
run_sudo docker pull "$MONGO_IMAGE" || true
|
||||||
|
else
|
||||||
|
echo "mongo 镜像已存在,跳过拉取."
|
||||||
|
fi
|
||||||
|
# 证书目录在 compose up 前就要就绪(compose 内 nginx 容器会挂载)
|
||||||
|
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
|
||||||
|
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
|
||||||
|
run_sudo mkdir -p "$NGINX_SSL_DIR"
|
||||||
|
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.pem" "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.key" "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$NGINX_SSL_DIR/"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" "$NGINX_SSL_DIR/"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
|
||||||
|
# 先停掉本项目容器(不停止宿主机 nginx)→ 准备宿主机站点并确保 nginx 在线 → 启动业务容器
|
||||||
|
yh_compose_down
|
||||||
|
echo ""
|
||||||
|
echo "[3/3] 宿主机 Nginx 站点与服务..."
|
||||||
|
yh_install_host_nginx_site_conf
|
||||||
|
ensure_host_nginx_started
|
||||||
|
yh_compose_up
|
||||||
|
yh_post_deploy_healthcheck
|
||||||
|
|
||||||
|
# 可选:web/promotion/视频发布 -> data/uploads + MongoDB(须 server/.env 中 YH_IMPORT_PROMOTION_SITE_ID)
|
||||||
|
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "完成. api:9527 web:9528 admin:9529"
|
echo "完成. 对外仅 443;反代: https://$NGINX_DOMAIN"
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 推送到 Gitea: https://gitea.yuxindazhineng.com/whm/web
|
|
||||||
set -e
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
GITEA_URL="https://gitea.yuxindazhineng.com/whm/web.git"
|
|
||||||
|
|
||||||
if [ ! -d .git ]; then
|
|
||||||
echo "初始化 Git 仓库..."
|
|
||||||
git init
|
|
||||||
git branch -M main
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git remote get-url origin 2>/dev/null; then
|
|
||||||
echo "添加远程: $GITEA_URL"
|
|
||||||
git remote add origin "$GITEA_URL"
|
|
||||||
else
|
|
||||||
git remote set-url origin "$GITEA_URL"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "添加并提交..."
|
|
||||||
git add .
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
git commit -m "chore: push to gitea (yh_web)"
|
|
||||||
else
|
|
||||||
echo "无新变更"
|
|
||||||
fi
|
|
||||||
echo "推送到 origin..."
|
|
||||||
git push -u origin main || git push -u origin main --force
|
|
||||||
echo "完成: https://gitea.yuxindazhineng.com/whm/web"
|
|
||||||
266
restart.sh
Normal file → Executable file
266
restart.sh
Normal file → Executable file
@@ -1,13 +1,265 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 仅重启项目(不拉代码),适用于配置/环境变更后重启
|
# 仅不拉代码,其余与 pull-and-restart.sh 一致:构建到 deploy/ 并重启
|
||||||
# 用法:cd /home/yxd/project/yh_web && ./restart.sh
|
# 用法:cd 项目根 && ./restart.sh(拉取后可直接执行,无需 chmod)
|
||||||
# 或:PROJECT_ROOT=/home/yxd/project/yh_web ./restart.sh
|
# 行尾:LF
|
||||||
set -e
|
set -e
|
||||||
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
|
ROOT="${PROJECT_ROOT:-$(cd "$(dirname "$0")" && pwd)}"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
echo "重启 yh_web ($ROOT)..."
|
run_sudo() { sudo "$@"; }
|
||||||
|
|
||||||
|
ensure_curl() {
|
||||||
|
if command -v curl >/dev/null 2>&1; then return 0; fi
|
||||||
|
echo "未检测到 curl,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then run_sudo apt-get update -qq; run_sudo apt-get install -y curl
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then run_sudo dnf install -y curl
|
||||||
|
elif command -v yum >/dev/null 2>&1; then run_sudo yum install -y curl
|
||||||
|
else echo "无法自动安装 curl."; exit 1; fi
|
||||||
|
echo "curl 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Docker(用 run_sudo 检测,与后续 compose 一致;支持 Podman)----------
|
||||||
|
ensure_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1 && run_sudo docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker 已就绪."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Docker/Podman 守护进程未连接,尝试启动..."
|
||||||
|
run_sudo systemctl start podman 2>/dev/null || true
|
||||||
|
run_sudo systemctl start docker 2>/dev/null || true
|
||||||
|
if run_sudo docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker 已就绪."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "错误:无法连接 Docker/Podman 守护进程,请执行: sudo systemctl start podman 或 sudo systemctl start docker" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "未检测到 Docker 或未启动,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq
|
||||||
|
run_sudo apt-get install -y docker.io docker-compose-plugin 2>/dev/null || run_sudo apt-get install -y docker.io docker-compose
|
||||||
|
run_sudo systemctl start docker
|
||||||
|
run_sudo systemctl enable docker
|
||||||
|
elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y docker
|
||||||
|
else
|
||||||
|
run_sudo yum install -y docker
|
||||||
|
fi
|
||||||
|
run_sudo systemctl start docker
|
||||||
|
run_sudo systemctl enable docker
|
||||||
|
else
|
||||||
|
echo "无法自动安装 Docker,请先安装 Docker 与 Docker Compose 后重试."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Docker 安装完成."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_registry_mirror() {
|
||||||
|
REG_CONF_D="/etc/containers/registries.conf.d"
|
||||||
|
REG_MIRROR_CONF="$REG_CONF_D/99-docker-mirror.conf"
|
||||||
|
echo "配置 Docker Hub 镜像加速(Podman)..."
|
||||||
|
run_sudo mkdir -p "$REG_CONF_D"
|
||||||
|
run_sudo tee "$REG_MIRROR_CONF" >/dev/null <<'REGEOF'
|
||||||
|
# 国内 Docker Hub 拉取加速,多镜像备用
|
||||||
|
unqualified-search-registries = ["docker.io"]
|
||||||
|
[[registry]]
|
||||||
|
location = "docker.io"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.m.daocloud.io"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.1ms.run"
|
||||||
|
[[registry.mirror]]
|
||||||
|
location = "docker.xuanyuan.me"
|
||||||
|
REGEOF
|
||||||
|
echo "已写入 $REG_MIRROR_CONF"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_compose() {
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && return 0
|
||||||
|
command -v docker-compose >/dev/null 2>&1 && return 0
|
||||||
|
[ -x /usr/local/bin/docker-compose ] && return 0
|
||||||
|
echo "未检测到 Docker Compose,正在尝试安装(优先插件)..."
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
if ! run_sudo docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
|
||||||
|
run_sudo dnf install -y dnf-plugins-core 2>/dev/null || true
|
||||||
|
run_sudo dnf config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
|
||||||
|
run_sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
|
||||||
|
run_sudo dnf install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
if ! run_sudo docker compose version >/dev/null 2>&1; then
|
||||||
|
echo "系统源无插件,尝试添加 Docker CE 源(阿里云镜像)..."
|
||||||
|
run_sudo yum install -y yum-utils 2>/dev/null || true
|
||||||
|
run_sudo yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo 2>/dev/null || \
|
||||||
|
run_sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null || true
|
||||||
|
run_sudo yum install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
run_sudo apt-get update -qq 2>/dev/null; run_sudo apt-get install -y docker-compose-plugin 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "Docker Compose 插件已就绪." && return 0
|
||||||
|
echo "正在安装独立版 Docker Compose(国内 DaoCloud 镜像)..."
|
||||||
|
COMPOSE_ARCH="$(uname -m)"
|
||||||
|
case "$COMPOSE_ARCH" in
|
||||||
|
x86_64) COMPOSE_ARCH=x86_64 ;;
|
||||||
|
aarch64|arm64) COMPOSE_ARCH=aarch64 ;;
|
||||||
|
*) COMPOSE_ARCH=x86_64 ;;
|
||||||
|
esac
|
||||||
|
COMPOSE_VER="v2.24.0"
|
||||||
|
COMPOSE_URL_CN="https://get.daocloud.io/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
|
||||||
|
if ! run_sudo curl -sfL --connect-timeout 20 --max-time 90 "$COMPOSE_URL_CN" -o /usr/local/bin/docker-compose; then
|
||||||
|
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VER}/docker-compose-linux-${COMPOSE_ARCH}"
|
||||||
|
run_sudo curl -sfL --max-time 90 "$COMPOSE_URL" -o /usr/local/bin/docker-compose
|
||||||
|
fi
|
||||||
|
run_sudo chmod +x /usr/local/bin/docker-compose
|
||||||
|
run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || { run_sudo rm -f /usr/local/bin/docker-compose; echo "独立版运行失败(可能架构不符),请尝试: dnf install -y docker-compose-plugin 或 yum install -y docker-compose-plugin" >&2; return 0; }
|
||||||
|
echo "Docker Compose 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- 检测并安装 Nginx(反代 + 强制 HTTPS)----------
|
||||||
|
ensure_nginx() {
|
||||||
|
command -v nginx >/dev/null 2>&1 && return 0
|
||||||
|
echo "未检测到 Nginx,正在安装..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then run_sudo apt-get update -qq; run_sudo apt-get install -y nginx
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then run_sudo dnf install -y nginx
|
||||||
|
elif command -v yum >/dev/null 2>&1; then run_sudo yum install -y nginx
|
||||||
|
else echo "无法自动安装 Nginx."; exit 1; fi
|
||||||
|
run_sudo systemctl enable nginx 2>/dev/null || true
|
||||||
|
run_sudo systemctl start nginx 2>/dev/null || true
|
||||||
|
echo "Nginx 已安装."
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_curl
|
||||||
|
ensure_docker
|
||||||
|
ensure_docker_compose
|
||||||
|
ensure_registry_mirror
|
||||||
|
ensure_nginx
|
||||||
|
|
||||||
|
resolve_compose_cmd() {
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
|
||||||
|
if [ -x /usr/local/bin/docker-compose ]; then
|
||||||
|
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
|
||||||
|
if [ "$r" -eq 0 ]; then echo "/usr/local/bin/docker-compose"; return; fi
|
||||||
|
echo "检测到 /usr/local/bin/docker-compose 无法运行(可能架构不符),正在重装..." >&2
|
||||||
|
run_sudo rm -f /usr/local/bin/docker-compose
|
||||||
|
ensure_docker_compose || true
|
||||||
|
fi
|
||||||
|
run_sudo docker-compose version >/dev/null 2>&1 && echo "docker-compose" && return
|
||||||
|
ensure_docker_compose || true
|
||||||
|
run_sudo docker compose version >/dev/null 2>&1 && echo "docker compose" && return
|
||||||
|
if [ -x /usr/local/bin/docker-compose ]; then
|
||||||
|
r=0; run_sudo /usr/local/bin/docker-compose version >/dev/null 2>&1 || r=1
|
||||||
|
[ "$r" -eq 0 ] && echo "/usr/local/bin/docker-compose" || echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
COMPOSE_CMD=""
|
||||||
|
compose_cmd() {
|
||||||
|
if [ -z "$COMPOSE_CMD" ]; then COMPOSE_CMD="$(resolve_compose_cmd)"; fi
|
||||||
|
if [ -z "$COMPOSE_CMD" ]; then echo "错误:无法找到 docker compose,请手动安装到 /usr/local/bin/docker-compose"; exit 1; fi
|
||||||
|
run_sudo env REGISTRY_MIRROR="${REGISTRY_MIRROR}" GOPROXY="${GOPROXY}" $COMPOSE_CMD "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "重启 yh_web ($ROOT)(不拉代码,仅构建并启动)..."
|
||||||
|
|
||||||
|
# 环境配置:缺失时从 server/.env.example 复制
|
||||||
|
if [ ! -f server/.env ]; then
|
||||||
|
if [ -f server/.env.example ]; then
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
echo "已从 server/.env.example 创建 server/.env"
|
||||||
|
else
|
||||||
|
mkdir -p server
|
||||||
|
ND="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
|
||||||
|
printf 'MONGODB_URI=mongodb://mongo:27017\nMONGODB_DB=yxd-agent-testing\nPORT=8088\nGIN_MODE=release\nALLOWED_ORIGINS=https://%s\n' "$ND" > server/.env
|
||||||
|
echo "已创建默认 server/.env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
bash "$ROOT/scripts/merge-server-env-from-example.sh" "$ROOT" || true
|
||||||
|
[ -f server/.env ] && sed -i 's/\r$//' server/.env 2>/dev/null || true
|
||||||
[ -f server/.env ] && set -a && source server/.env && set +a
|
[ -f server/.env ] && set -a && source server/.env && set +a
|
||||||
docker compose down 2>/dev/null || docker-compose down
|
|
||||||
docker compose up -d 2>/dev/null || docker-compose up -d
|
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||||
echo "完成. api:9527 web:9528 admin:9529"
|
export REGISTRY_MIRROR="${REGISTRY_MIRROR:-docker.m.daocloud.io/library/}"
|
||||||
|
|
||||||
|
# 与 pull-and-restart 一致:宿主机 9527 检查
|
||||||
|
if grep -q '9527' "$ROOT/docker-compose.yml" 2>/dev/null; then
|
||||||
|
echo "错误: docker-compose.yml 仍含 9527,会与 sshd 冲突。请拉取最新代码后再执行。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建到 deploy/(与 pull-and-restart.sh 相同,仅无 git 拉取)
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" "$ROOT/deploy/api"
|
||||||
|
echo "构建 web 前端 -> deploy/web/dist ..."
|
||||||
|
run_sudo docker run --rm -v "$ROOT/web:/app" -v "$ROOT/deploy/web/dist:/out" -w /app \
|
||||||
|
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||||
|
# 与 pull-and-restart 一致:文档根是 deploy/web/dist,须把 promotion(含 social 视频)拷入 dist
|
||||||
|
echo "同步 web/promotion -> deploy/web/dist/promotion ..."
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist/promotion"
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a --exclude='_pptx_extract/' --exclude='视频发布/' \
|
||||||
|
"$ROOT/web/promotion/" "$ROOT/deploy/web/dist/promotion/"
|
||||||
|
else
|
||||||
|
mkdir -p "$ROOT/deploy/web/dist/promotion/social"
|
||||||
|
cp -a "$ROOT/web/promotion/social/." "$ROOT/deploy/web/dist/promotion/social/" 2>/dev/null || true
|
||||||
|
[ -f "$ROOT/web/promotion/logo.png" ] && cp -a "$ROOT/web/promotion/logo.png" "$ROOT/deploy/web/dist/promotion/" || true
|
||||||
|
[ -f "$ROOT/web/promotion/index.html" ] && cp -a "$ROOT/web/promotion/index.html" "$ROOT/deploy/web/dist/promotion/" || true
|
||||||
|
fi
|
||||||
|
echo "构建 admin 前端 -> deploy/admin/dist ..."
|
||||||
|
# 与 pull-and-restart.sh 一致:须挂载项目根,@yh-web -> ../web/src(仅挂 admin 会构建失败或产物异常)
|
||||||
|
run_sudo docker run --rm -v "$ROOT:/repo" -v "$ROOT/deploy/admin/dist:/out" -w /repo/admin \
|
||||||
|
"${REGISTRY_MIRROR}node:20-alpine" sh -c "rm -rf /out/* 2>/dev/null; (npm ci --legacy-peer-deps 2>/dev/null || npm install --legacy-peer-deps) && npm run build && cp -r dist/. /out/"
|
||||||
|
echo "构建 api 二进制 -> deploy/api/server ..."
|
||||||
|
run_sudo docker run --rm -v "$ROOT/server:/src" -v "$ROOT/deploy/api:/out" -w /src -e GOPROXY="${GOPROXY}" \
|
||||||
|
"${REGISTRY_MIRROR}golang:1.21-alpine" sh -c "go build -mod=vendor -o /out/server ."
|
||||||
|
run_sudo chmod -R a+rX "$ROOT/deploy/web/dist" "$ROOT/deploy/admin/dist" 2>/dev/null || true
|
||||||
|
if [ ! -f "$ROOT/deploy/web/dist/index.html" ] || [ ! -f "$ROOT/deploy/admin/dist/index.html" ]; then
|
||||||
|
echo "错误: 构建产物不完整(缺少 index.html),请检查上方构建日志。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash "$ROOT/scripts/verify-admin-dist.sh" "$ROOT"
|
||||||
|
|
||||||
|
compose_cmd build api
|
||||||
|
|
||||||
|
MONGO_IMAGE="${REGISTRY_MIRROR}mongo:7"
|
||||||
|
if ! run_sudo docker image inspect "$MONGO_IMAGE" >/dev/null 2>&1; then
|
||||||
|
echo "拉取 mongo 镜像(仅首次或镜像缺失时)..."
|
||||||
|
run_sudo docker pull "$MONGO_IMAGE" || true
|
||||||
|
else
|
||||||
|
echo "mongo 镜像已存在,跳过拉取."
|
||||||
|
fi
|
||||||
|
|
||||||
|
NGINX_DOMAIN="${NGINX_DOMAIN:-yuheng.yuxindazhineng.com}"
|
||||||
|
NGINX_SSL_DIR="/etc/ssl/yh_web/$NGINX_DOMAIN"
|
||||||
|
run_sudo mkdir -p "$NGINX_SSL_DIR"
|
||||||
|
if [ -f "$ROOT/nginx/$NGINX_DOMAIN.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN.key" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.pem" "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN.key" "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem"
|
||||||
|
elif [ -f "$ROOT/nginx/fullchain.pem" ] && [ -f "$ROOT/nginx/privkey.pem" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/fullchain.pem" "$ROOT/nginx/privkey.pem" "$NGINX_SSL_DIR/"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
|
||||||
|
elif [ -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" ] && [ -f "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" ]; then
|
||||||
|
run_sudo cp -f "$ROOT/nginx/$NGINX_DOMAIN/fullchain.pem" "$ROOT/nginx/$NGINX_DOMAIN/privkey.pem" "$NGINX_SSL_DIR/"
|
||||||
|
run_sudo chmod 644 "$NGINX_SSL_DIR/fullchain.pem"
|
||||||
|
run_sudo chmod 600 "$NGINX_SSL_DIR/privkey.pem" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. "$ROOT/scripts/lib-yh-compose-deploy.sh"
|
||||||
|
yh_compose_down
|
||||||
|
echo "宿主机 Nginx 站点与服务..."
|
||||||
|
yh_install_host_nginx_site_conf
|
||||||
|
ensure_host_nginx_started
|
||||||
|
yh_compose_up
|
||||||
|
yh_post_deploy_healthcheck
|
||||||
|
|
||||||
|
bash "$ROOT/scripts/run-promotion-import-on-deploy.sh" "$ROOT"
|
||||||
|
|
||||||
|
echo "完成. 对外仅 443,反代: https://$NGINX_DOMAIN"
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 在 Docker 中运行项目,统一域名: https://yuheng.yuxindazhineng.com
|
|
||||||
# 端口: 9527=api, 9528=index, 9529=admin(直连);对外通过 nginx 80/443
|
|
||||||
set -e
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "=== yh_web Docker 启动 ==="
|
|
||||||
echo "对外域名与 HTTPS 由 Nginx Proxy Manager 配置,本机端口: 9527=api, 9528=web, 9529=admin"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 可选:从 server/.env 加载到当前 shell,供 docker-compose 使用
|
|
||||||
if [ -f server/.env ]; then
|
|
||||||
set -a
|
|
||||||
source server/.env
|
|
||||||
set +a
|
|
||||||
echo "已加载 server/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose build --no-cache 2>/dev/null || docker-compose build --no-cache
|
|
||||||
docker compose up -d 2>/dev/null || docker-compose up -d
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "已启动。请用 NPM 配置的域名访问(如 https://yuheng.yuxindazhineng.com)"
|
|
||||||
echo "直连: api :9527, web :9528, admin :9529"
|
|
||||||
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)"
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
# 复制为 .env 或 .env.production 后修改
|
# 复制为 .env 后按需修改(一键脚本会在缺失时自动复制)
|
||||||
# Go 不会自动加载 .env,需在启动前导出变量(见项目根目录 .env.example 的说明)
|
# 本地开发:先启动 MongoDB,改为 mongodb://localhost:27017
|
||||||
|
# Docker 部署:保持 mongodb://mongo:27017(compose 服务名)
|
||||||
|
|
||||||
MONGODB_URI=mongodb://localhost:27017
|
MONGODB_URI=mongodb://mongo:27017
|
||||||
MONGODB_DB=yxd-agent-testing
|
MONGODB_DB=yxd-agent-testing
|
||||||
PORT=8080
|
PORT=8088
|
||||||
GIN_MODE=release
|
GIN_MODE=release
|
||||||
SERVER_DOMAIN=https://api.example.com
|
# 对外域名(CORS、日志),与 nginx 反代域名一致(官网 https://yuheng.yuxindazhineng.com/)
|
||||||
ALLOWED_ORIGINS=
|
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
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# 需在项目根目录构建: docker build -f server/Dockerfile .
|
# 需在项目根目录构建: docker build -f server/Dockerfile .
|
||||||
FROM golang:1.21-alpine AS builder
|
# 使用 vendor 构建,无需在构建时访问 proxy.golang.org(服务器无外网时也能 build)
|
||||||
|
# 国内默认走镜像;海外可 --build-arg REGISTRY_MIRROR= 直连
|
||||||
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
FROM ${REGISTRY_MIRROR}golang:1.21-alpine AS builder
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
RUN go mod download && CGO_ENABLED=0 go build -o /app/server .
|
# 构建参数:脚本可传 GOPROXY,避免 proxy.golang.org 超时;有 vendor 时主要用 -mod=vendor 离线构建
|
||||||
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
ENV GOPROXY=$GOPROXY
|
||||||
|
RUN CGO_ENABLED=0 go build -mod=vendor -o /app/server .
|
||||||
|
|
||||||
FROM alpine:3.19
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
# 产品视频 .mov → .mp4 服务端转码(handlers/promotion_transcode.go)
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
COPY --from=builder /app/server .
|
COPY --from=builder /app/server .
|
||||||
EXPOSE 9527
|
EXPOSE 8088
|
||||||
ENTRYPOINT ["./server"]
|
ENTRYPOINT ["./server"]
|
||||||
|
|||||||
9
server/Dockerfile.run
Normal file
9
server/Dockerfile.run
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 仅运行时:不包含二进制,启动时挂载 deploy/api 到 /app,/app/server 由宿主机构建
|
||||||
|
ARG REGISTRY_MIRROR=docker.m.daocloud.io/library/
|
||||||
|
FROM ${REGISTRY_MIRROR}alpine:3.19
|
||||||
|
# 与编译镜像一致:挂载的二进制需在容器内调用 ffmpeg 做推广视频转码
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata ffmpeg
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8088
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
@@ -22,3 +22,8 @@ go run main.go
|
|||||||
```
|
```
|
||||||
|
|
||||||
默认端口 8080
|
默认端口 8080
|
||||||
|
|
||||||
|
## 推广视频转码(promotion 目录)
|
||||||
|
|
||||||
|
上传到 `sites/{site_id}/promotion/**.mov` 后,服务会异步转 **MP4**(需本机安装 **ffmpeg**,与 Docker 镜像一致)。启动时也会扫描遗留 `.mov` 补转码。详见 `handlers/promotion_transcode.go`。
|
||||||
|
关闭:`SKIP_PROMOTION_TRANSCODE=1`。
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
echo Starting backend with hot reload...
|
|
||||||
if not exist tmp mkdir tmp
|
|
||||||
|
|
||||||
CompileDaemon -command=tmp\main.exe -build="go build -o tmp\main.exe ."
|
|
||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
@@ -18,6 +19,8 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.3.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/joho/godotenv v1.5.1 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.6 // indirect
|
github.com/klauspost/compress v1.17.6 // indirect
|
||||||
@@ -27,15 +30,34 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/pion/datachannel v1.5.8 // indirect
|
||||||
|
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||||
|
github.com/pion/ice/v2 v2.3.36 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.29 // indirect
|
||||||
|
github.com/pion/logging v0.2.2 // indirect
|
||||||
|
github.com/pion/mdns v0.0.12 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.14 // indirect
|
||||||
|
github.com/pion/rtp v1.8.7 // indirect
|
||||||
|
github.com/pion/sctp v1.8.19 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||||
|
github.com/pion/srtp/v2 v2.0.20 // indirect
|
||||||
|
github.com/pion/stun v0.6.1 // indirect
|
||||||
|
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||||
|
github.com/pion/turn/v2 v2.1.6 // indirect
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.9.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/wlynxg/anet v0.0.3 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/net v0.21.0 // indirect
|
golang.org/x/net v0.22.0 // indirect
|
||||||
golang.org/x/sync v0.11.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||||
|
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -39,6 +43,9 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
|
|||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
@@ -50,11 +57,52 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
|
github.com/pion/datachannel v1.5.8 h1:ph1P1NsGkazkjrvyMfhRBUAWMxugJjq2HfQifaOoSNo=
|
||||||
|
github.com/pion/datachannel v1.5.8/go.mod h1:PgmdpoaNBLX9HNzNClmdki4DYW5JtI7Yibu8QzbL3tI=
|
||||||
|
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||||
|
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||||
|
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||||
|
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
|
||||||
|
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
|
||||||
|
github.com/pion/interceptor v0.1.29 h1:39fsnlP1U8gw2JzOFWdfCU82vHvhW9o0rZnZF56wF+M=
|
||||||
|
github.com/pion/interceptor v0.1.29/go.mod h1:ri+LGNjRUc5xUNtDEPzfdkmSqISixVTBF/z/Zms/6T4=
|
||||||
|
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||||
|
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||||
|
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
|
||||||
|
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||||
|
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
|
||||||
|
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
|
||||||
|
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||||
|
github.com/pion/rtp v1.8.7 h1:qslKkG8qxvQ7hqaxkmL7Pl0XcUm+/Er7nMnu6Vq+ZxM=
|
||||||
|
github.com/pion/rtp v1.8.7/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
|
||||||
|
github.com/pion/sctp v1.8.19 h1:2CYuw+SQ5vkQ9t0HdOPccsCz1GQMDuVy5PglLgKVBW8=
|
||||||
|
github.com/pion/sctp v1.8.19/go.mod h1:P6PbDVA++OJMrVNg2AL3XtYHV4uD6dvfyOovCgMs0PE=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
|
||||||
|
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
|
||||||
|
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
|
||||||
|
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
|
||||||
|
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||||
|
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||||
|
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||||
|
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||||
|
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||||
|
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||||
|
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||||
|
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
|
||||||
|
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||||
|
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||||
|
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
|
||||||
|
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -63,10 +111,15 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||||
|
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
@@ -85,16 +138,28 @@ golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
|||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||||
|
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||||
|
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -103,20 +168,36 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||||
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
@@ -124,6 +205,7 @@ google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cn
|
|||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -34,6 +35,37 @@ type Claims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseClaimsFromTokenString 解析 Authorization Bearer 或裸 JWT;失败返回 false
|
||||||
|
func ParseClaimsFromTokenString(tokenStr string) (*Claims, bool) {
|
||||||
|
tokenStr = strings.TrimSpace(tokenStr)
|
||||||
|
if len(tokenStr) > 7 && strings.EqualFold(tokenStr[:7], "bearer ") {
|
||||||
|
tokenStr = strings.TrimSpace(tokenStr[7:])
|
||||||
|
}
|
||||||
|
if tokenStr == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var claims Claims
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &claims, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LivePublishAllowed 仅允许已登录后台账号发起 WebRTC 推流(与 AuthRequired 身份范围一致)
|
||||||
|
func LivePublishAllowed(tokenStr string) bool {
|
||||||
|
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Login 后台登录,仅 role_id=9527 超级管理员可登录
|
// Login 后台登录,仅 role_id=9527 超级管理员可登录
|
||||||
func Login(c *gin.Context) {
|
func Login(c *gin.Context) {
|
||||||
var input LoginInput
|
var input LoginInput
|
||||||
@@ -122,25 +154,13 @@ func AuthRequired() gin.HandlerFunc {
|
|||||||
if tokenStr == "" {
|
if tokenStr == "" {
|
||||||
tokenStr = c.Query("token")
|
tokenStr = c.Query("token")
|
||||||
}
|
}
|
||||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||||
tokenStr = tokenStr[7:]
|
if !ok {
|
||||||
}
|
|
||||||
if tokenStr == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims Claims
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
return []byte(jwtSecret), nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
|
// 仅超级管理员或超级用户(role_id=0, role=admin)可访问后台
|
||||||
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
if claims.RoleID != models.RoleIDSuperAdmin && !(claims.RoleID == models.RoleIDSuperUser && claims.Role == "admin") {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "无后台访问权限"})
|
||||||
@@ -164,25 +184,13 @@ func SuperUserAuthRequired() gin.HandlerFunc {
|
|||||||
if tokenStr == "" {
|
if tokenStr == "" {
|
||||||
tokenStr = c.Query("token")
|
tokenStr = c.Query("token")
|
||||||
}
|
}
|
||||||
if len(tokenStr) > 7 && tokenStr[:7] == "Bearer " {
|
claims, ok := ParseClaimsFromTokenString(tokenStr)
|
||||||
tokenStr = tokenStr[7:]
|
if !ok {
|
||||||
}
|
|
||||||
if tokenStr == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var claims Claims
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, &claims, func(t *jwt.Token) (interface{}, error) {
|
|
||||||
return []byte(jwtSecret), nil
|
|
||||||
})
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅 role_id=9527 且 role=admin 可配置短信平台
|
// 仅 role_id=9527 且 role=admin 可配置短信平台
|
||||||
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
|
if claims.RoleID != models.RoleIDSuperAdmin || claims.Role != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "仅超级管理员可配置短信平台"})
|
||||||
|
|||||||
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
if config.MongoClient == nil {
|
||||||
|
c.JSON(http.StatusOK, defaultHomepageData())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
siteID := getOfficialSiteID(ctx)
|
siteID := getOfficialSiteID(ctx)
|
||||||
if siteID == "" {
|
if siteID == "" {
|
||||||
c.JSON(http.StatusOK, defaultHomepageData())
|
c.JSON(http.StatusOK, defaultHomepageData())
|
||||||
@@ -186,29 +191,27 @@ func DownloadHomepage(c *gin.Context) {
|
|||||||
|
|
||||||
func defaultHomepageData() models.HomepageData {
|
func defaultHomepageData() models.HomepageData {
|
||||||
return models.HomepageData{
|
return models.HomepageData{
|
||||||
LogoText: "YUHENG ONE",
|
LogoText: "宇恒一号",
|
||||||
NavLinks: []models.NavLink{{Label: "MISSION", URL: "#"}, {Label: "DOWNLOAD", URL: "#"}, {Label: "CONTACT", URL: "#"}},
|
NavLinks: []models.NavLink{{Label: "产品简介", URL: "#intro"}, {Label: "产品视频", URL: "#videos"}, {Label: "联系我们", URL: "#contact"}},
|
||||||
Title: "宇恒一号",
|
Title: "宇恒一号",
|
||||||
Subtitle: "INTERSTELLAR EXPLORER EDITION",
|
Subtitle: "",
|
||||||
Description: "跨越星际的智能伙伴 · 探索无限可能<br>\n 引领您进入前所未有的数字宇宙",
|
Description: "",
|
||||||
DownloadText: "START EXPLORING",
|
DownloadText: "下载",
|
||||||
DownloadURL: "#",
|
DownloadURL: "#",
|
||||||
Platforms: []models.PlatformItem{
|
Platforms: []models.PlatformItem{},
|
||||||
{Name: "WINDOWS", URL: "#"},
|
Version: "",
|
||||||
{Name: "MACOS", URL: "#"},
|
LaunchYear: "发布日期:以官网为准",
|
||||||
{Name: "LINUX", URL: "#"},
|
BadgeText: "完全免费",
|
||||||
{Name: "IOS", URL: "#"},
|
DownloadWindowsURL: "/promotion/downloads/yuheng-windows.zip",
|
||||||
{Name: "ANDROID", URL: "#"},
|
DownloadAndroidURL: "/promotion/downloads/yuheng-android.apk",
|
||||||
},
|
|
||||||
Version: "VERSION 3.2.1",
|
|
||||||
LaunchYear: "LAUNCH: 2024",
|
|
||||||
BadgeText: "FREE ACCESS",
|
|
||||||
Features: []models.FeatureItem{
|
Features: []models.FeatureItem{
|
||||||
{Title: "星际导航", Desc: "先进的AI导航系统,精准定位您的需求,引领探索之旅"},
|
{Title: "星际导航", Desc: "先进的 AI 导航系统,精准定位您的需求,引领探索之旅"},
|
||||||
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
|
{Title: "量子同步", Desc: "跨维度数据同步技术,您的数据在多宇宙中保持一致"},
|
||||||
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
|
{Title: "星际防护", Desc: "来自未来的安全加密协议,守护您的数字资产安全"},
|
||||||
},
|
},
|
||||||
FooterText: "© 2024 YUHENG ONE // STELLAR EXPLORATION INITIATIVE",
|
FooterText: "© 2024 宇恒一号 · 成都宇信达智能科技有限公司",
|
||||||
|
LiveRoomURL: "",
|
||||||
|
LiveRoomTitle: "视频直播",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -16,22 +19,47 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadDir = "uploads"
|
// getUploadDir 上传根目录:容器内通过 UPLOAD_DIR 挂载到独立可写路径(如 /uploads),避免 /app 只读
|
||||||
|
func getUploadDir() string {
|
||||||
|
if d := os.Getenv("UPLOAD_DIR"); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return "uploads"
|
||||||
|
}
|
||||||
|
|
||||||
// ListSiteAssets 站点功能模块/上传文件列表
|
// pathPrefix 站点下相对路径前缀,用于多级目录
|
||||||
|
func pathPrefix(siteID string) string {
|
||||||
|
return "sites/" + siteID + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSiteAssets 站点功能模块/上传文件列表;query path 为当前目录相对路径(空为根);downloadable=1 时返回该站点下所有可下载文件(供首页编辑选择)
|
||||||
func ListSiteAssets(c *gin.Context) {
|
func ListSiteAssets(c *gin.Context) {
|
||||||
siteID := c.Param("site_id")
|
siteID := c.Param("site_id")
|
||||||
if siteID == "" {
|
if siteID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
onlyDownloadable := c.Query("downloadable") == "1" || c.Query("downloadable") == "true"
|
||||||
|
if onlyDownloadable {
|
||||||
|
listDownloadableAssets(c, siteID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := c.Query("path")
|
||||||
|
prefix := pathPrefix(siteID)
|
||||||
|
if path != "" {
|
||||||
|
prefix = prefix + path
|
||||||
|
if prefix[len(prefix)-1] != '/' {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
filter := bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix) + "[^/]+$"}}
|
||||||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||||
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
|
cursor, err := coll.Find(ctx, filter, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -43,11 +71,179 @@ func ListSiteAssets(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, _ := coll.CountDocuments(ctx, bson.M{"site_id": siteID})
|
total, _ := coll.CountDocuments(ctx, filter)
|
||||||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total})
|
subDirs := listSubDirs(c, siteID, path)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"list": list, "total": total, "sub_dirs": subDirs})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadSiteAsset 上传功能模块/文件
|
func listDownloadableAssets(c *gin.Context, siteID string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
filter := bson.M{"site_id": siteID, "downloadable": true}
|
||||||
|
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||||||
|
cursor, err := coll.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
var list []models.SiteAsset
|
||||||
|
if err = cursor.All(ctx, &list); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"list": list, "total": len(list)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDownloadableAssets 仅返回可下载文件列表(供首页编辑选择,仅需 homepage:edit 权限)
|
||||||
|
func ListDownloadableAssets(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
if siteID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listDownloadableAssets(c, siteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
|
||||||
|
prefix := pathPrefix(siteID)
|
||||||
|
if currentPath != "" {
|
||||||
|
prefix = prefix + currentPath
|
||||||
|
if prefix[len(prefix)-1] != '/' {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix)}})
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
var docs []struct {
|
||||||
|
FilePath string `bson:"file_path"`
|
||||||
|
}
|
||||||
|
_ = cursor.All(ctx, &docs)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, d := range docs {
|
||||||
|
rel := strings.TrimPrefix(d.FilePath, prefix)
|
||||||
|
if rel == "" || rel == d.FilePath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(rel, "/", 2)
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
seen[parts[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再扫描物理目录
|
||||||
|
baseDir := filepath.Join(getUploadDir(), filepath.FromSlash(prefix))
|
||||||
|
entries, _ := os.ReadDir(baseDir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
seen[e.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(seen))
|
||||||
|
for n := range seen {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func promotionMimeType(ext string) string {
|
||||||
|
switch strings.ToLower(ext) {
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime"
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4"
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
|
||||||
|
func ServePromotionMedia(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
raw := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||||
|
if siteID == "" || raw == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rel := filepath.ToSlash(filepath.Clean(raw))
|
||||||
|
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
|
||||||
|
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
|
||||||
|
relBack, err := filepath.Rel(baseDir, fullPath)
|
||||||
|
if err != nil || strings.HasPrefix(relBack, "..") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := os.Stat(fullPath)
|
||||||
|
if err != nil || fi.IsDir() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(fullPath)
|
||||||
|
ct := promotionMimeType(ext)
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", ct)
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeSiteUploadDest 与整文件上传、分片合并完成时一致的目标路径(非 preserve 时 saveName 含当前时间戳)
|
||||||
|
func computeSiteUploadDest(siteID, folder, originalFilename string, preserve bool) (relPath, destPath string, errMsg string) {
|
||||||
|
name := originalFilename
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
nameNoExt := strings.TrimSuffix(name, ext)
|
||||||
|
var saveName string
|
||||||
|
if preserve {
|
||||||
|
saveName = filepath.Base(name)
|
||||||
|
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||||||
|
return "", "", "无效的文件名"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(ext) == 0 {
|
||||||
|
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||||||
|
} else {
|
||||||
|
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folderClean := ""
|
||||||
|
if folder != "" {
|
||||||
|
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||||||
|
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||||||
|
return "", "", "无效的目录路径"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if folderClean != "" {
|
||||||
|
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||||||
|
} else {
|
||||||
|
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||||||
|
}
|
||||||
|
destPath = filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||||||
|
return relPath, destPath, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
||||||
func UploadSiteAsset(c *gin.Context) {
|
func UploadSiteAsset(c *gin.Context) {
|
||||||
siteID := c.Param("site_id")
|
siteID := c.Param("site_id")
|
||||||
if siteID == "" {
|
if siteID == "" {
|
||||||
@@ -61,20 +257,30 @@ func UploadSiteAsset(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
baseDir := filepath.Join(uploadDir, "sites", siteID)
|
folder := strings.TrimSpace(c.PostForm("folder"))
|
||||||
|
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
||||||
|
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
||||||
|
|
||||||
|
relPath, destPath, errMsg := computeSiteUploadDest(siteID, folder, file.Filename, preserve)
|
||||||
|
if errMsg != "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if preserve {
|
||||||
|
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
defer cancelDel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
|
||||||
|
_ = os.Remove(destPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 避免覆盖:加时间戳
|
|
||||||
name := file.Filename
|
|
||||||
ext := filepath.Ext(name)
|
|
||||||
nameNoExt := name[:len(name)-len(ext)]
|
|
||||||
saveName := nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
|
||||||
relPath := filepath.Join("sites", siteID, saveName)
|
|
||||||
destPath := filepath.Join(uploadDir, relPath)
|
|
||||||
|
|
||||||
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||||||
return
|
return
|
||||||
@@ -84,12 +290,13 @@ func UploadSiteAsset(c *gin.Context) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
doc := models.SiteAsset{
|
doc := models.SiteAsset{
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
Name: file.Filename,
|
Name: file.Filename,
|
||||||
FilePath: relPath,
|
FilePath: relPath,
|
||||||
Size: file.Size,
|
Size: file.Size,
|
||||||
ContentType: file.Header.Get("Content-Type"),
|
ContentType: file.Header.Get("Content-Type"),
|
||||||
CreatedAt: time.Now().Format(time.RFC3339),
|
Downloadable: downloadable,
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
||||||
"site_id": doc.SiteID,
|
"site_id": doc.SiteID,
|
||||||
@@ -97,6 +304,7 @@ func UploadSiteAsset(c *gin.Context) {
|
|||||||
"file_path": doc.FilePath,
|
"file_path": doc.FilePath,
|
||||||
"size": doc.Size,
|
"size": doc.Size,
|
||||||
"content_type": doc.ContentType,
|
"content_type": doc.ContentType,
|
||||||
|
"downloadable": doc.Downloadable,
|
||||||
"created_at": doc.CreatedAt,
|
"created_at": doc.CreatedAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,6 +313,8 @@ func UploadSiteAsset(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
||||||
|
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg)
|
||||||
|
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSiteAsset 删除站点资源
|
// DeleteSiteAsset 删除站点资源
|
||||||
@@ -133,7 +343,7 @@ func DeleteSiteAsset(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fullPath := filepath.Join(uploadDir, asset.FilePath)
|
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
|
||||||
os.Remove(fullPath)
|
os.Remove(fullPath)
|
||||||
|
|
||||||
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
|
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
|
||||||
@@ -143,3 +353,71 @@ func DeleteSiteAsset(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateSiteFolderInput 创建目录
|
||||||
|
type CreateSiteFolderInput struct {
|
||||||
|
Path string `json:"path" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSiteFolder 在站点下创建多级目录
|
||||||
|
func CreateSiteFolder(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
if siteID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input CreateSiteFolderInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写目录路径"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(input.Path)
|
||||||
|
if clean == "." || clean == ".." || strings.HasPrefix(clean, "..") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseDir := filepath.Join(getUploadDir(), "sites", siteID, clean)
|
||||||
|
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "path": filepath.ToSlash(clean)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadSiteAsset 前台公开下载:仅当资源标记为可下载时返回文件(供首页等使用)
|
||||||
|
func DownloadSiteAsset(c *gin.Context) {
|
||||||
|
siteID := c.Param("site_id")
|
||||||
|
assetIDStr := c.Param("asset_id")
|
||||||
|
if siteID == "" || assetIDStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oid, err := bson.ObjectIDFromHex(assetIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||||||
|
var asset models.SiteAsset
|
||||||
|
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !asset.Downloadable {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "该资源不可下载"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
|
||||||
|
if _, err := os.Stat(fullPath); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "attachment; filename=\""+asset.Name+"\"")
|
||||||
|
if asset.ContentType != "" {
|
||||||
|
c.Header("Content-Type", asset.ContentType)
|
||||||
|
}
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -67,11 +67,14 @@ func GetPageByID(c *gin.Context) {
|
|||||||
|
|
||||||
// CreatePageInput 创建网页
|
// CreatePageInput 创建网页
|
||||||
type CreatePageInput struct {
|
type CreatePageInput struct {
|
||||||
SiteID string `json:"site_id" binding:"required"`
|
SiteID string `json:"site_id" binding:"required"`
|
||||||
Slug string `json:"slug" binding:"required"`
|
Slug string `json:"slug" binding:"required"`
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Type string `json:"type"` // homepage, page
|
Type string `json:"type"` // homepage, page
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ContentMode string `json:"content_mode"` // html | builder
|
||||||
|
RoutePath string `json:"route_path"`
|
||||||
|
Published *bool `json:"published"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePage 创建网页
|
// CreatePage 创建网页
|
||||||
@@ -98,6 +101,15 @@ func CreatePage(c *gin.Context) {
|
|||||||
"content": input.Content,
|
"content": input.Content,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
if input.ContentMode != "" {
|
||||||
|
doc["content_mode"] = input.ContentMode
|
||||||
|
}
|
||||||
|
if input.RoutePath != "" {
|
||||||
|
doc["route_path"] = input.RoutePath
|
||||||
|
}
|
||||||
|
if input.Published != nil {
|
||||||
|
doc["published"] = *input.Published
|
||||||
|
}
|
||||||
res, err := coll.InsertOne(ctx, doc)
|
res, err := coll.InsertOne(ctx, doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -108,10 +120,13 @@ func CreatePage(c *gin.Context) {
|
|||||||
|
|
||||||
// UpdatePageInput 更新网页
|
// UpdatePageInput 更新网页
|
||||||
type UpdatePageInput struct {
|
type UpdatePageInput struct {
|
||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
|
ContentMode *string `json:"content_mode"`
|
||||||
|
RoutePath *string `json:"route_path"`
|
||||||
|
Published *bool `json:"published"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePage 更新网页
|
// UpdatePage 更新网页
|
||||||
@@ -142,6 +157,15 @@ func UpdatePage(c *gin.Context) {
|
|||||||
if input.Content != nil {
|
if input.Content != nil {
|
||||||
set["content"] = *input.Content
|
set["content"] = *input.Content
|
||||||
}
|
}
|
||||||
|
if input.ContentMode != nil {
|
||||||
|
set["content_mode"] = *input.ContentMode
|
||||||
|
}
|
||||||
|
if input.RoutePath != nil {
|
||||||
|
set["route_path"] = *input.RoutePath
|
||||||
|
}
|
||||||
|
if input.Published != nil {
|
||||||
|
set["published"] = *input.Published
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,12 +179,21 @@ func Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 超级管理员仅一个:第一个注册用户为超级管理员,后续均为普通用户
|
||||||
|
count, _ := coll.CountDocuments(ctx, bson.M{})
|
||||||
|
roleID := models.RoleIDUser
|
||||||
|
role := "user"
|
||||||
|
if count == 0 {
|
||||||
|
roleID = models.RoleIDSuperAdmin
|
||||||
|
role = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
doc := bson.M{
|
doc := bson.M{
|
||||||
"username": username,
|
"username": username,
|
||||||
"mobile": input.Mobile,
|
"mobile": input.Mobile,
|
||||||
"password": utils.HashPassword(input.Password),
|
"password": utils.HashPassword(input.Password),
|
||||||
"role": "admin",
|
"role": role,
|
||||||
"role_id": models.RoleIDSuperAdmin,
|
"role_id": roleID,
|
||||||
}
|
}
|
||||||
if input.Email != "" {
|
if input.Email != "" {
|
||||||
doc["email"] = input.Email
|
doc["email"] = input.Email
|
||||||
|
|||||||
@@ -15,17 +15,9 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 预定义角色(与 users.role_id 对应)
|
const customRoleIDStart = 1000 // 自定义角色 role_id 从此值起
|
||||||
var roleMeta = []struct {
|
|
||||||
RoleID int `json:"role_id"`
|
|
||||||
RoleName string `json:"role_name"`
|
|
||||||
}{
|
|
||||||
{models.RoleIDSuperAdmin, "超级管理员"},
|
|
||||||
{models.RoleIDSuperUser, "超级用户"},
|
|
||||||
{models.RoleIDUser, "普通用户"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRolePermissionsList 返回所有角色及其权限(用于角色权限管理页)
|
// GetRolePermissionsList 返回所有角色及其权限(含预定义与自定义)
|
||||||
func GetRolePermissionsList(c *gin.Context) {
|
func GetRolePermissionsList(c *gin.Context) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -44,23 +36,56 @@ func GetRolePermissionsList(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
permMap := make(map[int][]string)
|
permMap := make(map[int][]string)
|
||||||
|
nameMap := make(map[int]string)
|
||||||
for _, d := range docs {
|
for _, d := range docs {
|
||||||
permMap[d.RoleID] = d.Permissions
|
permMap[d.RoleID] = d.Permissions
|
||||||
|
if d.RoleName != "" {
|
||||||
|
nameMap[d.RoleID] = d.RoleName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allKeys := allPermissionKeys()
|
||||||
list := make([]gin.H, 0, len(roleMeta))
|
// 预定义角色固定在前(9527, 0, 1),再按 role_id 排自定义
|
||||||
for _, r := range roleMeta {
|
predef := []int{models.RoleIDSuperAdmin, models.RoleIDSuperUser, models.RoleIDUser}
|
||||||
perms := permMap[r.RoleID]
|
seen := make(map[int]bool)
|
||||||
|
list := make([]gin.H, 0)
|
||||||
|
for _, rid := range predef {
|
||||||
|
seen[rid] = true
|
||||||
|
perms := permMap[rid]
|
||||||
if perms == nil {
|
if perms == nil {
|
||||||
perms = []string{}
|
perms = []string{}
|
||||||
}
|
}
|
||||||
if r.RoleID == models.RoleIDSuperAdmin {
|
if rid == models.RoleIDSuperAdmin {
|
||||||
perms = allPermissionKeys()
|
perms = allKeys
|
||||||
|
}
|
||||||
|
name := nameMap[rid]
|
||||||
|
if name == "" {
|
||||||
|
name = models.DefaultRoleNames[rid]
|
||||||
}
|
}
|
||||||
list = append(list, gin.H{
|
list = append(list, gin.H{
|
||||||
"role_id": r.RoleID,
|
"role_id": rid,
|
||||||
"role_name": r.RoleName,
|
"role_name": name,
|
||||||
"permissions": perms,
|
"permissions": perms,
|
||||||
|
"is_custom": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
if seen[d.RoleID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[d.RoleID] = true
|
||||||
|
name := d.RoleName
|
||||||
|
if name == "" {
|
||||||
|
name = "角色" + strconv.Itoa(d.RoleID)
|
||||||
|
}
|
||||||
|
perms := d.Permissions
|
||||||
|
if perms == nil {
|
||||||
|
perms = []string{}
|
||||||
|
}
|
||||||
|
list = append(list, gin.H{
|
||||||
|
"role_id": d.RoleID,
|
||||||
|
"role_name": name,
|
||||||
|
"permissions": perms,
|
||||||
|
"is_custom": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -69,11 +94,6 @@ func GetRolePermissionsList(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRolePermissionsInput 更新某角色权限
|
|
||||||
type UpdateRolePermissionsInput struct {
|
|
||||||
Permissions []string `json:"permissions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateRolePermissions 更新指定角色的权限
|
// UpdateRolePermissions 更新指定角色的权限
|
||||||
func UpdateRolePermissions(c *gin.Context) {
|
func UpdateRolePermissions(c *gin.Context) {
|
||||||
roleIDStr := c.Param("role_id")
|
roleIDStr := c.Param("role_id")
|
||||||
@@ -87,7 +107,10 @@ func UpdateRolePermissions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var input UpdateRolePermissionsInput
|
var input struct {
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -98,7 +121,12 @@ func UpdateRolePermissions(c *gin.Context) {
|
|||||||
|
|
||||||
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||||
filter := bson.M{"role_id": roleID}
|
filter := bson.M{"role_id": roleID}
|
||||||
update := bson.M{"$set": bson.M{"role_id": roleID, "permissions": input.Permissions}}
|
set := bson.M{"role_id": roleID, "permissions": input.Permissions}
|
||||||
|
// 超级管理员(9527)已拦截;其余预定义(0/1)与自定义角色均可更新显示名称
|
||||||
|
if input.RoleName != "" {
|
||||||
|
set["role_name"] = input.RoleName
|
||||||
|
}
|
||||||
|
update := bson.M{"$set": set}
|
||||||
opts := options.UpdateOne().SetUpsert(true)
|
opts := options.UpdateOne().SetUpsert(true)
|
||||||
_, err = coll.UpdateOne(ctx, filter, update, opts)
|
_, err = coll.UpdateOne(ctx, filter, update, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,3 +135,72 @@ func UpdateRolePermissions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions})
|
c.JSON(http.StatusOK, gin.H{"message": "保存成功", "role_id": roleID, "permissions": input.Permissions})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRoleInput 创建角色
|
||||||
|
type CreateRoleInput struct {
|
||||||
|
RoleName string `json:"role_name" binding:"required"`
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole 创建自定义角色
|
||||||
|
func CreateRole(c *gin.Context) {
|
||||||
|
var input CreateRoleInput
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写角色名称"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Permissions == nil {
|
||||||
|
input.Permissions = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||||
|
cursor, _ := coll.Find(ctx, bson.M{"role_id": bson.M{"$gte": customRoleIDStart}}, options.Find().SetSort(bson.D{{Key: "role_id", Value: -1}}).SetLimit(1))
|
||||||
|
var docs []models.RolePermissionsDoc
|
||||||
|
_ = cursor.All(ctx, &docs)
|
||||||
|
cursor.Close(ctx)
|
||||||
|
nextID := customRoleIDStart
|
||||||
|
for _, d := range docs {
|
||||||
|
if d.RoleID >= customRoleIDStart {
|
||||||
|
nextID = d.RoleID + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := models.RolePermissionsDoc{
|
||||||
|
RoleID: nextID,
|
||||||
|
RoleName: input.RoleName,
|
||||||
|
Permissions: input.Permissions,
|
||||||
|
}
|
||||||
|
_, err := coll.InsertOne(ctx, bson.M{"role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "role_id": doc.RoleID, "role_name": doc.RoleName, "permissions": doc.Permissions})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole 删除自定义角色(仅 role_id >= customRoleIDStart)
|
||||||
|
func DeleteRole(c *gin.Context) {
|
||||||
|
roleIDStr := c.Param("role_id")
|
||||||
|
roleID, err := strconv.Atoi(roleIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 role_id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roleID < customRoleIDStart {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "预定义角色不可删除"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
coll := config.GetDB(config.DBName).Collection("role_permissions")
|
||||||
|
_, err = coll.DeleteOne(ctx, bson.M{"role_id": roleID})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||||
|
}
|
||||||
|
|||||||
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"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"yh_web/server/config"
|
"yh_web/server/config"
|
||||||
|
"yh_web/server/pkg/traffic"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -30,5 +31,6 @@ func GetStats(c *gin.Context) {
|
|||||||
"conversations": conversations,
|
"conversations": conversations,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"files": files,
|
"files": files,
|
||||||
|
"bandwidth": traffic.Snapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
150
server/handlers/web_routes.go
Normal file
150
server/handlers/web_routes.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
|
"yh_web/server/config"
|
||||||
|
"yh_web/server/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// effectivePagePath 对外访问路径:优先 route_path,否则 /{slug};index 且无 route_path 时返回空(由首页单独处理)
|
||||||
|
func effectivePagePath(p models.Page) string {
|
||||||
|
if p.RoutePath != "" {
|
||||||
|
path := strings.TrimSpace(p.RoutePath)
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if p.Slug == "" || p.Slug == homepageSlug {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/" + p.Slug
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWebRoutes 前台:获取站点已发布页面的动态路由列表(无需鉴权)
|
||||||
|
func GetWebRoutes(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if config.MongoClient == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteID := c.Query("site_id")
|
||||||
|
if siteID == "" {
|
||||||
|
siteID = getOfficialSiteID(ctx)
|
||||||
|
}
|
||||||
|
if siteID == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll := config.GetDB(config.DBName).Collection("pages")
|
||||||
|
opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}})
|
||||||
|
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var pages []models.Page
|
||||||
|
if err = cursor.All(ctx, &pages); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
routes := make([]gin.H, 0)
|
||||||
|
for _, p := range pages {
|
||||||
|
if p.Published != nil && !*p.Published {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := effectivePagePath(p)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
routes = append(routes, gin.H{
|
||||||
|
"path": path,
|
||||||
|
"title": p.Title,
|
||||||
|
"slug": p.Slug,
|
||||||
|
"id": p.ID.Hex(),
|
||||||
|
"mode": p.ContentMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"site_id": siteID, "routes": routes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWebPageByPath 前台:按路径取单页内容(无需鉴权)
|
||||||
|
func GetWebPageByPath(c *gin.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if config.MongoClient == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务暂不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteID := c.Query("site_id")
|
||||||
|
if siteID == "" {
|
||||||
|
siteID = getOfficialSiteID(ctx)
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(c.Query("path"))
|
||||||
|
if siteID == "" || path == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 site_id 或 path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
coll := config.GetDB(config.DBName).Collection("pages")
|
||||||
|
|
||||||
|
var page models.Page
|
||||||
|
tryDecode := func(filter bson.M) bool {
|
||||||
|
page = models.Page{}
|
||||||
|
err := coll.FindOne(ctx, filter).Decode(&page)
|
||||||
|
return err == nil && !page.ID.IsZero()
|
||||||
|
}
|
||||||
|
if !tryDecode(bson.M{"site_id": siteID, "route_path": path}) {
|
||||||
|
alt := strings.TrimPrefix(path, "/")
|
||||||
|
if alt != "" {
|
||||||
|
tryDecode(bson.M{"site_id": siteID, "route_path": alt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page.ID.IsZero() {
|
||||||
|
slug := strings.TrimPrefix(path, "/")
|
||||||
|
if slug != "" {
|
||||||
|
tryDecode(bson.M{"site_id": siteID, "slug": slug})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page.ID.IsZero() {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if page.Published != nil && !*page.Published {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "页面未发布"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": page.ID.Hex(),
|
||||||
|
"site_id": page.SiteID,
|
||||||
|
"slug": page.Slug,
|
||||||
|
"title": page.Title,
|
||||||
|
"type": page.Type,
|
||||||
|
"content": page.Content,
|
||||||
|
"content_mode": page.ContentMode,
|
||||||
|
"route_path": page.RoutePath,
|
||||||
|
"updated_at": page.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
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/models"
|
||||||
"yh_web/server/pkg/logger"
|
"yh_web/server/pkg/logger"
|
||||||
"yh_web/server/pkg/schema"
|
"yh_web/server/pkg/schema"
|
||||||
|
"yh_web/server/pkg/weblive"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -79,7 +80,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
r.MaxMultipartMemory = 32 << 20 // 大单片先落临时文件;整体体积受 Nginx client_max_body_size 限制
|
||||||
r.Use(middleware.ErrorLogger())
|
r.Use(middleware.ErrorLogger())
|
||||||
|
r.Use(middleware.TrafficMeter())
|
||||||
|
|
||||||
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
// CORS(ALLOWED_ORIGINS 为空则允许所有来源;否则仅允许配置的域名)
|
||||||
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
allowedOriginsEnv := os.Getenv("ALLOWED_ORIGINS")
|
||||||
@@ -96,7 +99,7 @@ func main() {
|
|||||||
c.Header("Access-Control-Allow-Origin", "*")
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
}
|
}
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, Origin, X-Requested-With")
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
@@ -142,6 +145,12 @@ func main() {
|
|||||||
c.JSON(http.StatusOK, structure)
|
c.JSON(http.StatusOK, structure)
|
||||||
})
|
})
|
||||||
admin.GET("/stats", handlers.GetStats)
|
admin.GET("/stats", handlers.GetStats)
|
||||||
|
admin.POST("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.CreateYuhengCloudRegister)
|
||||||
|
admin.GET("/yuheng-cloud-accounts", handlers.RequirePermission(models.PermYuhengCloudManage), handlers.ListYuhengCloudRegisterRecords)
|
||||||
|
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
|
||||||
|
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
|
||||||
|
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
|
||||||
|
admin.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser)
|
||||||
|
|
||||||
// 用户管理
|
// 用户管理
|
||||||
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
||||||
@@ -160,8 +169,17 @@ func main() {
|
|||||||
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
|
admin.GET("/sites/:site_id/homepage/download", handlers.RequirePermission(models.PermHomepageEdit), handlers.DownloadHomepage)
|
||||||
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
|
admin.GET("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.GetHomepage)
|
||||||
admin.PUT("/sites/:site_id/homepage", handlers.RequirePermission(models.PermHomepageEdit), handlers.UpdateHomepage)
|
admin.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)
|
admin.GET("/sites/:site_id/assets", handlers.RequirePermission(models.PermSiteManage), handlers.ListSiteAssets)
|
||||||
|
// 分片路由须在 POST .../assets 整文件上传之前注册,避免被更泛的路由误匹配
|
||||||
|
admin.POST("/sites/:site_id/assets/init-multipart", handlers.RequirePermission(models.PermModuleUpload), handlers.InitMultipartUpload)
|
||||||
|
admin.GET("/sites/:site_id/assets/multipart/:upload_id/status", handlers.RequirePermission(models.PermModuleUpload), handlers.MultipartUploadStatus)
|
||||||
|
admin.POST("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||||
|
admin.PUT("/sites/:site_id/assets/multipart/:upload_id/chunk/:chunk_index", handlers.RequirePermission(models.PermModuleUpload), handlers.PutMultipartChunk)
|
||||||
|
admin.POST("/sites/:site_id/assets/multipart/:upload_id/complete", handlers.RequirePermission(models.PermModuleUpload), handlers.CompleteMultipartUpload)
|
||||||
|
admin.DELETE("/sites/:site_id/assets/multipart/:upload_id", handlers.RequirePermission(models.PermModuleUpload), handlers.AbortMultipartUpload)
|
||||||
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
admin.POST("/sites/:site_id/assets", handlers.RequirePermission(models.PermModuleUpload), handlers.UploadSiteAsset)
|
||||||
|
admin.POST("/sites/:site_id/folders", handlers.RequirePermission(models.PermModuleUpload), handlers.CreateSiteFolder)
|
||||||
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
admin.DELETE("/sites/:site_id/assets/:asset_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSiteAsset)
|
||||||
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
admin.GET("/sites", handlers.RequirePermission(models.PermSiteManage), handlers.GetSites)
|
||||||
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
|
admin.GET("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.GetSiteByID)
|
||||||
@@ -170,10 +188,14 @@ func main() {
|
|||||||
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
admin.DELETE("/sites/:site_id", handlers.RequirePermission(models.PermSiteManage), handlers.DeleteSite)
|
||||||
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
admin.GET("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.GetOfficialSite)
|
||||||
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
admin.PUT("/system/official-site", handlers.RequirePermission(models.PermSiteManage), handlers.SetOfficialSite)
|
||||||
|
admin.GET("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.GetChunkUploadCleanupConfig)
|
||||||
|
admin.PUT("/system/chunk-upload-cleanup", handlers.RequirePermission(models.PermSiteManage), handlers.UpdateChunkUploadCleanupConfig)
|
||||||
|
|
||||||
// 角色权限管理
|
// 角色权限管理
|
||||||
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
admin.GET("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.GetRolePermissionsList)
|
||||||
|
admin.POST("/role-permissions", handlers.RequirePermission(models.PermRolePermission), handlers.CreateRole)
|
||||||
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
|
admin.PUT("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.UpdateRolePermissions)
|
||||||
|
admin.DELETE("/role-permissions/:role_id", handlers.RequirePermission(models.PermRolePermission), handlers.DeleteRole)
|
||||||
|
|
||||||
// 网页管理(按站点)
|
// 网页管理(按站点)
|
||||||
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
|
admin.GET("/pages", handlers.RequirePermission(models.PermPageManage), handlers.GetPages)
|
||||||
@@ -201,6 +223,12 @@ func main() {
|
|||||||
|
|
||||||
// 官网站点首页(前台,无需鉴权)
|
// 官网站点首页(前台,无需鉴权)
|
||||||
r.GET("/api/web/homepage", handlers.GetWebHomepage)
|
r.GET("/api/web/homepage", handlers.GetWebHomepage)
|
||||||
|
r.GET("/api/web/routes", handlers.GetWebRoutes)
|
||||||
|
r.GET("/api/web/page", handlers.GetWebPageByPath)
|
||||||
|
|
||||||
|
// 前台直播弹幕账号(与后台 users 无关;需 MongoDB)
|
||||||
|
r.POST("/api/web/site/register", handlers.WebSiteRegister)
|
||||||
|
r.POST("/api/web/site/login", handlers.WebSiteLogin)
|
||||||
|
|
||||||
// 前台 API 路由组
|
// 前台 API 路由组
|
||||||
web := r.Group("/api/web")
|
web := r.Group("/api/web")
|
||||||
@@ -208,11 +236,23 @@ func main() {
|
|||||||
web.GET("/info", func(c *gin.Context) {
|
web.GET("/info", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "web api"})
|
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")
|
port := os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动后扫描 uploads 下各站点 promotion 中遗留的 .mov 并补转码(与上传后异步转码逻辑一致)
|
||||||
|
go handlers.SweepPromotionTranscodeOnStartup()
|
||||||
|
// 定期删除未合并、超过保留期的分片临时目录(.chunk-uploads)
|
||||||
|
go handlers.StartStaleChunkUploadSweep(context.Background())
|
||||||
|
|
||||||
r.Run(":" + port)
|
r.Run(":" + port)
|
||||||
}
|
}
|
||||||
|
|||||||
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,27 +12,40 @@ const (
|
|||||||
PermSMSConfig = "sms_config"
|
PermSMSConfig = "sms_config"
|
||||||
PermPaymentConfig = "payment_config"
|
PermPaymentConfig = "payment_config"
|
||||||
PermRolePermission = "role:permission" // 角色权限管理
|
PermRolePermission = "role:permission" // 角色权限管理
|
||||||
|
PermYuhengCloudManage = "yuheng_cloud:manage" // 宇恒云账号(云端注册留痕)
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllPermissions 所有可配置权限(用于角色权限管理页)
|
// PermissionItem 单条权限定义(JSON 须用小写 key/name,供前端展示与勾选)
|
||||||
var AllPermissions = []struct {
|
type PermissionItem struct {
|
||||||
Key string
|
Key string `json:"key"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
}{
|
|
||||||
{PermSiteManage, "站点管理"},
|
|
||||||
{PermHomepageEdit, "首页编辑"},
|
|
||||||
{PermPageManage, "网页管理"},
|
|
||||||
{PermModuleUpload, "功能模块上传"},
|
|
||||||
{PermUserManage, "用户管理"},
|
|
||||||
{PermWorkspaceManage, "工作空间"},
|
|
||||||
{PermConversationManage, "对话管理"},
|
|
||||||
{PermSMSConfig, "短信配置"},
|
|
||||||
{PermPaymentConfig, "支付配置"},
|
|
||||||
{PermRolePermission, "角色权限管理"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RolePermissionsDoc MongoDB 文档:角色 ID -> 权限列表
|
// AllPermissions 所有可配置权限(用于角色权限管理页)
|
||||||
|
var AllPermissions = []PermissionItem{
|
||||||
|
{Key: PermSiteManage, Name: "站点管理"},
|
||||||
|
{Key: PermHomepageEdit, Name: "首页编辑"},
|
||||||
|
{Key: PermPageManage, Name: "网页管理"},
|
||||||
|
{Key: PermModuleUpload, Name: "功能模块上传"},
|
||||||
|
{Key: PermUserManage, Name: "用户管理"},
|
||||||
|
{Key: PermWorkspaceManage, Name: "工作空间"},
|
||||||
|
{Key: PermConversationManage, Name: "对话管理"},
|
||||||
|
{Key: PermSMSConfig, Name: "短信配置"},
|
||||||
|
{Key: PermPaymentConfig, Name: "支付配置"},
|
||||||
|
{Key: PermRolePermission, Name: "角色权限管理"},
|
||||||
|
{Key: PermYuhengCloudManage, Name: "宇恒云账号管理"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolePermissionsDoc MongoDB 文档:角色 ID -> 名称与权限列表(支持自定义角色)
|
||||||
type RolePermissionsDoc struct {
|
type RolePermissionsDoc struct {
|
||||||
RoleID int `bson:"role_id" json:"role_id"`
|
RoleID int `bson:"role_id" json:"role_id"`
|
||||||
|
RoleName string `bson:"role_name,omitempty" json:"role_name"`
|
||||||
Permissions []string `bson:"permissions" json:"permissions"`
|
Permissions []string `bson:"permissions" json:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预定义角色 ID 的默认名称(未在 DB 中存 role_name 时使用)
|
||||||
|
var DefaultRoleNames = map[int]string{
|
||||||
|
RoleIDSuperAdmin: "超级管理员",
|
||||||
|
RoleIDSuperUser: "超级用户",
|
||||||
|
RoleIDUser: "普通用户",
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ type Site struct {
|
|||||||
|
|
||||||
// Page 网页(属于某站点)
|
// Page 网页(属于某站点)
|
||||||
type Page struct {
|
type Page struct {
|
||||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
SiteID string `bson:"site_id" json:"site_id"`
|
SiteID string `bson:"site_id" json:"site_id"`
|
||||||
Slug string `bson:"slug" json:"slug"` // index, about, ...
|
Slug string `bson:"slug" json:"slug"` // index, about, ...
|
||||||
Title string `bson:"title" json:"title"`
|
Title string `bson:"title" json:"title"`
|
||||||
Type string `bson:"type" json:"type"` // homepage, page
|
Type string `bson:"type" json:"type"` // homepage, page
|
||||||
Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串
|
Content string `bson:"content" json:"content"` // html 模式为 HTML;builder 模式为 JSON(见文档)
|
||||||
UpdatedAt string `bson:"updated_at" json:"updated_at"`
|
ContentMode string `bson:"content_mode,omitempty" json:"content_mode"` // html | builder,空视为 html
|
||||||
|
RoutePath string `bson:"route_path,omitempty" json:"route_path"` // 自定义前台路径,如 /about;空则用 /{slug}
|
||||||
|
Published *bool `bson:"published,omitempty" json:"published"` // nil 或未设视为已发布
|
||||||
|
UpdatedAt string `bson:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HomepageData 首页可编辑数据(与 yuheng-download-space 对应)
|
// HomepageData 首页可编辑数据(与 yuheng-download-space 对应)
|
||||||
@@ -37,6 +40,15 @@ type HomepageData struct {
|
|||||||
BadgeText string `json:"badge_text"` // FREE ACCESS
|
BadgeText string `json:"badge_text"` // FREE ACCESS
|
||||||
Features []FeatureItem `json:"features"` // 星际导航等
|
Features []FeatureItem `json:"features"` // 星际导航等
|
||||||
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
|
FooterText string `json:"footer_text"` // © 2024 YUHENG ONE
|
||||||
|
// 侧栏直链(与 web 首页 Home.vue 一致,同域静态路径)
|
||||||
|
DownloadWindowsURL string `json:"download_windows_url,omitempty"`
|
||||||
|
DownloadAndroidURL string `json:"download_android_url,omitempty"`
|
||||||
|
// BodyBuilder 首页下方扩展区:与网页积木相同 JSON 字符串 {"version":1,"blocks":[...]},空则仅展示上方模板
|
||||||
|
BodyBuilder string `json:"body_builder,omitempty"`
|
||||||
|
// 直播:前台 /live 页「进入直播间」跳转的外部地址(抖音/B 站/自建 H5 等);留空则仅提示在后台配置
|
||||||
|
LiveRoomURL string `json:"live_room_url,omitempty"`
|
||||||
|
// 直播页与首页直播模块主标题
|
||||||
|
LiveRoomTitle string `json:"live_room_title,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavLink struct {
|
type NavLink struct {
|
||||||
@@ -56,11 +68,12 @@ type FeatureItem struct {
|
|||||||
|
|
||||||
// SiteAsset 站点功能模块/上传文件
|
// SiteAsset 站点功能模块/上传文件
|
||||||
type SiteAsset struct {
|
type SiteAsset struct {
|
||||||
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
|
||||||
SiteID string `bson:"site_id" json:"site_id"`
|
SiteID string `bson:"site_id" json:"site_id"`
|
||||||
Name string `bson:"name" json:"name"`
|
Name string `bson:"name" json:"name"`
|
||||||
FilePath string `bson:"file_path" json:"file_path"` // 相对路径
|
FilePath string `bson:"file_path" json:"file_path"` // 相对路径,可含多级目录
|
||||||
Size int64 `bson:"size" json:"size"`
|
Size int64 `bson:"size" json:"size"`
|
||||||
ContentType string `bson:"content_type" json:"content_type"`
|
ContentType string `bson:"content_type" json:"content_type"`
|
||||||
CreatedAt string `bson:"created_at" json:"created_at"`
|
Downloadable bool `bson:"downloadable" json:"downloadable"` // 是否允许下载
|
||||||
|
CreatedAt string `bson:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
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"}},
|
"files": {{Keys: bson.D{{Key: "user_id", Value: 1}}, Name: "idx_user_id"}},
|
||||||
"system_config": {},
|
"system_config": {},
|
||||||
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
"role_permissions": {{Keys: bson.D{{Key: "role_id", Value: 1}}, Name: "idx_role_id", Unique: true}},
|
||||||
|
"site_users": {{Keys: bson.D{{Key: "username", Value: 1}}, Name: "idx_username", Unique: true}},
|
||||||
|
"yuheng_cloud_register_records": {{Keys: bson.D{{Key: "created_at", Value: -1}}, Name: "idx_created_at"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexSpec struct {
|
type indexSpec struct {
|
||||||
@@ -48,6 +50,8 @@ var tableDDL = map[string]string{
|
|||||||
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
|
"messages": "CREATE TABLE IF NOT EXISTS \x60messages\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60conversation_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '对话ID',\n \x60role\x60 VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'role: user/assistant/system',\n \x60content\x60 LONGTEXT COMMENT '内容',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_conversation_id\x60 (\x60conversation_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';",
|
||||||
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
"files": "CREATE TABLE IF NOT EXISTS \x60files\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60user_id\x60 VARCHAR(24) NOT NULL DEFAULT '' COMMENT '用户ID',\n \x60name\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '文件名',\n \x60file_path\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '存储路径',\n \x60size\x60 BIGINT NOT NULL DEFAULT 0 COMMENT '字节数',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_user_id\x60 (\x60user_id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';",
|
||||||
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
"system_config": "CREATE TABLE IF NOT EXISTS \x60system_config\x60 (\n \x60id\x60 VARCHAR(64) NOT NULL COMMENT '配置键 payment, sms_platform 等',\n \x60payload\x60 JSON COMMENT '配置内容(支付/短信等)',\n \x60updated_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '更新时间',\n PRIMARY KEY (\x60id\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';",
|
||||||
|
"site_users": "CREATE TABLE IF NOT EXISTS \x60site_users\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录名',\n \x60password_hash\x60 VARCHAR(128) NOT NULL DEFAULT '' COMMENT '密码哈希',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间(UTC)',\n PRIMARY KEY (\x60id\x60),\n UNIQUE KEY \x60uk_username\x60 (\x60username\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='前台用户(弹幕)';",
|
||||||
|
"yuheng_cloud_register_records": "CREATE TABLE IF NOT EXISTS \x60yuheng_cloud_register_records\x60 (\n \x60id\x60 VARCHAR(24) NOT NULL COMMENT '主键',\n \x60username\x60 VARCHAR(255) NOT NULL DEFAULT '' COMMENT '账号',\n \x60password\x60 VARCHAR(512) NOT NULL DEFAULT '' COMMENT '密码明文留痕',\n \x60created_at\x60 VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',\n PRIMARY KEY (\x60id\x60),\n KEY \x60idx_created_at\x60 (\x60created_at\x60)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='宇恒云注册请求本地留痕';",
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
// CollectionInfo 线上集合信息(名称、文档数、索引)
|
||||||
|
|||||||
108
server/pkg/traffic/meter.go
Normal file
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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user