Compare commits

..
Author SHA1 Message Date
stefanfeng d9f12685fa Merge pull request 'revert: sync production to verified test version' (#34) from codex/sync-production-to-test-20260916 into main
revert: sync production to verified test version
2026-09-16 14:59:56 +08:00
stefanfeng b3367eedaa revert: sync production code to verified test version 2026-09-16 14:57:37 +08:00
stefanfeng e6a4988cf9 Merge pull request 'revert: restore production to avatar PR 28' (#33) from codex/rollback-production-to-pr28-20260916 into main 2026-09-16 11:27:37 +08:00
stefanfeng becf2c7c52 revert: restore production to avatar PR 28 2026-09-16 11:26:47 +08:00
stefanfeng d421a9da72 Merge pull request 'revert: restore authorization management behavior' (#32) from codex/rollback-auth-availability-20260915 into main
Reviewed-on: #32
2026-09-15 15:45:20 +08:00
stefanfeng f4389612d1 revert: restore authorization management behavior 2026-09-15 15:43:23 +08:00
stefanfeng ac5a332bce Merge pull request 'fix: align authorization page with frontend api types' (#31) from codex/fix-frontend-build-20260915 into main
Reviewed-on: #31
2026-09-15 15:04:54 +08:00
stefanfeng 9ba89ef2d1 fix: align authorization page with frontend api types 2026-09-15 14:42:20 +08:00
stefanfeng b1023eb783 Merge pull request 'feat: refine embedded avatar authorization controls' (#30) from codex/avatar-auth-controls-20260915 into main
Reviewed-on: #30
2026-09-15 14:34:38 +08:00
stefanfeng 2f287ef538 feat: refine embedded avatar authorization controls 2026-09-15 14:27:22 +08:00
stefanfeng f5cbbe9eef Merge pull request 'fix(avatar): restore app shell session before loading h5' (#28) from codex/avatar-app-open-hotfix into main
Reviewed-on: #28
2026-09-11 15:47:55 +08:00
stefanfeng 5fc56143ee fix(avatar): restore app shell session before loading h5 2026-09-11 15:46:31 +08:00
stefanfeng d521585bf2 Merge pull request 'fix(avatar): mark native shell before bridge readiness' (#27) from codex/avatar-shell-recharge-hotfix into main
Reviewed-on: #27
2026-09-11 13:40:03 +08:00
stefanfeng 35e47bf0a1 fix(avatar): mark native shell before bridge readiness 2026-09-11 13:39:41 +08:00
stefanfeng f52e42d9c0 Merge pull request 'fix(avatar): 仅在 uniapp 容器展示充值入口' (#26) from codex/avatar-payments-finance-20260908 into main
Reviewed-on: #26
2026-09-11 12:12:25 +08:00
stefanfeng 1ab56ad0b1 fix(avatar): 仅在 uniapp 容器展示充值入口 2026-09-11 11:56:48 +08:00
stefanfeng 1889c8ebba Merge pull request 'fix(avatar): 强制每轮跟随对方当前语言' (#25) from codex/avatar-language-enforcement-20260909 into main
Reviewed-on: #25
2026-09-11 11:55:21 +08:00
stefanfeng 910a05107a Merge pull request 'fix(avatar): 杜绝发布后继续运行旧镜像' (#24) from codex/avatar-deploy-integrity-20260909 into main
Reviewed-on: #24
2026-09-11 11:55:13 +08:00
stefanfeng 5ce12771b9 fix(avatar): persist SQLite WAL across releases 2026-09-09 11:06:03 +08:00
stefanfeng 4c152230aa build(avatar): preserve backend dependency cache 2026-09-09 10:58:09 +08:00
stefanfeng 9ce46cd883 fix(avatar): support legacy deployment Python 2026-09-09 10:56:43 +08:00
stefanfeng 140ac20281 fix(avatar): prevent stale deployment images 2026-09-09 10:47:18 +08:00
9 changed files with 257 additions and 197 deletions
+9
View File
@@ -1,12 +1,16 @@
# 构建阶段:安装依赖并打包 H5
FROM node:18-alpine AS build
ARG APP_GIT_SHA=unknown
ARG APP_BUILD_TIME=unknown
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN printf '{"gitSha":"%s","buildTime":"%s"}\n' "$APP_GIT_SHA" "$APP_BUILD_TIME" > public/version.json
RUN npm run build
# 运行阶段:nginx 托管静态资源并反向代理 /api 到后端
@@ -14,6 +18,11 @@ RUN npm run build
# 新版 nginx(>=1.31) 用 pwrite 写 pid 文件会被拦导致致命退出;1.28 用 write() 可正常启动。
FROM nginx:1.28-alpine
ARG APP_GIT_SHA=unknown
ARG APP_BUILD_TIME=unknown
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
org.opencontainers.image.created=${APP_BUILD_TIME}
COPY --from=build /app/dist /usr/share/nginx/html
# 覆盖 nginx 默认主配置(含唯一可写的 pid /tmp/nginx.pid,规避受限容器内 /run 不可写导致反复重启)
COPY nginx.conf /etc/nginx/nginx.conf
+7
View File
@@ -7,6 +7,13 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 120 --retries 10 -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
ARG APP_GIT_SHA=unknown
ARG APP_BUILD_TIME=unknown
ENV APP_GIT_SHA=${APP_GIT_SHA} \
APP_BUILD_TIME=${APP_BUILD_TIME}
LABEL org.opencontainers.image.revision=${APP_GIT_SHA} \
org.opencontainers.image.created=${APP_BUILD_TIME}
COPY . .
# 后端使用 SQLite(avatar.db 落在 /app 内),平铺结构以 `uvicorn main:app` 启动
+28 -3
View File
@@ -1,13 +1,14 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import os
import importlib.util
import logging
import os
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from database import init_db, SessionLocal
from database import engine, init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
from fastapi.staticfiles import StaticFiles
import routers.avatars
@@ -54,7 +55,31 @@ app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files
@app.get("/api/health")
def health():
return ok({"status": "ok"})
checks = _runtime_checks()
return ok({
"status": "ok" if all(checks.values()) else "degraded",
"gitSha": os.getenv("APP_GIT_SHA", "unknown"),
"buildTime": os.getenv("APP_BUILD_TIME", "unknown"),
"checks": checks,
})
def _runtime_checks():
return {
"database": _database_is_ready(),
"uploads": os.path.isdir(UPLOAD_DIR) and os.access(UPLOAD_DIR, os.W_OK),
"pdfOcr": importlib.util.find_spec("pymupdf") is not None,
}
def _database_is_ready():
try:
with engine.connect() as connection:
connection.exec_driver_sql("SELECT 1")
return True
except Exception:
logger.exception("Database readiness check failed")
return False
def seed():
+23 -142
View File
@@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = {
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
_LATIN_LANGUAGE_MARKERS = {
"English": re.compile(
r"\b(?:i|you|we|they|he|she|have|has|had|friend|who|what|where|when|why|how|"
r"symptoms?|disease|please|can|could|would|should|is|are|was|were|the|this|that)\b",
re.IGNORECASE,
),
"French": re.compile(
r"\b(?:je|tu|vous|nous|ils|elle|une|des|avec|pour|pourquoi|comment|bonjour|est|sont)\b",
re.IGNORECASE,
),
"Spanish": re.compile(
r"\b(?:yo|tu|usted|nosotros|ellos|ella|una|con|para|por que|como|hola|esta|son)\b",
re.IGNORECASE,
),
"German": re.compile(
r"\b(?:ich|du|sie|wir|eine|mit|fur|warum|wie|hallo|ist|sind|haben)\b",
re.IGNORECASE,
),
"Portuguese": re.compile(
r"\b(?:eu|voce|nos|eles|ela|uma|com|para|porque|como|ola|esta|sao|tenho)\b",
re.IGNORECASE,
),
"Italian": re.compile(
r"\b(?:io|tu|voi|noi|loro|una|con|per|perche|come|ciao|sono|avere)\b",
re.IGNORECASE,
),
}
class ChatMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@@ -515,67 +487,15 @@ def _qa_requires_per_turn_rendering(
return bool(history) or _qa_requires_language_adaptation(question, answer)
def _latin_language_name(value: str) -> str:
scores = {
language: len(pattern.findall(value or ""))
for language, pattern in _LATIN_LANGUAGE_MARKERS.items()
}
language, score = max(scores.items(), key=lambda item: item[1])
return language if score else "the same natural language as the latest user message"
def _turn_language_name(value: str) -> str:
writing_system = _dominant_writing_system(value)
return {
"han": "Chinese",
"japanese": "Japanese",
"korean": "Korean",
"cyrillic": "the same Cyrillic-script language as the latest user message",
"arabic": "the same Arabic-script language as the latest user message",
"hebrew": "Hebrew",
"devanagari": "the same Devanagari-script language as the latest user message",
"thai": "Thai",
"greek": "Greek",
"latin": _latin_language_name(value),
}.get(writing_system, "the same natural language as the latest user message")
def _per_turn_language_instruction(question: str = "") -> str:
language = _turn_language_name(question)
def _per_turn_language_instruction() -> str:
return (
f"MANDATORY OUTPUT LANGUAGE FOR THIS TURN: {language}. "
"Write the entire answer only in that language. This instruction overrides the languages used by "
"conversation history, profile data, standard answers, retrieved documents, and custom prompts. "
"Translate grounded source material faithfully when necessary. Do not mention language detection, "
"translation, or this instruction."
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
"自然语言。不要说明你检测、切换或翻译了语言。"
)
def _answer_requires_language_repair(question: str, answer: str) -> bool:
question_system = _dominant_writing_system(question)
answer_system = _dominant_writing_system(answer)
return (
question_system != "unknown"
and answer_system != "unknown"
and question_system != answer_system
)
def _language_repair_messages(question: str, answer: str) -> list[dict]:
return [
{"role": "system", "content": _per_turn_language_instruction(question)},
{
"role": "system",
"content": (
"Rewrite the supplied draft in the mandatory output language. Preserve every grounded fact, "
"number, proper noun, uncertainty, and safety qualification. Add no new information and output "
"only the rewritten answer."
),
},
{"role": "user", "content": answer.strip()},
]
def _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -808,7 +728,7 @@ def _build_prompt(
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
messages.append({"role": "system", "content": _per_turn_language_instruction(question)})
messages.append({"role": "system", "content": _per_turn_language_instruction()})
messages.append({"role": "user", "content": question.strip()})
return messages
@@ -871,41 +791,6 @@ def _call_qwen(
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
def _call_billed_qwen(
db: Session,
avatar: Avatar,
messages: list[dict],
temperature: float,
usage_source: str,
model_config: ChatModelConfig,
) -> tuple[str, dict]:
reservation = reserve_avatar_tokens(
db,
avatar,
usage_source,
model_config.model,
messages,
model_config.max_tokens,
)
try:
model_result = _call_qwen(
messages=messages,
temperature=temperature,
model_config=model_config,
)
answer = model_result["answer"]
token_usage = settle_reservation(
db,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
return answer, token_usage
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
def _iter_qwen_stream(
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
):
@@ -1014,36 +899,32 @@ def _resolve_reply(
token_usage = None
if model_client is not None:
answer = model_client(messages=messages, temperature=temperature)
if _answer_requires_language_repair(question, str(answer or "")):
answer = model_client(
messages=_language_repair_messages(question, str(answer)),
temperature=0.0,
)
else:
model_config = get_chat_model_config()
answer, token_usage = _call_billed_qwen(
reservation = reserve_avatar_tokens(
db,
avatar,
messages,
temperature,
usage_source,
model_config,
model_config.model,
messages,
model_config.max_tokens,
)
if _answer_requires_language_repair(question, answer):
logger.warning(
"chat response language mismatch avatar=%s source=%s expected=%s",
avatar.id,
usage_source,
_turn_language_name(question),
try:
model_result = _call_qwen(
messages=messages,
temperature=temperature,
model_config=model_config,
)
answer, token_usage = _call_billed_qwen(
answer = model_result["answer"]
token_usage = settle_reservation(
db,
avatar,
_language_repair_messages(question, answer),
0.0,
f"{usage_source}_language_repair",
model_config,
reservation,
model_result.get("usage"),
fallback_total=estimate_fallback_usage(messages, answer),
)
except Exception as exc:
release_reservation(db, reservation, str(exc))
raise
answer = str(answer or "").strip()
if image_contexts and _answer_denies_available_image(answer):
logger.warning(
@@ -6,7 +6,6 @@ from fastapi import HTTPException
from models import Avatar, User
from routers.chat import (
_answer_requires_language_repair,
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
@@ -15,7 +14,6 @@ from routers.chat import (
_qa_requires_per_turn_rendering,
_require_owned_avatar,
_resolve_reply,
_turn_language_name,
)
@@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase):
messages = fake_model.call_args.kwargs["messages"]
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
self.assertIn("French", messages[-2]["content"])
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
def test_latest_user_message_has_an_adjacent_language_override(self):
history = [
@@ -121,37 +119,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
self.assertIn("English", messages[-2]["content"])
def test_reported_alzheimer_question_is_explicitly_english(self):
question = "I have a friend who has symptoms of Alzheimer's disease"
self.assertEqual(_turn_language_name(question), "English")
messages = _build_prompt(self.avatar, [], question, [])
self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"])
def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self):
question = "I have a friend who has symptoms of Alzheimer's disease"
fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."])
result = _resolve_reply(
None,
self.avatar,
question,
[],
qa_pairs=[],
search_fn=lambda *_args, **_kwargs: [],
model_client=fake_model,
usage_source="takeover",
)
self.assertEqual(result["answer"], "Please arrange a medical assessment soon.")
self.assertEqual(fake_model.call_count, 2)
repair_messages = fake_model.call_args.kwargs["messages"]
self.assertIn("English", repair_messages[0]["content"])
self.assertIn("建议尽快就医评估", repair_messages[-1]["content"])
self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。"))
self.assertIn("最新用户消息", messages[-2]["content"])
self.assertIn("立即切换到相同语言", messages[-2]["content"])
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
@@ -0,0 +1,33 @@
import main
def test_health_reports_release_and_runtime_capabilities(monkeypatch):
monkeypatch.setenv("APP_GIT_SHA", "test-sha")
monkeypatch.setenv("APP_BUILD_TIME", "2026-09-09T00:00:00Z")
monkeypatch.setattr(main, "_runtime_checks", lambda: {
"database": True,
"uploads": True,
"pdfOcr": True,
})
response = main.health()
assert response["code"] == 200
assert response["data"]["status"] == "ok"
assert response["data"]["gitSha"] == "test-sha"
assert response["data"]["buildTime"] == "2026-09-09T00:00:00Z"
assert response["data"]["checks"] == {
"database": True,
"uploads": True,
"pdfOcr": True,
}
def test_health_is_degraded_when_a_required_capability_is_missing(monkeypatch):
monkeypatch.setattr(main, "_runtime_checks", lambda: {
"database": True,
"uploads": True,
"pdfOcr": False,
})
assert main.health()["data"]["status"] == "degraded"
+24 -10
View File
@@ -1,42 +1,56 @@
# 会会数字分身 —— Docker 测试实例(独立端口,不干扰现有 :8088 huihui 部署)
services:
avatar-backend:
build: ./backend
image: avatar-test-backend:latest
build:
context: ./backend
args:
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
image: avatar-test-backend:${APP_GIT_SHA}
container_name: avatar-test-backend
restart: unless-stopped
env_file:
- .env
environment:
DATABASE_URL: sqlite:////data/avatar.db
DATABASE_URL: sqlite:////data/db/avatar.db
UPLOAD_DIR: /data/uploads
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- avatar-data:/data
# Mount the directory, not only avatar.db: SQLite WAL/SHM files must survive recreation.
- ${AVATAR_DB_DIR:?AVATAR_DB_DIR must contain the persistent avatar.db}:/data/db
- ${AVATAR_UPLOAD_DIR:?AVATAR_UPLOAD_DIR must point to persistent uploads}:/data/uploads
expose:
- "8000"
ports:
- "8011:8000" # 仅用于直接调试 API;前端经内部网络访问,不走 host 端口
healthcheck:
test: ["CMD", "python", "-c", "import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=5))['data']; assert d['status']=='ok' and all(d['checks'].values())"]
interval: 10s
timeout: 8s
retries: 12
start_period: 20s
networks:
- avatar-net
avatar-frontend:
build: .
image: avatar-test-frontend:latest
build:
context: .
args:
APP_GIT_SHA: ${APP_GIT_SHA:?APP_GIT_SHA must be the full release commit}
APP_BUILD_TIME: ${APP_BUILD_TIME:?APP_BUILD_TIME must be set}
image: avatar-test-frontend:${APP_GIT_SHA}
container_name: avatar-test-frontend
restart: unless-stopped
ports:
- "8099:80" # 浏览器访问 http://<host>:8099
depends_on:
- avatar-backend
avatar-backend:
condition: service_healthy
networks:
- avatar-net
networks:
avatar-net:
driver: bridge
volumes:
avatar-data:
@@ -61,7 +61,9 @@ WECHAT_VIRTUAL_PRODUCT_2=<100元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_3=<1000元套餐商品ID>
WECHAT_VIRTUAL_PRODUCT_4=<10000元套餐商品ID>
DATABASE_URL=sqlite:////data/avatar.db
AVATAR_DB_DIR=/srv/digital-avatar/data/db
AVATAR_UPLOAD_DIR=/srv/digital-avatar/data/uploads
DATABASE_URL=sqlite:////data/db/avatar.db
UPLOAD_DIR=/data/uploads
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
@@ -74,13 +76,17 @@ VISION_OCR_MODEL=qwen-vl-ocr
VISION_MAX_OUTPUT_TOKENS=2048
VISION_TIMEOUT_SECONDS=90
VISION_TOKEN_RESERVE=12000
APP_GIT_SHA=<本次发布的完整提交SHA>
APP_BUILD_TIME=<UTC ISO-8601构建时间>
CHAT_IMAGE_MAX_BYTES=8388608
CHAT_IMAGE_MAX_PIXELS=16000000
CHAT_ATTACHMENT_RETENTION_HOURS=24
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
```
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。数据库文件与上传目录必须从宿主机显式挂载,不能存放在容器临时层。
`AVATAR_DB_DIR` 和 `AVATAR_UPLOAD_DIR` 必须是已备份的宿主机绝对路径,编排缺少任一变量都会直接拒绝构建或启动,防止误挂空卷造成用户、分身或知识库“丢失”的假象。SQLite 必须挂载整个数据库目录,不能只挂载 `avatar.db` 单文件,否则 `avatar.db-wal` 和 `avatar.db-shm` 会留在容器临时层,换容器后可能出现数据状态回退。
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
@@ -97,7 +103,7 @@ App 与 H5 积分充值使用会会支付体系的 `payment-v3/payment/pay`,
```bash
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
cp /srv/digital-avatar/data/db/avatar.db "$BACKUP_DIR/"
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
```
@@ -107,13 +113,22 @@ tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
git fetch origin
git checkout <已验收的提交SHA>
cd digital-avatar-app
docker compose build --pull avatar-backend avatar-frontend
docker compose up -d avatar-backend avatar-frontend
export APP_GIT_SHA="$(git rev-parse HEAD)"
export APP_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker compose build --pull --no-cache avatar-backend avatar-frontend
docker compose up -d --force-recreate --wait avatar-backend avatar-frontend
docker compose ps
curl -fsS http://127.0.0.1:8099/api/health
python3 scripts/verify-deployment.py \
https://digital.99hui.com "$APP_GIT_SHA" \
--backend-container avatar-backend \
--frontend-container avatar-frontend \
--expected-db-source /srv/digital-avatar/data/db \
--expected-upload-source /srv/digital-avatar/data/uploads
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
```
Jenkins 必须以 `verify-deployment.py` 返回成功作为发布成功条件,不能只以镜像构建或容器启动成功作为条件。脚本会同时核对公网前后端 Git SHA、数据库可读、上传目录可写、PDF OCR 依赖和宿主机数据挂载;任意一项不一致都会返回非零状态并阻止发布标绿。镜像使用 Git SHA 标签,不再依赖可被旧缓存覆盖的 `latest`。
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
## 4. 网关要求
@@ -153,7 +168,7 @@ location /api/ {
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 的 `gitSha` 与发布 SHA 一致,`database`、`uploads`、`pdfOcr` 三项检查均为 `true`。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
11. 微信虚拟支付在沙箱环境完成下单、支付回调、查单兜底和退款回调;错误 OpenID、商品、环境或金额均被拒绝。
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Fail a deployment unless frontend and backend run the expected release."""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
def fetch_json(url):
with urllib.request.urlopen(url, timeout=20) as response:
if response.status != 200:
raise RuntimeError(f"{url} returned HTTP {response.status}")
return json.load(response)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("base_url", help="Public site URL, for example https://digital.99hui.com")
parser.add_argument("expected_sha", help="Full Git commit SHA being deployed")
parser.add_argument("--backend-container", help="Backend container name for image and mount checks")
parser.add_argument("--frontend-container", help="Frontend container name for image checks")
parser.add_argument("--expected-db-source", help="Required host directory mounted for SQLite and its WAL files")
parser.add_argument("--expected-upload-source", help="Required host source mounted as the upload directory")
args = parser.parse_args()
base_url = args.base_url.rstrip("/")
errors = []
try:
health = fetch_json(f"{base_url}/api/health").get("data") or {}
except Exception as exc:
errors.append(f"cannot read backend release metadata: {exc}")
health = {}
try:
frontend = fetch_json(f"{base_url}/version.json")
except Exception as exc:
errors.append(f"cannot read frontend release metadata: {exc}")
frontend = {}
if health.get("status") != "ok":
errors.append(f"backend status is {health.get('status')!r}")
failed_checks = [name for name, passed in (health.get("checks") or {}).items() if not passed]
if failed_checks:
errors.append("backend checks failed: " + ", ".join(failed_checks))
if health.get("gitSha") != args.expected_sha:
errors.append(f"backend SHA is {health.get('gitSha')!r}")
if frontend.get("gitSha") != args.expected_sha:
errors.append(f"frontend SHA is {frontend.get('gitSha')!r}")
if args.backend_container:
backend = inspect_container(args.backend_container, errors)
check_container_revision(backend, args.expected_sha, "backend", errors)
check_mount(backend, args.expected_db_source, "database", errors)
check_mount(backend, args.expected_upload_source, "uploads", errors)
elif args.expected_db_source or args.expected_upload_source:
errors.append("--backend-container is required when checking data mounts")
if args.frontend_container:
frontend_container = inspect_container(args.frontend_container, errors)
check_container_revision(frontend_container, args.expected_sha, "frontend", errors)
if errors:
print("Deployment verification failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print(f"Deployment verified: {args.expected_sha}")
print("Backend checks: database, uploads, pdfOcr")
return 0
def inspect_container(name, errors):
try:
output = subprocess.check_output(
["docker", "inspect", name], universal_newlines=True
)
return json.loads(output)[0]
except Exception as exc:
errors.append(f"cannot inspect container {name!r}: {exc}")
return {}
def check_container_revision(container, expected_sha, label, errors):
actual = ((container.get("Config") or {}).get("Labels") or {}).get(
"org.opencontainers.image.revision"
)
if actual != expected_sha:
errors.append(f"{label} container image SHA is {actual!r}")
def check_mount(container, expected_source, label, errors):
if not expected_source:
return
expected = os.path.realpath(expected_source)
sources = {
os.path.realpath(mount.get("Source", ""))
for mount in container.get("Mounts") or []
}
if expected not in sources:
errors.append(f"{label} mount source {expected!r} is not attached")
if __name__ == "__main__":
raise SystemExit(main())