From d26f6ecfd379e612c24f6177254b82d14488689a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 30 Oct 2026 18:46:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B3=E8=81=94=E9=BB=98=E8=AE=A4=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=B4=A6=E5=8F=B7=E4=B8=8E=E5=BD=93=E5=89=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=AE=A2=E5=8D=95=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- README.md | 2 +- docs/architecture.md | 2 +- logistics_chat/config.py | 1 + logistics_chat/customer_service.py | 65 +++++++++++++++++++++++++----- logistics_chat/db.py | 11 ++++- logistics_chat/history_service.py | 12 +++--- logistics_chat/order_data.py | 30 +++++++++----- logistics_chat/visitor_service.py | 25 +++++++++--- tests/test_customer.py | 55 ++++++++++++++++++++++++- 10 files changed, 170 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 6a8f41f..9603c6a 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,10 @@ 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. +# Bind one explicit tenant, or leave empty to use the current local dataset grants. CUSTOMER_DEMO_TENANT_ID= +# On initial association, grant the default account the tenants currently in the local table. +CUSTOMER_DEMO_LOCAL_ORDERS=true # 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. diff --git a/README.md b/README.md index 066b4bf..2e71753 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ data: {"d": 1} 订单数据保存在聊天服务运行时 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` 用于首次创建默认账号。 +默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `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` 的客户范围过滤。 diff --git a/docs/architecture.md b/docs/architecture.md index a86dc6b..c0e8071 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ tests/ # 按业务模块组织的回归 - `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` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。 -- 客户认证使用独立 `customer_users` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer` 和 `g.customer_tenant_id`;只保存密码哈希与登录 token 哈希。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。 +- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 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 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。 diff --git a/logistics_chat/config.py b/logistics_chat/config.py index 39096dd..6287c4a 100644 --- a/logistics_chat/config.py +++ b/logistics_chat/config.py @@ -34,6 +34,7 @@ def default_config(): '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(), + 'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'), '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 index 25a4faa..331a203 100644 --- a/logistics_chat/customer_service.py +++ b/logistics_chat/customer_service.py @@ -1,6 +1,7 @@ """Customer authentication and order scope, independent of staff authentication.""" import hashlib +import json import secrets import time @@ -23,25 +24,64 @@ def demo_tenant(connection): return rows[0][0] if len(rows) == 1 else '' +def user_tenants(connection, row): + if row[4]: + return (row[4],) + rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257', + (row[0],)).fetchall() + return tuple(item[0] for item in rows) if len(rows) <= 256 else () + + +def seed_local_grants(connection, row): + if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]: + return + if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row): + connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) ' + "SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],)) + if user_tenants(connection,row): + connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],)) + + +def scope_key(tenants): + return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None + + +def effective_tenants(tenants): + configured = get_setting('ORDER_DATA_TENANT_ID') + return tuple(value for value in tenants if not configured or value == configured) + + +def current_scope_key(): + from .visitor_service import customer_tenant_ids + return scope_key(customer_tenant_ids()) + + 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)''') + is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''') + if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]: + connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized 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)') + connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants ( + customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''') 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))) + row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo ' + 'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone() + seed_local_grants(connection,row) -def auth_stamp(row): - return hashlib.sha256((row[2] + '\n' + row[4]).encode()).hexdigest() +def auth_stamp(row, tenants): + return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest() def allowed_user(row): @@ -52,6 +92,7 @@ def allowed_user(row): def attach_customer(): g.customer = None g.customer_tenant_id = None + g.customer_tenant_ids = () token = request.cookies.get(COOKIE_NAME) if not visitor_id() or not valid_key(token): return @@ -64,9 +105,11 @@ def attach_customer(): 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]): + tenants = effective_tenants(user_tenants(connection,row)) if row else () + if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]): g.customer = {'id':row[0], 'username':row[1], 'name':row[3]} - g.customer_tenant_id = row[4] or None + g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None + g.customer_tenant_ids = tenants finally: connection.close() @@ -82,9 +125,9 @@ def bind_order_conversation(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)) + result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? ' + 'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))', + (current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key())) connection.commit() return result.rowcount == 1 finally: @@ -143,12 +186,14 @@ def login(data, attempts, lock): return {'ok':False, 'error':'账号或密码不正确。'}, 403 with lock: attempts.pop(ip, None) - if row[6] and not row[4]: + if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute( + 'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]: 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() + seed_local_grants(connection,row) old_token = request.cookies.get(COOKIE_NAME) if valid_key(old_token): connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?', @@ -156,7 +201,7 @@ def login(data, attempts, lock): 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)) + (hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,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='/') diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 03ebac2..77f15a7 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -54,8 +54,15 @@ def init_db(): 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') + if 'customer_scope' not in cols: + cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT') + if 'customer_tenant_id' in cols: + # Preserve conversations created by the earlier local single-tenant login. + import hashlib + import json + for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall(): + scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None + cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid)) 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 diff --git a/logistics_chat/history_service.py b/logistics_chat/history_service.py index 61689e7..2d0c0b7 100644 --- a/logistics_chat/history_service.py +++ b/logistics_chat/history_service.py @@ -1,8 +1,8 @@ """Conversation CRUD with unchanged response data.""" from .db import connect, now_str -from .visitor_service import visitor_id, owns_conversation, customer_tenant_id -from .customer_service import current_customer_id +from .visitor_service import visitor_id, owns_conversation +from .customer_service import current_customer_id, current_scope_key WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。' @@ -25,8 +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=? ' - '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() + 'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) ' + 'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall() conn.close() return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows] @@ -59,8 +59,8 @@ def clear_all(): conn = connect() cur = conn.cursor() owner = 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()) + owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))' + scope = (owner,current_customer_id(),current_scope_key()) cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions ' 'WHERE conv_id IN ('+owned+'))', scope) cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope) diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 011123b..d0dcb93 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -7,7 +7,7 @@ from datetime import datetime from .config import get_setting from .order_tracking import build_tracking -from .visitor_service import customer_tenant_id +from .visitor_service import customer_tenant_ids from .customer_service import order_login_response @@ -112,7 +112,7 @@ def import_tracking_records(connection, records, replace=False): return {'imported': imported, 'preserved': preserved, 'missing': missing} -def lookup_order(reference, tenant_id=None): +def lookup_order(reference, tenant_id=None, tenant_ids=None): """Exact, bounded lookup; scope comes from server configuration, never model SQL.""" from .db import connect @@ -122,13 +122,25 @@ def lookup_order(reference, tenant_id=None): query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' values = [reference] * 3 tenant = get_setting('ORDER_DATA_TENANT_ID') - if tenant_id is not None: + if tenant_ids is not None: + if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256: + return [] + allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128} + if tenant_id is not None: + allowed.intersection_update({str(tenant_id)}) + if tenant: + allowed.intersection_update({str(tenant)}) + if not allowed: + return [] + query += ' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')' + values.extend(sorted(allowed)) + elif tenant_id is not None: if not str(tenant_id).strip(): return [] if tenant and str(tenant_id) != str(tenant): return [] tenant = str(tenant_id) - if tenant: + if tenant and tenant_ids is None: query += ' AND tenant_id=?' values.append(str(tenant)) return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)] @@ -218,8 +230,8 @@ def is_order_followup(message): def answer_query(message, conv_id): references = extract_reference(message) - tenant = customer_tenant_id() - if references and tenant is None: + tenants = customer_tenant_ids() + if references and not tenants: return order_login_response() if len(references) > 1: return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'} @@ -252,12 +264,12 @@ def answer_query(message, conv_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 order_login_response() if not tenants else {'t':'请提供订单号、入仓码或 Shipment ID。'} return None - if tenant is None: + if not tenants: return order_login_response() try: - matches = lookup_order(reference, tenant_id=tenant) + matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants) except OrderDataUnavailable: return {'t':'订单信息暂时无法读取,请稍后重试。'} if not matches: diff --git a/logistics_chat/visitor_service.py b/logistics_chat/visitor_service.py index 0dd3f78..666eeb1 100644 --- a/logistics_chat/visitor_service.py +++ b/logistics_chat/visitor_service.py @@ -37,6 +37,19 @@ def customer_tenant_id(): return tenant if tenant and len(tenant) <= 128 else None +def customer_tenant_ids(): + if not has_app_context(): + return () + # Also retain compatibility with a trusted single-tenant authentication adapter. + single = customer_tenant_id() + if single: + return (single,) + values = getattr(g,'customer_tenant_ids',()) + if not isinstance(values,(list,tuple)) or len(values) > 256: + return () + return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128))) + + def attach_visitor(): key = request.cookies.get(COOKIE_NAME) g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None @@ -66,21 +79,21 @@ def start_session(data): def owns_conversation(conv_id): - from .customer_service import current_customer_id + from .customer_service import current_customer_id, current_scope_key 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=? ' - 'AND (customer_id IS NULL OR (customer_id=? AND customer_tenant_id=?))', - (conv_id, owner, current_customer_id(),customer_tenant_id())).fetchone()) + 'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))', + (conv_id, owner, current_customer_id(),current_scope_key())).fetchone()) finally: connection.close() def owns_human_session(sid): - from .customer_service import current_customer_id + from .customer_service import current_customer_id, current_scope_key owner = visitor_id() if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807: return False @@ -88,8 +101,8 @@ def owns_human_session(sid): 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=? AND (h.customer_id IS NULL OR ' - '(h.customer_id=? AND h.customer_tenant_id=?))', - (sid, owner, current_customer_id(),customer_tenant_id())).fetchone()) + '(h.customer_id=? AND h.customer_scope=?))', + (sid, owner, current_customer_id(),current_scope_key())).fetchone()) finally: connection.close() diff --git a/tests/test_customer.py b/tests/test_customer.py index fbd8382..1e0610c 100644 --- a/tests/test_customer.py +++ b/tests/test_customer.py @@ -114,8 +114,9 @@ class CustomerTests(unittest.TestCase): 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() + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close() self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False self.assertTrue(self.login().json['ok']) reply=self.events('ORDER20260001')[0] self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply) @@ -149,3 +150,55 @@ class CustomerTests(unittest.TestCase): 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) + + def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self): + self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close() + self.assertTrue(self.login().json['authenticated']) + self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001') + self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002') + with self.app.app_context(): + connection=db.connect() + order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close() + self.assertIn('未找到',self.events('ORDER20260003')[0]['t']) + self.login(username='other') + self.assertIn('未找到',self.events('ORDER20260001')[0]['t']) + self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002') + + def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self): + self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close() + self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid) + with self.app.app_context(): + connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");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.assertIn('未找到',self.events('ORDER20260002')[0]['t']) + + def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self): + from logistics_chat.customer_service import init_customer_tables + self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close() + self.login() + with self.app.app_context(): + connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit() + init_customer_tables(connection);connection.commit();connection.close() + self.login() + self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t']) + with self.app.app_context(): + connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close() + + def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self): + self.app.config['CUSTOMER_DEMO_TENANT_ID']='' + with self.app.app_context(): + connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close() + self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid) + self.app.config['ORDER_DATA_TENANT_ID']='tenant-a' + 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.assertIn('未找到',self.events('ORDER20260002')[0]['t'])