From 783f930380d77238213e51bb52fe3f0e5c6f6fe7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 30 Oct 2026 18:27:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=AE=A2=E6=88=B7=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=92=8C=E8=AE=A2=E5=8D=95=E4=BC=9A=E8=AF=9D=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 ++ AGENTS.md | 3 +- README.md | 8 +- deploy/gray_config.py | 2 +- deploy/nginx.conf.example | 2 +- docs/architecture.md | 4 +- logistics_chat/__init__.py | 6 +- logistics_chat/chat_service.py | 8 ++ logistics_chat/config.py | 5 + logistics_chat/customer_service.py | 180 +++++++++++++++++++++++++++++ logistics_chat/db.py | 6 + logistics_chat/history_service.py | 17 +-- logistics_chat/order_data.py | 13 ++- logistics_chat/routes/__init__.py | 4 +- logistics_chat/routes/customer.py | 27 +++++ logistics_chat/visitor_service.py | 14 ++- static/css/chat.css | 12 ++ static/js/chat.js | 46 +++++++- static/js/customer.js | 121 +++++++++++++++++++ templates/index.html | 15 +++ tests/frontend/chat.test.cjs | 65 ++++++++++- tests/test_customer.py | 151 ++++++++++++++++++++++++ tests/test_gray_config.py | 4 +- tests/test_order_data.py | 4 +- tests/test_visitor.py | 2 +- 25 files changed, 695 insertions(+), 31 deletions(-) create mode 100644 logistics_chat/customer_service.py create mode 100644 logistics_chat/routes/customer.py create mode 100644 static/js/customer.js create mode 100644 tests/test_customer.py diff --git a/.env.example b/.env.example index e89ca2a..6a8f41f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,13 @@ DEEPSEEK_API_KEY= # Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows. ORDER_DATA_TENANT_ID= +# Default front-end customer account: admin / 123456. It grants no staff access. +CUSTOMER_DEMO_ENABLED=true +CUSTOMER_DEMO_USERNAME=admin +# Bind an explicit local order tenant. Empty auto-binds only if the dataset has one tenant. +CUSTOMER_DEMO_TENANT_ID= +# Optional password hash used only when creating the default account; not plaintext. +# CUSTOMER_DEMO_PASSWORD_HASH= # Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable. ASR_PROVIDER=baidu BAIDU_ASR_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index 69a4653..9cd8c6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` | | Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support | +| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout | | Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery | | Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` | | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` | @@ -63,4 +64,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi ## Security & Configuration Tips -Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; business tables start empty. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize their chat history, never customer orders; customer login/linking remains pending. Do not assign unowned legacy records to a new visitor. +Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor. diff --git a/README.md b/README.md index 09cb2a2..066b4bf 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,11 @@ data: {"d": 1} ## 数据库与运行数据 -订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。匿名聊天不读取订单表;客户登录及关联将在后续接入,届时查询必须使用服务端验证的客户范围。`ORDER_DATA_TENANT_ID` 只是额外限制,不能代替客户身份。 +订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。 + +默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。`CUSTOMER_DEMO_TENANT_ID` 可指定其关联范围;未配置时仅在本地订单恰有一个客户范围时自动关联,多客户数据不会任意选取。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id` 和 `enabled`。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。 + +登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。 游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。 @@ -260,7 +264,7 @@ data: {"d": 1} 脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order` 的取数实现即可保留聊天契约。 -首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders`、`tickets` 和 `order_test_data` 十张表,并兼容已有数据,不预置账号或订单数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 +首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。 普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。 diff --git a/deploy/gray_config.py b/deploy/gray_config.py index 57193a9..9f461fe 100644 --- a/deploy/gray_config.py +++ b/deploy/gray_config.py @@ -119,7 +119,7 @@ server {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} - location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{ + location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{ auth_basic off; proxy_pass http://127.0.0.1:{self.port}; }} diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index dccc88b..22607ee 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -47,7 +47,7 @@ server { location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; } - location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ { + location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ { auth_basic off; proxy_pass http://127.0.0.1:5000; } diff --git a/docs/architecture.md b/docs/architecture.md index d5d9c8c..a86dc6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,7 +110,9 @@ tests/ # 按业务模块组织的回归 - SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。 - `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。 -- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。客户身份尚未接入,匿名 HTTP 查单在读取订单前返回账号关联提示;`g.customer_tenant_id` 只允许后续可信认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。 +- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。 +- 客户认证使用独立 `customer_users` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer` 和 `g.customer_tenant_id`;只保存密码哈希与登录 token 哈希。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。 +- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出后清除当前可见订单内容,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。 - 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。 - 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 - 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。 diff --git a/logistics_chat/__init__.py b/logistics_chat/__init__.py index 2be35e5..3c5c024 100644 --- a/logistics_chat/__init__.py +++ b/logistics_chat/__init__.py @@ -5,11 +5,12 @@ def create_app(config=None): from collections import defaultdict import logging from pathlib import Path + from threading import Lock from flask import Flask from .config import ROOT_DIR, default_config - from . import db, file_service, hooks, visitor_service + from . import customer_service, db, file_service, hooks, visitor_service from .routes import register_routes from .routes.speech import MemoryAudioRequest @@ -21,8 +22,11 @@ def create_app(config=None): app.config.update(config) app.extensions['human_typing'] = {} app.extensions['rate_hits'] = defaultdict(list) + app.extensions['customer_login_attempts'] = {} + app.extensions['customer_login_lock'] = Lock() app.before_request(hooks.rate_limit) app.before_request(visitor_service.attach_visitor) + app.before_request(customer_service.attach_customer) app.after_request(hooks.no_cache_html) app.teardown_appcontext(db.close_connections) register_routes(app) diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index ab75d98..cbdea02 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -85,6 +85,9 @@ def create_chat_stream(msg, conv_id): if conv_id: from .visitor_service import owns_conversation if not owns_conversation(conv_id): + from .customer_service import needs_customer_login, order_login_response + if needs_customer_login(conv_id): + return (sse(order_login_response()) + sse({'d':1}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) order_answer = answer_query(msg, conv_id) if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg): @@ -102,6 +105,11 @@ def create_chat_stream(msg, conv_id): if order_answer is not None: def track_stream(): + if 'card' in order_answer: + from .customer_service import bind_order_conversation, current_customer_id + if current_customer_id() and not bind_order_conversation(conv_id): + yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1}) + return save_message(conv_id, 'user', msg) content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False) if 'card' in order_answer else order_answer['t']) diff --git a/logistics_chat/config.py b/logistics_chat/config.py index f7e014a..39096dd 100644 --- a/logistics_chat/config.py +++ b/logistics_chat/config.py @@ -29,6 +29,11 @@ def default_config(): 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(), 'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(), 'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(), + 'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'), + 'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(), + 'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH', + 'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''), + 'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(), 'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions', 'MODEL_NAME': 'deepseek-chat', 'MAX_CONTEXT': 4, diff --git a/logistics_chat/customer_service.py b/logistics_chat/customer_service.py new file mode 100644 index 0000000..25a4faa --- /dev/null +++ b/logistics_chat/customer_service.py @@ -0,0 +1,180 @@ +"""Customer authentication and order scope, independent of staff authentication.""" + +import hashlib +import secrets +import time + +from flask import g, has_app_context, jsonify, request +from werkzeug.security import check_password_hash + +from .config import get_setting +from .db import connect +from .visitor_service import valid_key, visitor_id + +COOKIE_NAME = 'baiyue_customer' +SESSION_SECONDS = 8 * 3600 + + +def demo_tenant(connection): + configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID') + if configured: + return configured + rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall() + return rows[0][0] if len(rows) == 1 else '' + + +def init_customer_tables(connection): + connection.execute('''CREATE TABLE IF NOT EXISTS customer_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, display_name TEXT NOT NULL, + tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, + is_demo INTEGER NOT NULL DEFAULT 0)''') + connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions ( + token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL, + visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''') + connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)') + if get_setting('CUSTOMER_DEMO_ENABLED'): + connection.execute('''INSERT OR IGNORE INTO customer_users + (username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''', + (get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'), + get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection))) + + +def auth_stamp(row): + return hashlib.sha256((row[2] + '\n' + row[4]).encode()).hexdigest() + + +def allowed_user(row): + return bool(row and row[5] and (not row[6] or ( + get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME')))) + + +def attach_customer(): + g.customer = None + g.customer_tenant_id = None + token = request.cookies.get(COOKIE_NAME) + if not visitor_id() or not valid_key(token): + return + connection = connect() + try: + session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions ' + 'WHERE token_hash=? AND visitor_id=? AND expires_at>?', + (hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone() + if not session: + return + row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo ' + 'FROM customer_users WHERE id=?', (session[0],)).fetchone() + if allowed_user(row) and secrets.compare_digest(auth_stamp(row), session[1]): + g.customer = {'id':row[0], 'username':row[1], 'name':row[3]} + g.customer_tenant_id = row[4] or None + finally: + connection.close() + + +def current_customer_id(): + customer = getattr(g, 'customer', None) if has_app_context() else None + return customer['id'] if customer else None + + +def bind_order_conversation(conv_id): + """A conversation containing order facts also requires its customer login.""" + if not conv_id: + return True + connection = connect() + try: + result = connection.execute('UPDATE chat_history SET customer_id=?,customer_tenant_id=? WHERE id=? AND visitor_id=? ' + 'AND (customer_id IS NULL OR (customer_id=? AND customer_tenant_id=?))', + (current_customer_id(),g.customer_tenant_id,conv_id,visitor_id(),current_customer_id(),g.customer_tenant_id)) + connection.commit() + return result.rowcount == 1 + finally: + connection.close() + + +def session_status(): + return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer} + + +def order_login_response(): + if current_customer_id(): + return {'t':'当前账号尚未关联订单,请联系人工客服。'} + return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True} + + +def needs_customer_login(conv_id): + if current_customer_id(): + return False + connection = connect() + try: + return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? ' + 'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone()) + finally: + connection.close() + + +def login(data, attempts, lock): + if not isinstance(data, dict): + return {'ok':False, 'error':'请输入账号和密码。'}, 400 + username, password = data.get('username'), data.get('password') + if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256: + return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400 + now = int(time.time()) + # A separate short failed-login budget supplements the global request limiter. + ip = request.remote_addr or 'unknown' + with lock: + failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300] + if len(attempts) > 2048: + for key in list(attempts): + if not attempts[key] or attempts[key][-1] <= now - 300: + attempts.pop(key,None) + if len(failures) >= 5: + return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429 + attempts[ip] = failures + [now] + connection = connect() + try: + row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo ' + 'FROM customer_users WHERE username=?', (username.strip(),)).fetchone() + digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH') + try: + valid = check_password_hash(digest, password) + except (ValueError, TypeError): + valid = False + if not valid or not allowed_user(row): + return {'ok':False, 'error':'账号或密码不正确。'}, 403 + with lock: + attempts.pop(ip, None) + if row[6] and not row[4]: + tenant = demo_tenant(connection) + if tenant: + connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0])) + row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo ' + 'FROM customer_users WHERE id=?', (row[0],)).fetchone() + old_token = request.cookies.get(COOKIE_NAME) + if valid_key(old_token): + connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?', + (hashlib.sha256(old_token.encode()).hexdigest(), visitor_id())) + token = secrets.token_hex(32) + connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,)) + connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)', + (hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row),now+SESSION_SECONDS)) + connection.commit() + response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}}) + response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/') + return response + finally: + connection.close() + + +def logout(): + token = request.cookies.get(COOKIE_NAME) + if valid_key(token): + connection = connect() + try: + connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?', + (hashlib.sha256(token.encode()).hexdigest(),visitor_id())) + connection.commit() + finally: + connection.close() + response = jsonify({'ok':True,'authenticated':False,'user':None}) + response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax') + return response diff --git a/logistics_chat/db.py b/logistics_chat/db.py index fbe7d08..03ebac2 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -52,6 +52,10 @@ def init_db(): cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL') if 'visitor_id' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT') + if 'customer_id' not in cols: + cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER') + if 'customer_tenant_id' not in cols: + cur.execute('ALTER TABLE chat_history ADD COLUMN customer_tenant_id TEXT') cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)') cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads ( filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL @@ -136,6 +140,8 @@ def init_db(): ''') from .order_data import init_order_table init_order_table(conn) + from .customer_service import init_customer_tables + init_customer_tables(conn) old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall() for cid, title in old: cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str())) diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py index a30e6b9..61689e7 100644 --- a/logistics_chat/history_service.py +++ b/logistics_chat/history_service.py @@ -1,7 +1,8 @@ """Conversation CRUD with unchanged response data.""" from .db import connect, now_str -from .visitor_service import visitor_id, owns_conversation +from .visitor_service import visitor_id, owns_conversation, customer_tenant_id +from .customer_service import current_customer_id WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。' @@ -24,7 +25,8 @@ def new_chat(): def get_history(): conn = connect() rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? ' - 'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),)).fetchall() + 'AND (customer_id IS NULL OR (customer_id=? AND customer_tenant_id=?)) ' + 'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),customer_tenant_id())).fetchall() conn.close() return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows] @@ -57,12 +59,13 @@ def clear_all(): conn = connect() cur = conn.cursor() owner = visitor_id() - owned = 'SELECT id FROM chat_history WHERE visitor_id=?' + owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_tenant_id=?))' + scope = (owner,current_customer_id(),customer_tenant_id()) cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions ' - 'WHERE conv_id IN ('+owned+'))', (owner,)) - cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', (owner,)) - cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', (owner,)) - cur.execute('DELETE FROM chat_history WHERE visitor_id=?', (owner,)) + 'WHERE conv_id IN ('+owned+'))', scope) + cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope) + cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope) + cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope) conn.commit() conn.close() return {'ok': True} diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 9c436ae..011123b 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -8,6 +8,7 @@ from datetime import datetime from .config import get_setting from .order_tracking import build_tracking from .visitor_service import customer_tenant_id +from .customer_service import order_login_response FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id', @@ -23,7 +24,6 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目 'parent':'父订单号', 'updated':'来源更新时间'} IDENTIFIER = re.compile(r'(? 1: return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'} reference = references[0] if references else None @@ -248,11 +248,14 @@ def answer_query(message, conv_id): except (ValueError, KeyError, TypeError): pass if not reference: - if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I): - return {'t':ORDER_LOGIN_TEXT if tenant is None else '请提供订单号、入仓码或 Shipment ID。'} + if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码)|订单状态|物流轨迹|shipment\s*id|' + r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|' + r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|' + r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b', message, re.I): + return order_login_response() if tenant is None else {'t':'请提供订单号、入仓码或 Shipment ID。'} return None if tenant is None: - return {'t': ORDER_LOGIN_TEXT} + return order_login_response() try: matches = lookup_order(reference, tenant_id=tenant) except OrderDataUnavailable: diff --git a/logistics_chat/routes/__init__.py b/logistics_chat/routes/__init__.py index a5e3d84..1c25175 100644 --- a/logistics_chat/routes/__init__.py +++ b/logistics_chat/routes/__init__.py @@ -2,6 +2,6 @@ def register_routes(app): - from . import admin, business, chat, files, history, human, pages, speech, visitor - for module in (admin, business, chat, files, history, human, pages, speech, visitor): + from . import admin, business, chat, customer, files, history, human, pages, speech, visitor + for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor): app.register_blueprint(module.bp) diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py new file mode 100644 index 0000000..1bd8546 --- /dev/null +++ b/logistics_chat/routes/customer.py @@ -0,0 +1,27 @@ +"""Public, visitor-bound customer login endpoints. No staff privileges.""" + +from flask import Blueprint, current_app, request +from werkzeug.exceptions import RequestEntityTooLarge +from .. import customer_service + +bp = Blueprint('customer', __name__) + + +@bp.get('/api/customer/session') +def session(): + return customer_service.session_status() + + +@bp.post('/api/customer/login') +def login(): + request.max_content_length = 2048 + try: + return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'], + current_app.extensions['customer_login_lock']) + except RequestEntityTooLarge: + return {'ok':False,'error':'登录信息过长。'}, 413 + + +@bp.post('/api/customer/logout') +def logout(): + return customer_service.logout() diff --git a/logistics_chat/visitor_service.py b/logistics_chat/visitor_service.py index f3d7522..0dd3f78 100644 --- a/logistics_chat/visitor_service.py +++ b/logistics_chat/visitor_service.py @@ -15,6 +15,7 @@ CUSTOMER_PATHS = { '/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history', '/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback', '/api/speech/config', '/api/speech/transcribe', + '/api/customer/session', '/api/customer/login', '/api/customer/logout', } @@ -27,7 +28,7 @@ def visitor_id(): def customer_tenant_id(): - # Future login adapters may set this only after validating a customer session. + # Set only after validating a server-side customer session. # Visitor cookies, request parameters and headers are deliberately not accepted. value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None if isinstance(value, bool) or not isinstance(value, (str, int)): @@ -65,25 +66,30 @@ def start_session(data): def owns_conversation(conv_id): + from .customer_service import current_customer_id owner = visitor_id() if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807: return False connection = connect() try: - return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=?', - (conv_id, owner)).fetchone()) + return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? ' + 'AND (customer_id IS NULL OR (customer_id=? AND customer_tenant_id=?))', + (conv_id, owner, current_customer_id(),customer_tenant_id())).fetchone()) finally: connection.close() def owns_human_session(sid): + from .customer_service import current_customer_id owner = visitor_id() if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807: return False connection = connect() try: return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id ' - 'WHERE s.id=? AND h.visitor_id=?', (sid, owner)).fetchone()) + 'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR ' + '(h.customer_id=? AND h.customer_tenant_id=?))', + (sid, owner, current_customer_id(),customer_tenant_id())).fetchone()) finally: connection.close() diff --git a/static/css/chat.css b/static/css/chat.css index 2eba2f0..3cd5484 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -42,6 +42,18 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; } .clear-all-btn { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; } .clear-all-btn:hover { color: var(--danger); } +.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; } +.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; } +.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; } +.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); } +.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; } +.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; } +.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; } +.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); } +#customer-error { margin-top: 12px; color: var(--danger); font-size: 14px; } +.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; } +.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); } +#customer-submit { border-color: var(--brand); background: var(--brand); color: white; } .left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; } .main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; } .chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; } diff --git a/static/js/chat.js b/static/js/chat.js index 2cdb129..1ddd096 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -40,6 +40,7 @@ let uploadController = null; let historyRequestVersion = 0; let followLatest = true; let voiceInput = null; +let customerLogin = null; let creatingConv = null; let sendPreparing = false; let conversationVersion = 0; @@ -227,6 +228,10 @@ window.__busy = false; // 生成中状态,用 window 属性避免作用域 const I18N = { zh: { appTitle: "柏悦物流智能助手", + customerLogin: "登录", customerLogout: "退出登录", customerCancel: "取消", + customerUsername: "账号", customerPassword: "密码", customerQuery: "登录查询", + customerSigningIn: "登录中…", customerLoginFailed: "登录失败,请稍后重试。", + customerLogoutFailed: "退出失败,请重试。", newChat: "新建对话", clearAll: "清空全部历史", history: "历史对话", @@ -333,6 +338,10 @@ const I18N = { }, en: { appTitle: "Baiyue Logistics AI Assistant", + customerLogin: "Sign in", customerLogout: "Sign out", customerCancel: "Cancel", + customerUsername: "Username", customerPassword: "Password", customerQuery: "Sign in to view orders", + customerSigningIn: "Signing in…", customerLoginFailed: "Could not sign in. Please try again.", + customerLogoutFailed: "Could not sign out. Please try again.", newChat: "New Chat", clearAll: "Clear All History", history: "History", @@ -449,6 +458,7 @@ function t(key){ function applyLanguage(lang){ LANG = lang === "en" ? "en" : "zh"; const dict = I18N[LANG]; + if(customerLogin) customerLogin.render(); document.documentElement.lang = LANG === "en" ? "en" : "zh-CN"; document.querySelectorAll("[data-i18n]").forEach(function(element){ const value = dict[element.dataset.i18n]; @@ -885,7 +895,9 @@ function openConversation(id){ chatDom.innerHTML = welcomeHTML(); return; } + let previousQuestion = ''; msgs.forEach(m=>{ + if(m.role === 'user') previousQuestion = m.content; if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){ try{ const bubble = addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)), m.ctime); @@ -896,6 +908,9 @@ function openConversation(id){ }else{ const b = addMsg(m.content, m.role === "user", undefined, m.ctime); if(m.role === "assistant" && b) attachActions(b, m.content, "", false); + if(m.role === 'assistant' && b && (/^请先使用客户账号登录并关联订单/.test(m.content) || /^订单查询将在客户账号登录并关联后开放/.test(m.content))){ + attachCustomerLogin(b, previousQuestion); + } } }); // 等渲染完再滚到底部 @@ -1409,6 +1424,19 @@ function mdToPlain(md){ .replace(/“|”/g, '"'); } +function attachCustomerLogin(bubble, question){ + const button = document.createElement('button'); + button.type = 'button'; button.className = 'customer-login-cta'; + button.textContent = t('customerQuery'); + const version = conversationVersion; + button.onclick = function(){ + customerLogin.require(async function(){ + if(version === conversationVersion && question) await retryAnswer(bubble, question); + }); + }; + bubble.appendChild(button); +} + async function retryAnswer(bubble, question){ if(window.humanSid || humanRequestToken){ setComposerStatus(t('retryAIOnly')); return; } if(await doSend(question)){ @@ -1629,7 +1657,8 @@ async function doSend(retryText){ raw = "运单号 " + card.no + "(" + (card.status || "") + ")"; }catch(e){ /* 解析失败就按普通文本显示 */ } } - attachActions(aiBubble, raw, fullContent, true); + attachActions(aiBubble, raw, fullContent, !aiBubble.dataset.loginRequired); + if(aiBubble.dataset.loginRequired) attachCustomerLogin(aiBubble, fullContent); renderFollowups(aiBubble); updateToBottom(); loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下 @@ -1679,6 +1708,7 @@ async function doSend(retryText){ } if(!obj || typeof obj !== 'object') return; if(obj.e){ fail(obj.e); return; } + if(obj.login_required) aiBubble.dataset.loginRequired = '1'; if(obj.card){ clearTimeout(firstTimer); aiBubble.classList.add("track-card"); @@ -1876,6 +1906,7 @@ function syncChatViewport(){ chatContainer.style.height = ''; chatContainer.style.top = ''; } + if(customerLogin) customerLogin.viewport(chatContainer.style.height, chatContainer.style.top); chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom); autoGrow(); if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd(); @@ -1952,6 +1983,19 @@ voiceInput = ChatVoice.create({ if(!await doSend()) voiceInput.setMode(false); } }); +customerLogin = CustomerLogin.create({fetch:fetch, text:t, + onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); }, + onChange:function(user,previous){ + if(previous === undefined) return; + if(previous && (!user || user.id !== previous.id)){ + resetConversation(null); chatDom.innerHTML=welcomeHTML(); + } + loadHistory(); + }, + onError:function(message){ setComposerStatus(message,true); } +}); +customerLogin.viewport(chatContainer.style.height,chatContainer.style.top); +customerLogin.refresh(); window.addEventListener('resize', function(){ if(!isNarrow()) voiceInput.setMode(false); }); diff --git a/static/js/customer.js b/static/js/customer.js new file mode 100644 index 0000000..e2108ba --- /dev/null +++ b/static/js/customer.js @@ -0,0 +1,121 @@ +// Customer login has no relationship to the protected staff workspace. +var CustomerLogin = { + create(options){ + const overlay=document.getElementById('customer-login'); + const form=document.getElementById('customer-login-form'); + const username=document.getElementById('customer-username'); + const password=document.getElementById('customer-password'); + const submit=document.getElementById('customer-submit'); + const cancel=document.getElementById('customer-cancel'); + const error=document.getElementById('customer-error'); + const account=document.getElementById('customer-account'); + let user, pending=null, opener=null, busy=false, version=0; + overlay.hidden=true; + function status(message){ error.textContent=message || ''; error.hidden=!message; } + function render(){ + account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin'); + submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin'); + cancel.textContent=options.text('customerCancel'); + document.getElementById('customer-login-title').textContent=options.text('customerLogin'); + document.getElementById('customer-username-label').textContent=options.text('customerUsername'); + document.getElementById('customer-password-label').textContent=options.text('customerPassword'); + } + function update(next){ + const previous=user; + user=next; + render(); + if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){ + options.onChange(user,previous); + } + } + async function request(path, data){ + const controller=new AbortController(); + const timer=setTimeout(()=>controller.abort(),10000); + try{ + const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST', + headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal}); + const result=await response.json(); + if(!response.ok || !result.ok){ + const failure=new Error(result.error || result.e || options.text('customerLoginFailed')); + failure.publicMessage=true;throw failure; + } + return result; + }finally{ clearTimeout(timer); } + } + async function refresh(){ + if(busy) return; + const token=++version; + try{ + const result=await request('/api/customer/session'); + if(token === version) update(result.authenticated && result.user ? result.user : null); + }catch(error){} // Query endpoints still enforce login if status cannot refresh. + } + function close(){ + overlay.hidden=true; + password.value=''; + pending=null; + if(opener && opener.isConnected) opener.focus({preventScroll:true}); + opener=null; + } + function open(onSuccess){ + if(busy) return; + pending=typeof onSuccess === 'function' ? onSuccess : null; + opener=document.activeElement; + status(''); password.value=''; overlay.hidden=false; + if(options.onOpen) options.onOpen(); + render(); username.focus({preventScroll:true}); + } + function setBusy(value){ + busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value; + username.disabled=value; password.disabled=value; render(); + } + form.addEventListener('submit',async function(event){ + event.preventDefault(); + if(busy) return; + const token=++version; + setBusy(true);status(''); + try{ + const result=await request('/api/customer/login',{username:username.value,password:password.value}); + if(token !== version) return; + update(result.user); + const resume=pending;close(); + if(resume) await resume(); + }catch(failure){ + if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed')); + }finally{ if(token === version) setBusy(false); } + }); + cancel.onclick=function(){ if(!busy) close(); }; + overlay.addEventListener('click',function(event){ if(event.target === overlay && !busy) close(); }); + overlay.addEventListener('keydown',function(event){ + if(event.key === 'Escape' && !busy){ event.preventDefault();close(); } + if(event.key === 'Tab'){ + const controls=[username,password,submit,cancel].filter(control=>!control.disabled); + if(!controls.length){ event.preventDefault();return; } + const index=controls.indexOf(document.activeElement); + const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length; + event.preventDefault();controls[next].focus({preventScroll:true}); + } + }); + account.onclick=async function(){ + if(busy) return; + if(!user){ open(); return; } + const token=++version;setBusy(true); + try{ + await request('/api/customer/logout',{}); + if(token === version) update(null); + }catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); } + finally{ if(token === version) setBusy(false); } + }; + window.addEventListener('focus',refresh); + document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); }); + render(); + return {open:open,refresh:refresh,render:render,require:async function(resume){ + if(busy) return; + await refresh(); + if(user){ await resume(); }else open(resume); + },viewport:function(height,top){ + overlay.style.height=height || ''; overlay.style.top=top || ''; + overlay.style.bottom=height ? 'auto' : ''; + }}; + } +}; diff --git a/templates/index.html b/templates/index.html index ad52090..ccfe250 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,7 @@
@@ -68,8 +69,22 @@ + + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 8295265..efda82a 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -113,7 +113,7 @@ async function customerPage(pageOptions = {}){ return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'drafts.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'customer.js', 'drafts.js', 'voice.js', 'chat.js']){ vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file}); if(file === 'voice.js'){ const createVoice=context.ChatVoice.create; @@ -997,6 +997,69 @@ test('deleting a conversation removes only its saved draft', async () => { assert.equal(store.read(9),'');assert.equal(store.read(10),'B草稿'); }); +test('an order login action resumes the original question and preserves a newer draft', async () => { + const app=await customerPage({human:false});let authenticated=false; + app.hooks['/api/customer/session']=()=>response({ok:true,authenticated,user:authenticated ? {id:1,name:'admin'} : null}); + app.hooks['/api/customer/login']=()=>{authenticated=true;return response({ok:true,authenticated:true,user:{id:1,name:'admin'}});}; + app.hooks['/stream_chat']=()=>streamResponse([`data: ${JSON.stringify(authenticated ? {t:'账号范围内的订单结果'} : {t:'请先登录',login_required:true})}\n\ndata: {"d":1}\n\n`]); + await app.context.window.fillInput('查询ORDER20260001');await flush(); + const action=app.element('chatDom').querySelector('.customer-login-cta');assert.ok(action); + app.element('input-text').value='下一条未发送草稿';app.element('input-text').listeners.input(); + await app.click(action);assert.equal(app.element('customer-login').hidden,false); + app.element('customer-username').value='admin';app.element('customer-password').value='123456'; + await app.element('customer-login-form').listeners.submit({preventDefault(){}});await flush(); + assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,''); + assert.equal(app.element('input-text').value,'下一条未发送草稿'); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,2); + assert.match(app.element('chatDom').textContent,/账号范围内的订单结果/); + assert.match(app.element('customer-account').textContent,/admin/); +}); + +test('invalid customer credentials leave the login form open without sending another question', async () => { + const app=await customerPage({human:false}); + app.hooks['/api/customer/login']=()=>response({ok:false,error:'账号或密码不正确。'},403); + await app.click(app.element('customer-account')); + app.element('customer-username').value='admin';app.element('customer-password').value='wrong'; + await app.element('customer-login-form').listeners.submit({preventDefault(){}}); + assert.equal(app.element('customer-login').hidden,false); + assert.match(app.element('customer-error').textContent,/账号或密码/); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/login').length,1); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,0); + await app.click(app.element('customer-cancel')); + assert.equal(app.element('customer-login').hidden,true);assert.equal(app.element('customer-password').value,''); +}); + +test('a late login cannot replay an order into a newer conversation', async () => { + const app=await customerPage({human:false});let release; + app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null}); + app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"先登录","login_required":true}\n\ndata: {"d":1}\n\n']); + app.hooks['/api/customer/login']=()=>new Promise(resolve=>{release=resolve;}); + await app.context.window.fillInput('查询ORDER20260001');await flush(); + await app.click(app.element('chatDom').querySelector('.customer-login-cta')); + app.element('customer-username').value='admin';app.element('customer-password').value='123456'; + const signing=app.element('customer-login-form').listeners.submit({preventDefault(){}}); + await app.click(app.element('.new-chat-btn')); + app.element('input-text').value='新话题';app.element('input-text').listeners.input(); + release(response({ok:true,authenticated:true,user:{id:1,name:'admin'}}));await signing; + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1); + assert.equal(app.element('input-text').value,'新话题'); +}); + +test('signing out clears visible order conversation and returns to guest mode', async () => { + const app=await customerPage({human:false}); + app.hooks['/api/customer/login']=()=>response({ok:true,authenticated:true,user:{id:1,name:'admin'}}); + app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null}); + await app.click(app.element('customer-account')); + app.element('customer-username').value='admin';app.element('customer-password').value='123456'; + await app.element('customer-login-form').listeners.submit({preventDefault(){}}); + await app.context.window.fillInput('ORDER20260001');await flush(); + assert.ok(app.element('chatDom').querySelector('.user-message')); + await app.click(app.element('customer-account')); + assert.equal(app.element('customer-account').textContent,'登录'); + assert.equal(app.element('chatDom').querySelector('.user-message'),null); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/logout').length,1); +}); + test('an unavailable image gets one readable replacement instead of a broken image', async () => { const app=await customerPage({human:false}); let shown; diff --git a/tests/test_customer.py b/tests/test_customer.py new file mode 100644 index 0000000..fbd8382 --- /dev/null +++ b/tests/test_customer.py @@ -0,0 +1,151 @@ +"""Customer login, tenant scope and sensitive-history lifecycle.""" + +import hashlib +import json +from pathlib import Path +import tempfile +import time +import unittest +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier +from unittest.mock import patch + +from logistics_chat import create_app, db, order_data +from logistics_chat.customer_service import COOKIE_NAME + + +class CustomerTests(unittest.TestCase): + def setUp(self): + folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup) + root=Path(folder.name) + self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads', + 'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'', + 'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000}) + self.a,self.b=self.app.test_client(),self.app.test_client() + for client,key in ((self.a,'1'*64),(self.b,'2'*64)): + client.post('/api/visitor/session',json={'key':key}) + with self.app.app_context(): + connection=db.connect() + order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30}, + {'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}]) + connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)', + ('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b')) + connection.commit();connection.close() + + def login(self, client=None, username='admin'): + return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'}) + + def events(self,message,conv=None,client=None): + response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv}) + return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')] + + def test_guest_must_login_then_customer_reads_only_the_linked_scope(self): + with patch('logistics_chat.order_data.lookup_order') as lookup: + self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called() + self.assertTrue(self.login().json['ok']) + self.assertTrue(self.a.get('/api/customer/session').json['authenticated']) + self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001') + self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t']) + forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'}) + self.assertNotIn('99 kg',forged.get_data(as_text=True)) + + def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self): + secure=self.app.test_client() + secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost') + response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost') + for flag in ('Secure','HttpOnly','SameSite=Lax'): + self.assertIn(flag,response.headers['Set-Cookie']) + self.login();token=self.a.get_cookie(COOKIE_NAME).value + with self.app.app_context(): + connection=db.connect() + self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456') + self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?', + (hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close() + with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600): + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token) + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + + def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self): + self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value) + self.assertFalse(self.b.get('/api/customer/session').json['authenticated']) + self.b.post('/api/customer/logout') + self.assertTrue(self.a.get('/api/customer/session').json['authenticated']) + for index in range(5): + response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'}) + self.assertEqual(response.status_code,403) + self.assertEqual(self.login(self.b).status_code,429) + + def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self): + self.login();cid=self.a.post('/api/new_chat').json['id'] + self.events('ORDER20260001',cid) + self.a.post('/api/customer/logout') + self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[]) + self.assertEqual(self.a.get('/api/get_history').json,[]) + self.assertTrue(self.events('继续查询',cid)[0]['login_required']) + self.a.delete('/api/clear_all') + self.login(username='other') + self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[]) + self.login() + self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json)) + self.login(self.b) + self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[]) + + def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self): + for path in ('login','logout'): + self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'}, + headers={'Origin':'https://foreign.example'}).status_code,403) + self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400) + self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400) + self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413) + + def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self): + self.login() + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close() + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + self.assertEqual(self.login().status_code,403) + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close() + self.login() + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close() + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + + def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self): + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='' WHERE username='admin'");connection.commit();connection.close() + self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + self.assertTrue(self.login().json['ok']) + reply=self.events('ORDER20260001')[0] + self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply) + with self.app.app_context(): + connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close() + self.login() + self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001') + + def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self): + self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid) + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close() + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + self.login() + self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[]) + self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002') + + def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self): + self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False + self.assertFalse(self.a.get('/api/customer/session').json['authenticated']) + self.assertEqual(self.login().status_code,403) + self.assertTrue(self.login(username='other').json['authenticated']) + + def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self): + barrier=Barrier(6) + def attempt(index): + with self.app.test_client() as client: + client.set_cookie('baiyue_visitor','1'*64) + barrier.wait(timeout=5) + return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code + with ThreadPoolExecutor(max_workers=6) as executor: + statuses=list(executor.map(attempt,range(6))) + self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1) diff --git a/tests/test_gray_config.py b/tests/test_gray_config.py index f8ecfbc..99b92bc 100644 --- a/tests/test_gray_config.py +++ b/tests/test_gray_config.py @@ -67,12 +67,14 @@ class GrayConfigTests(unittest.TestCase): self.assertIn('auth_basic off;', body) self.assertIn('auth_basic "Baiyue gray acceptance";', config) for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages', + '/api/customer/session','/api/customer/login','/api/customer/logout', '/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll', '/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'): with self.subTest(public_path=path):self.assertRegex(path,pattern) for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets', '/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt', - '/api/visitor/human/accept','/api/get_history/unknown','/api/unknown'): + '/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra', + '/api/get_history/unknown','/api/unknown'): with self.subTest(staff_path=path):self.assertNotRegex(path,pattern) example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8') self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern) diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 7fbfa2f..321527a 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -22,8 +22,6 @@ class OrderDataTests(unittest.TestCase): self.client.post('/api/visitor/session', json={'key':'1'*64}) self.context = self.app.app_context() self.context.push() - from flask import g - g.customer_tenant_id = 'tenant-a' self.addCleanup(self.context.pop) self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a', 'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208', @@ -32,6 +30,8 @@ class OrderDataTests(unittest.TestCase): 'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}, {'time':'2026-10-29 10:00', 'status':'PROCESSING'}]} self.import_rows([self.record]) + self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok']) + self.client.get('/api/customer/session') def import_rows(self, rows, replace=False): connection = db.connect() diff --git a/tests/test_visitor.py b/tests/test_visitor.py index dd1d5e0..5b94955 100644 --- a/tests/test_visitor.py +++ b/tests/test_visitor.py @@ -119,7 +119,7 @@ class VisitorTests(unittest.TestCase): self.assertEqual(staff.data,b'A_PRIVATE') def test_guest_keys_and_client_claims_cannot_unlock_orders(self): - for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001'): + for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status'): with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model: response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'}) self.assertIn('客户账号登录并关联',response.get_data(as_text=True))