卡密开放接口 · 接入文档
给接入方(买卡密的人 / 你自己的软件客户端)看。服务端实现在
app/src/routes/open/license.ts 与 app/src/services/open-license.ts。
0. 三十秒版
POST https://<你的域名>/api/v1/open/license/activate ← 首次激活,拿到 sessionId + licenseToken
POST https://<你的域名>/api/v1/open/license/verify ← 每次启动 / 定时心跳
POST https://<你的域名>/api/v1/open/license/unbind ← 换机前解绑
GET https://<你的域名>/api/v1/open/notice ← 拉应用公告,同时计一次启动
GET https://<你的域名>/api/v1/open/vars ← 读应用云变量(远端配置,可多条)
GET https://<你的域名>/api/v1/open/ping ← 排障探针
三个接口都要带 4 个头:
| 头 | 值 |
|---|---|
x-kami-key | 应用接入密钥 ak_...(商户后台「应用列表」每张卡片上可见) |
x-kami-timestamp | 当前时间,epoch 毫秒 |
x-kami-nonce | 一次性随机串,8-64 字符(每次请求换新的) |
x-kami-signature | 见下面第 2 节 |
签名密钥 sk_... 只在你的后端 / 构建机里用,绝不能打进客户端二进制。
客户端只做两件不需要密钥的事:把 code + 机器码发给你的后端,或者直接调接口但只带签名(见 4.2 的推荐架构)。
1. 数据流
用户输入卡密
│
├─ 你的客户端 ──→ 你的服务端(推荐:在这里持 sk_ 密钥签名)
│ │
│ └──→ Kami /open/license/activate
│ └──→ { sessionId, licenseToken, expiresAt }
│
└─ 或者客户端直接调(需内置 sk_,不推荐,见 6.4)
licenseToken 是一个 HMAC 签名的凭证,客户端可以离线验签 + 读到期时间,
所以不必每次启动都联网 —— 但每隔 renewAfter - now 必须联网续期一次(默认 12 小时)。
2. 签名算法(照抄即可)
2.1 规范化 body
先把 body 对象按键名递归排序、去掉 undefined,再序列化。这样换个字段顺序签名不会变。
function canonicalize(v: unknown): string {
if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'
if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`
return `{${Object.entries(v as Record<string, unknown>)
.filter(([, x]) => x !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, x]) => `${JSON.stringify(k)}:${canonicalize(x)}`)
.join(',')}}`
}
2.2 待签串
METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH
METHOD:大写,POSTPATH:包含/api/v1前缀的完整路径,例如/api/v1/open/license/activateTIMESTAMP:与x-kami-timestamp完全一致NONCE:与x-kami-nonce完全一致BODY_HASH:base64url(HMAC-SHA256("body", canonicalize(body)))—— 注意 key 的字面量就是body
2.3 签名
x-kami-signature = base64url_hmac_sha256(licenseSecret, 待签串)
base64url = 标准 base64 把 + 换 -、/ 换 _、去掉末尾 =。
2.4 参考实现
TypeScript / Node / Bun
import { createHmac } from 'node:crypto'
const b64url = (s: string) =>
createHmac('sha256', 'body').update(s).digest('base64url')
const hmac = (key: string, msg: string) =>
createHmac('sha256', key).update(msg, 'utf8').digest('base64url')
export function sign(secret: string, method: string, path: string, body: unknown) {
const ts = String(Date.now())
const nonce = crypto.randomUUID().replaceAll('-', '')
const payload = [method, path, ts, nonce, b64url(canonicalize(body))].join('\n')
return { ts, nonce, signature: hmac(secret, payload) }
}
Python
import base64, hashlib, hmac, json, time, uuid
def _b64(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def _canon(v) -> str:
if v is None or not isinstance(v, (dict, list)):
return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
if isinstance(v, list):
return "[" + ",".join(_canon(x) for x in v) + "]"
items = sorted((k, x) for k, x in v.items() if x is not None)
return "{" + ",".join(json.dumps(k) + ":" + _canon(x) for k, x in items) + "}"
def _hmac(key: str, msg: str) -> str:
return _b64(hmac.new(key.encode(), msg.encode(), hashlib.sha256).digest())
def sign(secret: str, method: str, path: str, body: dict):
ts = str(int(time.time() * 1000))
nonce = uuid.uuid4().hex
payload = "\n".join([method, path, ts, nonce, _hmac("body", _canon(body))])
return {"x-kami-timestamp": ts, "x-kami-nonce": nonce,
"x-kami-signature": _hmac(secret, payload)}
Go
func sign(secret, method, path string, body any) (ts, nonce, sig string) {
ts = strconv.FormatInt(time.Now().UnixMilli(), 10)
nonce = uuid.NewString()
bodyHash := b64(hmacSHA256("body", canon(body)))
payload := strings.Join([]string{method, path, ts, nonce, bodyHash}, "\n")
return ts, nonce, b64(hmacSHA256(secret, payload))
}
canon 与上面两个实现同理:map 的 key 排序、剔除 nil、数组保序。
3. 接口详情
所有接口都返回同一层外壳:
{ "code": 0, "message": "激活成功", "data": { "...": "见下" } }
code:0 成功;4xx 见第 5 节。业务结论在 data.verdict。
3.1 POST /api/v1/open/license/activate
请求:
{
"code": "VIP-GF7G-5HTH-SHBQ-BHP7",
"machineCode": "HW-doc-demo-01",
"deviceId": "可选,你的设备编号",
"version": "1.2.0"
}
machineCode:8-128 字符,你自己算的机器指纹。建议:CPU 序列号 + 主板 UUID + 系统盘序列号拼起来再 SHA-256 取前 32 位,这样重装系统不变、换硬件才变。version:你软件的版本号,服务端判断是否需要强制更新。
成功响应(真实样本):
{
"code": 0,
"message": "激活成功",
"data": {
"ok": true,
"verdict": "ok",
"message": "激活成功",
"serverTime": 1794125118712,
"nonce": "X_ngZMrppjOiflFn",
"data": {
"codeMasked": "VIP-********BHP7",
"app": "前缀演示 4107",
"kind": "月卡",
"durationDays": 30,
"expiresAt": 1796717118669,
"remainingMs": 2591999957,
"status": "active",
"sessionId": "cmw7jrtgp0000isk8f3o7a5r2",
"licenseToken": "v1.eyJsaWQ...hkeM2oC2FzoQGAe3k29cGtL1wRn_zHjRG89TjruyDsE",
"cardKey": "b29ba19ca4705f3b417c9c64d083edfa461655a8f9d1abb500269e06959d1444",
"renewAfter": 1794168318712,
"notice": null,
"updateNotice": null,
"currentVersion": null,
"minVersion": null,
"forceUpdate": false
}
}
}
| 字段 | 用法 |
|---|---|
sessionId | 之后调 /verify、/unbind 的凭据,存内存或加密存盘 |
licenseToken | 离线凭证,见第 4 节 |
cardKey | 验 licenseToken 用的密钥(每张卡一个),只在这里和 /verify 里下发 |
expiresAt | 到期时间(epoch 毫秒),null = 永久卡 |
renewAfter | 到这个时间前必须联网续期一次 |
forceUpdate | true 时客户端应拦在更新页 |
3.2 POST /api/v1/open/license/verify
{ "sessionId": "cmw7...", "machineCode": "HW-doc-demo-01", "version": "1.2.0" }
sessionId 丢了就传 code(会重新按卡密查)。返回结构与 activate 相同,cardKey 会再下发一次。
verdict 为 ok 时放行;其他值一律按 data.message 提示用户。
组织黑名单(activate / verify 都会先过这道闸)
商户如果建了组织(或加入了别人的组织),组织里有一份公共黑名单,条目分三类:
| type | 写法 | 说明 |
|---|---|---|
machine | A7F3-2C91-B8DE-65A0 | 机器码,大小写不敏感、忽略首尾空白 |
ip | 198.51.100.7 | 单个 IP,IPv4 / IPv6 都行 |
ip_range | 203.0.113.0/24、203.0.113.0-203.0.113.255、203.0.113.* | CIDR、闭区间、尾段通配都认 |
命中逻辑在服务端:activate 和 verify 拿你传的 machineCode 和请求源 IP(x-forwarded-for 第一跳)去比对该应用所属商户所在组织的生效条目,命中就直接返回
{ "code": 0, "message": "该设备或网络已被封禁,请联系卖家",
"data": { "ok": false, "verdict": "org_blocked" } }
这时不要重试、不要自动解绑换机 —— 换机也还在名单里。给用户一条明确出路:联系卖家申诉,卖家在组织里删掉条目后立刻恢复(服务端缓存 5 秒)。
成员提交的条目要创建者批准才生效,创建者自己提交的直接生效;所以「刚加的条目没拦住」通常是还在待批准。
3.3 POST /api/v1/open/license/unbind
{ "sessionId": "cmw7...", "machineCode": "HW-doc-demo-01" }
成功:
{ "code": 0, "message": "已解绑,可以换机激活",
"data": { "ok": true, "verdict": "ok",
"data": { "codeMasked": "VIP-********BHP7", "cooldownMs": 0, "deducted": false } } }
cooldownMs > 0 表示冷却期未过,此时 verdict 是 rate_limited,message 里带还剩几小时。
3.4 GET /api/v1/open/ping
curl https://<域名>/api/v1/open/ping -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "服务正常",
"data": { "ok": true, "app": "前缀演示 4107", "serverTime": 1794124725849,
"proof": "V1RY2DaiJE7m4LtEh8XD1iGAOrYAXUH7gDmcXCOO7EA",
"clockSkewToleranceMs": 120000 } }
自检密钥对不对:proof == base64url(HMAC(licenseSecret, "PING\n" + serverTime))。
3.5 GET /api/v1/open/notice
拉取商户在后台发布的公告,并顺带记一次应用启动。只需要接入密钥,不需要签名四件套 ——
公告内容本来就是给所有终端用户看的,签名不增加任何保护。
每个应用只有一条公告:商户改了就是覆盖这一条,客户端拿到的永远是当前有效的那条;
要下架就让商户把「对客户端可见」关掉。
curl https://<域名>/api/v1/open/notice -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "ok",
"data": {
"app": { "name": "Lumen 阅读器", "currentVersion": "2.4.1", "minVersion": "2.0.0" },
"tips": { "notice": "本周末维护,略有卡顿", "updateNotice": "修复了夜间模式闪烁" },
"notices": [
{ "id": 2, "appId": 2, "title": "今晚 23:00 停机维护",
"content": "预计 30 分钟,期间无法登录。",
"level": "warning", "visible": true, "pinned": true,
"publishedAt": "2026-11-13T16:33:40.376Z" }
],
"launches": 128, "day": "2026-11-14",
"serverTime": 1794587533935, "requestId": "c0178e29181980d6"
} }
字段说明:
| 字段 | 含义 |
|---|---|
notices | 数组,0 或 1 项:商户写着公告且 visible=true 时才有;按列表遍历的写法不用改 |
notices[].level | info 普通 / warning 提醒 / critical 重要,客户端可据此配色 |
notices[].pinned | 商户勾了「重点标记」,适合登录前强弹 |
tips | 旧版字段兼容:商户早期在应用配置里写的「应用公告 / 更新公告」原文 |
launches | 该应用的累计启动次数(含本次) |
day | 本次计数落到的自然日(东八区),与后台「今日」一致 |
计数规则:
- 每成功调用一次就 +1,包括本地调试。要压测请用单独的测试应用,别拿正式 key 刷。
- 累计数写在应用上;同时按
appId + 东八区自然日聚合,商户后台能看到「今日 / 近 7 天」。 - 限流:单应用 600 次/分钟(与其它开放接口同一档)。超限返回 429,且不计入启动次数。
- 没带
x-kami-key时返回 200 + 空公告,用于「服务可用性探测」,同样不计次。
客户端建议:启动时先请求它,notices[0] 存在就展示(pinned 或 critical 的弹在登录前);
请求失败(离线、超时)不要阻塞启动,公告是提示不是许可证。
3.6 GET /api/v1/open/vars
读商户在后台设置的云变量 —— 一个应用可以有多条,每条一个名字(key),
客户端拿到的是一张扁平 map。同样只需要接入密钥,不需要签名:这些值本来就要下发给所有终端用户。
curl https://<域名>/api/v1/open/vars -H 'x-kami-key: ak_xxx'
{ "code": 0, "message": "ok",
"data": {
"app": { "name": "夜行工具箱" },
"variables": {
"beta": true,
"features": { "promo": true, "channels": ["web", "app"] },
"maxTabs": 6,
"theme": "night"
},
"count": 4,
"updatedAt": "2026-11-13T17:03:52.953Z",
"serverTime": 1794589433049, "requestId": "fa17b21df9ceef29"
} }
没设置时 variables 是空对象,不是 404:
{ "code": 0, "message": "ok",
"data": { "app": { "name": "灰盒 · 颜色提取" }, "variables": {},
"count": 0, "updatedAt": null,
"serverTime": 1794589433089, "requestId": "ba001c05a273c9d8" } }
所以客户端要写成「读到了就覆盖内置默认值」,空对象不是失败:
const defaults = { theme: 'day', maxTabs: 3, beta: false, features: {} }
const res = await fetch(`${BASE}/open/vars`, { headers: { 'x-kami-key': KEY } })
const { variables } = (await res.json()).data
const config = { ...defaults, ...variables } // variables 里的值已经是原生类型
字段说明:
| 字段 | 含义 |
|---|---|
variables | 扁平的 名字 → 值 对象;值已按商户选的类型还原:number 是数字、boolean 是布尔、json 是对象、string 是字符串 |
variables[key] 的类型 | 由商户在后台逐条指定,"3.5" 与 3.5 不会混淆 |
count | 变量条数,省得自己 Object.keys().length |
updatedAt | 这批变量里最后一次保存时间(UTC ISO);一条都没有时是 null |
app.name | 应用名,多应用共用一个客户端时用来确认 key 没拿错 |
四种类型混在一张表里,值各自保持类型:
"variables": {
"changelog": "https://kami.example.com/changelog", // string
"maxTabs": 3.5, // number,不是 "3.5"
"beta": true, // boolean
"features": { "theme": "night", "items": [1, 2] } // json,数组可以当字段值
}
限制(服务端会校验,不合法直接 400):
- 名字:
[A-Za-z_][A-Za-z0-9_.-]*,最长 64 字符,不能重名,__proto__/constructor/prototype保留。 - 一个应用最多 50 条。
- 文本最长 4096 字符;JSON 序列化后最长 16384 字符,嵌套最多 8 层。
- 每条 JSON 变量的顶层必须是对象(
{ ... }),不支持顶层数组 —— 一条变量回答不了「当前生效的是第几项」。
要存列表就包一层:{ "items": [ ... ] },数组作为字段值是可以的。
- 数字不接受
NaN/Infinity/ 千分位;会按 JS 双精度规范化(1.50→1.5)。 - 布尔接受
true/false(也兼容1/0、yes/no、on/off)。
客户端建议:
- 只读:没有客户端写入接口。写入只在商户后台(
PUT /merchant/apps/:id/cloud-vars),
否则任何拿到 ak_ 的人都能改你的线上配置。
- 别把密钥类内容放进来:
ak_是打在客户端里的,云变量等同于「公开配置」。 - 一次请求拿全部变量,比一条一个接口省往返;想按
updatedAt或条数判断要不要重读都行。 - 这个接口不计启动次数;限频与公告同一档(600 次/分钟)。
- 保存是「整组提交」:后台提交什么就是全集,没提交的名字会被删掉 —— 所以改名等价于「删旧 + 加新」。
3.7 统计口径(客户端无需调用)
商户后台的「数据统计」只看卡密,不额外埋点:
| 指标 | 来源 | 口径 |
|---|---|---|
| 卡密用户分布 | License.activatedIp 首次激活时的 IP | 一张卡密只算一次,精确到省 |
| 新增激活卡密 | LicenseEvent(kind=activate) | 同一张卡密的首个 activate 记录,换机重装不重复计 |
| 激活请求 | LicenseEvent(kind=activate) 全量 | 含换机、同机重装导致的重放 |
| 覆盖省级地区 | 有激活卡密的省 / 自治区 / 直辖市 / 特别行政区 | 上限 34 |
IP 归属解析链:内网 / 回环 / CGNAT → 本机/内网;GeoCN.mmdb → 省级;其后用 APNIC 段表兜底港澳台;仍未命中记 境外。
归属地只在首次激活时算一次,写进 License.activatedIp / activatedRegionCode / activatedRegionName,之后统计全部是本地聚合,开放接口不因为统计多一次写。
留存:统计数据最多保留 90 天,面板只提供 7 / 30 / 90 天三档。服务启动时清一次、之后每 24 小时清一次 LicenseEvent 里超过 90 天的记录;卡密本身(License)与归属地不清。解绑冷却只看最近一次 unbind,不受清理影响。
4. 客户端如何做到「被破解也跑不起来」
这是整套设计的重点,四层,缺一层都能被绕过:
4.1 服务端是唯一权威
到期时间、绑定机器、是否停用,全部由服务端在 /verify 时判定。
客户端不能参与计算 —— 用户改本机时间、改返回值、改配置里的到期日,对 /verify 的结果没影响。
body 里塞 expiresAt 这类未知字段会被 schema 拒掉(422),塞进去也不会被读。
4.2 离线凭证用签名,不用加密
licenseToken = "v1." + base64url(payload) + "." + base64url(HMAC-SHA256(payload, cardKey))
payload = { lid, app, sid, mc, exp, iat, nbf, days }
客户端本地校验:
1. 拆三段,验 sig == HMAC(cardKey, "v1." + body)
2. payload.mc == 本机机器码(必须验,否则复制 token 到别的机器就能用)
3. payload.exp == 0 || now < exp * 1000
4. now < payload.nbf * 1000(离线宽限期内)
改任何一项,签名都不对。不要把 cardKey 写死进代码 —— 每张卡不同,
泄露一张不影响别人;也不要把它写进日志。
4.3 建议的架构(真正防破解的那个)
客户端 ──(你的私有协议)──→ 你的服务端 ──(HMAC 签名)──→ Kami
sk_ 密钥只在你服务端。客户端即使被完全逆向,也只能「重放你服务端的合法响应」——
而响应绑定了 nonce,每次不同,重放直接 401。
如果反过来把 sk_ 打进客户端,逆向者拿到密钥就能自己签「激活成功」,这套就白做了。
所以:单机软件也要有一个薄服务端(哪怕是 Cloudflare Worker)。
4.4 响应也要验
服务端每个响应都带 x-kami-response-signature:
x-kami-response-signature = base64url(HMAC(licenseSecret,
"RESPONSE\n" + 请求的 nonce + "\n" + code + "\n" + base64url(HMAC("body", canonicalize(data)))))
客户端验一下,中间人就没法把 {"verdict":"code_expired"} 改写或伪造。
x-kami-server-time 头顺便帮你校时。
5. 错误码与 verdict
HTTP 层
| code | 含义 | 处理 |
|---|---|---|
| 0 | 成功 | —— |
| 401 | 缺 x-kami-key / 签名错 / 重放 / 时间偏差 | 检查密钥与算法;时间偏差时用 x-kami-server-time 校准后重试 |
| 404 | 应用不存在或已下架 | 检查 apiKey |
| 422 | 参数不合法(长度、类型) | 看 message 里的字段名 |
| 429 | 限流 | 按 x-open-detail.retryAfter 秒重试 |
verdict(data.verdict,HTTP 200,看这个决定放行)
| verdict | 含义 | 客户端该做什么 |
|---|---|---|
ok | 通过 | 放行 |
code_invalid | 卡密不存在 / 格式错 | 提示「卡密无效」,别回显用户输入 |
code_disabled | 被商户停用 | 提示「该卡密已停用」 |
code_expired | 已过期 | 提示续费 |
device_mismatch / machine_conflict | 不是绑定机器 | 提示换回原机器,或走解绑 |
org_blocked | 命中所属组织的公共黑名单(机器码 / IP / IP 段) | 直接拒绝登录,提示联系卖家;不要自动重试 |
session_revoked | 会话被吊销(解绑/换机) | 重新 activate |
clock_skew | 本机时间与服务器差太多 | 用 serverTime 校准后重试 |
replay | nonce 重复 | 你代码里的错误:每次请求必须新 nonce |
rate_limited | 限流 | 稍后重试 / 显示冷却剩余 |
server_error | 服务端内部问题 | 宁可放行一次,别当盗版处理,否则你自己掉用户 |
6. 安全边界(务必读)
6.1 已经防住的
- 伪造卡密:卡密 16 位 base32(80 bit 熵)+ 服务端查库,猜中概率约 1/1.2e24
- 伪造请求:HMAC-SHA256 签名,body 被改一个字节就 401
- 重放:nonce 一次性 + 时间戳 2 分钟窗口
- 改本机时间:服务端时间算到期
- 复制 token 到别的机器:token 里绑了
mc - 改 token:签名覆盖整个 payload
- 改响应:响应签名
- 撞码:同 IP 10 分钟 20 次失败上限
- 密钥泄露:apiKey 与 licenseSecret 分离,泄露前者伪造不了签名;两者都可一键轮换
- 时序攻击:所有密钥比较走
timingSafeEqual
6.2 需要你配合的
sk_不进客户端(见 4.3)cardKey不落盘明文、不进日志machineCode要真的跟硬件绑定,别用「安装目录路径」这种换个目录就变的东西- 校验失败时别删用户数据,只锁功能 —— 误判的代价比盗版大
6.3 已知限制(不藏着)
- 限流与 nonce 表是进程内内存:单机 bun 够用;多实例部署要换 Redis,
否则 A 实例发的 nonce 在 B 实例上不认,会出现偶发 replay。
- 服务端全离线时:客户端靠
licenseToken活 12 小时。想更久改
OPEN_OFFLINE_GRACE_MS,但也别太长 —— 那是盗版能够「拔网线白嫖」的窗口。
- 机器码可被伪装:能改硬件指纹的虚拟机/沙盘可以伪造
machineCode。
要更强的绑定需要上设备证书(每个客户端的私钥签名),那是另一个工程量级。
7. 快速自测
KEY=ak_你的接入密钥
SECRET=sk_你的签名密钥
TS=$(date +%s000)
NONCE=$(openssl rand -hex 12)
BODY='{"code":"VIP-XXXX-XXXX-XXXX-XXXX","machineCode":"HW-test-0001"}'
# body hash 要用规范化 JSON,简单例子(字段已按字母序、无空格)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'body' -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
SIG=$(printf 'POST\n/api/v1/open/license/activate\n%s\n%s\n%s' "$TS" "$NONCE" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
curl -s -X POST https://<域名>/api/v1/open/license/activate \
-H "content-type: application/json" \
-H "x-kami-key: $KEY" -H "x-kami-timestamp: $TS" \
-H "x-kami-nonce: $NONCE" -H "x-kami-signature: $SIG" \
-d "$BODY"
8. 服务端实现索引
| 文件 | 作用 |
|---|---|
app/src/routes/open/license.ts | 路由、签名校验、nonce 去重、响应签名 |
app/src/services/open-license.ts | 激活 / 校验 / 解绑逻辑、审计 |
app/src/routes/open/cloud-var.ts | 云变量只读接口(GET /open/vars)、商户写入接口 |
app/src/services/app-cloud-var.ts | 云变量类型校验、规范化、类型还原 |
app/src/utils/signature.ts | 规范化、HMAC、token 签发与解析 |
app/src/types/open/types.ts | verdict 常量、限制、宽限期 |
app/prisma/migrations/20261108075543_open_api_licensing/ | LicenseSession / LicenseEvent 表 |
| 商户后台 | 「应用列表」每张卡片上可看 / 复制 / 轮换两把密钥 |
app/src/services/organization.ts | 组织、邀请、公共黑名单与命中查询 |
app/src/utils/ip.ts | IP / CIDR / 区间 / 通配的归一化与匹配 |
app/src/utils/geo.ts | IP 归属地:GeoCN.mmdb + APNIC 港澳台段表 + 内网判定 |
app/src/services/app-stats.ts | 激活归属回填、统计面板取数 |
app/src/routes/merchant/stats.ts | 商户统计面板接口 GET /api/v1/merchant/stats |
app/prisma/migrations/20261114025906_stats_geo/ | License 归属地字段(同批建的 AppRequestStat 已在 20261114043000_drop_request_stats 中移除) |