Compare commits
6 Commits
db1bbfa1d4
...
41115faa16
| Author | SHA1 | Date | |
|---|---|---|---|
| 41115faa16 | |||
|
|
0b770340c8 | ||
| 177a9c7dec | |||
| a6a3928091 | |||
| ab1b00f94a | |||
|
|
5972f45784 |
@ -133,41 +133,45 @@ jobs:
|
||||
sed -i "s|redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.ivolces.com:6379/0|${{ env.REDIS_URL }}|g" k8s/celery-deployment.yaml
|
||||
|
||||
# All kubectl operations with retry (K3s 内网连接可能抖动)
|
||||
for attempt in 1 2 3; do
|
||||
echo "Deploy attempt $attempt/3..."
|
||||
export KUBECTL_TIMEOUT="--request-timeout=4s"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
echo "Deploy attempt $attempt/5..."
|
||||
{
|
||||
# Create/update image pull secret for CR
|
||||
kubectl create secret docker-registry cr-pull-secret \
|
||||
kubectl $KUBECTL_TIMEOUT create secret docker-registry cr-pull-secret \
|
||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
|
||||
|
||||
# Create/update secrets (业务密钥,DB 已写在 yaml 里)
|
||||
kubectl create secret generic video-backend-secrets \
|
||||
kubectl $KUBECTL_TIMEOUT create secret generic video-backend-secrets \
|
||||
--from-literal=ARK_API_KEY='${{ secrets.ARK_API_KEY }}' \
|
||||
--from-literal=TOS_ACCESS_KEY='${{ secrets.TOS_ACCESS_KEY }}' \
|
||||
--from-literal=TOS_SECRET_KEY='${{ secrets.TOS_SECRET_KEY }}' \
|
||||
--from-literal=DJANGO_SECRET_KEY='${{ secrets.DJANGO_SECRET_KEY }}' \
|
||||
--from-literal=ALIYUN_SMS_ACCESS_KEY='${{ secrets.ALIYUN_SMS_ACCESS_KEY }}' \
|
||||
--from-literal=ALIYUN_SMS_ACCESS_SECRET='${{ secrets.ALIYUN_SMS_ACCESS_SECRET }}' \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
|
||||
|
||||
# Apply manifests
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/celery-deployment.yaml
|
||||
kubectl apply -f k8s/web-deployment.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/cert-manager-issuer.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/redirect-https-middleware.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/backend-deployment.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/celery-deployment.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.yaml
|
||||
kubectl $KUBECTL_TIMEOUT apply -f k8s/ingress.yaml
|
||||
|
||||
# Preserve real client IP
|
||||
kubectl patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
|
||||
kubectl $KUBECTL_TIMEOUT patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true
|
||||
|
||||
kubectl rollout restart deployment/video-backend
|
||||
kubectl rollout restart deployment/celery-worker
|
||||
kubectl rollout restart deployment/video-web
|
||||
kubectl $KUBECTL_TIMEOUT rollout restart deployment/video-backend
|
||||
kubectl $KUBECTL_TIMEOUT rollout restart deployment/celery-worker
|
||||
kubectl $KUBECTL_TIMEOUT rollout restart deployment/video-web
|
||||
} 2>&1 | tee /tmp/deploy.log && break
|
||||
echo "Attempt $attempt failed, retrying in 10s..."
|
||||
sleep 10
|
||||
echo "Attempt $attempt failed, retrying in 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
# ===== Log Center: failure reporting =====
|
||||
|
||||
@ -3446,7 +3446,8 @@ def asset_poll_status_view(request, asset_id):
|
||||
except Asset.DoesNotExist:
|
||||
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if asset.remote_asset_id:
|
||||
# 已经 active 且有 URL 的素材跳过远程查询(避免跨项目素材被误删)
|
||||
if asset.remote_asset_id and asset.status != 'active':
|
||||
from utils import assets_client
|
||||
from utils.assets_client import AssetsAPIError
|
||||
try:
|
||||
|
||||
430
docs/https-and-certificate.md
Normal file
430
docs/https-and-certificate.md
Normal file
@ -0,0 +1,430 @@
|
||||
# HTTPS 跳转 & 证书生成流程分析
|
||||
|
||||
> **本文档面向 AI Agent / 开发者**:总结了在 K3s + Traefik v3 + cert-manager 架构下,实现 HTTP→HTTPS 自动跳转和 Let's Encrypt 自动证书的完整方案。其他项目可直接参照本文档修改自己的 CI/CD 流水线和 K8s 配置。
|
||||
|
||||
---
|
||||
|
||||
## 零、其他项目接入指南(快速参考)
|
||||
|
||||
### 需要做的 3 件事
|
||||
|
||||
#### 1. 新增文件:`k8s/cert-manager-issuer.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: airlabsv001@gmail.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik
|
||||
```
|
||||
|
||||
> 如果集群已有同名 ClusterIssuer(多项目共享同一集群),这一步可跳过,`kubectl apply` 是幂等的。
|
||||
|
||||
#### 2. 新增文件:`k8s/redirect-https-middleware.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
```
|
||||
|
||||
#### 3. 修改 `k8s/ingress.yaml`
|
||||
|
||||
确保包含以下 3 个 annotation 和 TLS 配置:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: 你的-ingress-名称
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod" # ← 触发自动证书签发
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd" # ← HTTP→HTTPS 跳转
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- 你的域名-api.example.com # ← 改成你的域名
|
||||
- 你的域名.example.com
|
||||
secretName: 你的项目-tls # ← 证书存储的 Secret 名,随便起,不要和其他项目冲突
|
||||
rules:
|
||||
- host: 你的域名-api.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: 你的-backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- host: 你的域名.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: 你的-web-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
#### 4. 修改 CI/CD 流水线(deploy.yaml)
|
||||
|
||||
在 `kubectl apply` 部署步骤中,**在 ingress.yaml 之前**加上这两行:
|
||||
|
||||
```yaml
|
||||
# 原来只有这些:
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/web-deployment.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
|
||||
# 改成:
|
||||
kubectl apply -f k8s/cert-manager-issuer.yaml # ← 新增:注册 Let's Encrypt CA
|
||||
kubectl apply -f k8s/redirect-https-middleware.yaml # ← 新增:HTTP→HTTPS 重定向中间件
|
||||
kubectl apply -f k8s/backend-deployment.yaml
|
||||
kubectl apply -f k8s/web-deployment.yaml
|
||||
kubectl apply -f k8s/ingress.yaml
|
||||
```
|
||||
|
||||
> **顺序很重要**:cert-manager-issuer 和 middleware 必须在 ingress 之前 apply,否则 ingress 引用的资源不存在会导致证书签发失败或重定向不生效。
|
||||
|
||||
### 集群前置条件(每台服务器只需执行一次)
|
||||
|
||||
以下命令需要 **SSH 到每台 K8s master 节点手动执行一次**,不需要写进 CI/CD:
|
||||
|
||||
```bash
|
||||
# 1. 确认 cert-manager 已安装
|
||||
kubectl get pods -n cert-manager
|
||||
# 如果没有,需要先安装:https://cert-manager.io/docs/installation/
|
||||
|
||||
# 2. 配置 Traefik 全局 HTTP→HTTPS 重定向
|
||||
kubectl -n kube-system patch deployment traefik --type=json -p '[
|
||||
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--entryPoints.web.http.redirections.entryPoint.to=:443"},
|
||||
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--entryPoints.web.http.redirections.entryPoint.scheme=https"},
|
||||
{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--entryPoints.web.http.redirections.entryPoint.permanent=true"}
|
||||
]'
|
||||
```
|
||||
|
||||
> **关键**:`to=:443` 不能写成 `to=websecure`。Traefik 内部 websecure 端口是 8443,写 `websecure` 会导致重定向 URL 带 `:8443`,用户无法访问。
|
||||
|
||||
### 验证清单
|
||||
|
||||
```bash
|
||||
# HTTP 跳转
|
||||
curl -I http://你的域名
|
||||
# 预期: 308 Permanent Redirect → https://你的域名
|
||||
|
||||
# 证书有效
|
||||
curl -v https://你的域名 2>&1 | grep "issuer"
|
||||
# 预期: issuer: ... Let's Encrypt ...
|
||||
|
||||
# 证书状态
|
||||
kubectl get certificate -A
|
||||
# 预期: Ready = True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、HTTP → HTTPS 自动跳转
|
||||
|
||||
### 问题
|
||||
用户通过 `http://` 访问时不会自动跳转到 `https://`。
|
||||
|
||||
### 根因
|
||||
Traefik v3(K3s 内置 Ingress Controller)对配置了 TLS 的 Ingress 默认只创建 HTTPS 路由,HTTP 请求没有对应路由处理,导致无法重定向。
|
||||
|
||||
### 修复方案
|
||||
在 Traefik Deployment 全局添加 HTTP→HTTPS 重定向参数(无需每个 Ingress 单独配置,集群内所有项目自动生效):
|
||||
|
||||
```
|
||||
--entryPoints.web.http.redirections.entryPoint.to=:443
|
||||
--entryPoints.web.http.redirections.entryPoint.scheme=https
|
||||
--entryPoints.web.http.redirections.entryPoint.permanent=true
|
||||
```
|
||||
|
||||
**执行命令**(在 K8s master 节点):
|
||||
```bash
|
||||
kubectl -n kube-system patch deployment traefik --type=json -p '[
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.to=:443"},
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.scheme=https"},
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.permanent=true"}
|
||||
]'
|
||||
```
|
||||
|
||||
> **注意**: `to=:443` 而不是 `to=websecure`。Traefik 内部 websecure 监听在 8443 端口,如果写 `to=websecure` 重定向 URL 会带上 `:8443` 端口号,导致用户访问失败。写 `:443` 可以确保重定向目标是标准 HTTPS 端口。
|
||||
|
||||
### 测试服状态
|
||||
已修复 ✅ — `http://airflow-studio.test.airlabs.art` → 308 → `https://airflow-studio.test.airlabs.art`
|
||||
|
||||
### 正式服状态
|
||||
未修复 ❌ — 需要在正式服 K8s 集群执行同样的 `kubectl patch` 命令。
|
||||
|
||||
---
|
||||
|
||||
## 二、SSL 证书生成流程
|
||||
|
||||
### 整体架构
|
||||
|
||||
```
|
||||
用户浏览器
|
||||
│
|
||||
┌────▼────┐
|
||||
│ DNS │ *.airlabs.art → 集群外网 IP
|
||||
└────┬────┘
|
||||
│
|
||||
┌─────────▼──────────┐
|
||||
│ Traefik (K3s) │ Ingress Controller
|
||||
│ Port 80 / 443 │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌───────────▼────────────┐
|
||||
│ Ingress 资源 │ 定义域名 → Service 映射
|
||||
│ + TLS secretName │ 指定证书存储位置
|
||||
│ + cert-manager注解 │ 触发自动证书签发
|
||||
└───────────┬────────────┘
|
||||
│
|
||||
┌───────────▼────────────┐
|
||||
│ cert-manager │ 监听 Ingress 变化
|
||||
│ (集群内 Pod) │ 自动管理证书生命周期
|
||||
└───────────┬────────────┘
|
||||
│
|
||||
┌───────────▼────────────┐
|
||||
│ Let's Encrypt │ 免费证书颁发机构 (CA)
|
||||
│ (外部服务) │ 通过 ACME 协议验证域名
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
### 详细步骤
|
||||
|
||||
#### 第 1 步:ClusterIssuer 定义 CA 配置
|
||||
|
||||
文件: `k8s/cert-manager-issuer.yaml`
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory # Let's Encrypt 生产 API
|
||||
email: airlabsv001@gmail.com # 证书到期提醒邮箱
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-key # ACME 账号私钥存储
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: traefik # 使用 Traefik 完成验证
|
||||
```
|
||||
|
||||
- `ClusterIssuer` 是全局资源,集群内所有 namespace 都可使用
|
||||
- ACME 账号注册后私钥保存在 `letsencrypt-prod-key` Secret 中
|
||||
|
||||
#### 第 2 步:Ingress 触发证书签发
|
||||
|
||||
文件: `k8s/ingress.yaml`
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod" # ← 告诉 cert-manager 用哪个 Issuer
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- airflow-studio-api.airlabs.art # ← 需要证书的域名
|
||||
- airflow-studio.airlabs.art
|
||||
secretName: airflow-studio-tls # ← 证书存到这个 Secret
|
||||
```
|
||||
|
||||
当 cert-manager 检测到这个 Ingress 有 `cert-manager.io/cluster-issuer` 注解,会自动:
|
||||
1. 创建一个 `Certificate` 资源
|
||||
2. 创建一个 `CertificateRequest` 资源
|
||||
3. 创建一个 `Order` 资源
|
||||
4. 创建一个 `Challenge` 资源(每个域名一个)
|
||||
|
||||
#### 第 3 步:HTTP-01 验证(关键环节)
|
||||
|
||||
cert-manager 使用 **HTTP-01 验证**来证明你拥有该域名:
|
||||
|
||||
```
|
||||
Let's Encrypt 服务器 你的集群
|
||||
│ │
|
||||
│ 1. 给你一个 token │
|
||||
│ ──────────────────────────────────────────► │
|
||||
│ │
|
||||
│ 2. 在 http://<域名>/.well-known/ │
|
||||
│ acme-challenge/<token> 放置响应 │
|
||||
│ │ cert-manager 自动创建
|
||||
│ 3. Let's Encrypt 访问该 URL 验证 │ 临时 Ingress 路由
|
||||
│ ──────────────────────────────────────────► │ 处理这个路径
|
||||
│ │
|
||||
│ 4. 验证通过,签发证书 │
|
||||
│ ◄────────────────────────────────────────── │
|
||||
```
|
||||
|
||||
**验证成功的前提条件**:
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| DNS 解析正确 | 域名必须指向集群的外网 IP |
|
||||
| 80 端口开放 | Let's Encrypt 只通过 HTTP 80 端口验证 |
|
||||
| Traefik 正常运行 | 需要处理 `/.well-known/acme-challenge/` 请求 |
|
||||
| cert-manager 已安装 | 集群内必须有 cert-manager Pod 在运行 |
|
||||
| 无防火墙拦截 | 安全组/防火墙不能阻断 Let's Encrypt 到 80 端口的访问 |
|
||||
|
||||
#### 第 4 步:证书存储与使用
|
||||
|
||||
验证通过后:
|
||||
- cert-manager 将证书和私钥存入 Secret `airflow-studio-tls`
|
||||
- `tls.crt` — 证书链(服务器证书 + 中间证书)
|
||||
- `tls.key` — 私钥
|
||||
- Traefik 自动读取该 Secret,用于 HTTPS 握手
|
||||
|
||||
#### 第 5 步:自动续期
|
||||
|
||||
- Let's Encrypt 证书有效期 **90 天**
|
||||
- cert-manager 在到期前 **30 天**自动续期(`renewalTime`)
|
||||
- 续期过程与首次签发相同(HTTP-01 验证)
|
||||
|
||||
---
|
||||
|
||||
## 三、正式服 HTTPS "不安全" 排查
|
||||
|
||||
### 当前正式服证书状态(从外部检测)
|
||||
|
||||
```
|
||||
Subject: CN=airflow-studio-api.airlabs.art
|
||||
Issuer: C=US, O=Let's Encrypt, CN=R13
|
||||
Valid: 2026-04-04 ~ 2026-07-03
|
||||
SAN: airflow-studio-api.airlabs.art, airflow-studio.airlabs.art
|
||||
Chain: 完整 (R13 → ISRG Root X1)
|
||||
Verify: return:1 (通过)
|
||||
```
|
||||
|
||||
**证书本身是有效的。** 从 openssl 命令行验证完全通过。
|
||||
|
||||
### 浏览器提示"不安全"的可能原因
|
||||
|
||||
#### 原因 1:正式服 HTTP 80 端口未跳转 HTTPS(最可能)
|
||||
|
||||
```bash
|
||||
# 测试结果
|
||||
curl http://airflow-studio.airlabs.art/login → HTTP 200(直接返回页面,没有跳转!)
|
||||
```
|
||||
|
||||
正式服 80 端口直接返回了页面内容(通过 nginx),浏览器地址栏显示 `http://` 时会标记为"不安全"。这不是证书问题,而是**用户没有被引导到 HTTPS**。
|
||||
|
||||
**解决**: 在正式服集群执行同样的 Traefik redirect patch 命令(见第一节)。
|
||||
|
||||
#### 原因 2:HSTS 头未设置
|
||||
|
||||
即使有了跳转,首次访问仍走 HTTP。添加 HSTS 头可以让浏览器记住始终用 HTTPS:
|
||||
|
||||
在 `web/nginx.conf` 中添加(仅在 Traefik 终结 TLS 的情况下由后端设置无效,需在 Ingress 层设置):
|
||||
|
||||
```yaml
|
||||
# ingress.yaml annotation
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "default-hsts@kubernetescrd"
|
||||
```
|
||||
|
||||
或创建 HSTS Middleware:
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: hsts
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
```
|
||||
|
||||
#### 原因 3:混合内容 (Mixed Content)
|
||||
|
||||
页面通过 HTTPS 加载,但其中某些资源(图片、API、JS)通过 HTTP 加载。
|
||||
- 前端源码已检查:**无 `http://` 硬编码** ✅
|
||||
- 可能来源:数据库中存储的视频/图片 URL 是 `http://` 开头
|
||||
- 排查:在浏览器 F12 → Console 查看是否有 "Mixed Content" 警告
|
||||
|
||||
#### 原因 4:cert-manager 未部署到正式服集群
|
||||
|
||||
正式服和测试服是**不同的 K8s 集群**。需要确认正式服集群也安装了 cert-manager:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n cert-manager
|
||||
```
|
||||
|
||||
如果没有安装,证书不会自动签发,Traefik 会使用自签证书(浏览器会报不安全)。
|
||||
|
||||
---
|
||||
|
||||
## 四、测试服 vs 正式服对比排查表
|
||||
|
||||
| 检查项 | 测试服 | 正式服 | 检查命令 |
|
||||
|--------|--------|--------|----------|
|
||||
| cert-manager 运行 | ✅ | ❓ 待确认 | `kubectl get pods -n cert-manager` |
|
||||
| ClusterIssuer 存在 | ✅ | ❓ 待确认 | `kubectl get clusterissuer` |
|
||||
| Certificate Ready | ✅ Ready | ❓ 待确认 | `kubectl get certificate -A` |
|
||||
| TLS Secret 存在 | ✅ | ❓ 待确认 | `kubectl get secret airflow-studio-tls` |
|
||||
| 证书链完整 | ✅ Let's Encrypt | ✅ Let's Encrypt | `openssl s_client -connect <domain>:443` |
|
||||
| HTTP→HTTPS 跳转 | ✅ 308 | ❌ 返回 200 | `curl -I http://<domain>` |
|
||||
| Traefik redirect 配置 | ✅ 已配置 | ❌ 未配置 | `kubectl get deploy traefik -n kube-system -o yaml` |
|
||||
| 80 端口外网可达 | ✅ | ✅ | `curl http://<domain>` |
|
||||
| 443 端口外网可达 | ✅ | ✅ | `curl -k https://<domain>` |
|
||||
| 前端混合内容 | ✅ 无 | ❓ 待确认 | 浏览器 F12 Console |
|
||||
|
||||
---
|
||||
|
||||
## 五、正式服修复操作清单
|
||||
|
||||
### 步骤 1:SSH 到正式服 K8s master 节点
|
||||
|
||||
### 步骤 2:检查 cert-manager
|
||||
```bash
|
||||
kubectl get pods -n cert-manager
|
||||
kubectl get clusterissuer
|
||||
kubectl get certificate -A
|
||||
kubectl describe certificate airflow-studio-tls
|
||||
```
|
||||
|
||||
### 步骤 3:如果证书状态异常,删除重签
|
||||
```bash
|
||||
kubectl delete secret airflow-studio-tls
|
||||
# cert-manager 会自动重新签发(需要 1-3 分钟)
|
||||
kubectl get certificate -A -w # 等待 Ready=True
|
||||
```
|
||||
|
||||
### 步骤 4:配置 HTTP→HTTPS 全局跳转
|
||||
```bash
|
||||
kubectl -n kube-system patch deployment traefik --type=json -p '[
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.to=:443"},
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.scheme=https"},
|
||||
{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--entryPoints.web.http.redirections.entryPoint.permanent=true"}
|
||||
]'
|
||||
```
|
||||
|
||||
### 步骤 5:验证
|
||||
```bash
|
||||
# HTTP 跳转
|
||||
curl -I http://airflow-studio.airlabs.art/login
|
||||
# 预期: 308 → https://airflow-studio.airlabs.art/login
|
||||
|
||||
# HTTPS 证书
|
||||
curl -v https://airflow-studio.airlabs.art/login 2>&1 | grep -E "SSL|subject|issuer"
|
||||
```
|
||||
@ -5,6 +5,7 @@ metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: "traefik"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
|
||||
8
k8s/redirect-https-middleware.yaml
Normal file
8
k8s/redirect-https-middleware.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: redirect-https
|
||||
spec:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
permanent: true
|
||||
@ -39,9 +39,12 @@ const DownloadIcon = () => (
|
||||
// Mention tag with thumbnail + hover preview
|
||||
function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?: string; assetType?: string }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [thumbBroken, setThumbBroken] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
const isAudio = assetType === 'Audio' || assetType === 'audio';
|
||||
const isVideo = assetType === 'Video' || assetType === 'video';
|
||||
const showThumb = thumbUrl && !thumbBroken;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -49,7 +52,7 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
|
||||
ref={ref}
|
||||
className={styles.mentionTag}
|
||||
onMouseEnter={() => {
|
||||
if (!isAudio && thumbUrl && ref.current) {
|
||||
if (!isAudio && showThumb && ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
|
||||
setHover(true);
|
||||
@ -59,18 +62,30 @@ function MentionTag({ label, thumbUrl, assetType }: { label: string; thumbUrl?:
|
||||
>
|
||||
{isAudio ? (
|
||||
<span style={{ marginRight: 3, fontSize: 13, verticalAlign: 'middle' }}>♫</span>
|
||||
) : thumbUrl ? (
|
||||
) : showThumb ? (
|
||||
<img
|
||||
src={tosThumb(thumbUrl, 28)}
|
||||
alt=""
|
||||
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
|
||||
onError={() => setThumbBroken(true)}
|
||||
/>
|
||||
) : null}
|
||||
) : isVideo ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" style={{ verticalAlign: 'middle', marginRight: 3, opacity: 0.6 }}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
{hover && thumbUrl && createPortal(
|
||||
{hover && showThumb && createPortal(
|
||||
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
|
||||
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} />
|
||||
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
<div className={styles.mentionPreviewLabel}>{label}</div>
|
||||
</div>,
|
||||
document.body
|
||||
@ -149,7 +164,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
||||
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
||||
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null);
|
||||
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number; isAssetRef?: boolean } | null>(null);
|
||||
|
||||
const startDetailLeave = useCallback(() => {
|
||||
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
|
||||
@ -294,11 +309,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
onMouseEnter={(e) => {
|
||||
if (ref.type === 'audio') return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 });
|
||||
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2, isAssetRef: ref.isAssetRef });
|
||||
}}
|
||||
onMouseLeave={() => setRefPreview(null)}
|
||||
>
|
||||
{ref.type === 'video' ? (
|
||||
{ref.type === 'video' && !ref.isAssetRef ? (
|
||||
<video src={ref.previewUrl} className={styles.refMedia} muted />
|
||||
) : ref.type === 'audio' ? (
|
||||
<div className={styles.audioThumb}>
|
||||
@ -309,7 +324,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
|
||||
<img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@ -421,10 +436,10 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
{/* Reference thumbnail hover preview */}
|
||||
{refPreview && createPortal(
|
||||
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
|
||||
{refPreview.type === 'video' ? (
|
||||
{refPreview.type === 'video' && !refPreview.isAssetRef ? (
|
||||
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
|
||||
) : (
|
||||
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} />
|
||||
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
|
||||
</div>,
|
||||
|
||||
@ -98,6 +98,7 @@ export function PromptInput() {
|
||||
img.setAttribute('width', '16');
|
||||
img.setAttribute('height', '16');
|
||||
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
|
||||
img.onerror = () => { img.style.display = 'none'; };
|
||||
span.appendChild(img);
|
||||
}
|
||||
// @ 前缀隐藏(textContent 保留用于模式匹配,视觉上不显示)
|
||||
@ -253,6 +254,27 @@ export function PromptInput() {
|
||||
if (!el) return;
|
||||
setPrompt(el.textContent || '');
|
||||
setEditorHtml(el.innerHTML);
|
||||
// Sync assetMentions from DOM — prevents stale refs after deleting @mention spans
|
||||
const mentions: Record<string, unknown>[] = [];
|
||||
el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => {
|
||||
const s = span as HTMLElement;
|
||||
if (s.dataset.assetId) {
|
||||
mentions.push({
|
||||
assetId: s.dataset.assetId,
|
||||
label: s.dataset.assetName || s.textContent?.replace('@', '') || '',
|
||||
thumbUrl: s.dataset.thumbUrl || '',
|
||||
assetType: s.dataset.assetType || 'Image',
|
||||
duration: parseFloat(s.dataset.duration || '0'),
|
||||
});
|
||||
} else if (s.dataset.assetGroupId) {
|
||||
mentions.push({
|
||||
groupId: s.dataset.assetGroupId,
|
||||
label: s.dataset.groupName || s.textContent?.replace('@', '') || '',
|
||||
thumbUrl: s.dataset.thumbUrl || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
useInputBarStore.setState({ assetMentions: mentions });
|
||||
}, [setPrompt, setEditorHtml]);
|
||||
|
||||
// Remove orphaned mention spans when a reference is deleted
|
||||
|
||||
@ -220,9 +220,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
if (task.model) store.setModel(task.model as 'seedance_2.0' | 'seedance_2.0_fast');
|
||||
if (task.aspectRatio) store.setAspectRatio(task.aspectRatio as any);
|
||||
if (task.duration) store.setDuration(task.duration);
|
||||
// Load references from task
|
||||
// Load references from task (exclude asset library refs — they restore via @mentions in editorHtml)
|
||||
if (task.references && task.references.length > 0) {
|
||||
const refs = task.references.filter(r => r.previewUrl).map(r => ({
|
||||
const refs = task.references.filter(r => r.previewUrl && !r.isAssetRef).map(r => ({
|
||||
id: r.id,
|
||||
file: null as unknown as File,
|
||||
previewUrl: r.previewUrl,
|
||||
@ -485,7 +485,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
{task.references.map((ref) => (
|
||||
<div key={ref.id} className={styles.refItem}>
|
||||
<div style={{ position: 'relative', width: 56, height: 56 }}>
|
||||
{ref.type === 'video' ? (
|
||||
{ref.type === 'video' && !ref.isAssetRef ? (
|
||||
<video src={ref.previewUrl} className={styles.refImg} muted style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'video' })} />
|
||||
) : ref.type === 'audio' ? (
|
||||
<div className={styles.refAudioPlaceholder} style={{ cursor: 'pointer' }} onClick={() => ref.previewUrl && setRefMediaPreview({ url: ref.previewUrl, type: 'audio' })}>
|
||||
@ -496,7 +496,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
</svg>
|
||||
</div>
|
||||
) : ref.previewUrl ? (
|
||||
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
|
||||
<img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : (
|
||||
<div className={styles.refAudioPlaceholder} style={{ fontSize: 12, color: 'var(--color-text-disabled)' }}>无预览</div>
|
||||
)}
|
||||
|
||||
@ -31,14 +31,23 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function isAssetUrl(url: string): boolean {
|
||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||
}
|
||||
|
||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
const references = (v.reference_urls || []).map((ref, i) => ({
|
||||
const references = (v.reference_urls || []).map((ref, i) => {
|
||||
const url = ref.url || '';
|
||||
const assetRef = isAssetUrl(url);
|
||||
return {
|
||||
id: `ref_${v.task_id}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video',
|
||||
previewUrl: ref.url,
|
||||
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
||||
previewUrl: assetRef ? (ref.thumb_url || '') : url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
isAssetRef: assetRef || undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: String(v.id),
|
||||
taskId: v.task_id,
|
||||
|
||||
@ -31,14 +31,23 @@ function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function isAssetUrl(url: string): boolean {
|
||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||
}
|
||||
|
||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
const references = (v.reference_urls || []).map((ref, i) => ({
|
||||
const references = (v.reference_urls || []).map((ref, i) => {
|
||||
const url = ref.url || '';
|
||||
const assetRef = isAssetUrl(url);
|
||||
return {
|
||||
id: `ref_${v.task_id}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video',
|
||||
previewUrl: ref.url,
|
||||
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
||||
previewUrl: assetRef ? (ref.thumb_url || '') : url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
isAssetRef: assetRef || undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: String(v.id),
|
||||
taskId: v.task_id,
|
||||
|
||||
@ -59,7 +59,7 @@ function isAssetUrl(url: string): boolean {
|
||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||
}
|
||||
|
||||
/** Build ReferenceSnapshot[] from raw reference_urls, excluding asset refs. */
|
||||
/** Build ReferenceSnapshot[] from raw reference_urls (including asset refs with thumb_url). */
|
||||
function buildReferenceSnapshots(
|
||||
refs: Array<Record<string, string>>,
|
||||
taskId: string,
|
||||
@ -67,15 +67,23 @@ function buildReferenceSnapshots(
|
||||
return refs
|
||||
.filter((ref) => {
|
||||
const url = ref.url || '';
|
||||
return !isAssetUrl(url) && url.trim() !== '';
|
||||
// 素材库引用必须有 thumb_url 才能显示缩略图
|
||||
if (isAssetUrl(url)) return !!(ref.thumb_url);
|
||||
return url.trim() !== '';
|
||||
})
|
||||
.map((ref, i) => ({
|
||||
.map((ref, i) => {
|
||||
const url = ref.url || '';
|
||||
const assetRef = isAssetUrl(url);
|
||||
return {
|
||||
id: `ref_${taskId}_${i}`,
|
||||
type: (ref.type || 'image') as 'image' | 'video' | 'audio',
|
||||
previewUrl: ref.url || '',
|
||||
// 素材库引用用 thumb_url,直接上传用原始 url
|
||||
previewUrl: assetRef ? ref.thumb_url : url,
|
||||
label: ref.label || `素材${i + 1}`,
|
||||
role: ref.role,
|
||||
}));
|
||||
isAssetRef: assetRef || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract asset mention metadata from raw reference_urls. */
|
||||
@ -610,8 +618,10 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
}
|
||||
|
||||
if (task.mode === 'universal') {
|
||||
// task.references only contains file refs (assets filtered in backendToFrontend)
|
||||
const references: UploadedFile[] = task.references.map((r) => ({
|
||||
// Only include direct file refs — asset library refs are tracked via assetMentions
|
||||
const references: UploadedFile[] = task.references
|
||||
.filter((r) => !r.isAssetRef)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
previewUrl: r.previewUrl,
|
||||
@ -661,8 +671,10 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
|
||||
}
|
||||
|
||||
// For regeneration, we need to re-submit with the same TOS URLs
|
||||
// Set up the input bar state, then call addTask
|
||||
const references: UploadedFile[] = task.references.map((r) => ({
|
||||
// Only include direct file refs — asset library refs go via assetMentions fallback
|
||||
const references: UploadedFile[] = task.references
|
||||
.filter((r) => !r.isAssetRef)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
previewUrl: r.previewUrl,
|
||||
|
||||
@ -32,6 +32,7 @@ export interface ReferenceSnapshot {
|
||||
previewUrl: string;
|
||||
label: string;
|
||||
role?: string;
|
||||
isAssetRef?: boolean;
|
||||
}
|
||||
|
||||
export interface GenerationTask {
|
||||
@ -75,7 +76,7 @@ export interface BackendTask {
|
||||
result_url: string;
|
||||
thumbnail_url: string;
|
||||
error_message: string;
|
||||
reference_urls: { url: string; type: string; role: string; label: string }[];
|
||||
reference_urls: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
|
||||
is_favorited: boolean;
|
||||
seed: number;
|
||||
created_at: string;
|
||||
@ -406,7 +407,7 @@ export interface AssetVideo {
|
||||
seconds_consumed: number;
|
||||
cost_amount?: number;
|
||||
aspect_ratio: string;
|
||||
reference_urls?: { url: string; type: string; role: string; label: string }[];
|
||||
reference_urls?: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user