Bladeren bron

feat(dms): 访客归属改用本机 IP —— IP 存 c_ip,访客身份为 访客_<IP>

用户要求:前端取本机 IP,IP 存进 c_ip;游客用「访客」+IP 拼接来判定。
接口 http://121.43.55.7:10011/proxy/verify/getIPByBrowser。

实测接口:返回**纯文本 IP**(Content-Type 标的是 json,正文是裸 IP),带
Access-Control-Allow-Origin: *。⚠️ 但它是 HTTP、页面是 HTTPS —— 混合内容会被浏览器拦掉,
所以不能"直接调用",必须走同源代理(与聊天后端同一类问题)。

实现:
- 新增 src/utils/client-ip.ts:ensureClientIp()(缓存 + 并发去重 + 3s 超时 + 失败返回空串
  不抛异常)、getCachedClientIp();runtime-config 加 getClientIpApiBaseUrl()
- vite.config.ts 加 /ip-api/ 代理;四份 .env 加 VITE_IP_API;
  index.html 注入 globalThis.VITE_IP_API;main.ts 启动预热(不等待)
- chat-sessions-dms.ts:1887 的 c_ip 存**真实 IP**(只填空,拿不到就不写——不拿访客标签
  冒充 IP);resolveVisitorLabel() = 访客_<IP>(拿不到退回 访客_<uuid>),
  1887/1889 的写入前都 await ensureClientIp()

验证:代理链路 curl 通(返回本机 IP);DMS 端到端 65/65(打桩 IP,断言 c_ip 与访客归属);
兜底路径独立进程验过(接口抛错 → 退回 访客_<uuid>、不抛异常);载荷字段 19/19;
回归全绿;build 与 validate-harness 通过。

⚠️ 两点须知:
1) 用 IP 判定的代价:同一出口 IP(NAT)下不同人会算成同一个访客;
2) 生产需 nginx 把 /ip-api/ 转发到 http://121.43.55.7:10011/(去掉前缀)——
   不配也能跑,只是拿不到 IP 时访客归属退回随机 id。
测试已改为打桩(真实服务抖动会让断言偶发失败)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 maand geleden
bovenliggende
commit
22ac63768f

+ 5 - 0
.env.development

@@ -36,3 +36,8 @@ VITE_DMS_TARGET="http://121.43.55.7:10081"
 # 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
 # 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
 VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"
+
+# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
+# 它是 **HTTP**,而页面是 HTTPS —— 直连会被混合内容拦掉,所以走同源代理
+VITE_IP_API="/ip-api"
+VITE_IP_TARGET="http://121.43.55.7:10011"

+ 6 - 0
.env.production

@@ -15,3 +15,9 @@ VITE_CHAT_API="http://192.168.2.23:8000"
 # 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
 # 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
 VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"
+
+# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
+# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 把 /ip-api/ 转发到
+#    http://121.43.55.7:10011/ 并去掉前缀**,否则请求会被浏览器按混合内容拦掉
+#    (拦掉不致命:拿不到 IP 会退回 访客_<浏览器随机 id>,只是访客归属不稳定)
+VITE_IP_API="/ip-api"

+ 6 - 0
.env.qingpu

@@ -17,3 +17,9 @@ VITE_REDIRECT_MAP={"demo":"https://demo.heijingai.com/FTA-c2p/%23/overview","zha
 # 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
 # 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
 VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"
+
+# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
+# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 把 /ip-api/ 转发到
+#    http://121.43.55.7:10011/ 并去掉前缀**,否则请求会被浏览器按混合内容拦掉
+#    (拦掉不致命:拿不到 IP 会退回 访客_<浏览器随机 id>,只是访客归属不稳定)
+VITE_IP_API="/ip-api"

+ 6 - 0
.env.test

@@ -12,3 +12,9 @@ VITE_CHAT_API="http://192.168.2.23:8000"
 # 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
 # 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
 VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"
+
+# 取本机 IP 的接口(DMS 访客归属:访客_<IP>,见 src/utils/client-ip.ts)
+# ⚠️ 它只有 HTTP,页面是 HTTPS —— 生产**必须由 nginx 把 /ip-api/ 转发到
+#    http://121.43.55.7:10011/ 并去掉前缀**,否则请求会被浏览器按混合内容拦掉
+#    (拦掉不致命:拿不到 IP 会退回 访客_<浏览器随机 id>,只是访客归属不稳定)
+VITE_IP_API="/ip-api"

+ 12 - 0
harness/docs/reference/current-api-call-sites.md

@@ -116,6 +116,18 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 - 触发:页面初始化的内联脚本
 - 现状:只在 `VITE_USE_KNOWLEDGE_API === false` 时执行,四份 env 里**只有 `.env.test` 是 false**
 
+### 7. 取本机 IP(DMS 访客归属用)—— **在用(2026-09-22 新增)**
+
+- 触发:应用启动时预热一次(`main.ts`),写 DMS 前会 `await` 一次(内部缓存 + 并发去重)
+- 接口:`GET {VITE_IP_API}/proxy/verify/getIPByBrowser` → **纯文本 IP**
+  (响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP —— 按 text 读,别 JSON.parse)
+- **必须走同源代理**:接口是 `http://121.43.55.7:10011`,页面是 HTTPS ——
+  接口带 `Access-Control-Allow-Origin: *`(跨域没问题),但**混合内容**会被浏览器拦掉。
+  dev 见 `vite.config.ts` 的 `/ip-api/`;生产需要 nginx 把 `/ip-api/` 转发到
+  `http://121.43.55.7:10011/`(**去掉前缀**)
+- 用途:DMS 1887 的 `c_ip` 存这个 IP;访客身份(1889 的 `c_credit_code`)用 `访客_<IP>`
+- **拿不到 IP 不致命**:退回 `访客_<浏览器随机 id>`,只是归属不稳定(见 `src/utils/client-ip.ts`)
+
 ## 三、已经没有人调用的(死代码,只标注未删)
 
 | 位置 | 原本支撑的功能 | 处置 |

+ 38 - 0
harness/progress.md

@@ -3590,3 +3590,41 @@ DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站
 
 **顺带修正**:测试里两处**假通过**的断言(第一版查错了公司代码 → 断言恒真)已改为查
 「该行实际带着的代码」,并注明原因,避免以后再犯。
+
+### 续 20:访客归属改用本机 IP(用户要求)
+
+**用户要求**:前端取本机 IP,**IP 存 `c_ip`**;**游客用「访客」+ IP 拼接来判定**。
+接口:`http://121.43.55.7:10011/proxy/verify/getIPByBrowser`,直接调用即可。
+
+**实测接口**:`GET` 返回**纯文本 IP**(`116.232.248.128`,虽然 Content-Type 标的是 json);
+带 `Access-Control-Allow-Origin: *`(跨域没问题)。
+⚠️ **但它是 HTTP、页面是 HTTPS → 混合内容会被浏览器拦掉**,所以**不能"直接调用"**,
+走了同源代理(与聊天后端同一类问题)。
+
+**实现**:
+- 新增 `src/utils/client-ip.ts`:`ensureClientIp()`(缓存 + 并发去重 + 3s 超时 +
+  **失败返回空串不抛异常**)、`getCachedClientIp()`;`runtime-config.ts` 加 `getClientIpApiBaseUrl()`
+- `vite.config.ts` 加 `/ip-api/` 代理 → `http://121.43.55.7:10011`(去掉前缀);
+  `.env.*` 四份都加 `VITE_IP_API`(dev 是代理前缀,生产/青浦/test 是同源路径 + 注释说明需 nginx 转发);
+  `index.html` 注入 `globalThis.VITE_IP_API`;`main.ts` 启动预热一次(不等待)
+- `chat-sessions-dms.ts`:
+  - **1887 的 `c_ip` 存真实 IP**(只填空;拿不到 IP 就不写 —— 不拿访客标签冒充 IP)
+  - **访客身份 `resolveVisitorLabel()` = `访客_<IP>`**,取不到 IP 退回 `访客_<uuid>`
+  - **1889 的 `c_credit_code`** 用它(那个栏目没有 `c_ip` 列);`upsertDmsSession` /
+    `upsertDmsRecord` 进入时都 `await ensureClientIp()`
+
+**运行过的验证**:
+- **代理链路**:`curl -k https://localhost:8083/ip-api/proxy/verify/getIPByBrowser` → 返回本机 IP ✓
+- **打桩后的 DMS 端到端 65/65**:`c_ip` = 桩 IP、`resolveDmsCreditCode()` = `访客_<桩 IP>`
+- **兜底路径单独验过**(独立进程、让接口抛错):`resolveVisitorLabel()` 返回 `访客_<uuid>`,
+  不抛异常、不影响写入 ✓
+- `verify-dms-payload-fields` **19/19**(字段集合仍含 `c_ip`);回归全绿
+  (企业分类 84/84、适配层 27/27、答案流式 165/165、判空 30/30、聊天任务 33/33、政策卡 24/24);
+  `npm run build` 与 `validate-harness` 通过
+
+**⚠️ 两点必须知道**:
+1. **用 IP 判定访客的代价**:同一出口 IP(公司/学校 NAT)下**不同人会算成同一个访客**。
+2. **测试改成打桩了**:第一版直接打真实 IP 服务 → 抖了一次(3 项偶发失败)。
+   现在桩成 `203.0.113.9`(TEST-NET-3 段),真实服务另用 curl 验过 ✓。
+3. **生产要配 nginx**:`/ip-api/` → `http://121.43.55.7:10011/`(去掉前缀)。
+   不配也能跑:拿不到 IP 就退回随机 id,只是访客归属不稳定。

+ 24 - 2
harness/tools/verify-dms-chat-storage.mjs

@@ -26,6 +26,20 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
 
 const base = process.argv[2] || 'https://localhost:8083/dms-api';
 globalThis.VITE_DMS_API = base;
+// 取本机 IP 的接口(DMS 访客归属用)。与浏览器同一条代理路径:页面是 HTTPS、接口是 HTTP,
+// 直连会被混合内容拦掉。node 里没有这个限制,但走代理更接近真实链路。
+globalThis.VITE_IP_API = '/ip-api';
+// 取 IP 接口**打桩**(TEST-NET-3 段,不会与真实 IP 混淆):
+// 真实服务已用 curl 验过(`https://localhost:8083/ip-api/proxy/verify/getIPByBrowser` 返回本机 IP),
+// 但这支测试要的是**确定性** —— 依赖外部服务会让断言偶发失败(实测抖过一次)。
+const STUB_IP = '203.0.113.9';
+const realFetch = globalThis.fetch;
+globalThis.fetch = async (url, init) => {
+  if (String(url).includes('/proxy/verify/getIPByBrowser')) {
+    return new Response(STUB_IP, { status: 200, headers: { 'Content-Type': 'application/json' } });
+  }
+  return realFetch(url, init);
+};
 
 const {
   upsertDmsSession,
@@ -187,7 +201,11 @@ try {
   check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
   check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00')));
   enterpriseInfo.value = null; // 下面这条断言的是**未登录**时的归属形态
-  check('访客归属形态 访客_<id>', /^访客_.+/.test(resolveDmsCreditCode()), resolveDmsCreditCode());
+  check(
+    '访客归属形态 = 访客_<本机 IP>',
+    /^访客_\d{1,3}(\.\d{1,3}){3}$/.test(resolveDmsCreditCode()),
+    resolveDmsCreditCode()
+  );
 
   // ── 8. 1887 的企业字段(2026-09-22 用户要求)──────────────────────
   // 规则:登录态的值优先级最高(覆盖);对话带出的 company_info **只填空**(未登录不修改);
@@ -219,7 +237,11 @@ try {
     entRow?.c_credit_code === '91310000AAAAAAAAAA',
     JSON.stringify(entRow?.c_credit_code)
   );
-  check('未登录:**访客标识进 c_ip**', /^访客_/.test(String(entRow?.c_ip)), JSON.stringify(entRow?.c_ip));
+  check(
+    '未登录:**c_ip 存的是真实 IP**',
+    /^\d{1,3}(\.\d{1,3}){3}$/.test(String(entRow?.c_ip || '')),
+    JSON.stringify(entRow?.c_ip)
+  );
   check('未登录:企业图片已填', entRow?.c_ent_image_url === 'https://example.com/a.png', JSON.stringify(entRow?.c_ent_image_url));
   check('未登录:是否登录 = "0"', entRow?.c_is_login === '0', JSON.stringify(entRow?.c_is_login));
   // ⚠️ 不断言「等于 resolveVisitorLabel() 的当前返回值」:node 里没有 localStorage,

+ 7 - 0
harness/tools/verify-dms-payload-fields.mjs

@@ -16,6 +16,8 @@
  */
 
 globalThis.VITE_DMS_API = 'http://dms.invalid/dms'; // 只为让 base 非空;请求会被打桩拦住
+globalThis.VITE_IP_API = '/ip-api'; // 取本机 IP(DMS 访客归属用);下面同样打桩
+const STUB_IP = '203.0.113.9'; // TEST-NET-3 段,不会与真实 IP 混淆
 
 // ── 打桩 fetch:捕获请求,返回可控响应 ────────────────────────────────
 const captured = [];
@@ -25,6 +27,11 @@ globalThis.fetch = async (url, init = {}) => {
   const body = init.body ? String(init.body) : '';
   captured.push({ url: String(url), body });
 
+  // 取 IP 的接口:返回**纯文本 IP**(真实服务也是裸 IP,不带 JSON)
+  if (String(url).includes('/proxy/verify/getIPByBrowser')) {
+    return new Response(STUB_IP, { status: 200, headers: { 'Content-Type': 'application/json' } });
+  }
+
   if (String(url).includes('/content/selectContentList')) {
     return json({ code: 202, message: '数据不存在', content: '数据不存在' }); // 空 → 走新增分支
   }

+ 1 - 0
index.html

@@ -19,6 +19,7 @@
       globalThis.VITE_CHAT_API = "%VITE_CHAT_API%" || "";
       // DMS 数据管理服务(会话/问答记录/反馈)。token 由 vite 代理注入,前端不持有
       globalThis.VITE_DMS_API = "%VITE_DMS_API%" || "";
+      globalThis.VITE_IP_API = "%VITE_IP_API%" || "";
       globalThis.isQingpuEnv = "MODE" === "qingpu";
       globalThis.__ZHAOSHANG_LLM_MODELS = ["zhaoshang", "zhaoshang_staging", "zhaoshang_safe"];
       globalThis.__isZhaoshangLLM = function (model) {

+ 6 - 0
src/main.ts

@@ -4,6 +4,12 @@ import "@/styles/common/index.less";
 import "ant-design-vue/dist/reset.css";
 
 import App from "./App.vue";
+import { ensureClientIp } from "@/utils/client-ip";
+
+// 预热本机 IP(DMS 访客归属要用:`访客_<IP>`)。
+// 这里只是**发起**、不等待:写 DMS 的地方自己会 await(内部有缓存 + 并发去重),
+// 先起一次能让第一笔 DMS 写入不用现等;失败也只是退回随机 id,不影响任何功能。
+void ensureClientIp();
 
 const IS_IOS =
   typeof navigator !== "undefined" &&

+ 35 - 22
src/network/api/dms/chat-sessions-dms.ts

@@ -32,6 +32,7 @@ import {
   type DmsRow,
 } from "./client";
 import { enterpriseInfo } from "@/components/useEnterpriseAuth";
+import { ensureClientIp, getCachedClientIp } from "@/utils/client-ip";
 import type { RemoteSession, RemoteSessionRecord } from "@/network/api/chat-sessions";
 
 /**
@@ -71,10 +72,10 @@ const resolveSource = (): string => ((globalThis as any).source as string) || "z
 
 /**
  * 记录归属的「统一社会信用代码」。
- * 已登录 → 企业的 credit_code;未登录(访客)→ `访客_<访客id>`。
+ * 已登录 → 企业的 credit_code;未登录(访客)→ 访客标识(**`访客_<本机 IP>`**,见下)。
  */
 export const resolveDmsCreditCode = (): string =>
-  enterpriseInfo.value?.credit_code || `访客_${getOrCreateAssistantVisitorId()}`;
+  enterpriseInfo.value?.credit_code || resolveVisitorLabel();
 
 /**
  * 1887 助手会话的**必填运营字段** —— 只能传空值/默认值。
@@ -134,14 +135,24 @@ export const parseDmsTimestamp = (value: unknown): number => {
 // 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
 
 /**
- * 访客标识(`访客_<id>`)。
+ * 访客标识 —— **`访客_<本机 IP>`**(用户 2026-09-22 要求:游客用「访客」拼 IP 来判定)。
  *
- * 2026-09-22 用户改口径:**它写进 1887 的 `c_ip`(访客IP)列,不再占用 `c_credit_code`** ——
- * `c_credit_code` 只放**真实**的统一社会信用代码(登录 → 企业代码;未登录 → 对话带出的公司代码)。
+ * 为什么用 IP:`访客_<浏览器随机 id>` 换个浏览器/清掉 localStorage 就换人了;
+ * 用 IP 至少同一网络出口下能认成同一个访客。IP 由 `ensureClientIp()` 取得
+ * (见 `src/utils/client-ip.ts`,走同源代理)。
  *
- * ⚠️ 与 `resolveDmsCreditCode()` 的分工:那个仍给 **1889** 用它当归属键(1889 没有 `c_ip` 列)。
+ * ⚠️ **取不到 IP 时退回随机 id**(`访客_<uuid>`):绝不能让"拿不到 IP"变成"写不进 DMS"。
+ * ⚠️ 用 IP 的代价:同一个出口 IP(公司/学校 NAT)下的**不同人会被认成同一个访客** ——
+ *    对 DMS 的归属统计来说这是可接受的取舍,但排查时要知道这一点。
+ *
+ * 它出现在两处:
+ * - **1889 的 `c_credit_code`**(那个栏目没有 `c_ip` 列,归属只能放这里)
+ * - 1887 的 `c_ip` 放的是**真实 IP**(不是这个标签),见 `upsertDmsSession`
  */
-export const resolveVisitorLabel = (): string => `访客_${getOrCreateAssistantVisitorId()}`;
+export const resolveVisitorLabel = (): string => {
+  const ip = getCachedClientIp();
+  return ip ? `访客_${ip}` : `访客_${getOrCreateAssistantVisitorId()}`;
+};
 
 const asRecord = (value: unknown): Record<string, unknown> | null =>
   value && typeof value === "object" && !Array.isArray(value)
@@ -179,24 +190,19 @@ const isSameRowValue = (a: unknown, b: unknown): boolean =>
 /**
  * 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
  *
- * 写哪些、谁能覆盖谁(用户 2026-09-22 定,含当天第二次修订):
+ * 写哪些、谁能覆盖谁(用户 2026-09-22 定):
  * - **`c_credit_code` 只放真实信用代码**:登录 → 企业代码;未登录 → 对话带出的公司代码。
- *   **访客标识 `访客_<id>` 改放 `c_ip`**(当年把它塞进 c_credit_code 是权宜)。
+ *   访客身份(`访客_<IP>`)**不写这里**,它落在 1889 的 `c_credit_code` 上。
+ * - **`c_ip` 放本机 IP**(用户 2026-09-22:IP 存在 c_ip)——只填空,拿不到 IP 就不写。
  * - **登录态的值优先级最高**:`c_ent_name` / `c_credit_code` / `c_is_login="1"` 无条件写(覆盖)。
  * - **对话带出的 `company_info`**(`c_ent_name` / `c_credit_code` / `c_ent_image_url`):
- *   与库里**值不同就更新**(用户 2026-09-22 修订:不再只是"填空",值变了也要改);
- *   但**登录态已经写死的两个字段不会被它顶掉**(登录值仍然优先)。
- * - **`c_ip` / `c_is_login` 只在为空时写**:`c_ip` 已有值就不动(可能是后端回填的真实 IP),
- *   `c_is_login` 的空值形态说明这一行还没被登录态认领过。
+ *   与库里**值不同就更新**;但**已被登录态认领过的行**(`c_is_login === "1"`)**不许被它改**
+ *   —— 否则退登后再问到别的公司,会把这行改到别家公司名下、而 `c_is_login` 还是 `"1"`。
+ * - **`c_is_login` 只在为空时写**:空值形态说明这一行还没被登录态认领过。
  * - **`c_title` 不写**(用户要求:标题由运营侧生成);DMS 的系统字段 `title` 只在**创建那一刻**
  *   带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
  * - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
  *   它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
- *
- * ⚠️ 派生的影响(**尚未处理,需产品拍板**):访客行现在带着真实公司代码,
- * 而 `fetchDmsSessions` 是按 `c_credit_code` 查「我的会话」的 ——
- * 那家公司登录后,**这个访客的会话会出现在它的列表里**(改造前访客行是 `访客_<id>`,匹配不上)。
- * 要挡的话在 `fetchDmsSessions` 里加一个「只认 `c_is_login="1"`」的条件即可。
  */
 export const upsertDmsSession = (params: {
   sessionId: string;
@@ -208,6 +214,9 @@ export const upsertDmsSession = (params: {
   companyInfo?: unknown;
 }): Promise<boolean> =>
   enqueueDmsWrite(`session:${params.sessionId}`, async () => {
+    // 先确保拿到本机 IP:下面的 c_ip 与访客归属都要用它。
+    // 内部有缓存 + 并发去重,拿不到也不抛 —— 拿不到时访客归属会退回随机 id。
+    await ensureClientIp();
     const logged = enterpriseInfo.value;
     const fromChat = readSessionCompanyFields(params.companyInfo);
 
@@ -226,10 +235,12 @@ export const upsertDmsSession = (params: {
     if (fromChat.imageUrl) fromChatFields.c_ent_image_url = fromChat.imageUrl;
 
     /** 只在字段为空时才写的值 */
-    const fillIfEmpty: DmsRow = {
-      // 访客标识进 c_ip(已有值不动:可能是后端回填的真实 IP)
-      c_ip: resolveVisitorLabel(),
-    };
+    const clientIp = getCachedClientIp();
+    const fillIfEmpty: DmsRow = {};
+    // c_ip 放**本机 IP**(用户 2026-09-22:IP 存在 c_ip)。
+    // 只填空:已有值可能是后端回填的、或这台机器上次的出口 IP。
+    // 拿不到 IP 就**不写**(宁可空着,也不要拿访客标签冒充 IP)。
+    if (clientIp) fillIfEmpty.c_ip = clientIp;
     if (!logged) {
       fillIfEmpty.c_is_login = "0";
     }
@@ -326,6 +337,8 @@ export const upsertDmsRecord = (params: {
   answer?: string;
 }): Promise<boolean> =>
   enqueueDmsWrite(`record:${params.recordId}`, async () => {
+    // 同样先确保拿到 IP:1889 没有 c_ip 列,访客归属只能写进 c_credit_code
+    await ensureClientIp();
     const base: DmsRow = {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_session_id: params.sessionId,

+ 62 - 0
src/utils/client-ip.ts

@@ -0,0 +1,62 @@
+/**
+ * 取本机对外 IP —— 用于 DMS 的**访客归属**(用户 2026-09-22 要求)。
+ *
+ * 接口:`GET {VITE_IP_API}/proxy/verify/getIPByBrowser`
+ * 返回**纯文本 IP**(响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP,
+ * 实测 `116.232.248.128`)—— 所以**按 text 读、不要 JSON.parse**。
+ *
+ * ⚠️ **必须走同源代理**:接口是 `http://121.43.55.7:10011`,而页面是 HTTPS。
+ * 接口本身带 `Access-Control-Allow-Origin: *`(跨域不成问题),但**混合内容**会被浏览器
+ * 直接拦掉 —— 与聊天后端同一类问题。所以 dev 走 vite 代理(`vite.config.ts` 的 `/ip-api/`),
+ * 生产需要 nginx 等价转发。
+ *
+ * 拿不到(没配代理 / 超时 / 返回不是 IP)一律返回空串、**不抛异常**:
+ * 调用方会退回旧的「访客_<浏览器随机 id>」——**绝不能因为取不到 IP 就写不进 DMS**。
+ */
+
+import { getClientIpApiBaseUrl } from "@/utils/runtime-config";
+
+/** 进程内缓存:一次会话里 IP 不会变,没必要每轮都问 */
+let cachedIp = "";
+/** 并发去重:多个写入同时要 IP 时只发一次请求 */
+let inflight: Promise<string> | null = null;
+
+/** 形如 `1.2.3.4` 才算数(IPv6 也放行:含冒号的裸地址) */
+const isIpLike = (text: string): boolean =>
+  /^\d{1,3}(\.\d{1,3}){3}$/.test(text) || /^[0-9a-f:]{6,}$/i.test(text);
+
+/** 已经拿到的 IP(没拿到就是空串)。同步可用,给不方便 await 的地方读缓存。 */
+export const getCachedClientIp = (): string => cachedIp;
+
+/** 取 IP(并发调用只发一次请求;失败返回空串,下次调用会重试) */
+export const ensureClientIp = (): Promise<string> => {
+  if (cachedIp) return Promise.resolve(cachedIp);
+  if (inflight) return inflight;
+
+  const base = getClientIpApiBaseUrl();
+  if (!base) {
+    return Promise.resolve(""); // 没配代理:静默退回随机 id,不打扰用户
+  }
+
+  inflight = (async () => {
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), 3000);
+    try {
+      const res = await fetch(`${base}/proxy/verify/getIPByBrowser`, { signal: controller.signal });
+      const text = (await res.text()).trim();
+      if (isIpLike(text)) {
+        cachedIp = text;
+      } else {
+        console.warn("[client-ip] 返回的内容不像 IP,已忽略:", JSON.stringify(text.slice(0, 40)));
+      }
+    } catch (err) {
+      console.warn("[client-ip] 取 IP 失败(DMS 访客归属退回浏览器随机 id):", (err as Error)?.message || err);
+    } finally {
+      clearTimeout(timer);
+      inflight = null;
+    }
+    return cachedIp;
+  })();
+
+  return inflight;
+};

+ 13 - 0
src/utils/runtime-config.ts

@@ -35,3 +35,16 @@ export function getDmsApiBaseUrl() {
   const value = bootstrap && bootstrap !== "%VITE_DMS_API%" ? bootstrap : fromEnv;
   return value.trim().replace(/\/+$/, "");
 }
+
+/**
+ * 取本机 IP 的接口前缀(见 `src/utils/client-ip.ts`)。
+ *
+ * 与其它后端同一个套路:`index.html` 注入 `globalThis.VITE_IP_API`,拿不到就退回构建期变量。
+ * **必须是同源路径**:接口只有 HTTP,页面是 HTTPS,直连会被混合内容拦掉。
+ */
+export function getClientIpApiBaseUrl() {
+  const bootstrap = ((globalThis as any).VITE_IP_API || "") as string;
+  const fromEnv = (import.meta.env.VITE_IP_API || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_IP_API%" ? bootstrap : fromEnv;
+  return value.trim().replace(/\/+$/, "");
+}

+ 8 - 0
vite.config.ts

@@ -115,6 +115,14 @@ export default defineConfig(({ mode }) => {
             });
           },
         },
+        // 取本机 IP 的接口(DMS 访客归属用,见 src/utils/client-ip.ts)
+        // 走同源代理的原因与聊天后端一样:页面是 HTTPS,而它是 HTTP —— 直连会被混合内容拦掉
+        // (接口本身带 Access-Control-Allow-Origin: *,跨域不是问题,混合内容才是)
+        "/ip-api/": {
+          target: env.VITE_IP_TARGET || "http://121.43.55.7:10011",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/ip-api/, ""),
+        },
         "/asr/": {
           //匹配的路径
           target: "https://human-screen-v3.metamaker.cn", //目标url