版本 v1.0 · 2026-08-14 · 面向内部业务/管理层
调研人:dev1(东京隔离开发机)
调研对象仓库:doctor-ai-brain(核心) + settlement(结算/渠道域)
能做,但这不是一个"前端画张地图"的活。
地图渲染、点位闪烁、统计卡片这些都是成熟技术,占不到总工作量的三分之一。真正的工程量集中在两个数据缺口上:
好消息有两条,且是决定性的:
因此建议的路径是:先补数据,再上大屏。P0 阶段(约 2 周)用"签约地址 + 登录 IP 双轨定位"把静态地图和统计卡先跑通,P1 阶段(约 1 周)再补准实时刷新和在线状态口径。
本报告所有技术结论均来自对代码的物理检索,每条结论在第 11 节给出 文件:行号 出处,可逐条复核。
明确的边界,不允许模糊:
| 项目 | 状态 |
|---|---|
| 代码层结论(表结构、字段、写入链路、部署参数) | ✅ 已物理 grep 实证,可信 |
| 数据层结论(多少机构填了地址、IP 覆盖率、日均登录量) | ❌ 未实测。本机是隔离开发机,按环境规矩不连生产库 |
| 性能结论(查询耗时、大屏并发) | ❌ 未实测,缺生产同栈 |
凡涉及"填充率/数据量/耗时"的判断,本报告一律不给数字,而是把需要在测试环境或生产库执行的 SQL 整理进第 10 节,由具备权限的同事执行后回填。这是本项目的数据完整性铁律要求——没有实测数字就不下数据结论。
系统里存在四类账号,全部收敛在统一用户表 users 中,用 role 区分:
| 角色 | role 值 | 关联 | 登录入口 |
|---|---|---|---|
| 平台超管 | admin |
无 | 管理后台 |
| 平台员工 | platform_staff |
无 | 管理后台 |
| 渠道账号 | channel |
channel_id → channels.id |
渠道 H5 |
| 机构管理员 | org_admin |
linked_org_id → organizations.id |
管理后台 |
| 机构席位 | org_seat |
linked_org_id → organizations.id |
席位密码登录(H5/平板) |
机构主体是 organizations 表;机构的签约信息(含归属渠道)在 org_signup_profiles 表,该表以 org_id 作主键与机构 1:1,channel_id 外键指向 channels。
这意味着"机构归属哪个渠道"是一个已经建模好的、可直接查询的强关联,不是靠命名约定猜出来的。
| 现有页面 | 后端 | 现在能看什么 | 缺什么 |
|---|---|---|---|
平台看板 /dashboard |
admin_dashboard.py |
用户数/机构数/今日登录数/最近 8 条活动(含 IP 文本) | 无地域维度,IP 只是一串字符 |
机构管理 /admin/orgs |
机构 CRUD 相关端点 | 机构列表、席位、访问链接 | 纯表格,无地图 |
机构洞察 /admin/org-insights |
admin_activity.py |
转化/风险/增长三榜、活跃机构数 | 无地域维度 |
席位活动 /admin/seat-activity |
admin_activity.py |
行为流水 + 登录记录合并时间线 | 单机构视角,无全局 |
审计日志 /admin/audit |
审计端点 | 登录成功/失败/锁定明细 | 纯表格 |
结算总览 /admin/minicup |
结算域 dashboard.py |
结算待办 | 与机构地理无关 |
结论:业务方描述的"仅能表格查询、缺乏全局视图"完全属实,且现有六个视图里没有任何一个带地理维度。
| # | 建设目标 | 现状判定 | 说明 |
|---|---|---|---|
| 1 | 中国电子地图底图 | 🆕 需新建 | 前端无任何地图库,recharts 不支持地理投影 |
| 2 | 机构登录行为实时上屏 | 🟡 半成品 | 登录事实已落库且带真实 IP;缺的是"推到前端"的通道 |
| 3 | 对应地区自动点亮 | 🔴 卡点 | 无省市字段、无坐标、无 IP 归属地能力,地区无法判定 |
| 4 | 下钻:机构名称 | ✅ 现成 | organizations.name |
| 5 | 下钻:归属渠道 | ✅ 现成 | org_signup_profiles.channel_id → channels.name,同库可 JOIN |
| 6 | 下钻:登录 IP | ✅ 现成 | auth_audit_logs.ip(INET 列,生产记录真实公网 IP) |
| 7 | 下钻:登录时段 | ✅ 现成 | auth_audit_logs.created_at(带索引) |
| 8 | 下钻:在线状态 | 🔴 需新建 | 系统无在线概念,见 G3 |
| 9 | 下钻:机构基础信息 | ✅ 现成 | 机构名/联系人/电话/启用状态/创建时间 + 签约档案 |
| 10 | 全国活跃机构数卡片 | ✅ 可复用 | 现有 org-insights 已有活跃机构口径,可复用不重造 |
| 11 | 各省份活跃度卡片 | 🔴 卡点 | 同 #3,依赖省份归属能力 |
| 12 | 渠道对应机构分布卡片 | ✅ 可复用 | 同库 JOIN,只差地理维度 |
判定汇总:12 项里 6 项现成、2 项可复用、1 项半成品、3 项需新建,其中 3 个"红灯"全部指向同一个根因——地理维度缺失。
organizations| 字段 | 类型 | 对大屏的价值 | 备注 |
|---|---|---|---|
id / name |
int / varchar(200) | 下钻主信息 | ✅ |
contact_name / contact_phone |
varchar | 下钻基础信息 | 可空 |
is_active |
bool | 机构启停 | 机构级只有人工开关,无到期时间 |
created_at |
timestamptz | 入驻时间 | ✅ |
store_address |
varchar(200) | 地理候选源 A | ⚠️ 门店名片用途,席位自己在 H5 设置页填 |
latitude / longitude |
numeric(10,7) | 坐标候选源 A | ⚠️ 由微信选点回填,只有配置过名片的机构才有 |
关键判断:latitude/longitude 是 Phase 61 为"分享名片一键导航"设计的,来源是席位在 H5 手动选点,业务上从不强制。把它当地图的唯一数据源会导致大面积空白。覆盖率必须实测(第 10 节 SQL-1)。
channels / org_signup_profiles| 字段 | 对大屏的价值 |
|---|---|
channels.name / status / rate_percent |
渠道名称、启停 |
org_signup_profiles.channel_id |
机构 → 渠道归属(外键强关联) |
org_signup_profiles.address |
地理候选源 B(签约地址,varchar 240) |
org_signup_profiles.status |
签约状态(已开通/已到期/已停用等 9 态) |
重大有利结论:结算域虽然在 Phase 60 被拆成独立服务 settlement-api,但生产部署是同库共存(doctor_ai_brain),只是换了受限账号 settlement_rw 做权限隔离。核心侧管理端用的是超管账号,可以直接 JOIN 这三张表,无需跨服务 HTTP 编排。
这一条把方案复杂度降了一个量级——否则"渠道分布卡片"就得走服务间接口、处理超时和一致性。
关于地址来源的取舍:签约地址 address 是入驻资料的一部分,业务语义比门店名片地址更正式、更可能被认真填写。但代码层它是 Optional(提交审核时校验的是执照/门头照/视光师资质/设备照四类文件,没有校验地址非空),所以同样存在空值风险,必须实测(SQL-2)。
auth_audit_logs这是"登录实时上屏"的事实源,字段完全够用:
| 字段 | 类型 | 用途 |
|---|---|---|
user_id |
int(可空,索引) | 关联到席位/机构管理员/渠道账号 |
event_type |
varchar(20) | 5 类事件,登录成功为 login_success |
ip |
INET | 登录 IP(原生 IP 类型,可直接做网段运算) |
user_agent |
text | 终端类型 |
detail |
JSONB | 席位登录带 channel=seat_password + device_id |
created_at |
timestamptz(索引) | 登录时段 |
写入链路已验证覆盖全部角色:
auth_service.py:249 写 login_successseat_auth.py:362 写 login_success,detail.channel='seat_password'IP 真实性已验证(这一点最容易被想当然搞错):
Nginx 侧传了 X-Real-IP / X-Forwarded-For;容器侧 uvicorn 启动带了 --proxy-headers --forwarded-allow-ips *。两边配套,因此 request.client.host 拿到的是真实客户端公网 IP,不是 Docker 网关地址。
旁证:席位登录限流那段代码里留有生产实测记录——"全量 38 个有失败记录的 IP"。如果记的是网关地址,这个数只可能是 1。
seat_activity_logPhase 52 建的 append-only 行为流水,含 actor_type / org_id / event_type(功能打开/数据生成/提问/管理查看)/ created_at,并有 (org_id, created_at) 复合索引。
用途:如果"活跃"的口径要定义成"有实际业务行为"而不是"登录过",这张表就是事实源,且索引已经就位。
必须把话说清楚:系统目前无法回答"这个机构现在是否在线"。
/api/admin/sessions 管的是 session_tokens 表,那是对外 API 甲方会话,不覆盖席位和机构管理员;users.current_device_id 只记最后一次登录的设备,不是心跳。所以"在线状态"这四个字,必须先定义口径,再谈实现,见 G3 与决策点 D5。
现象:全库没有 province / city 字段,没有行政区划字典表;坐标只在门店名片场景稀疏存在;两处地址字段均为选填。
影响:地图能画出来,但"点亮对应地区"和"各省份活跃度"两个核心目标直接无法实现。
建议:
1. 新增机构地理信息落地字段(省 / 市 / 区 / 经度 / 纬度 / 来源 / 置信度 / 更新时间)——建议独立小表 org_geo,不往 organizations 上堆列,避免与结算域的表属主权限打架;
2. 存量数据一次性回填:取"签约地址 → 门店名片地址"的优先级,批量地理编码;解析不出来的进人工待办清单,不允许静默留空;
3. 增量:入驻审核通过时同步解析并落库;
4. 兜底:解析失败的机构在大屏上进"未定位机构"计数卡,明示数量,绝不假装全国已覆盖。
现象:全仓检索 geoip / ip2region / MaxMind / GeoLite 均 0 命中,无任何 IP 归属地库或服务。
影响:"登录点亮地区"这条实时链路缺最后一环——有 IP,但翻译不成省份。
建议:引入离线 IP 归属地库(如 ip2region xdb 或 MaxMind GeoLite2 City),装进 API 容器随镜像走。理由:
代价是需要定期更新库文件(季度级),可挂进现有的自动更新脚本。
现象:见 4.5。
建议:不要为了"在线"去做长连接心跳(成本高、收益低)。推荐用活跃度分级替代二元在线:
| 状态 | 口径建议 | 大屏表现 |
|---|---|---|
| 在线 | 最近 15 分钟内有登录或业务行为 | 点位常亮 + 呼吸动效 |
| 今日活跃 | 今日有登录或业务行为 | 点位亮色 |
| 近期活跃 | 7 日内有活动 | 点位淡色 |
| 沉默 | 7 日以上无活动 | 点位灰色 |
阈值需业务方拍板(D5)。这个口径的最大好处:零新增埋点,纯查询即可实现,且口径可解释、可审计。
现象:管理端目前全是请求-响应式接口,没有面向管理后台的推送通道。
建议:先用轮询,不要一上来就上 SSE/WebSocket。理由:
若后续业务确有秒级需求,再单独立项升级为 SSE。
现象:auth_audit_logs 模型注释写着"6 月留存 · APScheduler 每天 02:00 清",但全仓检索定时任务,只有 GPU 调度用到 APScheduler,没有任何审计清理任务的实现。
影响(对本项目反而偏正面,但必须说清):登录历史很可能从上线起一条没删,历史热力图/趋势回溯的数据基础比预期更好。但这是"注释与实现不符"的状态,属于隐患——哪天有人按注释补上清理任务,大屏的历史视图会突然被截断。
建议:本次立项时明确留存策略并写进文档,如果大屏需要长周期回溯,就把留存要求写死;同时按第 10 节 SQL-5 实测最早记录时间,确认真实留存跨度。
request.client 时,IP 传的是字面量字符串 "unknown",写入 INET 列会抛类型错误,而审计写入被 try/except 包住只打 warning 日志——这条登录记录会静默消失。生产走 Nginx 时极少触发,但大屏一旦把"登录数"当业务指标,就应该把它改成写 NULL 而不是丢整行。Dockerfile.api 的启动命令没有 --proxy-headers:生产用的是 deploy/Dockerfile.api(参数齐全),根目录那份是历史遗留。若哪天有人误用它构建,所有登录 IP 会集体退化成网关地址,地图会瞬间全国只剩一个点。建议本次一并对齐或删除。以下五条需要业务方明确,技术侧不替业务做主。每条给出推荐方案与理由。
| 选项 | 含义 | 优点 | 缺点 |
|---|---|---|---|
| A. 签约地址 | 机构注册在哪 | 稳定、权威、可核对 | 不反映实际使用地;地址缺失即无位置 |
| B. 登录 IP 归属地 | 人这次在哪登的 | 天然实时,覆盖率高 | 城市级精度;移动网络/VPN 会漂移 |
| C. 双轨(推荐) | 底图点位=签约地址;点亮=IP 归属地 | 兼顾"机构在哪"和"谁在用";两者不一致时可标记异地登录,本身就是有价值的风控信号 | 实现成本略高 |
推荐 C。理由:业务描述里"地域分布"和"登录实时点亮"其实是两个不同的问题,用一个数据源硬扛必然有一边失真。
推荐 15 秒轮询。可配置为 5~60 秒。理由见 G4。
| 选项 | 优点 | 缺点 |
|---|---|---|
| A. ECharts + 自托管中国 GeoJSON(推荐) | 无需 Key、无外网依赖、机构地址与坐标不出网、离线可用、省市下钻成熟 | 需新增依赖,底图为矢量非卫星图 |
| B. 高德/百度 JS API | 底图精美、自带地理编码 | 需申请 Key 并计费;机构坐标与登录位置要发给第三方;页面依赖外网 |
推荐 A。合规和可控性优先,且内部驾驶舱不需要卫星底图。注意现有前端技术栈是 Next.js 15 + shadcn/ui + Tailwind + Recharts,Recharts 不支持地理投影,必须新增地图库——这是本项目唯一需要新增的前端依赖,需按技术栈规矩走审批。
登录 IP 属于敏感信息。推荐:
admin(超管):看完整 IP;platform_staff(平台员工):IP 打码(如 220.181.*.*),只显示归属地;需业务方确认是否接受。
需明确三件事:
| 动作 | 内容 |
|---|---|
新增 org_geo 表 |
org_id(PK,FK) / province / city / district / lng / lat / source(签约地址|门店名片|人工) / confidence / updated_at |
| 新增 IP 归属地能力 | 离线库文件随镜像分发 + 解析结果 Redis 缓存(IP→省市,TTL 24h) |
| 索引优化 | auth_audit_logs 增加 (event_type, created_at) 复合索引——大屏所有查询都是"最近 N 分钟的 login_success",现有单列索引不够 |
| 存量回填 | 一次性脚本 + 人工待办清单,回填后必须跑第 10 节的填充率 SQL 验收,任一省份列为 0 不许标完成 |
新增只读端点,统一前缀 /api/admin/geo-cockpit:
| 端点 | 用途 |
|---|---|
GET /summary |
顶部统计卡:全国活跃机构数、在线数、今日登录次数、覆盖省份数、未定位机构数 |
GET /provinces |
各省份聚合:机构总数 / 活跃数 / 今日登录数(驱动地图着色) |
GET /points |
点位列表:机构坐标 + 活跃状态(支持按省份、渠道筛选) |
GET /recent-logins |
最近 N 条登录事件(驱动"点亮"动效与右侧滚动栏) |
GET /orgs/{org_id} |
下钻详情:机构基础信息 + 归属渠道 + 最近登录 IP/时间/终端 + 活跃状态 |
GET /channels |
渠道维度分布:各渠道机构数与省份分布 |
权限:require_role("admin"),并在菜单注册表新增 platform.geo_cockpit 菜单键(本项目菜单权限有单一事实源注册表,新页面必须注册,否则 RBAC 不认)。
/admin/geo-cockpit),不套在现有后台侧边栏壳里——大屏要投屏,不能带导航栏;⚠️ 以下为经验估算,非实测,且不含需求评审与验收往返时间。
| 阶段 | 内容 | 估算 | 交付标志 |
|---|---|---|---|
| P0 · 数据地基 | org_geo 表 + IP 归属地库接入 + 存量地址批量地理编码 + 填充率验收 |
4~5 人日 | 填充率 SQL 全部 >0,未定位清单明确 |
| P0 · 后端接口 | 6 个只读端点 + 复合索引 + 菜单注册 | 3~4 人日 | 接口真打通过,无 mock |
| P0 · 前端大屏 | 地图 + KPI 卡 + 点位下钻 + 渠道分布卡 | 5~6 人日 | 测试环境可投屏演示 |
| P0 小计 | 约 12~15 人日(2~3 周) | 静态版大屏可用 | |
| P1 · 准实时 | 15 秒轮询增量、点亮动效、在线状态口径落地、token 静默续期 | 3~4 人日 | 登录后 15 秒内上屏 |
| P2 · 增强(可选) | 异地登录标记、省份下钻到地市、历史回放、大屏主题与投屏模式 | 5~8 人日 | 按业务优先级排 |
关键前置:P0 的三块里,数据地基必须先做完。先做前端会得到一张漂亮但没有点的空地图,这是本项目最需要避免的返工。
| # | 风险 | 等级 | 应对 |
|---|---|---|---|
| R1 | 地址填充率过低,地图大面积空白 | 🔴 高 | 上线前先跑 SQL-1/2 摸底;低于阈值则先补数据再排期,不硬上 |
| R2 | IP 归属地精度只到城市级,移动网络会跨市漂移 | 🟡 中 | 大屏标注"登录位置为 IP 归属地,仅供参考";机构位置以签约地址为准 |
| R3 | 登录 IP + 地理位置上屏涉及经营信息与个人信息 | 🟡 中 | 权限限 admin;员工视角脱敏;投屏模式默认打码(D4) |
| R4 | 第三方地理编码会把机构地址发出去 | 🟡 中 | 优先离线方案;若必须用在线地理编码,仅在一次性回填时使用,且经业务确认 |
| R5 | 大屏常驻导致登录态过期白屏 | 🟡 中 | 实现静默续期 + 断线重连提示(P1 必做) |
| R6 | 大屏轮询查询打在核心库上 | 🟢 低 | 加复合索引 + 结果缓存 10 秒;生产库内存配置偏紧,需在测试环境压一轮 |
| R7 | 若误用根目录 Dockerfile 构建,IP 全部退化 | 🟢 低 | 本次一并对齐或删除(G6-2) |
以下 SQL 必须在具备权限的环境(测试库/生产库)执行,结果回填后本报告才算完成数据侧论证。本机不连生产库,不代跑,也不估算。
-- SQL-1 门店名片坐标覆盖率(判断候选源 A 可用性)
SELECT COUNT(*) AS org_total,
COUNT(*) FILTER (WHERE latitude IS NOT NULL AND longitude IS NOT NULL) AS has_latlng,
COUNT(*) FILTER (WHERE store_address IS NOT NULL AND store_address <> '') AS has_store_addr
FROM organizations
WHERE is_active = true;
-- SQL-2 签约地址覆盖率(判断候选源 B 可用性 · 大概率是主力数据源)
SELECT COUNT(*) AS profile_total,
COUNT(*) FILTER (WHERE address IS NOT NULL AND address <> '') AS has_address,
COUNT(DISTINCT channel_id) AS channel_cnt
FROM org_signup_profiles;
-- SQL-3 双源合并后的真实可定位率(决定地图是否有意义)
SELECT COUNT(*) AS total,
COUNT(*) FILTER (
WHERE COALESCE(NULLIF(p.address,''), NULLIF(o.store_address,'')) IS NOT NULL
) AS locatable
FROM organizations o
LEFT JOIN org_signup_profiles p ON p.org_id = o.id
WHERE o.is_active = true;
-- SQL-4 登录 IP 真实性抽验(若绝大多数落在 172./10./192.168. 私网段 → 代理头配置有问题,立即停止排期)
SELECT COUNT(*) AS login_30d,
COUNT(DISTINCT ip) AS distinct_ip,
COUNT(*) FILTER (WHERE ip << '172.16.0.0/12' OR ip << '10.0.0.0/8' OR ip << '192.168.0.0/16') AS private_ip
FROM auth_audit_logs
WHERE event_type = 'login_success' AND created_at > now() - interval '30 days';
-- SQL-5 审计真实留存跨度(验证 G5:清理任务未实现的推断)
SELECT MIN(created_at) AS earliest, MAX(created_at) AS latest, COUNT(*) AS rows
FROM auth_audit_logs;
-- SQL-6 登录角色构成(确认席位登录是否占主体 · 影响下钻信息设计)
SELECT u.role, COUNT(*) AS login_cnt, COUNT(DISTINCT u.linked_org_id) AS org_cnt
FROM auth_audit_logs a
JOIN users u ON u.id = a.user_id
WHERE a.event_type = 'login_success' AND a.created_at > now() - interval '30 days'
GROUP BY u.role ORDER BY login_cnt DESC;
-- SQL-7 渠道维度机构分布(渠道卡片数据基础)
SELECT c.name AS channel_name, COUNT(p.org_id) AS org_cnt,
COUNT(*) FILTER (WHERE p.status = '已开通') AS activated_cnt
FROM channels c
LEFT JOIN org_signup_profiles p ON p.channel_id = c.id
GROUP BY c.name ORDER BY org_cnt DESC;
-- SQL-8 日均登录量(评估轮询频率与大屏刷新压力)
SELECT date_trunc('day', created_at) AS d, COUNT(*) AS login_cnt
FROM auth_audit_logs
WHERE event_type = 'login_success' AND created_at > now() - interval '30 days'
GROUP BY 1 ORDER BY 1 DESC;
验收红线:SQL-3 的 locatable / total 若低于业务可接受阈值(建议不低于 70%),不应直接进入前端开发,应先安排数据补录。
| 结论 | 证据位置 |
|---|---|
| 机构表字段与经纬度用途 | app/models/organization.py:11-58 |
| 统一用户表六角色 / 渠道关联 | app/models/user.py:26-70,135-153 |
| 渠道表定义 | app/models/settlement.py:66-105 |
| 签约档案含 channel_id 与 address | app/models/settlement.py:109-145、settlement/backend/app/models/settlement.py:213 |
| 登录审计表字段(IP 为 INET) | app/models/audit.py:24-60 |
| 管理端登录写审计 | app/services/auth_service.py:249-256 |
| 席位登录写审计 | app/api/seat_auth.py:362-369 |
| 审计写入失败被吞(G6-1) | app/services/auth_service.py:487-510 |
| 取不到客户端时 IP 传 "unknown" | app/api/auth.py:69、app/api/seat_auth.py:135 |
| Nginx 传真实 IP 头 | deploy/nginx/chat.tongban.love.conf:107-111 |
| 生产 uvicorn 带 proxy-headers | deploy/Dockerfile.api:67 |
| 根目录 Dockerfile 缺该参数(G6-2) | Dockerfile.api:12 |
| 结算与核心同库共存、账号隔离 | settlement/deploy/docker-compose.prod.yml:32-34、settlement/deploy/README.md:24、settlement/db/grants.sql:33-48 |
| 入驻表单 address 为选填 | settlement/backend/app/api/settlement/store_signup.py:98,125-136 |
| 行为流水表与索引 | app/models/activity.py:55-77 |
| access token 30 分钟 | app/auth/jwt_local.py:26 |
| 现有看板 KPI 口径 | app/api/admin_dashboard.py:54-110 |
| 现有机构洞察/席位活动 | app/api/admin_activity.py:1-45 |
| 对外 API 会话管理(不覆盖席位) | app/api/admin_sessions.py:1-30 |
| 菜单注册单一事实源 | app/core/menu_registry.py:17-38 |
| 前端无地图库、图表用 Recharts | web/package.json:14-44 |
| 全仓无 IP 归属地库 | 检索 geoip\|ip2region\|maxmind\|geolite → 0 业务命中 |
| 无审计清理定时任务(G5) | 检索 APScheduler → 仅 app/services/gpu_scheduler_service.py:7 |
本报告结论基于代码物理检索,数据层结论待第 10 节 SQL 实测回填。本机为隔离开发环境,未连接生产库,不提供任何未经实测的数据指标。