feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s

This commit is contained in:
2026-09-15 08:54:13 +08:00
parent 5a0e9ecada
commit 4d684a71eb
28 changed files with 1502 additions and 104 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ separate node-local attachment directories.
Set these operator-owned paths before any Compose command:
```bash
export GOCHAT_IMAGE_REF='ghcr.io/gochat/gochat@sha256:<digest>'
export GOCHAT_IMAGE_REF='git.ipao.vip/rogee/gochat@sha256:<digest>'
export GOCHAT_BACKUP_DIR='/mnt/backup-local/gochat'
export GOCHAT_BACKUP_OFFSITE_DIR='/mnt/gochat-offsite'
export GOCHAT_BACKUP_OFFSITE_SOURCE='backup.example.com:/gochat'
@@ -0,0 +1,406 @@
# 匿名访客 IP 归属地客户名称:落地开发计划
> 日期:2026-09-14
> 状态:**已实施并验证**
> 需求确认:匿名 Web Widget 访客不再统一显示 `Anonymous Visitor`,而是根据服务端获取的客户端 IP 查询省市,展示为“省市客户”,例如 `河北保定客户`、`北京客户`。
## 1. 目标与范围
### 1.1 目标
当 Web Widget 访客未提供真实姓名时:
1. 由 GoChat 服务端获取请求的真实客户端 IP;
2. 使用本地 GeoIP 数据库查询省、市;
3. 生成联系人显示名:
- 省 + 市:`河北保定客户`
- 直辖市或仅有城市:`北京客户`
- 仅有省份:`河北客户`
- 无法识别:`匿名客户`
4. 真实姓名、邮箱、电话或已认证标识优先,不能被 IP 地区名称覆盖;
5. 名称继续写入现有 `contacts.name`,使会话列表、会话头部、联系人列表和搜索结果自动复用。
### 1.2 一期范围
- Web Widget 的 `/widget/init`、`/api/v1/widget/config` 匿名联系人创建/重新初始化;
- Web Widget 公开联系人创建路径中确实会创建匿名联系人的场景;
- IPv4、IPv6、内网/保留地址、无 GeoIP 记录等降级场景;
- 既有匿名联系人在下次 Widget 初始化时的兼容处理;
- 本地 GeoIP 数据库配置、容器挂载、启动检查和回归测试。
### 1.3 不在一期范围
- 浏览器定位权限、GPS 或 HTML5 Geolocation;
- 第三方在线 IP 查询 API;
- 手工联系人、邮箱、短信、商务通等非 Web Widget 渠道;
- 通过 IP 判断客户精确住址、公司地址或真实所在地;
- 历史联系人批量回填。当前后端没有完整保存历史 Widget 客户 IP,无法可靠回填;
- 新增联系人表省市字段、后台配置页面或独立地区筛选功能。
## 2. 已确认的现状
| 位置 | 当前行为 | 计划处理 |
| --- | --- | --- |
| `backend/internal/service/widget_service.go:1735` | 匿名联系人名称硬编码为 `Anonymous Visitor` | 改为调用地区名称生成逻辑 |
| `backend/internal/service/widget_service.go:293` | Widget 初始化只接收 `WidgetInitRequest`,未接收服务端 IP | 在内部请求上下文中注入服务端取得的 IP |
| `backend/internal/handler/widget/widget_handler.go:Init` | 未把 `c.ClientIP()` 传给 Widget Service | 仅由服务端设置内部字段,禁止 JSON 覆盖 |
| `backend/internal/handler/widget/widget_handler.go:Config` | 也可能触发联系人初始化 | 与 `Init` 使用同一套 IP 处理 |
| `backend/internal/model/contact.go` | 已有 `AdditionalAttributes` JSON 字段 | 保存必要的生成来源/地区元数据,不新增迁移 |
| `frontend/.../ConversationCard.vue`、`ConversationHeader.vue`、`ContactInfo.vue` | 均展示 `contact.name` | 一期无需改展示组件 |
| `frontend/.../ConversationInfo.vue` | 若存在 `created_at_ip` 会展示原始 IP | 一期默认不保存原始 IP,避免扩大隐私暴露 |
| `backend/internal/config/config.go` | 已有 Viper 配置和 `GOCHAT_*` 环境变量绑定机制 | 增加 GeoIP 数据库路径配置 |
| `deploy/docker/Dockerfile` | 生产镜像不携带第三方 GeoIP 数据 | 数据库通过只读 Volume 挂载,不提交到仓库/镜像 |
## 3. 产品行为契约
### 3.1 名称优先级
从高到低:
1. 访客主动提交的真实姓名;
2. 已存在联系人中的人工维护姓名;
3. 本次 IP 查询生成的省市名称;
4. `匿名客户`。
系统不得根据 IP 覆盖非空且非系统生成的真实姓名。
### 3.2 系统生成名称识别
使用 `AdditionalAttributes` 保存以下服务端管理属性:
```json
{
"visitor_name_source": "ip_geolocation",
"visitor_province": "河北",
"visitor_city": "保定",
"visitor_geo_at": "2026-09-14T12:00:00Z"
}
```
这些字段用于判断名称是否由系统生成、兼容旧的 `Anonymous Visitor`、保存地区审计信息。它们不是访客可提交的可信字段。
服务端管理字段:
- 不接受 Widget 客户端在 `additional_attributes` 中覆盖;
- 不记录原始 IP 到联系人属性;
- 不写入应用日志;
- 对外公开 Widget 响应时按现有响应契约过滤服务端内部元数据,避免把内部标记返回给访客。
### 3.3 重新初始化规则
- 新匿名联系人:根据当前 IP 生成名称;
- 旧名称为 `Anonymous Visitor` 或 `匿名客户`:下次初始化时允许升级为省市名称;
- 旧名称为系统生成的省市名称:保留名称,地区元数据可按当前策略更新;
- 旧名称由访客或客服设置:不覆盖;
- 无法解析 IP:不清空已有名称,新的联系人使用 `匿名客户`。
## 4. 技术方案
### 4.1 IP 获取
在 Widget Handler 层使用 Gin 的 `c.ClientIP()` 获取 IP,并传入服务层的内部字段,例如:
```go
ClientIP string `json:"-"`
```
要求:
- `json:"-"` 防止客户端通过请求体伪造;
- 不读取客户端自定义 `visitor_ip`、`client_ip` 等字段;
- `X-Forwarded-For` 只在 `server.trusted_proxies` 已正确配置时使用;
- 本地开发、内网和解析失败时安全降级,不阻断访客发消息。
需要覆盖的入口:
- `WidgetHandler.Init`;
- `WidgetHandler.Config`;
- 公开 Web Widget 联系人创建入口;
- 离线留言若在提交阶段创建/保存联系人信息,则在该链路中保留统一的地区处理边界,避免遗漏匿名离线客户。
服务层不直接依赖 Gin,继续通过内部 DTO 传递已验证的 IP。
### 4.2 本地 GeoIP
采用本地 MaxMind-compatible City MMDB 文件和 Go 读取库:
- 不在每次请求中调用第三方 HTTP API;
- 不把访客 IP 发送给第三方服务;
- 查询为本地内存读取,失败时直接降级;
- MMDB 文件不进入 Git、不写入容器镜像,由部署环境提供。
建议配置:
```yaml
geoip:
db_path: "/app/storage/geoip/GeoLite2-City.mmdb"
```
环境变量:
```text
GOCHAT_GEOIP_DB_PATH=/app/storage/geoip/GeoLite2-City.mmdb
```
配置行为:
- 路径为空:GeoIP 功能关闭,名称回退为 `匿名客户`;
- 文件不存在或无法读取:启动时记录一次告警,服务仍可启动;
- 文件格式错误:不阻断客户请求,使用匿名回退,不泄漏 IP;
- 服务停止时关闭 MMDB reader;
- 使用单个共享 reader,避免每个请求重复打开文件。
实现时先使用测试 IP 样本确认数据库能返回中国省、市及可用的中文名称;若只返回英文名称,增加有限的省市别名格式化,不在请求链路接入翻译服务。
### 4.3 地区名称格式化
新增最小格式化逻辑:
```text
province + city + "客户"
```
规则:
- 城市和省份相同或城市已包含省份时去重;
- 直辖市输出 `北京客户`,不输出 `北京北京市客户`;
- 城市为空时输出 `省份客户`;
- 省、市均为空时输出 `匿名客户`;
- 不把县、街道、经纬度等更细粒度信息放入名称;
- 保持名称长度在联系人字段限制内。
### 4.4 联系人写入
优先复用现有 `findOrCreateWidgetContact` 和联系人更新逻辑:
1. 先按邮箱、标识或电话查找已有联系人;
2. 已找到联系人时,判断姓名是否为系统生成;
3. 未找到联系人时,生成地区名称后创建;
4. 在写入前合并服务端地区元数据;
5. 通过现有 Contact Repo 保存,确保联系人名称进入已有搜索/序列化路径。
不新增联系人表字段,不新增第二套联系人名称字段。
如既有联系人名称发生自动升级,需要确认已有联系人事件/搜索索引机制是否会被触发;若现有 Repo 更新不会触发相应事件,则只在联系人读取时使用已有名称,不另建异步索引框架。
### 4.5 公开响应与安全
现有 `widgetContactFullPayload` 会返回联系人名称及附加属性。实现时必须:
- 不返回原始 IP;
- 不返回 `visitor_name_source` 等内部控制字段;
- 不允许客户端通过更新联系人接口覆盖服务端地区字段;
- 保留联系人名称的正常 Widget 契约,不影响访客继续提交真实姓名;
- 不根据 IP 自动改变联系人邮箱、电话、标识或认证状态。
## 5. 文件与代码变更计划
### 阶段 A:GeoIP 基础能力
预计涉及:
- `backend/go.mod`
- `backend/go.sum`
- `backend/internal/geoip/`(新增最小 reader/lookup/format 实现)
- `backend/internal/config/config.go`
- `backend/configs/config.yaml`
- `backend/internal/config/config_test.go`
工作内容:
- 增加 MMDB reader;
- 增加 IPv4/IPv6、空地址、内网地址和无记录处理;
- 增加中文名称/英文回退及省市格式化;
- 增加配置解析和缺失文件降级测试;
- 不实现远程 API、缓存服务或管理页面。
退出条件:GeoIP reader 可以独立测试,数据库不可用不会导致应用启动失败。
### 阶段 B:Widget 服务端接入
预计涉及:
- `backend/internal/service/widget_service.go`
- `backend/internal/handler/widget/widget_handler.go`
- `backend/internal/app/bootstrap.go`
- `backend/internal/model/contact.go`(仅在确有必要时调整属性保护辅助函数)
- 对应服务/Handler 测试文件
工作内容:
- 将服务端 IP 注入 `WidgetInitRequest` 内部字段;
- 在 `Init`、`Config` 及纳入一期的公开 Widget 联系人入口接入;
- 替换 `Anonymous Visitor` 创建逻辑;
- 保留真实姓名优先;
- 保护服务端地区属性;
- 启动时创建共享 GeoIP reader,注入 Widget Service;
- 缺失 GeoIP 数据时回退,不阻塞聊天。
退出条件:匿名新访客可得到省市名称,真实姓名和已识别联系人不被覆盖。
### 阶段 C:离线路径与兼容处理
预计涉及:
- `backend/internal/service/widget_service.go` 的离线留言转换逻辑;
- `backend/internal/handler/widget/widget_handler.go` 的离线留言入口;
- `backend/internal/model/widget_offline_message.go` 或对应迁移,仅在 IP/地区必须跨异步转换保存时增加字段;
- 离线消息相关测试。
优先采用无迁移方案:在离线留言提交时完成地区解析并保存必要的非敏感地区结果。如果现有离线消息表无法保存该结果,再评估增加最小字段及 up/down migration,不直接保存原始 IP。
退出条件:离线留言转换出的匿名联系人不会重新退回空名称或 `Anonymous Visitor`。
### 阶段 D:部署与文档
预计涉及:
- `deploy/docker/docker-compose.yml`
- `deploy/docker/docker-compose.dev.yml`
- `deploy/docker/docker-compose.prod.yml`
- `deploy/quickstart/compose.yaml`(如 Quickstart 需要演示)
- `.env.example` 或对应环境示例;
- `docs/runbooks/` 中的部署说明。
工作内容:
- 为 GoChat 容器提供只读 GeoIP 数据库挂载点;
- 增加 `GOCHAT_GEOIP_DB_PATH` 示例;
- 明确 MMDB 下载、授权、更新和权限要求;
- 不将数据库文件提交仓库或 COPY 进镜像;
- 没有数据库时保持兼容运行。
## 6. 测试与验收矩阵
### 6.1 GeoIP 单元测试
| 场景 | 期望 |
| --- | --- |
| 有省、市的 IPv4 | 生成 `河北保定客户` |
| 直辖市 | 生成 `北京客户` |
| 只有省份 | 生成 `河北客户` |
| IPv6 | 可查询则正常生成,否则安全回退 |
| `127.0.0.1`、私网、保留地址 | 不调用无意义查询,回退 |
| 空 IP/非法 IP | 回退,不报 500 |
| 无 MMDB 文件 | 服务可启动,回退 |
| 中文名称缺失 | 使用确定性的名称回退/别名,不请求翻译服务 |
### 6.2 Widget 服务测试
新增或补充 `backend/internal/service/widget_service_test.go`:
- 无姓名 + 有地区 → 创建省市客户;
- 无姓名 + 无 GeoIP → 创建 `匿名客户`;
- 有真实姓名 + 有地区 → 保留真实姓名;
- 既有人工联系人 + 新 IP → 不改名;
- 既有 `Anonymous Visitor` → 下次初始化可升级;
- 既有系统生成名称 → 不被空查询清空;
- 邮箱/电话/identifier 命中已有联系人时不产生错误重命名;
- 客户传入伪造地区属性时,服务端字段不被覆盖;
- 不保存原始 IP。
### 6.3 Handler/API 测试
新增或补充 `backend/internal/handler/widget/widget_handler_test.go`:
- 请求体中的 `client_ip`、`visitor_ip` 不影响最终结果;
- `c.ClientIP()` 获取的地址能传入服务层;
- 未配置可信代理时伪造 `X-Forwarded-For` 不被采信;
- 配置可信代理后按现有 Gin 规则取得客户端 IP;
- `/widget/init` 与 `/api/v1/widget/config` 行为一致;
- 公开响应不包含原始 IP和内部地区控制字段;
- 离线消息路径按最终纳入范围验证。
### 6.4 前端验收
一期不改 Vue 展示组件,因为现有组件已经统一使用 `contact.name`。验收重点:
- 会话列表显示 `河北保定客户`;
- 会话头部显示 `北京客户`;
- 联系人搜索结果使用新名称;
- 客服手工改名后刷新页面仍保留真实姓名;
- 不因联系人附加属性变化破坏既有 ContactInfo/ConversationInfo 渲染。
### 6.5 构建和回归
```bash
cd backend
go test ./internal/...
go vet ./...
go build ./...
# 如修改了前端或执行完整前端回归
cd ../frontend
pnpm test -- --run
pnpm build
```
同时执行:
- 配置 YAML 解析检查;
- Compose 配置检查;
- Git diff 检查,确认没有提交 MMDB、`.env` 或原始 IP 测试数据;
- 使用隔离测试账号完成一次 Widget API → 联系人 → 会话列表显示验证。
## 7. 部署与回滚
### 7.1 发布前条件
- 生产环境已准备合法、可读的 GeoIP MMDB 文件;
- `GOCHAT_GEOIP_DB_PATH` 指向容器内只读路径;
- `server.trusted_proxies` 配置准确,不能使用任意代理网段;
- 隐私政策/数据保留规则确认地区推断用途;
- 至少验证一个中国省市、一个直辖市、一个无结果地址和 IPv6/私网降级场景。
### 7.2 灰度方式
1. 先发布带代码但不挂载 GeoIP 文件的镜像,确认服务仍正常;
2. 挂载测试数据库,在隔离 Widget 上验证名称;
3. 再对目标生产环境挂载正式数据库;
4. 观察匿名联系人创建失败率、Widget Init 错误率和联系人更新异常;
5. 如 GeoIP 文件异常,移除挂载或清空路径即可回退到 `匿名客户`,无需数据库回滚。
不新增高基数 IP 指标,不在日志中打印完整 IP。
## 8. 风险与边界
| 风险 | 处理 |
| --- | --- |
| VPN、代理、移动网络导致地区不准 | 名称明确代表 IP 归属地,不宣称客户精确位置 |
| MMDB 数据过期 | 在部署 runbook 中规定更新;文件不可用时安全回退 |
| 中国省市中文名称缺失 | 验证数据源 locale,使用有限别名格式化;不引入在线翻译 |
| 反向代理错误传递 IP | 只信任明确配置的代理,默认不信任 XFF |
| 客户伪造地区属性 | 服务端字段保护,客户端属性不能覆盖 |
| 真实姓名被覆盖 | 只处理空名、历史通用匿名名或系统生成名 |
| 历史联系人无法回填 | 只在下次 Widget 初始化时兼容升级,不做猜测性批量修改 |
| IP 属于个人信息 | 一期默认不持久化原始 IP,不向公开 Widget 返回内部元数据 |
## 9. 完成定义
满足以下条件才标记完成:
- [x] 匿名 Widget 新联系人可以按本地 GeoIP 生成省市客户名称;
- [x] `Anonymous Visitor`、无 GeoIP、内网 IP均有稳定回退;
- [x] 真实姓名、人工改名和已识别联系人不会被覆盖;
- [x] 服务端 IP来源不可由客户端请求体伪造;
- [x] 原始 IP不落库、不进日志、不通过公开 Widget 响应泄露;
- [x] 缺少/损坏 MMDB 不影响 Widget 初始化和消息发送;
- [x] 会话列表、会话头部、联系人搜索显示名称一致;
- [x] 后端测试、vet、build、配置/Compose检查通过;
- [x] 测试环境完成 API 到 Dashboard 的真实链路验证;
- [x] 部署说明包含 MMDB来源、挂载、更新和回滚方法。
## 10. 验证记录
- `GOCHAT_TEST_DB=sqlite go test ./...` 通过;
- `GOCHAT_TEST_DB=sqlite go test -race ./...` 通过;
- `go vet ./...`、`go build ./...` 通过;
- Frontend `pnpm build` 通过;
- 基础、开发、生产和 Quickstart Compose 配置解析通过;
- GeoIP、Widget Service、Widget Handler、公开联系人、离线留言和配置回归测试通过;
- 使用本地代码启动 Backend + Vite,通过 Widget API 创建匿名会话,并在 Dashboard 会话详情中验证 `匿名客户` 与测试消息可见;
- 中国省市/直辖市生成路径由 fake resolver 单元测试覆盖,正式 MMDB 不进入仓库,按 `docs/runbooks/anonymous-visitor-geoip.md` 外部挂载。
## 11. 明确跳过的复杂度
本计划不建设在线 IP 查询服务、独立地理位置微服务、联系人表新字段、后台 GeoIP 管理页面、历史联系人批量回填、GPS 定位和复杂缓存。只有在 GeoIP 查询量、地区筛选或数据保留要求证明现有 JSON 属性不足时,才单独增加这些能力。
+48
View File
@@ -0,0 +1,48 @@
# Anonymous Web Widget GeoIP 配置
GoChat 可使用本地 MaxMind-compatible City MMDB,将无姓名的 Web Widget 联系人命名为省市客户,例如 `河北保定客户`。
## 配置
生产 Compose 使用两个变量:
```dotenv
GOCHAT_GEOIP_DB_PATH=/run/gochat/geoip/GeoLite2-City.mmdb
GOCHAT_GEOIP_DB_FILE=../../.secrets/GeoLite2-City.mmdb
```
`GOCHAT_GEOIP_DB_FILE` 是宿主机上的 MMDB 文件,`GOCHAT_GEOIP_DB_PATH` 是容器内的只读挂载路径。生产 Compose 会把文件挂载到 GoChat 和 worker 容器;不要把数据库文件提交到 Git 或打包进镜像。
开发和 Quickstart 默认不启用 GeoIP。需要验证时,设置 `GOCHAT_GEOIP_DB_PATH` 并把数据库挂载到对应容器内路径。
## 数据库来源和更新
使用具备合法授权的 MaxMind-compatible City 数据库。下载、授权、更新频率和访问权限由部署方负责;数据库文件应由运行用户可读、不可写。
更新步骤:
1. 在宿主机下载并校验新的 MMDB 文件;
2. 原子替换 `GOCHAT_GEOIP_DB_FILE` 指向的文件;
3. 重启 `gochat` 和 `worker`,让进程重新打开数据库;
4. 查看启动日志中的 `GeoIP database opened`;
5. 用隔离 Widget 验证一个省市、一个直辖市和一个无记录地址。
## 故障回退
数据库缺失、损坏、过期或无法读取不会阻断服务启动或 Widget 请求。服务会记录一次启动告警,匿名联系人使用 `匿名客户`。
临时关闭功能:
```dotenv
GOCHAT_GEOIP_DB_PATH=
```
然后重新部署或重启服务即可,无需数据库迁移或数据回滚。
## 隐私边界
- IP 只在服务端请求处理期间用于本地查询;
- 一期不把原始 IP 写入联系人、日志或公开 Widget 响应;
- 只保存系统生成名称所需的省、市元数据;
- IP 归属地是网络出口的近似位置,不能代表客户精确所在地;
- 反向代理场景必须正确配置 `server.trusted_proxies`,否则不要信任 `X-Forwarded-For`。