From 6e37477e4f8ba6fa5471c104cd790e4826a4059b Mon Sep 17 00:00:00 2001 From: XingQue Date: Tue, 23 Jun 2026 21:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=93=BE=E6=8E=A5=E9=A1=B5=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=AE=B0=E5=BD=95=E5=8F=91=E9=80=81=E8=80=85?= =?UTF-8?q?IP=E4=B8=8E=E8=AE=BE=E5=A4=87=E6=8C=87=E7=BA=B9=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E8=AF=BB=E5=AE=A2=E6=9C=8DJWT=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- orders/views.py | 16 +++- utils/chat_utils.py | 10 ++- utils/link_sender_meta.py | 179 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 utils/link_sender_meta.py diff --git a/orders/views.py b/orders/views.py index 0f7626b..022ff66 100644 --- a/orders/views.py +++ b/orders/views.py @@ -39,6 +39,7 @@ from tencentcloud.sts.v20180813 import sts_client, models from utils.weixin_broadcast import WeixinBroadcastSender from utils.chat_utils import _send_group_message, _subscribe_users_to_group, establish_order_chat +from utils.link_sender_meta import build_link_sender_meta from utils.fadan_utils import check_fadan_qiangdan_eligible from orders.utils import ( @@ -3507,12 +3508,14 @@ class ShangjiaLianjieLiuYanZhuanFaView(APIView): 商家链接留言转发接口 路径:POST /dingdan/sjljlbxxzf 固定以 B{商家ID} 身份发送群消息。 + 附带 _linkMeta(发消息者 IP/定位/设备指纹等),仅 H5 链接页解析展示,不读取客服 JWT。 """ permission_classes = [] def post(self, request): token = request.data.get('token') - text = request.data.get('text', '') + text = (request.data.get('text') or '').strip() + client_info = request.data.get('clientInfo') if not token or not text: return Response({'code': 400, 'msg': '缺少参数'}) @@ -3535,6 +3538,9 @@ class ShangjiaLianjieLiuYanZhuanFaView(APIView): sender_name = "老板" sender_avatar = '' + sender_meta = build_link_sender_meta(request, link_token=token, client_info=client_info) + custom_payload = {'text': text, '_linkMeta': sender_meta} + appkey = get_self_goeasy_appkey() secret = get_self_goeasy_secret() if not appkey: @@ -3554,14 +3560,18 @@ class ShangjiaLianjieLiuYanZhuanFaView(APIView): sender_name=sender_name, sender_avatar=sender_avatar, message_text=text, - custom_payload=None, + custom_payload=custom_payload, group_name=group_name, order_id=order.OrderID, ) if not local_ok: return Response({'code': 500, 'msg': '消息发送失败'}) - return Response({'code': 200, 'msg': '发送成功'}) + return Response({ + 'code': 200, + 'msg': '发送成功', + 'data': {'senderMeta': sender_meta}, + }) class QiangdanView(APIView): diff --git a/utils/chat_utils.py b/utils/chat_utils.py index 37faacb..206c527 100644 --- a/utils/chat_utils.py +++ b/utils/chat_utils.py @@ -69,7 +69,15 @@ def _send_group_message(appkey, secret, group_id, sender_id, sender_name, sender if order_id: to_data["orderId"] = order_id - payload_str = custom_payload.get("text", message_text) if custom_payload else message_text + if custom_payload: + if isinstance(custom_payload, dict) and custom_payload.get('_linkMeta') is not None: + payload_str = json.dumps(custom_payload, ensure_ascii=False) + elif isinstance(custom_payload, dict): + payload_str = custom_payload.get('text', message_text) + else: + payload_str = str(custom_payload) + else: + payload_str = message_text request_body = { "appkey": appkey, diff --git a/utils/link_sender_meta.py b/utils/link_sender_meta.py new file mode 100644 index 0000000..0bf9cef --- /dev/null +++ b/utils/link_sender_meta.py @@ -0,0 +1,179 @@ +# utils/link_sender_meta.py +"""商家订单链接页:采集实际发消息者(浏览器/网络)信息,不涉及客服 JWT 或账号隐私。""" +import logging +import re + +import requests +from django.utils import timezone + +from config.views import IPUtils + +logger = logging.getLogger(__name__) + +_TRACKING_HEADER_KEYS = ( + 'HTTP_ACCEPT', + 'HTTP_ACCEPT_ENCODING', + 'HTTP_ACCEPT_LANGUAGE', + 'HTTP_REFERER', + 'HTTP_ORIGIN', + 'HTTP_HOST', + 'HTTP_USER_AGENT', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_REAL_IP', + 'HTTP_VIA', + 'HTTP_CF_CONNECTING_IP', + 'HTTP_TRUE_CLIENT_IP', + 'HTTP_X_CLIENT_IP', + 'HTTP_SEC_CH_UA', + 'HTTP_SEC_CH_UA_MOBILE', + 'HTTP_SEC_CH_UA_PLATFORM', + 'HTTP_SEC_FETCH_SITE', + 'HTTP_SEC_FETCH_MODE', + 'HTTP_SEC_FETCH_DEST', + 'HTTP_DNT', + 'HTTP_CONNECTION', +) + + +def _lookup_ip_geo(ip): + """通过 ip-api.com 查询 IP 归属(免费,无需 key)""" + if not ip or not IPUtils.validate_ip(ip) or IPUtils.is_private_ip(ip): + return {'note': '内网或非公网 IP,无法定位'} + + try: + fields = ( + 'status,message,country,regionName,city,district,zip,lat,lon,' + 'timezone,isp,org,as,query,mobile,proxy,hosting' + ) + resp = requests.get( + f'http://ip-api.com/json/{ip}?lang=zh-CN&fields={fields}', + timeout=3, + ) + if resp.status_code != 200: + return {} + data = resp.json() + if data.get('status') != 'success': + return {'note': data.get('message') or '定位查询失败'} + return { + 'country': data.get('country'), + 'region': data.get('regionName'), + 'city': data.get('city'), + 'district': data.get('district'), + 'zip': data.get('zip'), + 'lat': data.get('lat'), + 'lon': data.get('lon'), + 'timezone': data.get('timezone'), + 'isp': data.get('isp'), + 'org': data.get('org'), + 'as': data.get('as'), + 'is_mobile_network': data.get('mobile'), + 'is_proxy': data.get('proxy'), + 'is_hosting': data.get('hosting'), + } + except Exception as e: + logger.warning('IP 归属查询失败: %s', e) + return {} + + +def _parse_ip_chain(xff): + if not xff: + return [] + return [p.strip() for p in xff.split(',') if p.strip()] + + +def _build_ip_geo_chain(primary_ip, xff): + """主 IP + 代理链上最多 2 个公网 IP 的归属""" + chain = [] + seen = set() + candidates = [primary_ip] + _parse_ip_chain(xff or '') + for ip in candidates: + if not ip or ip in seen: + continue + seen.add(ip) + if not IPUtils.validate_ip(ip): + continue + chain.append({'ip': ip, 'geo': _lookup_ip_geo(ip)}) + if len(chain) >= 2: + break + return chain + + +def _parse_user_agent(ua): + if not ua: + return {} + ua = ua[:500] + info = {'raw': ua} + if 'MicroMessenger' in ua: + info['browser'] = '微信内置浏览器' + elif 'Edg/' in ua: + info['browser'] = 'Microsoft Edge' + elif 'Chrome/' in ua and 'Safari/' in ua: + info['browser'] = 'Chrome' + elif 'Firefox/' in ua: + info['browser'] = 'Firefox' + elif 'Safari/' in ua: + info['browser'] = 'Safari' + else: + info['browser'] = '未知浏览器' + + if 'Windows NT 10' in ua: + info['os'] = 'Windows 10/11' + elif 'Windows' in ua: + info['os'] = 'Windows' + elif 'Mac OS X' in ua or 'Macintosh' in ua: + info['os'] = 'macOS' + elif 'Android' in ua: + m = re.search(r'Android\s+([\d.]+)', ua) + info['os'] = f"Android {m.group(1)}" if m else 'Android' + elif 'iPhone' in ua or 'iPad' in ua: + info['os'] = 'iOS' + elif 'Linux' in ua: + info['os'] = 'Linux' + else: + info['os'] = '未知系统' + + info['device_type'] = 'mobile' if ('Mobile' in ua or 'Android' in ua or 'iPhone' in ua) else 'desktop' + return info + + +def _collect_tracking_headers(request): + out = {} + for key in _TRACKING_HEADER_KEYS: + val = request.META.get(key) + if val: + name = key.replace('HTTP_', '').lower().replace('_', '-') + out[name] = str(val)[:800] + return out + + +def build_link_sender_meta(request, link_token='', client_info=None): + """ + 构建实际发消息者(浏览器访问者)的追踪元数据。 + 不读取 JWT / 客服账号,避免泄露客服隐私。 + client_info: 前端 H5 上报的设备指纹与环境信息。 + """ + ip = IPUtils.get_client_ip(request) + xff = request.META.get('HTTP_X_FORWARDED_FOR', '') or '' + cf_ip = request.META.get('HTTP_CF_CONNECTING_IP', '') or None + true_client_ip = request.META.get('HTTP_TRUE_CLIENT_IP', '') or None + + meta = { + 'send_time': timezone.now().strftime('%Y-%m-%d %H:%M:%S'), + 'ip': ip, + 'x_forwarded_for': xff or None, + 'x_real_ip': request.META.get('HTTP_X_REAL_IP', '') or None, + 'remote_addr': request.META.get('REMOTE_ADDR', '') or None, + 'cf_connecting_ip': cf_ip, + 'true_client_ip': true_client_ip, + 'ip_geo_chain': _build_ip_geo_chain(ip, xff), + 'geo': _lookup_ip_geo(ip), + 'user_agent': request.META.get('HTTP_USER_AGENT', ''), + 'browser': _parse_user_agent(request.META.get('HTTP_USER_AGENT', '')), + 'headers': _collect_tracking_headers(request), + 'link_token_prefix': (link_token[:8] + '...') if link_token else None, + } + + if isinstance(client_info, dict) and client_info: + meta['device'] = client_info + + return meta