Self-host compose stack and production stability fixes
这个提交包含在:
11
.dockerignore
普通文件
11
.dockerignore
普通文件
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.manus
|
||||||
|
.manus-logs
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
10
.env.example
10
.env.example
@@ -1,8 +1,7 @@
|
|||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# App auth / storage / database
|
# App auth / storage / database
|
||||||
DATABASE_URL=mysql://user:password@127.0.0.1:4000/tennis_training_hub
|
DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub
|
||||||
JWT_SECRET=replace-with-strong-secret
|
JWT_SECRET=replace-with-strong-secret
|
||||||
VITE_APP_ID=tennis-training-hub
|
VITE_APP_ID=tennis-training-hub
|
||||||
OAUTH_SERVER_URL=
|
OAUTH_SERVER_URL=
|
||||||
@@ -12,6 +11,13 @@ BUILT_IN_FORGE_API_KEY=
|
|||||||
VITE_OAUTH_PORTAL_URL=
|
VITE_OAUTH_PORTAL_URL=
|
||||||
VITE_FRONTEND_FORGE_API_URL=
|
VITE_FRONTEND_FORGE_API_URL=
|
||||||
VITE_FRONTEND_FORGE_API_KEY=
|
VITE_FRONTEND_FORGE_API_KEY=
|
||||||
|
LOCAL_STORAGE_DIR=/data/app/storage
|
||||||
|
|
||||||
|
# Compose MySQL
|
||||||
|
MYSQL_DATABASE=tennis_training_hub
|
||||||
|
MYSQL_USER=tennis
|
||||||
|
MYSQL_PASSWORD=replace-with-db-password
|
||||||
|
MYSQL_ROOT_PASSWORD=replace-with-root-password
|
||||||
|
|
||||||
# LLM chat completion endpoint
|
# LLM chat completion endpoint
|
||||||
LLM_API_URL=https://one.hao.work/v1/chat/completions
|
LLM_API_URL=https://one.hao.work/v1/chat/completions
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ pnpm exec playwright install chromium
|
|||||||
单机部署推荐:
|
单机部署推荐:
|
||||||
|
|
||||||
1. 宿主机 nginx 处理 `80/443` 和 TLS
|
1. 宿主机 nginx 处理 `80/443` 和 TLS
|
||||||
2. `docker compose up -d --build` 启动 `app + media + worker`
|
2. `docker compose up -d --build` 启动 `app + media + worker + db`
|
||||||
3. nginx 将 `/` 转发到 `app:3000`,`/media/` 转发到 `media:8081`
|
3. nginx 将 `/` 转发到宿主机 `127.0.0.1:3002 -> app:3000`,`/media/` 转发到 `127.0.0.1:8081 -> media:8081`
|
||||||
|
|
||||||
详细步骤见:
|
详细步骤见:
|
||||||
|
|
||||||
@@ -92,9 +92,14 @@ pnpm exec playwright install chromium
|
|||||||
|
|
||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
|
- `MYSQL_DATABASE`
|
||||||
|
- `MYSQL_USER`
|
||||||
|
- `MYSQL_PASSWORD`
|
||||||
|
- `MYSQL_ROOT_PASSWORD`
|
||||||
- `LLM_API_URL`
|
- `LLM_API_URL`
|
||||||
- `LLM_API_KEY`
|
- `LLM_API_KEY`
|
||||||
- `LLM_MODEL`
|
- `LLM_MODEL`
|
||||||
|
- `LOCAL_STORAGE_DIR`
|
||||||
- `MEDIA_SERVICE_URL`
|
- `MEDIA_SERVICE_URL`
|
||||||
- `VITE_MEDIA_BASE_URL`
|
- `VITE_MEDIA_BASE_URL`
|
||||||
|
|
||||||
|
|||||||
29
client/src/const.test.ts
普通文件
29
client/src/const.test.ts
普通文件
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getLoginUrl } from "./const";
|
||||||
|
|
||||||
|
describe("getLoginUrl", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the in-app login route when oauth portal is unset", () => {
|
||||||
|
vi.stubEnv("VITE_OAUTH_PORTAL_URL", "");
|
||||||
|
|
||||||
|
expect(getLoginUrl()).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds the external oauth login url when portal is configured", () => {
|
||||||
|
vi.stubEnv("VITE_OAUTH_PORTAL_URL", "https://oauth.example.com");
|
||||||
|
vi.stubEnv("VITE_APP_ID", "tennis-training-hub");
|
||||||
|
|
||||||
|
const url = new URL(getLoginUrl());
|
||||||
|
|
||||||
|
expect(url.origin).toBe("https://oauth.example.com");
|
||||||
|
expect(url.pathname).toBe("/app-auth");
|
||||||
|
expect(url.searchParams.get("appId")).toBe("tennis-training-hub");
|
||||||
|
expect(url.searchParams.get("redirectUri")).toBe(
|
||||||
|
`${window.location.origin}/api/oauth/callback`
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get("type")).toBe("signIn");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,13 @@ export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
|||||||
|
|
||||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
// Generate login URL at runtime so redirect URI reflects the current origin.
|
||||||
export const getLoginUrl = () => {
|
export const getLoginUrl = () => {
|
||||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL?.trim();
|
||||||
const appId = import.meta.env.VITE_APP_ID;
|
const appId = import.meta.env.VITE_APP_ID;
|
||||||
|
|
||||||
|
if (!oauthPortalUrl) {
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
||||||
const state = btoa(redirectUri);
|
const state = btoa(redirectUri);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
server_name te.hao.work;
|
server_name te.hao.work;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
server_name te.hao.work;
|
server_name te.hao.work;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/te.hao.work/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/te.hao.work/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/te.hao.work/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/te.hao.work/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
client_max_body_size 512m;
|
client_max_body_size 512m;
|
||||||
|
add_header Strict-Transport-Security "max-age=15552000" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3002;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
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;
|
||||||
@@ -29,6 +48,8 @@ server {
|
|||||||
proxy_request_buffering off;
|
proxy_request_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
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;
|
||||||
|
|||||||
@@ -1,4 +1,40 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.4
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-tennis_training_hub}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-tennis}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-tennis_password}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-this-root-password}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3306:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: build
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub}
|
||||||
|
command: ["pnpm", "exec", "drizzle-kit", "migrate"]
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -7,12 +43,21 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
DATABASE_URL: mysql://${MYSQL_USER:-tennis}:${MYSQL_PASSWORD:-tennis_password}@db:3306/${MYSQL_DATABASE:-tennis_training_hub}
|
||||||
MEDIA_SERVICE_URL: http://media:8081
|
MEDIA_SERVICE_URL: http://media:8081
|
||||||
|
LOCAL_STORAGE_DIR: /data/app/storage
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:3002:3000"
|
||||||
|
volumes:
|
||||||
|
- app-data:/data/app
|
||||||
depends_on:
|
depends_on:
|
||||||
- media
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
media:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
media:
|
media:
|
||||||
@@ -24,7 +69,7 @@ services:
|
|||||||
MEDIA_DATA_DIR: /data/media
|
MEDIA_DATA_DIR: /data/media
|
||||||
MEDIA_EMBEDDED_WORKER: "0"
|
MEDIA_EMBEDDED_WORKER: "0"
|
||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "127.0.0.1:8081:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- media-data:/data/media
|
- media-data:/data/media
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -44,4 +89,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
app-data:
|
||||||
|
db-data:
|
||||||
media-data:
|
media-data:
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
## Topology
|
## Topology
|
||||||
|
|
||||||
- 宿主机 nginx:负责 `te.hao.work` 的 TLS、反向代理与大文件上传入口
|
- 宿主机 nginx:负责 `te.hao.work` 的 TLS、反向代理与大文件上传入口
|
||||||
|
- `db` 容器:MySQL 8,数据持久化到 `db-data`
|
||||||
|
- `migrate` 容器:一次性执行 Drizzle 迁移,成功后退出
|
||||||
- `app` 容器:Node 应用,端口 `3000`
|
- `app` 容器:Node 应用,端口 `3000`
|
||||||
- `media` 容器:Go 媒体服务,端口 `8081`
|
- `media` 容器:Go 媒体服务,端口 `8081`
|
||||||
- `worker` 容器:Go 媒体归档 worker,共享媒体卷
|
- `worker` 容器:Go 媒体归档 worker,共享媒体卷
|
||||||
|
- `app-data` 卷:上传视频等本地文件存储
|
||||||
|
- `db-data` 卷:MySQL 数据目录
|
||||||
- `media-data` 卷:录制片段、会话状态、归档成片
|
- `media-data` 卷:录制片段、会话状态、归档成片
|
||||||
|
|
||||||
## Required files
|
## Required files
|
||||||
@@ -21,13 +25,20 @@ cp .env.example .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
建议在 `.env` 中至少设置:
|
||||||
|
|
||||||
|
- `JWT_SECRET`
|
||||||
|
- `MYSQL_PASSWORD`
|
||||||
|
- `MYSQL_ROOT_PASSWORD`
|
||||||
|
- `LLM_API_KEY`
|
||||||
|
|
||||||
## nginx
|
## nginx
|
||||||
|
|
||||||
将 `deploy/nginx.te.hao.work.conf` 放到宿主机 nginx 站点目录,确认:
|
将 `deploy/nginx.te.hao.work.conf` 放到宿主机 nginx 站点目录,确认:
|
||||||
|
|
||||||
- `ssl_certificate`
|
- `ssl_certificate`
|
||||||
- `ssl_certificate_key`
|
- `ssl_certificate_key`
|
||||||
- `proxy_pass http://127.0.0.1:3000` 对应前端与业务 API
|
- `proxy_pass http://127.0.0.1:3002` 对应前端、业务 API 和 `/uploads/*`
|
||||||
- `proxy_pass http://127.0.0.1:8081` 对应媒体服务
|
- `proxy_pass http://127.0.0.1:8081` 对应媒体服务
|
||||||
|
|
||||||
启用后重载 nginx:
|
启用后重载 nginx:
|
||||||
@@ -39,7 +50,7 @@ systemctl reload nginx
|
|||||||
|
|
||||||
## Health checks
|
## Health checks
|
||||||
|
|
||||||
- `curl http://127.0.0.1:3000/api/trpc/auth.me`
|
- `curl http://127.0.0.1:3002/api/trpc/auth.me`
|
||||||
- `curl http://127.0.0.1:8081/media/health`
|
- `curl http://127.0.0.1:8081/media/health`
|
||||||
|
|
||||||
## Persistent data
|
## Persistent data
|
||||||
@@ -51,6 +62,8 @@ systemctl reload nginx
|
|||||||
- `public/sessions/<session_id>/recording.webm`
|
- `public/sessions/<session_id>/recording.webm`
|
||||||
- `public/sessions/<session_id>/recording.mp4`
|
- `public/sessions/<session_id>/recording.mp4`
|
||||||
|
|
||||||
|
应用本地上传文件默认位于 Docker volume `app-data` 下的 `/data/app/storage`。
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
1. 保留 `.env` 和 `media-data`
|
1. 保留 `.env` 和 `media-data`
|
||||||
|
|||||||
@@ -35,9 +35,20 @@ git commit -m "..."
|
|||||||
3. 再跑 `pnpm test`
|
3. 再跑 `pnpm test`
|
||||||
4. 若涉及媒体链路,再跑 `pnpm test:go`
|
4. 若涉及媒体链路,再跑 `pnpm test:go`
|
||||||
5. 最后跑 `pnpm test:e2e`
|
5. 最后跑 `pnpm test:e2e`
|
||||||
|
6. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
|
||||||
|
|
||||||
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
|
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
|
||||||
|
|
||||||
|
## Deployment-safe checks
|
||||||
|
|
||||||
|
涉及 compose、nginx、数据库或媒体服务调整时,提交前至少确认:
|
||||||
|
|
||||||
|
- `docker compose config` 可通过
|
||||||
|
- `docker compose ps` 中 `app`、`db`、`media`、`worker` 正常
|
||||||
|
- 一次性迁移容器 `migrate` 成功退出
|
||||||
|
- `curl -I https://te.hao.work/` 返回 `200`
|
||||||
|
- `curl http://127.0.0.1:8081/media/health` 返回 `{"ok":true,...}`
|
||||||
|
|
||||||
## Media-related changes
|
## Media-related changes
|
||||||
|
|
||||||
修改录制链路时至少检查:
|
修改录制链路时至少检查:
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ pnpm test:llm -- "你好,做个自我介绍"
|
|||||||
- 适合验证 `LLM_API_KEY`、`LLM_MODEL` 和网关连通性
|
- 适合验证 `LLM_API_KEY`、`LLM_MODEL` 和网关连通性
|
||||||
- 不建议纳入 `pnpm verify`,因为它依赖外部网络和真实密钥
|
- 不建议纳入 `pnpm verify`,因为它依赖外部网络和真实密钥
|
||||||
|
|
||||||
|
## Production smoke checks
|
||||||
|
|
||||||
|
部署到宿主机后,建议至少补以下联测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
curl -I https://te.hao.work/
|
||||||
|
curl http://127.0.0.1:8081/media/health
|
||||||
|
pnpm test:llm
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐再增加一轮浏览器级检查:
|
||||||
|
|
||||||
|
- 打开 `https://te.hao.work/`
|
||||||
|
- 打开 `https://te.hao.work/login`
|
||||||
|
- 打开 `https://te.hao.work/recorder`
|
||||||
|
- 确认没有 `pageerror` 或首屏 `console.error`
|
||||||
|
|
||||||
## Local browser prerequisites
|
## Local browser prerequisites
|
||||||
|
|
||||||
首次运行 Playwright 前执行:
|
首次运行 Playwright 前执行:
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
# Verified Features
|
# Verified Features
|
||||||
|
|
||||||
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 21:44 CST。
|
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-14 22:24 CST。
|
||||||
|
|
||||||
## 最新完整验证记录
|
## 最新完整验证记录
|
||||||
|
|
||||||
- 通过命令:`pnpm verify`
|
- 通过命令:`pnpm verify`
|
||||||
- 验证时间:2026-03-14 21:44 CST
|
- 验证时间:2026-03-14 22:23 CST
|
||||||
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(69/69),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5)
|
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(74/74),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(5/5)
|
||||||
|
|
||||||
|
## 生产部署联测
|
||||||
|
|
||||||
|
| 项目 | 验证方式 | 状态 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 |
|
||||||
|
| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 |
|
||||||
|
| 生产首页、登录页、录制页浏览器打开 | Playwright 访问 `https://te.hao.work/`、`/login`、`/recorder` | 通过 |
|
||||||
|
| 生产前端运行时异常检查 | Playwright `pageerror` / `console.error` 检查 | 通过 |
|
||||||
|
| 媒体健康检查 | `curl http://127.0.0.1:8081/media/health` | 通过 |
|
||||||
|
| compose 自包含服务 | `docker compose ps` 中 `app` / `db` / `media` / `worker` 正常运行,`migrate` 成功退出 | 通过 |
|
||||||
|
|
||||||
## 构建与编译通过
|
## 构建与编译通过
|
||||||
|
|
||||||
@@ -32,6 +43,7 @@
|
|||||||
| leaderboard | `pnpm test` | 通过 |
|
| leaderboard | `pnpm test` | 通过 |
|
||||||
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
|
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
|
||||||
| media 工具函数 | `pnpm test` | 通过 |
|
| media 工具函数 | `pnpm test` | 通过 |
|
||||||
|
| 登录 URL 回退逻辑 | `pnpm test` | 通过 |
|
||||||
|
|
||||||
## Go 媒体服务验证
|
## Go 媒体服务验证
|
||||||
|
|
||||||
@@ -53,6 +65,13 @@
|
|||||||
| 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 |
|
| 在线录制 | 启动摄像头、开始录制、手动标记、结束归档 | 通过 |
|
||||||
| 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 |
|
| 录制结果入库 | 归档完成后视频库可见录制结果 | 通过 |
|
||||||
|
|
||||||
|
## LLM 模块验证
|
||||||
|
|
||||||
|
| 项目 | 验证方式 | 状态 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `.env` 中的 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL` | `pnpm test:llm` | 通过 |
|
||||||
|
| `https://one.hao.work/v1/chat/completions` 联通性 | `pnpm test:llm` 实际返回文本 | 通过 |
|
||||||
|
|
||||||
## 已知非阻断警告
|
## 已知非阻断警告
|
||||||
|
|
||||||
- 测试与开发日志中会出现 `OAUTH_SERVER_URL` 未配置提示;当前 mocked auth 和本地验证链路不依赖真实 OAuth 服务,因此不会导致失败
|
- 测试与开发日志中会出现 `OAUTH_SERVER_URL` 未配置提示;当前 mocked auth 和本地验证链路不依赖真实 OAuth 服务,因此不会导致失败
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -20,6 +20,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.693.0",
|
"@aws-sdk/client-s3": "^3.693.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
||||||
|
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@mediapipe/drawing_utils": "^0.3.1675466124",
|
"@mediapipe/drawing_utils": "^0.3.1675466124",
|
||||||
"@mediapipe/pose": "^0.5.1675469404",
|
"@mediapipe/pose": "^0.5.1675469404",
|
||||||
@@ -50,9 +51,11 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@trpc/client": "^11.6.0",
|
"@trpc/client": "^11.6.0",
|
||||||
"@trpc/react-query": "^11.6.0",
|
"@trpc/react-query": "^11.6.0",
|
||||||
"@trpc/server": "^11.6.0",
|
"@trpc/server": "^11.6.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"axios": "^1.12.0",
|
"axios": "^1.12.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -82,20 +85,19 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-plugin-manus-runtime": "^0.0.57",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
|
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.0",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.1.3",
|
|
||||||
"@types/express": "4.17.21",
|
"@types/express": "4.17.21",
|
||||||
"@types/google.maps": "^3.58.1",
|
"@types/google.maps": "^3.58.1",
|
||||||
"@types/node": "^24.7.0",
|
"@types/node": "^24.7.0",
|
||||||
"@types/react": "^19.2.1",
|
"@types/react": "^19.2.1",
|
||||||
"@types/react-dom": "^19.2.1",
|
"@types/react-dom": "^19.2.1",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
@@ -108,8 +110,6 @@
|
|||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "^7.1.7",
|
|
||||||
"vite-plugin-manus-runtime": "^0.0.57",
|
|
||||||
"vitest": "^2.1.4"
|
"vitest": "^2.1.4"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
|
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
|
||||||
|
|||||||
30
pnpm-lock.yaml
自动生成的
30
pnpm-lock.yaml
自动生成的
@@ -22,6 +22,9 @@ importers:
|
|||||||
'@aws-sdk/s3-request-presigner':
|
'@aws-sdk/s3-request-presigner':
|
||||||
specifier: ^3.693.0
|
specifier: ^3.693.0
|
||||||
version: 3.907.0
|
version: 3.907.0
|
||||||
|
'@builder.io/vite-plugin-jsx-loc':
|
||||||
|
specifier: ^0.1.1
|
||||||
|
version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
||||||
@@ -109,6 +112,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.8
|
specifier: ^1.2.8
|
||||||
version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||||
|
'@tailwindcss/vite':
|
||||||
|
specifier: ^4.1.3
|
||||||
|
version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.90.2
|
specifier: ^5.90.2
|
||||||
version: 5.90.2(react@19.2.1)
|
version: 5.90.2(react@19.2.1)
|
||||||
@@ -121,6 +127,9 @@ importers:
|
|||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^11.6.0
|
specifier: ^11.6.0
|
||||||
version: 11.6.0(typescript@5.9.3)
|
version: 11.6.0(typescript@5.9.3)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^5.0.4
|
||||||
|
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.12.0
|
specifier: ^1.12.0
|
||||||
version: 1.12.2
|
version: 1.12.2
|
||||||
@@ -208,6 +217,12 @@ importers:
|
|||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||||
|
vite:
|
||||||
|
specifier: ^7.1.7
|
||||||
|
version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)
|
||||||
|
vite-plugin-manus-runtime:
|
||||||
|
specifier: ^0.0.57
|
||||||
|
version: 0.0.57
|
||||||
wouter:
|
wouter:
|
||||||
specifier: ^3.3.5
|
specifier: ^3.3.5
|
||||||
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
|
version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1)
|
||||||
@@ -215,18 +230,12 @@ importers:
|
|||||||
specifier: ^4.1.12
|
specifier: ^4.1.12
|
||||||
version: 4.1.12
|
version: 4.1.12
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@builder.io/vite-plugin-jsx-loc':
|
|
||||||
specifier: ^0.1.1
|
|
||||||
version: 0.1.1(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.55.0
|
specifier: ^1.55.0
|
||||||
version: 1.58.2
|
version: 1.58.2
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.15
|
specifier: ^0.5.15
|
||||||
version: 0.5.19(tailwindcss@4.1.14)
|
version: 0.5.19(tailwindcss@4.1.14)
|
||||||
'@tailwindcss/vite':
|
|
||||||
specifier: ^4.1.3
|
|
||||||
version: 4.1.14(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
|
||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -242,9 +251,6 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.1
|
specifier: ^19.2.1
|
||||||
version: 19.2.1(@types/react@19.2.1)
|
version: 19.2.1(@types/react@19.2.1)
|
||||||
'@vitejs/plugin-react':
|
|
||||||
specifier: ^5.0.4
|
|
||||||
version: 5.0.4(vite@7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6))
|
|
||||||
add:
|
add:
|
||||||
specifier: ^2.0.6
|
specifier: ^2.0.6
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
@@ -281,12 +287,6 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: 5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
|
||||||
specifier: ^7.1.7
|
|
||||||
version: 7.1.9(@types/node@24.7.0)(jiti@2.6.1)(lightningcss@1.30.1)(tsx@4.20.6)
|
|
||||||
vite-plugin-manus-runtime:
|
|
||||||
specifier: ^0.0.57
|
|
||||||
version: 0.0.57
|
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.1.4
|
specifier: ^2.1.4
|
||||||
version: 2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1)
|
version: 2.1.9(@types/node@24.7.0)(jsdom@28.1.0)(lightningcss@1.30.1)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const ENV = {
|
|||||||
isProduction: process.env.NODE_ENV === "production",
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||||
|
localStorageDir: process.env.LOCAL_STORAGE_DIR ?? "./data/storage",
|
||||||
llmApiUrl:
|
llmApiUrl:
|
||||||
process.env.LLM_API_URL ??
|
process.env.LLM_API_URL ??
|
||||||
(process.env.BUILT_IN_FORGE_API_URL
|
(process.env.BUILT_IN_FORGE_API_URL
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import "dotenv/config";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import net from "net";
|
import net from "net";
|
||||||
|
import path from "node:path";
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
import { registerOAuthRoutes } from "./oauth";
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
import { registerMediaProxy } from "./mediaProxy";
|
import { registerMediaProxy } from "./mediaProxy";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic } from "./static";
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -35,6 +36,10 @@ async function startServer() {
|
|||||||
// Configure body parser with larger size limit for file uploads
|
// Configure body parser with larger size limit for file uploads
|
||||||
app.use(express.json({ limit: "50mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
||||||
|
app.use(
|
||||||
|
"/uploads",
|
||||||
|
express.static(path.resolve(process.env.LOCAL_STORAGE_DIR || "data/storage"))
|
||||||
|
);
|
||||||
// OAuth callback under /api/oauth/callback
|
// OAuth callback under /api/oauth/callback
|
||||||
registerOAuthRoutes(app);
|
registerOAuthRoutes(app);
|
||||||
// tRPC API
|
// tRPC API
|
||||||
@@ -47,6 +52,7 @@ async function startServer() {
|
|||||||
);
|
);
|
||||||
// development mode uses Vite, production mode uses static files
|
// development mode uses Vite, production mode uses static files
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
const { setupVite } = await import("./vite");
|
||||||
await setupVite(app, server);
|
await setupVite(app, server);
|
||||||
} else {
|
} else {
|
||||||
serveStatic(app);
|
serveStatic(app);
|
||||||
|
|||||||
21
server/_core/static.ts
普通文件
21
server/_core/static.ts
普通文件
@@ -0,0 +1,21 @@
|
|||||||
|
import express, { type Express } from "express";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export function serveStatic(app: Express) {
|
||||||
|
const distPath =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? path.resolve(import.meta.dirname, "../..", "dist", "public")
|
||||||
|
: path.resolve(import.meta.dirname, "public");
|
||||||
|
if (!fs.existsSync(distPath)) {
|
||||||
|
console.error(
|
||||||
|
`Could not find the build directory: ${distPath}, make sure to build the client first`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
|
||||||
|
app.use("*", (_req, res) => {
|
||||||
|
res.sendFile(path.resolve(distPath, "index.html"));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,22 +46,3 @@ export async function setupVite(app: Express, server: Server) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveStatic(app: Express) {
|
|
||||||
const distPath =
|
|
||||||
process.env.NODE_ENV === "development"
|
|
||||||
? path.resolve(import.meta.dirname, "../..", "dist", "public")
|
|
||||||
: path.resolve(import.meta.dirname, "public");
|
|
||||||
if (!fs.existsSync(distPath)) {
|
|
||||||
console.error(
|
|
||||||
`Could not find the build directory: ${distPath}, make sure to build the client first`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(express.static(distPath));
|
|
||||||
|
|
||||||
// fall through to index.html if the file doesn't exist
|
|
||||||
app.use("*", (_req, res) => {
|
|
||||||
res.sendFile(path.resolve(distPath, "index.html"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
40
server/storage.test.ts
普通文件
40
server/storage.test.ts
普通文件
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
describe("storage fallback", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
delete process.env.BUILT_IN_FORGE_API_URL;
|
||||||
|
delete process.env.BUILT_IN_FORGE_API_KEY;
|
||||||
|
tempDir = await mkdtemp(path.join(os.tmpdir(), "tennis-storage-"));
|
||||||
|
process.env.LOCAL_STORAGE_DIR = tempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores files locally when remote storage is not configured", async () => {
|
||||||
|
const { storagePut, storageGet } = await import("./storage");
|
||||||
|
|
||||||
|
const stored = await storagePut("videos/test/sample.webm", Buffer.from("demo"));
|
||||||
|
const loaded = await storageGet("videos/test/sample.webm");
|
||||||
|
|
||||||
|
expect(stored).toEqual({
|
||||||
|
key: "videos/test/sample.webm",
|
||||||
|
url: "/uploads/videos/test/sample.webm",
|
||||||
|
});
|
||||||
|
expect(loaded).toEqual({
|
||||||
|
key: "videos/test/sample.webm",
|
||||||
|
url: "/uploads/videos/test/sample.webm",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Preconfigured storage helpers for Manus WebDev templates
|
// Preconfigured storage helpers for Manus WebDev templates
|
||||||
// Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
|
// Uses the Biz-provided storage proxy (Authorization: Bearer <token>)
|
||||||
|
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { ENV } from './_core/env';
|
import { ENV } from './_core/env';
|
||||||
|
|
||||||
type StorageConfig = { baseUrl: string; apiKey: string };
|
type StorageConfig = { baseUrl: string; apiKey: string };
|
||||||
@@ -18,6 +20,31 @@ function getStorageConfig(): StorageConfig {
|
|||||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseRemoteStorage(): boolean {
|
||||||
|
return Boolean(ENV.forgeApiUrl && ENV.forgeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalStoragePath(relKey: string): string {
|
||||||
|
return path.join(ENV.localStorageDir, normalizeKey(relKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLocalFile(
|
||||||
|
relKey: string,
|
||||||
|
data: Buffer | Uint8Array | string
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = getLocalStoragePath(relKey);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const content =
|
||||||
|
typeof data === "string" ? Buffer.from(data) : Buffer.from(data);
|
||||||
|
await writeFile(filePath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLocalFile(relKey: string): Promise<string> {
|
||||||
|
const filePath = getLocalStoragePath(relKey);
|
||||||
|
await readFile(filePath);
|
||||||
|
return `/uploads/${normalizeKey(relKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildUploadUrl(baseUrl: string, relKey: string): URL {
|
function buildUploadUrl(baseUrl: string, relKey: string): URL {
|
||||||
const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
|
const url = new URL("v1/storage/upload", ensureTrailingSlash(baseUrl));
|
||||||
url.searchParams.set("path", normalizeKey(relKey));
|
url.searchParams.set("path", normalizeKey(relKey));
|
||||||
@@ -72,6 +99,12 @@ export async function storagePut(
|
|||||||
data: Buffer | Uint8Array | string,
|
data: Buffer | Uint8Array | string,
|
||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
): Promise<{ key: string; url: string }> {
|
): Promise<{ key: string; url: string }> {
|
||||||
|
if (!canUseRemoteStorage()) {
|
||||||
|
const key = normalizeKey(relKey);
|
||||||
|
await writeLocalFile(key, data);
|
||||||
|
return { key, url: `/uploads/${key}` };
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, apiKey } = getStorageConfig();
|
const { baseUrl, apiKey } = getStorageConfig();
|
||||||
const key = normalizeKey(relKey);
|
const key = normalizeKey(relKey);
|
||||||
const uploadUrl = buildUploadUrl(baseUrl, key);
|
const uploadUrl = buildUploadUrl(baseUrl, key);
|
||||||
@@ -93,6 +126,14 @@ export async function storagePut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> {
|
export async function storageGet(relKey: string): Promise<{ key: string; url: string; }> {
|
||||||
|
if (!canUseRemoteStorage()) {
|
||||||
|
const key = normalizeKey(relKey);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
url: await readLocalFile(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, apiKey } = getStorageConfig();
|
const { baseUrl, apiKey } = getStorageConfig();
|
||||||
const key = normalizeKey(relKey);
|
const key = normalizeKey(relKey);
|
||||||
return {
|
return {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户