|
@@ -32,6 +32,7 @@ import {
|
|
|
type DmsRow,
|
|
type DmsRow,
|
|
|
} from "./client";
|
|
} from "./client";
|
|
|
import { enterpriseInfo } from "@/components/useEnterpriseAuth";
|
|
import { enterpriseInfo } from "@/components/useEnterpriseAuth";
|
|
|
|
|
+import { ensureClientIp, getCachedClientIp } from "@/utils/client-ip";
|
|
|
import type { RemoteSession, RemoteSessionRecord } from "@/network/api/chat-sessions";
|
|
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 =>
|
|
export const resolveDmsCreditCode = (): string =>
|
|
|
- enterpriseInfo.value?.credit_code || `访客_${getOrCreateAssistantVisitorId()}`;
|
|
|
|
|
|
|
+ enterpriseInfo.value?.credit_code || resolveVisitorLabel();
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 1887 助手会话的**必填运营字段** —— 只能传空值/默认值。
|
|
* 1887 助手会话的**必填运营字段** —— 只能传空值/默认值。
|
|
@@ -134,14 +135,24 @@ export const parseDmsTimestamp = (value: unknown): number => {
|
|
|
// 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
|
|
// 已上移到 ./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 =>
|
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -179,24 +190,19 @@ const isSameRowValue = (a: unknown, b: unknown): boolean =>
|
|
|
/**
|
|
/**
|
|
|
* 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
|
|
* 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
|
|
|
*
|
|
*
|
|
|
- * 写哪些、谁能覆盖谁(用户 2026-09-22 定,含当天第二次修订):
|
|
|
|
|
|
|
+ * 写哪些、谁能覆盖谁(用户 2026-09-22 定):
|
|
|
* - **`c_credit_code` 只放真实信用代码**:登录 → 企业代码;未登录 → 对话带出的公司代码。
|
|
* - **`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"` 无条件写(覆盖)。
|
|
* - **登录态的值优先级最高**:`c_ent_name` / `c_credit_code` / `c_is_login="1"` 无条件写(覆盖)。
|
|
|
* - **对话带出的 `company_info`**(`c_ent_name` / `c_credit_code` / `c_ent_image_url`):
|
|
* - **对话带出的 `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` 只在**创建那一刻**
|
|
* - **`c_title` 不写**(用户要求:标题由运营侧生成);DMS 的系统字段 `title` 只在**创建那一刻**
|
|
|
* 带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
|
|
* 带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
|
|
|
* - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
|
|
* - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
|
|
|
* 它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
|
|
* 它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
|
|
|
- *
|
|
|
|
|
- * ⚠️ 派生的影响(**尚未处理,需产品拍板**):访客行现在带着真实公司代码,
|
|
|
|
|
- * 而 `fetchDmsSessions` 是按 `c_credit_code` 查「我的会话」的 ——
|
|
|
|
|
- * 那家公司登录后,**这个访客的会话会出现在它的列表里**(改造前访客行是 `访客_<id>`,匹配不上)。
|
|
|
|
|
- * 要挡的话在 `fetchDmsSessions` 里加一个「只认 `c_is_login="1"`」的条件即可。
|
|
|
|
|
*/
|
|
*/
|
|
|
export const upsertDmsSession = (params: {
|
|
export const upsertDmsSession = (params: {
|
|
|
sessionId: string;
|
|
sessionId: string;
|
|
@@ -208,6 +214,9 @@ export const upsertDmsSession = (params: {
|
|
|
companyInfo?: unknown;
|
|
companyInfo?: unknown;
|
|
|
}): Promise<boolean> =>
|
|
}): Promise<boolean> =>
|
|
|
enqueueDmsWrite(`session:${params.sessionId}`, async () => {
|
|
enqueueDmsWrite(`session:${params.sessionId}`, async () => {
|
|
|
|
|
+ // 先确保拿到本机 IP:下面的 c_ip 与访客归属都要用它。
|
|
|
|
|
+ // 内部有缓存 + 并发去重,拿不到也不抛 —— 拿不到时访客归属会退回随机 id。
|
|
|
|
|
+ await ensureClientIp();
|
|
|
const logged = enterpriseInfo.value;
|
|
const logged = enterpriseInfo.value;
|
|
|
const fromChat = readSessionCompanyFields(params.companyInfo);
|
|
const fromChat = readSessionCompanyFields(params.companyInfo);
|
|
|
|
|
|
|
@@ -226,10 +235,12 @@ export const upsertDmsSession = (params: {
|
|
|
if (fromChat.imageUrl) fromChatFields.c_ent_image_url = fromChat.imageUrl;
|
|
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) {
|
|
if (!logged) {
|
|
|
fillIfEmpty.c_is_login = "0";
|
|
fillIfEmpty.c_is_login = "0";
|
|
|
}
|
|
}
|
|
@@ -326,6 +337,8 @@ export const upsertDmsRecord = (params: {
|
|
|
answer?: string;
|
|
answer?: string;
|
|
|
}): Promise<boolean> =>
|
|
}): Promise<boolean> =>
|
|
|
enqueueDmsWrite(`record:${params.recordId}`, async () => {
|
|
enqueueDmsWrite(`record:${params.recordId}`, async () => {
|
|
|
|
|
+ // 同样先确保拿到 IP:1889 没有 c_ip 列,访客归属只能写进 c_credit_code
|
|
|
|
|
+ await ensureClientIp();
|
|
|
const base: DmsRow = {
|
|
const base: DmsRow = {
|
|
|
c_credit_code: params.creditCode || resolveDmsCreditCode(),
|
|
c_credit_code: params.creditCode || resolveDmsCreditCode(),
|
|
|
c_session_id: params.sessionId,
|
|
c_session_id: params.sessionId,
|