13 changed files with 652 additions and 113 deletions
+13 -2
View File
@@ -24,7 +24,9 @@ done < "$release/release.env"
[[ "$CHAT_IMAGE" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$HOST_PORT" =~ ^[0-9]+$ ]] && ((HOST_PORT >= 1024 && HOST_PORT <= 65535)) || exit 2
[[ -d "$CHAT_PROXY_DIR" && -r "$CHAT_TLS_CERT" && -r "$CHAT_TLS_KEY" ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$CHAT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CHAT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded application image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
openssl x509 -in "$CHAT_TLS_CERT" -checkhost "$CHAT_DOMAIN" -noout >/dev/null
@@ -68,6 +70,8 @@ proxy_changed=0
rollback() {
result=$?
trap - ERR
# ERR is inherited by command substitutions; only the parent may roll back.
if ((BASH_SUBSHELL)); then exit "$result"; fi
set +e
if ((proxy_changed)); then
if [[ -f "$release/previous-site.conf" ]]; then cp -p "$release/previous-site.conf" "$site"; else rm -f -- "$site"; fi
@@ -100,7 +104,14 @@ install -m 644 "$release/site.conf" "$site"
nginx -t
nginx -s reload
for path in / /api/export_chat /api/get_history; do
code=$(curl --silent --show-error --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path")
code=''
# A successful reload signal precedes the new workers accepting connections.
for attempt in {1..10}; do
if code=$(curl --silent --connect-timeout 3 --max-time 8 --resolve "$CHAT_DOMAIN:443:127.0.0.1" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN$path"); then
[[ "$code" != 401 ]] || break
fi
sleep 1
done
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
done
printf '%s\n' "$release" > "$root/.current-release.new"
+3 -1
View File
@@ -18,7 +18,9 @@ done < "$release/release.env"
[[ "$CHAT_TLS_CERT" == "$root/tls/config/live/$CHAT_DOMAIN/fullchain.pem" ]] || exit 2
[[ "$CHAT_TLS_KEY" == "$root/tls/config/live/$CHAT_DOMAIN/privkey.pem" ]] || exit 2
[[ "$CHAT_CERTBOT_IMAGE" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$CHAT_CERTBOT_IMAGE_ID" ]] || exit 2
loaded_id=$(awk -F= '$1 == "CERTBOT_IMAGE_ID" {print $2}' "$release/loaded-image-ids.env")
[[ "$loaded_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
[[ "$(docker image inspect --format '{{.Id}}' "$CHAT_CERTBOT_IMAGE")" == "$loaded_id" ]] || { echo 'Loaded certificate image changed.' >&2; exit 2; }
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CHAT_CERTBOT_IMAGE")" == "$SOURCE_COMMIT" ]] || exit 2
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
+33 -3
View File
@@ -40,7 +40,7 @@ release=$2
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* ]] || exit 2
[[ "$(id -u)" == 0 ]] || { echo 'The managed gray deployment account must have the required host permissions.' >&2; exit 2; }
for program in docker nginx openssl curl ss systemctl; do command -v "$program" >/dev/null; done
for program in docker nginx openssl curl ss systemctl sha256sum; do command -v "$program" >/dev/null; done
if [[ -e "$root" && ! -f "$root/.project" ]] && [[ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
echo 'Refusing to reuse an unowned deployment directory.' >&2
exit 2
@@ -55,8 +55,38 @@ chown 10001:10001 "$root/data" "$root/logs"
install -d -m 700 "$release"
PREPARE
# Transport only tested images built without runtime credentials.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip | run_ssh "bash -o pipefail -c 'gzip -dc | docker load'"
# Verify the exact exported archive before loading. Classic and containerd image
# stores can report different IDs for the same archive, so retain target IDs too.
docker save "$CHAT_IMAGE" "$CERTBOT_IMAGE" | gzip > "$temporary/images.tar.gz"
archive_sha=$(sha256sum "$temporary/images.tar.gz" | awk '{print $1}')
scp "${ssh_options[@]}" "$temporary/images.tar.gz" "$target:$release/images.tar.gz"
run_ssh bash -s -- "$root" "$release" "$archive_sha" "$CHAT_IMAGE" "$CERTBOT_IMAGE" "$SOURCE_COMMIT" <<'LOAD'
set -Eeuo pipefail
umask 077
root=$1
release=$2
checksum=$3
app_image=$4
certbot_image=$5
source_commit=$6
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
[[ "$release" == "$root"/releases/* && "$release" != *..* ]] || exit 2
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
[[ "$checksum" =~ ^[a-f0-9]{64}$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
[[ "$app_image" =~ ^baiyue-logistics-chat-gray:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
[[ "$certbot_image" =~ ^baiyue-logistics-chat-certbot:[a-f0-9]{12}-b[0-9]+$ ]] || exit 2
printf '%s %s\n' "$checksum" "$release/images.tar.gz" | sha256sum --check --status
gzip -dc "$release/images.tar.gz" | docker load
for image in "$app_image" "$certbot_image"; do
[[ "$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")" == "$source_commit" ]] || exit 2
done
app_id=$(docker image inspect --format '{{.Id}}' "$app_image")
certbot_id=$(docker image inspect --format '{{.Id}}' "$certbot_image")
[[ "$app_id" =~ ^sha256:[a-f0-9]{64}$ && "$certbot_id" =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2
printf 'ARCHIVE_SHA256=%s\nCHAT_IMAGE_ID=%s\nCERTBOT_IMAGE_ID=%s\n' "$checksum" "$app_id" "$certbot_id" > "$release/loaded-image-ids.env"
rm -f -- "$release/images.tar.gz"
echo 'Image archive checksum and source labels verified; target image IDs recorded.'
LOAD
scp "${ssh_options[@]}" "$temporary/release/"* "$target:$release/"
run_ssh bash "$release/prepare-tls.sh" "$root" "$release"
run_ssh bash "$release/activate.sh" "$root" "$release"
+1
View File
@@ -86,6 +86,7 @@ tests/ # 按业务模块组织的回归
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
## 后续结构调整顺序
+2 -2
View File
@@ -37,11 +37,11 @@
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名、源提交和镜像 ID,生成私有配置并发送镜像及发布文件。
4. 构建完成后才绑定运行凭据。发布脚本校验路径、域名和源提交,生成私有配置并发送镜像及发布文件。服务器验证镜像归档的 SHA-256 后加载,记录目标主机的镜像 ID。
5. 首次签发域名证书,等待应用健康,再安装本服务的 Nginx 配置。已有业务的容器和站点配置不参与本次发布。
6. Jenkins 使用验收账号验证公网 HTTPS `/healthz`;之后在浏览器验证 AI 流式回复、上传、转人工和工作台。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,接收主机再次核验镜像 ID,不依赖 `latest`。
应用镜像必须选择 `production` 构建目标;`certbot` 仅用于证书运维。每个构建生成独立镜像标签,不依赖 `latest`。Docker 的 classic 与 containerd 镜像存储可能返回不同 ID;跨主机以归档校验和与源提交标签核验传输,激活前再与 `loaded-image-ids.env` 中的目标 ID 比较,拒绝加载后被替换的镜像。归档加载后删除,构建机 ID 保留在 `release.env` 供追溯。
## 数据与失败回退
+11 -2
View File
@@ -42,11 +42,20 @@ def human_poll(sid, after, typing_map):
def human_send(data):
sid = data.get('sid')
side = data.get('side', 'customer')
content = (data.get('content') or '').strip()
if not sid or not content:
content = data.get('content') or ''
if not sid or not isinstance(content, str) or not content.strip() or side not in ('customer', 'agent'):
return ({'ok': False}, 400)
content = content.strip()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
conn = connect()
conn.execute('BEGIN IMMEDIATE')
row = conn.execute('SELECT conv_id, status FROM human_sessions WHERE id=?', (sid,)).fetchone()
if not row:
conn.close()
return ({'ok': False, 'error': 'session_not_found'}, 404)
if row[1] == 'closed' or (data.get('conv_id') is not None and data['conv_id'] != row[0]):
conn.close()
return ({'ok': False, 'error': 'conversation_changed'}, 409)
conn.execute('INSERT INTO human_messages(session_id, side, content, ctime) VALUES(?,?,?,?)', (sid, side, content, now))
conn.commit()
conn.close()
+2
View File
@@ -87,6 +87,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
.human-session { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--brand-soft); color: var(--secondary); font-size: 12px; }
.human-session button { flex-shrink: 0; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--brand-text); font-size: 12px; }
.hot-bar-label { font-size: 10px; color: var(--muted); margin-right: 3px; }
.hot-bar .hot-capsule { border: 0; padding: 4px 8px; background: var(--surface-soft); font-size: 10px; }
.input-area { display: flex; align-items: flex-end; gap: 10px; border: 1px solid var(--border); border-radius: 12px; padding: 9px; background: var(--surface); box-shadow: 0 3px 15px rgba(28, 47, 80, .035); }
+247 -87
View File
@@ -10,6 +10,15 @@ let uploadVersion = 0;
let followLatest = true;
let creatingConv = null;
let sendPreparing = false;
let conversationVersion = 0;
let activeQuickTopic = null;
let humanConvId = null;
let humanStatus = '';
let humanRequestToken = null;
let humanPollTimer = null;
let humanSendingToken = null;
let humanClosing = false;
window.humanSid = 0;
SupportUI.initEmoji(document.getElementById('emoji-toggle'), document.getElementById('emoji-panel'), inputDom, function(){ autoGrow(); });
document.getElementById('notifications-toggle').onclick = function(){ SupportUI.enableNotifications(this); };
@@ -175,6 +184,17 @@ const I18N = {
offlineSaved: "留言已提交,工作时间(8:00-22:00)会尽快回复您。",
humanClosed: "人工客服已结束,您可以继续向 AI 提问。",
humanReply: "客服回复",
humanConnecting: "正在为您连接人工客服…",
humanWaiting: "正在排队,消息将由人工客服接入后回复。您也可以返回 AI 继续咨询。",
humanActive: "人工客服接待中,消息将发送给人工客服。",
returnAI: "返回 AI 问答",
humanSent: "已发送给人工客服",
humanSending: "正在发送给人工客服…",
humanSendFailed: "消息未发送成功,内容已保留,请重试。",
humanConnectFailed: "人工客服连接失败,请稍后重试。",
humanPollFailed: "人工客服连接中断,正在重试。您可以返回 AI 继续咨询。",
humanReturnFailed: "暂时无法结束人工会话,请重试。",
historyLoading: "正在加载这段会话,请稍候…",
recalled: "该消息已撤回",
ratingQuestion: "本次服务满意吗?",
ratingThanks: "感谢您的评价!",
@@ -248,6 +268,17 @@ const I18N = {
offlineSaved: "Message submitted. Our team will reply during service hours (08:00–22:00).",
humanClosed: "This support session has ended. You can continue chatting with the AI assistant.",
humanReply: "Support reply",
humanConnecting: "Connecting you to our support team…",
humanWaiting: "You are in the queue. A team member will reply after joining, or you can return to AI chat.",
humanActive: "You are chatting with our support team. Messages will be sent to a team member.",
returnAI: "Return to AI chat",
humanSent: "Sent to our support team",
humanSending: "Sending to our support team…",
humanSendFailed: "Message not sent. Your text has been kept. Please retry.",
humanConnectFailed: "Could not connect to our support team. Please try again.",
humanPollFailed: "Support connection lost. Retrying, or you can return to AI chat.",
humanReturnFailed: "Could not end the support session. Please try again.",
historyLoading: "Loading this conversation. Please wait…",
recalled: "This message was recalled",
ratingQuestion: "How was your support experience?",
ratingThanks: "Thank you for your feedback!",
@@ -339,6 +370,7 @@ function applyLanguage(lang){
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse;
updateHumanMode();
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
@@ -495,12 +527,19 @@ function welcomeHTML(){
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(!confirm(t("confirmClear"))) return;
if(cancelActive){ cancelActive(); cancelActive = null; }
const version = resetConversation(null);
openingConv = 'clearing';
fetch("/api/clear_all", {method:"DELETE"})
.then(()=>{
currentConvId = null;
.then(response=>{
if(!response.ok) throw new Error('Clear failed');
if(version !== conversationVersion) return;
openingConv = null;
chatDom.innerHTML = welcomeHTML();
loadHistory();
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
}
@@ -508,13 +547,11 @@ clearAllBtn.onclick = function(){
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
resetConversation(null);
inputDom.value = "";
autoGrow();
clearAttachment();
setComposerStatus("");
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = 0;
followLatest = true;
@@ -647,33 +684,56 @@ function renderRecentChat(){
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
if(creatingConv) return creatingConv;
creatingConv = fetch("/api/new_chat", {method:"POST"})
const version = conversationVersion;
const pending = fetch("/api/new_chat", {method:"POST"})
.then(async function(res){
const data = await res.json();
if(!res.ok || !data.id) throw new Error('Session failed');
return data;
})
.then(data=>{
if(version !== conversationVersion){
const error = new Error('Conversation changed');
error.name = 'AbortError';
throw error;
}
currentConvId = data.id;
if(data.welcome) addMsg(t('autoWelcome'), false);
return currentConvId;
}).finally(function(){ creatingConv = null; });
return creatingConv;
}).finally(function(){ if(creatingConv === pending) creatingConv = null; });
creatingConv = pending;
return pending;
}
function resetConversation(id){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
conversationVersion += 1;
stopHumanMode();
currentConvId = id;
openingConv = null;
creatingConv = null;
sendPreparing = false;
activeQuickTopic = null;
setBusy(false);
setComposerStatus('');
return conversationVersion;
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
if(openingConv === id) return; // 防连点
const version = resetConversation(id);
openingConv = id;
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
currentConvId = id;
setComposerStatus(t('historyLoading'));
renderSideHistory();
fetch("/api/get_messages?conv_id=" + id)
.then(res=>res.json())
.then(res=>{ if(!res.ok) throw new Error('History failed'); return res.json(); })
.then(msgs=>{
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus('');
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
@@ -693,7 +753,11 @@ function openConversation(id){
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ scrollChatToEnd(true); });
})["catch"](function(){ openingConv = null; });
}).catch(function(){
if(version !== conversationVersion) return;
openingConv = null;
setComposerStatus(t('networkFail'), true);
});
}
const CARD_PREFIX = "__CARD__";
@@ -749,12 +813,12 @@ function fillTrackCard(el, card){
}
}
function createMessageRow(isUser){
function createMessageRow(isUser, authorRole){
const row = document.createElement("div");
row.className = "msg-row " + (isUser ? "user-line" : "robot-line");
const author = document.createElement("div");
author.className = "message-author";
const role = isUser ? "you" : (window.humanSid ? "humanAgent" : "assistant");
const role = isUser ? "you" : (authorRole || "assistant");
author.innerHTML = (isUser ? "" : '<span class="author-mark">' + welcomeIcon("box") + '</span>') + '<span data-i18n="' + role + '">' + t(role) + '</span>';
row.appendChild(author);
return row;
@@ -775,19 +839,20 @@ loadHistory();
window.fillInput = function(words){
inputDom.value = words;
doSend();
return doSend();
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window.askNewTopic = function(words){
if(cancelActive){ cancelActive(); cancelActive = null; }
currentConvId = null; // 下次发送时自动新建会话
if(activeQuickTopic === words && (sendPreparing || window.__busy)) return;
resetConversation(null);
activeQuickTopic = words;
// 新话题先重置界面,发送第一条消息时再收起欢迎内容。
if(!chatDom.querySelector('.ai-welcome-card')){
chatDom.innerHTML = welcomeHTML();
}
inputDom.value = words;
doSend();
return doSend();
}
window.sendOrder = function(){
@@ -816,64 +881,161 @@ function myOrders(){
.catch(function(){ addMsg("查询失败,请重试", false); });
}
function updateHumanMode(){
const banner = document.getElementById('human-session');
const label = document.getElementById('human-session-label');
const button = document.getElementById('return-ai');
if(banner) banner.hidden = !window.humanSid && !humanRequestToken;
const text = t(humanRequestToken ? 'humanConnecting' : humanStatus === 'active' ? 'humanActive' : 'humanWaiting');
if(label && label.textContent !== text) label.textContent = text;
if(button){
button.textContent = t('returnAI');
button.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
sendBtn.disabled = Boolean(humanRequestToken || humanClosing || humanSendingToken);
}
function stopHumanMode(){
if(humanPollTimer !== null) clearInterval(humanPollTimer);
humanPollTimer = null;
humanConvId = null;
humanStatus = '';
humanRequestToken = null;
humanSendingToken = null;
humanClosing = false;
window.humanSid = 0;
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
updateHumanMode();
}
async function humanRequest(path, data){
const controller = new AbortController();
const timer = setTimeout(function(){ controller.abort(); }, 10000);
try{
const response = await fetch(path, {method:data ? 'POST' : 'GET',
headers:data ? {'Content-Type':'application/json'} : {},
body:data ? JSON.stringify(data) : undefined, signal:controller.signal});
const result = await response.json();
if(!response.ok){
const error = new Error('Human request failed');
error.status = response.status;
throw error;
}
return result;
}finally{ clearTimeout(timer); }
}
window.showHuman = async function(){
if(window.humanSid){
addMsg("您已在人工客服会话中,请直接提问。", false);
return;
if(humanRequestToken || humanClosing) return;
if(window.humanSid && humanConvId === currentConvId){ updateHumanMode(); return; }
if(window.humanSid) stopHumanMode();
const hour = new Date().getHours();
if(hour < 8 || hour >= 22) return leaveOfflineMessage();
const token = {version:conversationVersion};
humanRequestToken = token;
if(cancelActive){ cancelActive(); cancelActive = null; }
setBusy(false);
updateHumanMode();
try{
const cid = await ensureConv();
if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; }
if(!data.ok || !data.session_id) throw new Error('Human session failed');
window.humanSid = data.session_id;
humanConvId = cid;
humanStatus = 'waiting';
addMsg(t('humanWaiting'), false);
startHumanPoll();
}catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{
if(humanRequestToken === token){ humanRequestToken = null; updateHumanMode(); }
}
};
async function returnToAI(){
if(!window.humanSid || humanClosing || humanSendingToken) return;
const sid = window.humanSid, version = conversationVersion;
humanClosing = true;
updateHumanMode();
try{
const result = await humanRequest('/api/human/close', {sid:sid});
if(!result.ok) throw new Error('Close failed');
if(version !== conversationVersion || window.humanSid !== sid) return;
stopHumanMode();
setComposerStatus('');
addMsg(t('humanClosed'), false);
inputDom.focus();
}catch(error){
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
}finally{
if(version === conversationVersion){ humanClosing = false; updateHumanMode(); }
}
}
const returnAIButton = document.getElementById('return-ai');
if(returnAIButton) returnAIButton.onclick = returnToAI;
async function sendHumanMessage(content){
if(humanSendingToken || humanClosing) return;
const sid = window.humanSid, cid = currentConvId, version = conversationVersion;
const draft = inputDom.value, attachment = window.__pendingImg;
const token = {};
humanSendingToken = token;
updateHumanMode();
setComposerStatus(t('humanSending'));
try{
const result = await humanRequest('/api/human/send', {sid:sid, conv_id:cid, side:'customer', content:content});
if(!result.ok) throw new Error('Send failed');
if(version !== conversationVersion || humanSendingToken !== token) return;
addMsg(content, true);
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
if(window.__pendingImg === attachment) clearAttachment();
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
inputDom.focus();
}catch(error){
if(version !== conversationVersion || humanSendingToken !== token) return;
if(error.status === 404 || error.status === 409) stopHumanMode();
setComposerStatus(t('humanSendFailed'), true);
}finally{
if(humanSendingToken === token){ humanSendingToken = null; updateHumanMode(); }
}
var now = new Date();
var h = now.getHours();
var work = h >= 8 && h < 22;
if(!work){
leaveOfflineMessage();
return;
}
addMsg("正在为您转接人工客服...", false);
if(!currentConvId){
try{ await ensureConv(); }catch(e){ setComposerStatus(t('sessionFail'), true); return; }
}
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else if(d.closed){
leaveOfflineMessage();
}else{
addMsg("转接失败,请稍后再试。", false);
}
})
.catch(function(){
addMsg("网络错误,请稍后再试。", false);
});
}
window.humanSid = 0;
async function leaveOfflineMessage(){
const version = conversationVersion;
addMsg(t('humanHours'), false);
const message = window.prompt(t('offlinePrompt'));
if(!message) return;
try{
const response = await fetch('/api/ticket', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({content:message, phone:''})});
const data = await response.json();
if(!response.ok || !data.ok) throw new Error('Ticket failed');
addMsg(t('offlineSaved'), false);
}catch(error){ setComposerStatus(t('networkFail'), true); }
const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false);
}catch(error){
if(version !== conversationVersion) return;
if(!inputDom.value) inputDom.value = message;
setComposerStatus(t('networkFail'), true);
}
}
function startHumanPoll(){
let last=0;
let polling=false;
const sid=window.humanSid;
const cid=humanConvId, version=conversationVersion;
let hadPollError=false;
if(humanPollTimer !== null) clearInterval(humanPollTimer);
const pollTimer=setInterval(()=>{
if(window.humanSid !== sid){ clearInterval(pollTimer); return; }
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid){ clearInterval(pollTimer); return; }
if(polling) return;
polling=true;
fetch("/api/human/poll?sid="+sid+"&after="+last)
.then(r=>r.json())
humanRequest("/api/human/poll?sid="+sid+"&after="+last)
.then(d=>{
if(window.humanSid !== sid) return;
if(window.humanSid !== sid || version !== conversationVersion || currentConvId !== cid) return;
if(hadPollError){ setComposerStatus(''); hadPollError=false; }
humanStatus=d.status;
updateHumanMode();
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
@@ -888,7 +1050,7 @@ function startHumanPoll(){
system.textContent = m.content;
chatDom.appendChild(system);
}else if(m.side === 'agent'){
const bubble = addMsg(m.content, false);
const bubble = addMsg(m.content, false, 'humanAgent');
bubble.closest('.msg-row').setAttribute('data-human-message-id', String(m.id));
SupportUI.notify(t('humanReply'), m.content.substring(0, 50));
}
@@ -896,12 +1058,9 @@ function startHumanPoll(){
if(typing) typing.remove();
});
if(d.status==="closed"){
clearInterval(pollTimer);
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
const typing = document.getElementById('human-typing');
if(typing) typing.remove();
if(humanClosing) return;
stopHumanMode();
setBusy(false);
addMsg(t('humanClosed'), false);
SupportUI.addRating(chatDom, sid, {question:t('ratingQuestion'), thanks:t('ratingThanks'), failed:t('ratingFailed')});
scrollChatToEnd();
@@ -920,13 +1079,18 @@ function startHumanPoll(){
oldT.remove();
}
})
.catch(()=>{}).finally(function(){ polling=false; });
.catch(function(){
if(version !== conversationVersion || window.humanSid !== sid) return;
hadPollError=true;
setComposerStatus(t('humanPollFailed'), true);
}).finally(function(){ polling=false; });
}, 1000);
humanPollTimer = pollTimer;
}
function addMsg(content, isUser){
function addMsg(content, isUser, authorRole){
if(chatDom.querySelector('.ai-welcome-card')) chatDom.innerHTML = "";
const rowDiv = createMessageRow(isUser);
const rowDiv = createMessageRow(isUser, authorRole);
let robotMsg;
// 检测图片附件
let imgUrl = null;
@@ -1134,35 +1298,31 @@ async function doSend(retryText){
return;
}
if(uploadPending){ setComposerStatus(t("uploading")); return; }
if(humanSendingToken || humanClosing) return;
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + tag + imgUrl;
setComposerStatus("");
if(openingConv !== null){ setComposerStatus(t('historyLoading')); return; }
if(humanRequestToken || humanClosing){ updateHumanMode(); return; }
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
var content = sendWord;
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
if(!isRetry) addMsg(content, true);
inputDom.value = "";
autoGrow();
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
return;
}
if(window.humanSid && humanConvId === currentConvId) return sendHumanMessage(fullContent);
if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true;
const version = conversationVersion;
let convId;
try{
await ensureConv();
convId = await ensureConv();
}catch(error){
setComposerStatus(t('sessionFail'), true);
if(version === conversationVersion) setComposerStatus(t('sessionFail'), true);
return;
}finally{
sendPreparing = false;
if(version === conversationVersion) sendPreparing = false;
}
if(version !== conversationVersion || humanRequestToken) return;
if(!isRetry){
addMsg(fullContent, true);
inputDom.value = "";
@@ -1257,7 +1417,7 @@ async function doSend(retryText){
cancelActive = stop;
function handleFrame(dataText){
if(settled) return;
if(settled || version !== conversationVersion) return;
let obj;
try{
obj = JSON.parse(dataText);
@@ -1309,7 +1469,7 @@ async function doSend(retryText){
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: fullContent, conv_id: currentConvId}),
body: JSON.stringify({msg: fullContent, conv_id: convId}),
signal: controller.signal
}).then(function(res){
if(!res.ok){ throw new Error("HTTP " + res.status); }
+1
View File
@@ -49,6 +49,7 @@
</section>
</div>
<footer class="composer">
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
<div class="hot-bar">
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
+220 -14
View File
@@ -5,12 +5,14 @@ const path = require('node:path');
const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
async function customerPage(){
const elements = new Map(), listeners = {}, intervals = [], calls = [];
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, intervals = new Map(), calls = [], hooks = {};
let intervalId = 0, nextConvId = 1;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300;
this.listeners = {};
this.classList = {
contains: name => this.className.split(' ').includes(name),
add: name => { this.className += ' '+name; },
@@ -24,7 +26,8 @@ async function customerPage(){
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
setAttribute(name, value){ this.attributes[name] = value; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(){}
addEventListener(name, callback){ this.listeners[name] = callback; }
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
appendChild(child){ child.parent = this; this.children.push(child); }
append(...children){ children.forEach(child => this.appendChild(child)); }
focus(){}
@@ -39,31 +42,49 @@ async function customerPage(){
};
return this.children.flatMap(child => [...(matches(child) ? [child] : []), ...child.querySelectorAll(selector)]);
}
closest(selector){ return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector); }
closest(selector){
if(selector === 'button[data-question]' && this.dataset.question) return this;
if(selector === 'button[data-human-support]' && this.attributes['data-human-support'] != null) return this;
return this.classList.contains(selector.slice(1)) ? this : this.parent && this.parent.closest(selector);
}
remove(){ if(this.parent) this.parent.children = this.parent.children.filter(child => child !== this); }
}
const element = key => { if(!elements.has(key)) elements.set(key, new Element()); return elements.get(key); };
const quickButtons = [0, 1, 2].map(index => { const button = new Element(); button.dataset.questionIndex = String(index); return button; });
const document = {
body: new Element(), documentElement: {},
getElementById: id => ['human-typing', 'imgPreview', 'recentChatWrap'].includes(id) ? null : element(id),
querySelector: selector => element(selector), querySelectorAll: () => [],
querySelector: selector => element(selector), querySelectorAll: selector => selector === '[data-question-index]' ? quickButtons : [],
createElement: () => new Element(),
addEventListener: (name, callback) => { listeners[name] = callback; },
};
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, 12])); } }
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'},
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder,
requestAnimationFrame:callback => callback(),
localStorage:{getItem:() => null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false})},
setInterval: callback => { intervals.push(callback); return intervals.length; },
clearInterval(){}, setTimeout(){}, clearTimeout(){},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id), setTimeout(){}, clearTimeout(){},
fetch: async (url, options) => {
calls.push({url, body:options && options.body && JSON.parse(options.body)});
const data = url === '/api/new_chat' ? {id:1, welcome:'welcome'}
if(hooks[url]) return hooks[url](options);
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
if(url === '/stream_chat'){
let read = false;
return {ok:true, body:{getReader:() => ({read:async () => {
if(read) return {done:true};
read = true;
return {value:new TextEncoder().encode('data: {"t":"测试 AI 回复"}\n\ndata: {"d":1}\n\n'), done:false};
}})}};
}
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: url === '/api/rate' ? {ok:true} : [];
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
});
@@ -72,9 +93,13 @@ async function customerPage(){
}
listeners.DOMContentLoaded();
await flush();
await context.window.showHuman();
if(pageOptions.human !== false) await context.window.showHuman();
await flush();
return {context, element, calls, setState:value => { state=value; }, tick:async () => { intervals[0](); await flush(); }};
return {context, element, calls, hooks, quickButtons,
setState:value => { state=value; },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
}
test('the final customer-visible reply is processed before closure and a rating can be submitted', async () => {
@@ -103,3 +128,184 @@ test('recalling an earlier reply preserves newer replies in the customer log', a
assert.match(log.textContent, /最新回复/);
assert.doesNotMatch(log.textContent, /早先的回复/);
});
function response(data, status = 200){ return {ok:status >= 200 && status < 300, status, json:async () => data}; }
test('all three footer questions leave the human queue and send to separate AI conversations', async () => {
const app = await customerPage();
for(const button of app.quickButtons) await app.click(button);
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 3);
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
assert.deepEqual(requests.map(call => call.body.msg), ['帮我查询物流轨迹', '欧洲海运专线报价', '包裹异常处理']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
});
test('welcome and hot question buttons use the same AI routing as the footer', async () => {
const app = await customerPage();
for(const question of ['欧洲海运专线报价', '如何申请理赔?']){
const button = app.element('question-'+question);
button.dataset.question = question;
app.element('chatDom').listeners.click({target:button});
await flush();
}
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']);
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
});
test('the welcome contact button opens the human queue and does not send an AI question', async () => {
const app = await customerPage({human:false});
const button = app.element('contact-card');
button.setAttribute('data-human-support', '');
app.element('chatDom').listeners.click({target:button});
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 0);
assert.equal(app.element('human-session').hidden, false);
});
test('new chat clears human routing and the next quote is sent to AI', async () => {
const app = await customerPage();
await app.click(app.element('.new-chat-btn'));
await app.context.window.fillInput('请报价,30KG发往美国FBA仓');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '请报价,30KG发往美国FBA仓');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 0);
assert.equal(app.context.window.__busy, false);
});
test('opening AI history clears the human session and retains that conversation id', async () => {
const app = await customerPage({history:[{id:9, title:'之前的对话', ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
await app.context.window.fillInput('请继续回答这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 9);
assert.equal(app.context.window.humanSid, 0);
const authors = app.element('chatDom').querySelectorAll('.message-author');
assert.ok(authors.some(author => author.innerHTML.includes('data-i18n="assistant"')));
});
test('a repeated quick click while a new conversation is being created sends only once', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat'] = () => new Promise(done => { resolve=done; });
app.quickButtons[0].click();
app.quickButtons[0].click();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length, 1);
resolve(response({id:12, welcome:'welcome'}));
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('a late conversation creation cannot replace the newest quick question', async () => {
const app = await customerPage({human:false});
let resolve, count=0;
app.hooks['/api/new_chat'] = () => ++count === 1 ? new Promise(done => { resolve=done; }) : response({id:22, welcome:'welcome'});
app.quickButtons[0].click();
await app.click(app.quickButtons[2]);
resolve(response({id:21, welcome:'welcome'}));
await flush();
const requests = app.calls.filter(call => call.url === '/stream_chat');
assert.equal(requests.length, 1);
assert.equal(requests[0].body.conv_id, 22);
assert.equal(requests[0].body.msg, '包裹异常处理');
assert.equal(app.context.window.__busy, false);
});
test('a late history response cannot overwrite the latest quick conversation', async () => {
const app = await customerPage({history:[{id:9, title:'旧会话', ctime:'2026-10-29 12:00'}]});
let resolve;
app.hooks.messages = () => new Promise(done => { resolve=done; });
await app.click(app.element('historyWrap').children[0]);
await app.click(app.quickButtons[1]);
resolve(response([{role:'user', content:'旧会话的内容不应显示'}]));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧会话的内容不应显示/);
assert.match(app.element('chatDom').textContent, /测试 AI 回复/);
});
test('a delayed human connection cannot capture a newly selected AI topic', async () => {
const app = await customerPage({human:false});
let resolve;
app.hooks['/api/human/request'] = () => new Promise(done => { resolve=done; });
const connecting = app.context.window.showHuman();
await flush();
await app.click(app.quickButtons[1]);
resolve(response({ok:true, session_id:8}));
await connecting;
assert.equal(app.context.window.humanSid, 0);
assert.equal(app.element('human-session').hidden, true);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length, 1);
});
test('repeated contact clicks create one human request and show a visible queue state', async () => {
const app = await customerPage({human:false});
await Promise.all([app.context.window.showHuman(), app.context.window.showHuman()]);
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 1);
assert.equal(app.element('human-session').hidden, false);
assert.match(app.element('human-session-label').textContent, /正在排队/);
});
test('the contact shortcut outside service hours submits a ticket without entering human mode', async () => {
const app = await customerPage({human:false, hour:23, offlineMessage:'请明天跟进报价'});
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/human/request').length, 0);
assert.equal(app.calls.find(call => call.url === '/api/ticket').body.content, '请明天跟进报价');
assert.match(app.element('chatDom').textContent, /留言已提交/);
assert.equal(app.context.window.humanSid, 0);
});
test('a failed human send preserves the text and makes retry possible', async () => {
const app = await customerPage();
app.hooks['/api/human/send'] = () => response({ok:false}, 503);
await app.context.window.fillInput('请确认这批货的价格');
assert.equal(app.element('input-text').value, '请确认这批货的价格');
assert.match(app.element('composer-status').textContent, /内容已保留/);
assert.equal(app.element('send-button').disabled, false);
});
test('a duplicate human send cannot submit an in-flight message twice', async () => {
const app = await customerPage();
let resolve;
app.hooks['/api/human/send'] = () => new Promise(done => { resolve=done; });
const first = app.context.window.fillInput('请确认时效');
await app.context.window.fillInput('请确认时效');
assert.equal(app.calls.filter(call => call.url === '/api/human/send').length, 1);
resolve(response({ok:true}));
await first;
assert.equal(app.element('input-text').value, '');
});
test('returning to AI explicitly closes the human session and preserves the conversation', async () => {
const app = await customerPage();
await app.click(app.element('return-ai'));
await app.context.window.fillInput('请继续解答');
await flush();
assert.equal(app.calls.filter(call => call.url === '/api/human/close').length, 1);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id, 1);
assert.equal(app.context.window.humanSid, 0);
});
test('a stale human poll cannot append messages to a newly selected AI topic', async () => {
const app = await customerPage();
let resolve;
app.hooks.poll = () => new Promise(done => { resolve=done; });
await app.tick();
await app.click(app.quickButtons[1]);
resolve(response({status:'active', messages:[{id:1, side:'agent', content:'旧人工会话的回复'}]}));
await flush();
assert.doesNotMatch(app.element('chatDom').textContent, /旧人工会话的回复/);
});
test('human poll failures are visible and clear after the connection recovers', async () => {
const app = await customerPage();
app.hooks.poll = () => Promise.reject(new Error('Disconnected'));
await app.tick();
assert.match(app.element('composer-status').textContent, /连接中断/);
delete app.hooks.poll;
await app.tick();
assert.equal(app.element('composer-status').hidden, true);
});
+11
View File
@@ -67,6 +67,17 @@ class DevIntegrationTests(unittest.TestCase):
self.client.post('/api/human/accept', json={'sid': sid})
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'], 'active')
def test_human_send_rejects_closed_missing_or_mismatched_conversations(self):
sid = self.session()
payload = {'sid': sid, 'side': 'customer', 'content': '不能串到其他会话'}
other_cid = self.client.post('/api/new_chat').json['id']
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'conv_id': other_cid}).status_code, 409)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'sid': 99999}).status_code, 404)
self.assertEqual(self.client.post('/api/human/send', json={**payload, 'side': 'system'}).status_code, 400)
self.client.post('/api/human/close', json={'sid': sid})
self.assertEqual(self.client.post('/api/human/send', json=payload).status_code, 409)
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
def test_recall_targets_older_agent_message_and_is_idempotent(self):
sid = self.session()
for side, text in (('agent', '早先的回复'), ('customer', '客户消息'), ('agent', '最新回复')):
+29 -2
View File
@@ -35,7 +35,15 @@ elif name == 'nginx' and '-t' in args and scenario == 'proxy-fail':
if sum(item[0] == 'nginx' and '-t' in item[1] for item in calls) == 1:
sys.exit(19)
elif name == 'curl' and '-w' in args:
calls = [json.loads(line) for line in log.read_text().splitlines()]
if scenario == 'reload-pending' and sum(item[0] == 'curl' and '-w' in item[1] for item in calls) == 1:
sys.exit(60)
if scenario == 'proxy-unsafe':
print('200', end='')
sys.exit(0)
print('401', end='')
elif name == 'sleep':
pass
'''
@@ -66,13 +74,16 @@ class GrayActivationTests(unittest.TestCase):
unrelated.write_text('# unrelated site\n')
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
str(proxy), str(certificate), str(private_key), 'a'*40,
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'b'*64, 'sha256:' + 'c'*64)
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64)
release = Path(config.release)
write_release(release, config, 'fixture-model-key')
# Docker 26 and 29 may assign different IDs after an archive is loaded.
loaded_id = ('e' if scenario == 'image-mismatch' else 'b') * 64
(release / 'loaded-image-ids.env').write_text('CHAT_IMAGE_ID=sha256:' + loaded_id + '\n')
(release / 'site.htpasswd').write_text('new-auth-hash\n')
commands = directory / 'commands'
commands.mkdir()
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl'):
for name in ('docker', 'nginx', 'openssl', 'ss', 'curl', 'sleep'):
command = commands / name
command.write_text('#!' + sys.executable + '\n' + FAKE_COMMAND)
command.chmod(0o755)
@@ -105,6 +116,22 @@ class GrayActivationTests(unittest.TestCase):
def test_failed_application_restores_previous_release(self):
self.assert_rolled_back('app-fail')
def test_async_nginx_reload_waits_for_the_new_tls_workers(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('reload-pending')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
self.assertTrue(any(name == 'sleep' for name, args in calls))
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
self.assert_rolled_back('proxy-unsafe')
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
self.assertNotEqual(result.returncode, 0)
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
self.assertFalse(any(name == 'docker' and 'compose' in args for name, args in calls))
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
self.assert_rolled_back('proxy-fail')
+79
View File
@@ -0,0 +1,79 @@
"""Verify the real remote image-load guard without contacting Docker or a host."""
import gzip
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Image transfer requires Linux Bash')
class GrayImageTransferTests(unittest.TestCase):
def run_load(self, scenario):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
root = directory / 'gray-logistics-chat'
release = root / 'releases/build'
release.mkdir(parents=True)
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
archive = release / 'images.tar.gz'
archive.write_bytes(gzip.compress(b'fixture-image-archive'))
checksum = hashlib.sha256(archive.read_bytes()).hexdigest()
if scenario == 'corrupt':
archive.write_bytes(archive.read_bytes() + b'changed-in-transit')
commands = directory / 'commands'
commands.mkdir()
docker = commands / 'docker'
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, sys
args = sys.argv[1:]
with pathlib.Path(os.environ['FAKE_LOG']).open('a') as output:
output.write(' '.join(args) + '\\n')
if args == ['load']:
assert sys.stdin.buffer.read() == b'fixture-image-archive'
elif args[:2] == ['image', 'inspect']:
if args[3] == '{{.Id}}':
print('sha256:' + ('b' if '-gray:' in args[-1] else 'c') * 64)
else:
print(('e' if os.environ['FAKE_SCENARIO'] == 'wrong-source' else 'a') * 40)
else:
sys.exit(3)
''')
docker.chmod(0o755)
publish = (Path(__file__).resolve().parents[1] / 'deploy/publish-gray.sh').read_text()
remote = publish.split("<<'LOAD'\n", 1)[1].split('\nLOAD\n', 1)[0]
log = directory / 'commands.log'
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
FAKE_LOG=str(log), FAKE_SCENARIO=scenario)
result = subprocess.run(['bash', '-s', '--', str(root), str(release), checksum,
'baiyue-logistics-chat-gray:' + 'a' * 12 + '-b7',
'baiyue-logistics-chat-certbot:' + 'a' * 12 + '-b7', 'a' * 40],
input=remote, env=environment, capture_output=True, text=True, timeout=15)
return result, release, log
def test_verified_archive_records_target_ids_and_removes_archive(self):
result, release, log = self.run_load('success')
self.assertEqual(result.returncode, 0, result.stderr)
receipt = (release / 'loaded-image-ids.env').read_text()
self.assertIn('CHAT_IMAGE_ID=sha256:' + 'b' * 64, receipt)
self.assertIn('CERTBOT_IMAGE_ID=sha256:' + 'c' * 64, receipt)
self.assertFalse((release / 'images.tar.gz').exists())
def test_modified_archive_is_rejected_before_docker_load(self):
result, release, log = self.run_load('corrupt')
self.assertNotEqual(result.returncode, 0)
self.assertFalse(log.exists())
self.assertFalse((release / 'loaded-image-ids.env').exists())
def test_wrong_source_cannot_create_a_verified_receipt(self):
result, release, log = self.run_load('wrong-source')
self.assertNotEqual(result.returncode, 0)
self.assertFalse((release / 'loaded-image-ids.env').exists())
if __name__ == '__main__':
unittest.main()