关联默认客户账号与当前本地订单范围

This commit is contained in:
2026-10-30 18:46:03 +08:00
parent fef233344b
commit d26f6ecfd3
10 changed files with 170 additions and 37 deletions
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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` 的客户范围过滤。
+1 -1
View File
@@ -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 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
+1
View File
@@ -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,
+55 -10
View File
@@ -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='/')
+9 -2
View File
@@ -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
+6 -6
View File
@@ -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)
+21 -9
View File
@@ -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:
+19 -6
View File
@@ -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()
+54 -1
View File
@@ -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'])