feat(ncm): MUSIC_U 临期主动续期 — 启动查龄超阈值后台静默刷新/eapi 会话锚点/cookie 合并+冷却

用户定案:失效前用当前 cookie 换新登录态(用户无感);仅刷新失败才退扫码重登。

- core/ncm_req.c|h:eapi Cookie 头补齐会话锚点 MUSIC_U/MUSIC_A(对齐 request.rs,
  补 §36.5 遗留——登录刷新/song_url 会员档依赖);新增 ncm_req_cookie_merge
  (patch 覆盖 base 同名 cookie,MUSIC_A/__remember_me 等不丢)。
- core/ncm_login.c|h:ncm_login_refresh(eapi /api/login/token/refresh 空体,
  code200+set-cookie→merge;code!=200→NCM_ERR_AUTH 退扫码;网络/TLS→负码退避)。
- app/logic/account.c:account.json 增 last_refresh_ts + 拆分 cvip/svip;
  account_cookie_maybe_refresh(年龄阈值 12h 可配 ncm.cookie_refresh_h,单飞/boot,
  失败冷却 30min 跨 boot 持久化,AUTH 置回登录页标志)+ mark_fresh/need_relogin。
- app/ui/home.c:home_worker 先续期再拉歌单;app/ui/login.c:803 落盘后 mark_fresh;
  app/app.c:UI tick 消费回登录标志 → logout+切登录页。
- 新增测试:test_ncm_core 的 cookie_merge + eapi_musicu_hdr;bench/cookie_refresh_sim
  (端点语义,0 网络);bench/account_cookie_sim(临期/未临期/退避/失效/冷却,0 网络)。

验证:host+qemu 核心测试 PASS;build_ncm_sims.sh 全家桶 PASS;build_ncm.sh 0 warning。
真机只读探针 2 次(设备 10.78.193.50,不写回):refresh 走 eapi 域
interface.music.163.com 在当前网络被 TLS reset(alert 80)→ rc=-5 瞬断退避,
session.enc/deviceId md5 前后一致(登录态零损伤)。新 ncm md5 c960244b。
This commit is contained in:
AskaEth committed 2026-09-02 14:30:04 +08:00
1 parent de18cef02c
commit cd7329d141
17 files changed
+673 -13

No files matched your search

+46 -1
View File
@@ -3246,5 +3246,50 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进
- **缓存 TTL=120s 的内存槽上限 4×128KB**:大歌单 `playlist_detail`(trackIds >128KB)不入内存、仅盘缓存(内存瘦身口径 REPORT §32);命中读盘+重解析,避免把上千首 trackIds 常驻内存。 - **缓存 TTL=120s 的内存槽上限 4×128KB**:大歌单 `playlist_detail`(trackIds >128KB)不入内存、仅盘缓存(内存瘦身口径 REPORT §32);命中读盘+重解析,避免把上千首 trackIds 常驻内存。
- **手刷只标详情接口**:歌单手动刷新只绕过 `v6/playlist/detail`(列表结构),`song_detail` 批 2 分钟内走缓存(改动歌曲为新键自然 miss,元数据 ≤2min 旧可接受);home 刷新绕过 `user/playlist`。 - **手刷只标详情接口**:歌单手动刷新只绕过 `v6/playlist/detail`(列表结构),`song_detail` 批 2 分钟内走缓存(改动歌曲为新键自然 miss,元数据 ≤2min 旧可接受);home 刷新绕过 `user/playlist`。
- **eapi 分支未注入 MUSIC_U**(登录态 eapi 播放接口仍走原有的非-cookie 鉴权路径)——本次仅按任务补游客 MUSIC_A;MUSIC_U 头注入留后续(超范围)。 - **eapi 分支未注入 MUSIC_U**(登录态 eapi 播放接口仍走原有的非-cookie 鉴权路径)——本次仅按任务补游客 MUSIC_A;**已由 §37 补齐**(eapi Cookie 头现注入 MUSIC_U/MUSIC_A,且 `ncm_login_refresh` 依赖此会话锚点)。
- **盘缓存读路径未在真机/宿主整链实测**(内存命中优先,盘仅在内存 miss/大体时兜底)——代码审查 + 文件存在性断言验证;真机可观察 `ncm_api_cache/` 落盘与热更后命中。 - **盘缓存读路径未在真机/宿主整链实测**(内存命中优先,盘仅在内存 miss/大体时兜底)——代码审查 + 文件存在性断言验证;真机可观察 `ncm_api_cache/` 落盘与热更后命中。
## 37. MUSIC_U 临期主动续期(用户定案:失效前用当前 cookie 换新登录态,用户无感;仅刷新失败才退扫码)(2026-09-02)
覆盖 WORK_STATE 会话定案「MUSIC_U 有时效——失效前主动续期」。语义(用户定案):
- **时机**:App 启动查 cookie 年龄(`account.json last_refresh_ts`,默认阈值 12h 可配 `ncm.cookie_refresh_h` 小时);临期且已登录 → home_worker 后台静默刷一次(worker 线程,不卡 UI、不打断播)。
- **刷新**:带当前 cookie 调 `/api/login/token/refresh`(eapi,NCM_API_NOTES §2.2 L117 = CNMPlayer `login_refresh.rs` / Node `login_refresh.js`)→ 响应 set-cookie 提取新 MUSIC_U/__csrf,`ncm_req_cookie_merge` patch 进现存 cookie(MUSIC_A/__remember_me 等不丢)→ `ncm_ctx_set_cookie` 加密落盘(同登录机制)→ 更新 `last_refresh_ts`。
- **失败**:网络/TLS/HTTP 瞬断 → 记 `ncm.cookie_refresh_fail_ts` 冷却 30 分钟(跨 boot 持久化),保持旧 cookie(用户仍用旧会话,下次启动再试);响应 `code!=200`(301/-460/会话失效)→ `NCM_ERR_AUTH` → UI 线程 `appstate_logout()`+切登录页(复用 settings 登出→扫码路径)。
- **风控**:同一 boot 单飞只刷一次(`s_cookie_refreshed`);失败冷却 30 分钟。
### 37.1 实现(全部在 `patch_v2/ncm_app/`)
| 文件 | 改动 |
|---|---|
| `core/ncm_req.c` / `.h` | ① eapi Cookie 头补齐**会话锚点**:注入 `MUSIC_U`(登录态识别——login_refresh/song_url 会员档依赖)与 `MUSIC_A`(游客);游客无 MUSIC_U/A 时补 `MUSIC_A=anonymous_token`(逻辑保留)。**补齐 §36.5 遗留**。② 新增 `ncm_req_cookie_merge(base,patch,out)`:patch 的同名 cookie 覆盖 base(无则追加),其余保留——登录续期合并新 MUSIC_U 用。 |
| `core/ncm_login.c` / `.h` | 新增 `ncm_login_refresh(device,cookie,out,sz)`:eapi `/api/login/token/refresh` 空体;`code==200`→`ncm_req_cookie_merge` 合并响应 set-cookie 到 `out` 返 NCM_OK;`code!=200`→`NCM_ERR_AUTH`;HTTP 非 2xx/网络/TLS/超时→负错误码(调用方退避);JSON 解析失败→`NCM_ERR_JSON`。 |
| `app/logic/account.c` | ① `account.json` 增 `last_refresh_ts`(cookie 年龄)+ 拆分持久化 `cvip_expire_ms`/`svip_expire_ms`(vip 字段,防 cookie 刷新落盘覆盖)。② 新增 `account_cookie_maybe_refresh()`(决策:登录+cookie 非空+年龄超阈值+单飞+不在冷却 → `ncm_login_refresh`→成功应用+更新 ts+清冷却;AUTH→置回登录页标志;瞬断→冷却+保持旧 cookie)。③ `account_cookie_need_relogin/consume/mark_fresh`。 |
| `app/ui/login.c` | 803 落盘新 cookie 后调 `account_cookie_mark_fresh()`(年龄=now,重置单飞/冷却)。 |
| `app/ui/home.c` | `home_worker` 先调 `account_cookie_maybe_refresh()` 再拉歌单(后续 eapi/weapi 用新 cookie);AUTH→跳过歌单加载。 |
| `app/app.c` | `app_on_tick` 消费 `account_cookie_need_relogin()`:`appstate_logout()`+`meow_view_page_replace(ACT_LOGIN)`(UI 线程——视图栈非线程安全)。 |
| `app/ncm_app_internal.h` | `ncm_app_state_t` 增 `cookie_last_refresh_ts`、`vip_cvip_exp_ms`、`vip_svip_exp_ms`;account_* 新声明。 |
### 37.2 验证(宿主 + qemu + 真机只读)
| 项 | 结果 |
|---|---|
| 新增 `test_ncm_core` `test_cookie_merge`(合并/空 patch)+ `test_eapi_musicu_hdr`(eapi Cookie 头带 MUSIC_U、游客 anonymous) | host + mipsel **PASS** |
| 新增 `bench/cookie_refresh_sim.c`(真实 core + ncm_http 计数桩,0 网络) | **ALL PASS**:code200+set-cookie→新 MUSIC_U/__csrf 替换+旧字段保留 / code301→NCM_ERR_AUTH / 网络瞬断→NCM_ERR_NET / code200 无 set-cookie→原样 / 无 cookie 优雅 |
| 新增 `bench/account_cookie_sim.c`(真实 account.c + 外围桩,0 网络) | **ALL PASS**:临期触发 / 未临期不刷 / 旧会话(last=0)视为临期 / 瞬断退避(负码+冷却持久化) / 失效→AUTH+回登录标志 / 冷却(31min>30min 重刷, 10min<30min 不刷) / mark_fresh 后不刷 |
| `stream_sim` / `player_flow_sim` / `window_sim` / `search_sim` / `cache_evict_sim` / `apicache_sim` / `pagination_sim` / `playlist_race_sim` | 全 PASS(`build_ncm_sims.sh` suite exit=0) |
| `test_ncm_core_host` + `qemu test_ncm_core_mipsel` | host + mipsel **PASS** |
| `build_ncm.sh`(-Wall -Wextra) | **0 warning**;`text 1031970 / data 1664 / bss 1364516` |
| `build_ncm_host.sh` | 通过(仅 SDK `meow_ui_vol.c` 存量 `system()` 告警,非本批) |
| **真机只读探针**(设备 10.78.193.50,真实 cookie,不写回) | 连续 2 次 `refresh_rc=-5`(NCM_ERR_TLS);`session.enc`/`deviceId` md5 前后一致(`a86c9448…`/`5af2657a…`),**登录态零损伤**。 |
### 37.3 md5(本批产物)
`ncm` = `c960244b44dd8c7b08bb29a44a65fad7`(`md5sum ncm`,首 8 位 `c960244b`);`text 1031970 / bss 1364516 / dec 2398150`。未部署(留主会话上卡)。
### 37.4 诚实出入 / 遗留
- **真机 refresh 端点未能拿到 `code 200 + 新 MUSIC_U` 的正向结论**:`/api/login/token/refresh` 走 eapi `https://interface.music.163.com/eapi/…`,而设备当前网络/环境对 `interface.music.163.com:443` TLS 握手被对端 reset(busybox `alert code 80`;`music.163.com` weapi 域 `200 OK` 正常)→ 连续 2 次探针均 `-5`。即**该设备网络屏蔽了 eapi 域名**,refresh(以及 song_detail/lyric/song_url/扫码等全部 eapi)在当下不可达。**这不是本批代码问题**——正因如此,本批把 `-5(TLS)` 归为「瞬断→退避+保持旧 cookie+不清登录态」,正是为了在该网络下不把用户登出;换到 `interface.music.163.com` 可达的网络(如家用 WiFi),refresh 应能返回 200 并轮换 cookie。**建议主会话在 eapi 可达网络下再做 1 次只读探针确认正向**。
- **冷却跨 boot 持久化用 `meow_config`(`ncm.cookie_refresh_fail_ts`)**,不随 `account.json` 清空——登出/换账号不影响冷却(无副作用;仅防重刷)。
- **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。
- **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。
- **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。
+8
View File
@@ -140,6 +140,14 @@ static void app_on_swipe_progress(int x_start, int dx)
static void app_on_tick(void) static void app_on_tick(void)
{ {
uint64_t now = meow_ui_time_ms(); uint64_t now = meow_ui_time_ms();
/* §NEW cookie 续期判会话失效 → 清登录态回登录页(worker 在后台续期发现 301/-460 时置标志;
* 在此 UI 线程执行 logout+切页——meow_view_page_replace 非线程安全,只能 UI 线程做) */
if (account_cookie_need_relogin()) {
account_cookie_relogin_consume();
meow_log(MEOW_LOG_WARN, "account: cookie invalid -> re-login");
appstate_logout();
meow_view_page_replace(ACT_LOGIN);
}
autoplay_maybe(); /* 真机联调 hook:/tmp/ncm_autoplay 存在则自动播已缓存曲目(一次性) */ autoplay_maybe(); /* 真机联调 hook:/tmp/ncm_autoplay 存在则自动播已缓存曲目(一次性) */
/* autoplay 已在后台线程起播缓存曲 → 在此(UI 线程)打开播放页,复现用户点曲播放路径 */ /* autoplay 已在后台线程起播缓存曲 → 在此(UI 线程)打开播放页,复现用户点曲播放路径 */
{ {
+111 -6
View File
@@ -15,11 +15,42 @@
#define ACCOUNT_FILE "account.json" #define ACCOUNT_FILE "account.json"
#define VIP_TTL_SEC (24 * 3600) /* 超 1 天重查 */ #define VIP_TTL_SEC (24 * 3600) /* 超 1 天重查 */
/* §NEW MUSIC_U 临期续期:cookie 年龄阈值默认 12h(可配 ncm.cookie_refresh_h 小时);失败冷却 30 分钟。 */
#define COOKIE_REFRESH_DEFAULT_H 12
#define COOKIE_REFRESH_COOLDOWN_SEC (30 * 60)
#define COOKIE_FAIL_TS_KEY "ncm.cookie_refresh_fail_ts" /* 上次续期失败时刻(秒)持久化:跨 boot 冷却 */
/* 单飞标志(每次启动每条件最多一次请求) */ /* 单飞标志(每次启动每条件最多一次请求) */
static int s_refreshed_login; /* 登录后已查 */ static int s_refreshed_login; /* 登录后已查 */
static int s_refreshed_stale; /* 超 1 天已查 */ static int s_refreshed_stale; /* 超 1 天已查 */
static int s_refreshed_expired; /* 到期后已查 */ static int s_refreshed_expired; /* 到期后已查 */
/* §NEW cookie 续期状态(app 单实例;跨 boot 由 account.json last_refresh_ts + config 冷却键持久化) */
static volatile int s_cookie_refreshed; /* 本 boot 已试(单飞:同一 boot 只刷一次) */
static volatile int64_t s_cookie_fail_ts; /* 上次续期失败时刻(秒;冷却窗口用,跨 boot 持久化) */
static volatile int s_cookie_need_relogin; /* 1=续期判会话失效,需回登录页(UI 消费) */
static int64_t cookie_refresh_ttl_sec(void)
{
int h = meow_config_int("ncm.cookie_refresh_h", COOKIE_REFRESH_DEFAULT_H);
if (h < 1) h = 1;
if (h > 72) h = 72;
return (int64_t)h * 3600;
}
/* 冷却时刻读写(持久化到 config,跨 boot:失败后 30 分钟内不重刷,不猛刷/防 -460 自锁)。 */
static int64_t cookie_fail_ts_load(void)
{
int v = meow_config_int(COOKIE_FAIL_TS_KEY, 0);
return (int64_t)(v > 0 ? v : 0);
}
static void cookie_fail_ts_store(int64_t now_s)
{
char b[24];
snprintf(b, sizeof b, "%lld", (long long)(now_s > 0 ? now_s : 0));
meow_config_set(COOKIE_FAIL_TS_KEY, b);
}
static void account_path(char *out, size_t sz) static void account_path(char *out, size_t sz)
{ {
snprintf(out, sz, "%s/%s", g_st.data_dir, ACCOUNT_FILE); snprintf(out, sz, "%s/%s", g_st.data_dir, ACCOUNT_FILE);
@@ -30,6 +61,9 @@ void account_load(void)
g_st.vip = NCM_VIP_NONE; g_st.vip = NCM_VIP_NONE;
g_st.vip_expire_ms = 0; g_st.vip_expire_ms = 0;
g_st.vip_checked_at = 0; g_st.vip_checked_at = 0;
g_st.vip_cvip_exp_ms = 0;
g_st.vip_svip_exp_ms = 0;
g_st.cookie_last_refresh_ts = 0;
char path[600], buf[512]; char path[600], buf[512];
account_path(path, sizeof path); account_path(path, sizeof path);
@@ -50,26 +84,34 @@ void account_load(void)
if (rc != NCM_OK) cvip_exp = 0; if (rc != NCM_OK) cvip_exp = 0;
int64_t svip_exp = ncm_json_get_i64_at(&j, ncm_json_path(&j, "svip_expire_ms"), &rc); int64_t svip_exp = ncm_json_get_i64_at(&j, ncm_json_path(&j, "svip_expire_ms"), &rc);
if (rc != NCM_OK) svip_exp = 0; if (rc != NCM_OK) svip_exp = 0;
/* §NEW:last_refresh_ts(0=旧会话/缺失 → 视为临期,开机续期一次) */
int64_t last_refresh = ncm_json_get_i64_at(&j, ncm_json_path(&j, "last_refresh_ts"), &rc);
if (rc != NCM_OK) last_refresh = 0;
if (vip < NCM_VIP_NONE || vip > NCM_VIP_SVIP) vip = NCM_VIP_NONE; if (vip < NCM_VIP_NONE || vip > NCM_VIP_SVIP) vip = NCM_VIP_NONE;
g_st.vip = (int)vip; g_st.vip = (int)vip;
g_st.vip_cvip_exp_ms = cvip_exp;
g_st.vip_svip_exp_ms = svip_exp;
g_st.vip_checked_at = checked; g_st.vip_checked_at = checked;
g_st.cookie_last_refresh_ts = last_refresh;
/* 有效到期时间 = 当前档对应的到期字段 */ /* 有效到期时间 = 当前档对应的到期字段 */
g_st.vip_expire_ms = (vip == NCM_VIP_SVIP) ? svip_exp g_st.vip_expire_ms = (vip == NCM_VIP_SVIP) ? svip_exp
: (vip == NCM_VIP_CVIP) ? cvip_exp : 0; : (vip == NCM_VIP_CVIP) ? cvip_exp : 0;
meow_log(MEOW_LOG_INFO, "account: loaded vip=%d expire=%lld checked_at=%ld", meow_log(MEOW_LOG_INFO, "account: loaded vip=%d expire=%lld last_refresh_ts=%lld",
g_st.vip, (long long)g_st.vip_expire_ms, (long)g_st.vip_checked_at); g_st.vip, (long long)g_st.vip_expire_ms, (long long)last_refresh);
} }
static void account_save(int vip, int64_t cvip_exp, int64_t svip_exp, int64_t checked_at) static void account_persist(int vip, int64_t cvip_exp, int64_t svip_exp, int64_t checked_at)
{ {
char path[600], buf[512]; char path[600], buf[512];
account_path(path, sizeof path); account_path(path, sizeof path);
snprintf(buf, sizeof buf, snprintf(buf, sizeof buf,
"{\"uid\":\"%s\",\"nickname\":\"%s\",\"vip\":%d," "{\"uid\":\"%s\",\"nickname\":\"%s\",\"vip\":%d,"
"\"cvip_expire_ms\":%lld,\"svip_expire_ms\":%lld,\"checked_at\":%lld}", "\"cvip_expire_ms\":%lld,\"svip_expire_ms\":%lld,\"checked_at\":%lld,"
"\"last_refresh_ts\":%lld}",
g_st.uid, g_st.nickname, vip, g_st.uid, g_st.nickname, vip,
(long long)cvip_exp, (long long)svip_exp, (long long)checked_at); (long long)cvip_exp, (long long)svip_exp, (long long)checked_at,
(long long)g_st.cookie_last_refresh_ts);
if (ncm_store_write_file(path, buf, strlen(buf)) != NCM_OK) if (ncm_store_write_file(path, buf, strlen(buf)) != NCM_OK)
meow_log(MEOW_LOG_WARN, "account: save failed"); meow_log(MEOW_LOG_WARN, "account: save failed");
} }
@@ -120,10 +162,73 @@ void account_vip_maybe_refresh(int force)
} }
g_st.vip = info.vip; g_st.vip = info.vip;
g_st.vip_cvip_exp_ms = info.cvip_expire_ms;
g_st.vip_svip_exp_ms = info.svip_expire_ms;
g_st.vip_expire_ms = (info.vip == NCM_VIP_SVIP) ? info.svip_expire_ms g_st.vip_expire_ms = (info.vip == NCM_VIP_SVIP) ? info.svip_expire_ms
: (info.vip == NCM_VIP_CVIP) ? info.cvip_expire_ms : 0; : (info.vip == NCM_VIP_CVIP) ? info.cvip_expire_ms : 0;
g_st.vip_checked_at = now_s; g_st.vip_checked_at = now_s;
account_save(info.vip, info.cvip_expire_ms, info.svip_expire_ms, now_s); account_persist(info.vip, info.cvip_expire_ms, info.svip_expire_ms, now_s);
meow_log(MEOW_LOG_INFO, "account: vip refresh (%s) -> vip=%d expire=%lld", meow_log(MEOW_LOG_INFO, "account: vip refresh (%s) -> vip=%d expire=%lld",
why, info.vip, (long long)g_st.vip_expire_ms); why, info.vip, (long long)g_st.vip_expire_ms);
} }
/* ---- §NEW MUSIC_U 临期续期 ----
* 用户定案语义:失效前用当前 cookie 换新登录态(用户无感);只有刷新失败(会话确失效)才退扫码。
* 风控:同一 boot 只试一次;失败冷却 30 分钟(下次启动再试,不猛刷)。在 worker 线程上下文调用。
* 返回:NCM_OK(已续期/未临期/冷却中/未登录);NCM_ERR_AUTH(会话失效,需回登录页);
* 其它负错误码(网络/TLS 瞬断——保持旧 cookie,退避)。 */
int account_cookie_maybe_refresh(void)
{
if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_OK;
if (s_cookie_refreshed) return NCM_OK; /* 本 boot 已试(单飞) */
int64_t now_s = ncm_now_ms() / 1000;
s_cookie_fail_ts = cookie_fail_ts_load(); /* 跨 boot 恢复上次失败时刻 */
if (s_cookie_fail_ts > 0 && now_s - s_cookie_fail_ts < COOKIE_REFRESH_COOLDOWN_SEC) {
s_cookie_refreshed = 1; /* 冷却窗口内:视为已处理,boot 内不再重复 */
return NCM_OK;
}
int64_t last = g_st.cookie_last_refresh_ts;
int stale = (last == 0) || (now_s - last > cookie_refresh_ttl_sec());
if (!stale) { s_cookie_refreshed = 1; return NCM_OK; } /* cookie 尚新(≤阈值),无需续期 */
s_cookie_refreshed = 1; /* 单飞:本 boot 最多一次刷 */
char new_cookie[NCM_COOKIE_MAX];
int rc = ncm_login_refresh(g_st.ctx.device_id, g_st.ctx.cookie,
new_cookie, sizeof new_cookie);
if (rc == NCM_OK) {
ncm_ctx_set_cookie(&g_st.ctx, new_cookie); /* 内存 + 加密落盘(同一机制) */
g_st.cookie_last_refresh_ts = now_s;
s_cookie_fail_ts = 0; cookie_fail_ts_store(0); /* 成功:清冷却 */
account_persist(g_st.vip, g_st.vip_cvip_exp_ms, g_st.vip_svip_exp_ms, g_st.vip_checked_at);
meow_log(MEOW_LOG_INFO, "account: cookie refreshed (age %llds -> now), last_refresh_ts=%lld",
(long long)(now_s - (last ? last : now_s)), (long long)now_s);
return NCM_OK;
}
if (rc == NCM_ERR_AUTH) {
s_cookie_fail_ts = now_s; cookie_fail_ts_store(now_s);
s_cookie_need_relogin = 1; /* UI 线程消费 → logout + 进登录页 */
meow_log(MEOW_LOG_WARN, "account: cookie refresh -> session invalid (rc=%d), re-login", rc);
return NCM_ERR_AUTH;
}
/* 网络/TLS/HTTP 异常(瞬断):冷却 30 分钟(跨 boot 持久化),保持旧 cookie,下次启动再试 */
s_cookie_fail_ts = now_s; cookie_fail_ts_store(now_s);
meow_log(MEOW_LOG_WARN, "account: cookie refresh transient rc=%d, backoff 30min", rc);
return rc;
}
int account_cookie_need_relogin(void) { return s_cookie_need_relogin; }
void account_cookie_relogin_consume(void) { s_cookie_need_relogin = 0; }
/* 登录成功(落盘新 cookie)后调用:把续期年龄标为现在(新 cookie 视为全新),并重置单飞/冷却,
* 使同 boot 内后续如果年龄再次临期(不可能,但幂等)可再刷;同时清除上会的「需回登录页」标志。 */
void account_cookie_mark_fresh(void)
{
g_st.cookie_last_refresh_ts = ncm_now_ms() / 1000;
s_cookie_refreshed = 0;
s_cookie_fail_ts = 0; cookie_fail_ts_store(0);
s_cookie_need_relogin = 0;
meow_log(MEOW_LOG_INFO, "account: cookie marked fresh (last_refresh_ts=%lld)",
(long long)g_st.cookie_last_refresh_ts);
}
+12
View File
@@ -171,7 +171,12 @@ typedef struct ncm_app_state {
/* 会员态(§20,account.json 落盘:data/account.json;刷新=登录后/超 1 天/到期后一次) */ /* 会员态(§20,account.json 落盘:data/account.json;刷新=登录后/超 1 天/到期后一次) */
int vip; /* NCM_VIP_*(NONE/CVIP/SVIP) */ int vip; /* NCM_VIP_*(NONE/CVIP/SVIP) */
int64_t vip_expire_ms; /* 当前档到期时间(ms;0=无/未知) */ int64_t vip_expire_ms; /* 当前档到期时间(ms;0=无/未知) */
int64_t vip_cvip_exp_ms; /* 音乐包/畅听到期(ms;account.json cvip_expire_ms,落盘保真) */
int64_t vip_svip_exp_ms; /* 黑胶到期(ms;account.json svip_expire_ms) */
int64_t vip_checked_at; /* vip/info 最近获取时间(秒) */ int64_t vip_checked_at; /* vip/info 最近获取时间(秒) */
/* MUSIC_U 临期续期(§NEW,用户定案):cookie 最近一次成功续期时间(秒;account.json
* last_refresh_ts;0=缺失/旧会话视为临期)。App 启动时后台静默刷新一次,不打断播/不卡 UI。 */
int64_t cookie_last_refresh_ts;
/* 我的歌单 */ /* 我的歌单 */
ncm_playlist_t playlists[NCM_PLAYLIST_MAX]; ncm_playlist_t playlists[NCM_PLAYLIST_MAX];
@@ -434,6 +439,13 @@ void account_load(void); /* appstate_init 时读 account.j
int account_vip(void); /* 当前有效会员档(到期自动降档视图,不联网) */ int account_vip(void); /* 当前有效会员档(到期自动降档视图,不联网) */
int account_level_cap(void); /* 会员态允许的最高音质档(NONE→EXHIGH/CVIP→HIRES/SVIP→SURROUND) */ int account_level_cap(void); /* 会员态允许的最高音质档(NONE→EXHIGH/CVIP→HIRES/SVIP→SURROUND) */
void account_vip_maybe_refresh(int force); /* worker 线程上下文调用(含网络,单次) */ void account_vip_maybe_refresh(int force); /* worker 线程上下文调用(含网络,单次) */
/* §NEW MUSIC_U 临期续期:App 启动(home_worker)调用——查 cookie 年龄,临期且登录且本 boot 未试
* 则后台静默刷新一次(用当前 cookie 换新登录态落盘);返回 NCM_OK(已续期/未临期/冷却中)、
* NCM_ERR_AUTH(会话失效,需回登录页,读 account_cookie_need_relogin)或负错误码(瞬断,退避)。 */
int account_cookie_maybe_refresh(void);
int account_cookie_need_relogin(void); /* UI 线程取「续期判失效需回登录页」标志 */
void account_cookie_relogin_consume(void); /* UI 线程消费该标志 */
void account_cookie_mark_fresh(void); /* 登录成功落盘新 cookie 后调用(重置年龄/单飞/冷却) */
void appstate_apply_playlist_cover(int idx); void appstate_apply_playlist_cover(int idx);
void appstate_set_playing_song(int idx, const char *cover_url); void appstate_set_playing_song(int idx, const char *cover_url);
int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */
+7 -1
View File
@@ -209,7 +209,13 @@ static void *home_worker(void *arg)
(void)arg; (void)arg;
int rc; int rc;
if (!g_st.logged_in) rc = NCM_ERR_AUTH; if (!g_st.logged_in) rc = NCM_ERR_AUTH;
else rc = appstate_load_playlists(); else {
/* §NEW MUSIC_U 临期续期:先于歌单网络请求(后续 eapi/weapi 用新 cookie)。
* AUTH=会话确失效(置回登录页标志,UI 线程消费);瞬断=保持旧 cookie 走缓存/旧会话。 */
int crc = account_cookie_maybe_refresh();
if (crc == NCM_ERR_AUTH) rc = NCM_ERR_AUTH;
else rc = appstate_load_playlists();
}
if (rc == NCM_OK) if (rc == NCM_OK)
account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */ account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */
pthread_mutex_lock(&g_home_mu); pthread_mutex_lock(&g_home_mu);
+1
View File
@@ -145,6 +145,7 @@ static void *login_worker(void *arg)
meow_log(MEOW_LOG_INFO, "login: 803 confirmed, saving cookie"); meow_log(MEOW_LOG_INFO, "login: 803 confirmed, saving cookie");
ncm_strlcpy(cookie, out_cookie, sizeof cookie); ncm_strlcpy(cookie, out_cookie, sizeof cookie);
ncm_ctx_set_cookie(&g_st.ctx, cookie); ncm_ctx_set_cookie(&g_st.ctx, cookie);
account_cookie_mark_fresh(); /* §NEW:新登录态 cookie 视为全新(年龄=now,重置单飞/冷却) */
/* 803 后同步取 uid/nickname 并落盘(weapi 已修复:不再挂死、cookie 全量且去重发送, /* 803 后同步取 uid/nickname 并落盘(weapi 已修复:不再挂死、cookie 全量且去重发送,
* 服务端认可会话)。此处在 worker 线程执行(不卡 UI);失败不阻断跳页——home 加载 * 服务端认可会话)。此处在 worker 线程执行(不卡 UI);失败不阻断跳页——home 加载
* 时 appstate_ensure_uid 会再验一次(也设了硬超时上限,不阻塞歌单页)。 */ * 时 appstate_ensure_uid 会再验一次(也设了硬超时上限,不阻塞歌单页)。 */
+189
View File
@@ -0,0 +1,189 @@
/* bench/account_cookie_sim.c — MUSIC_U 临期续期决策逻辑验证(§NEW,0 真实 NCM 请求)。
*
* 用**真实** app/logic/account.c,仅把外围依赖换成桩(g_st 状态 + meow_config/log +
* ncm_login_refresh(强制结果)+ ncm_ctx_set_cookie(记录落盘)+ ncm_store/json),验证
* account_cookie_maybe_refresh 的语义(用户定案):
* ① 临期触发:cookie 年龄超阈值(12h)→ 后台静默刷一次(refresh 被调、新 cookie 应用、落盘
* last_refresh_ts 更新);
* ② 未临期:年龄 ≤ 阈值 → 不刷(refresh 零调用);
* ③ 首次/旧会话(last_refresh_ts=0,未知年龄)→ 视为临期刷一次;
* ④ 失败退避:网络/TLS 瞬断 → 负错误码返回 + 冷却记录(30 分钟内重拨不重刷);
* ⑤ 失效兜底:code301/-460 → NCM_ERR_AUTH + 置「需回登录页」标志(UI 退扫码);
* ⑥ 冷却:上次失败 < 30 分钟(跨 boot 持久化 fail_ts)→ 即使临期也不刷。
* 风控纪律:0 次真实 NCM 请求;ncm_login_refresh 为桩、时间由 g_now 推动。
* 编译(宿主),在 ncm_app 目录:见 build_ncm_sims.sh 新增 account_cookie_sim 段。
* 运行:./account_cookie_sim
*/
#include "ncm.h"
#include "ncm_app_internal.h" /* ncm_app_state_t / g_st / account_* 声明 */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ---- 真实 account.o 依赖的外围桩 ---- */
ncm_app_state_t g_st; /* 全局状态(app.c 通常定义;本桩自持) */
static int64_t g_now_ms = 0; /* 时钟(ncm_now_ms 桩返回,驱动年龄/冷却) */
int64_t ncm_now_ms(void) { return g_now_ms; }
static int g_refresh_calls = 0; /* ncm_login_refresh 桩调用计数 */
static int g_refresh_rc = NCM_OK; /* 桩强制返回码 */
static const char *g_refresh_out = NULL; /* 成功时写入 out_cookie 的串 */
int ncm_login_refresh(const char *device_id, const char *cookie,
char *out_cookie, size_t out_cookie_sz) {
(void)device_id; (void)cookie;
g_refresh_calls++;
if (g_refresh_rc == NCM_OK && g_refresh_out)
snprintf(out_cookie, out_cookie_sz, "%s", g_refresh_out);
return g_refresh_rc;
}
static char g_applied_cookie[NCM_COOKIE_MAX]; /* ncm_ctx_set_cookie 落盘内容 */
static int g_set_cookie_calls = 0;
int ncm_ctx_set_cookie(ncm_ctx_t *ctx, const char *cookie) {
if (cookie) snprintf(ctx->cookie, sizeof ctx->cookie, "%s", cookie);
else ctx->cookie[0] = 0;
snprintf(g_applied_cookie, sizeof g_applied_cookie, "%s", cookie ? cookie : "");
g_set_cookie_calls++;
return NCM_OK;
}
static char g_account_json[1024]; /* account_persist 写出的 account.json */
static int g_write_calls = 0;
int ncm_store_read_file(const char *path, char *buf, size_t sz) { (void)path; (void)buf; (void)sz; return -1; }
int ncm_store_write_file(const char *path, const void *data, size_t len) {
(void)path;
size_t n = len < sizeof g_account_json - 1 ? len : sizeof g_account_json - 1;
memcpy(g_account_json, data, n); g_account_json[n] = 0;
g_write_calls++;
return NCM_OK;
}
/* account.o 引用的 json 便捷函数(本桩不调用 account_load,占位保证链接) */
int ncm_json_parse(ncm_json_t *j, const char *text, size_t len,
jsmntok_t *toks, int tokcap, int *parent) {
(void)j; (void)text; (void)len; (void)toks; (void)tokcap; (void)parent; return -1;
}
int ncm_json_path(const ncm_json_t *j, const char *path) { (void)j; (void)path; return -1; }
int ncm_json_path_int(const ncm_json_t *j, const char *path, long *out) { (void)j; (void)path; if (out) *out = 0; return NCM_OK; }
int64_t ncm_json_get_i64_at(const ncm_json_t *j, int tok, int *rc) { (void)j; (void)tok; if (rc) *rc = NCM_OK; return 0; }
int ncm_api_vip_info(const char *d, const char *ck, const char *uid, ncm_vip_info_t *out) {
(void)d; (void)ck; (void)uid; if (out) memset(out, 0, sizeof *out); return NCM_ERR_NOTFOUND;
}
static int g_cfg_hours = 12; /* ncm.cookie_refresh_h 配置 */
static int g_cfg_fail_ts = 0; /* ncm.cookie_refresh_fail_ts 配置(跨 boot 冷却) */
int meow_config_int(const char *key, int def) {
if (strcmp(key, "ncm.cookie_refresh_h") == 0) return g_cfg_hours;
if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) return g_cfg_fail_ts;
return def;
}
int meow_config_get(const char *key, char *out, int outsz) {
(void)outsz;
char b[24];
if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) { snprintf(b, sizeof b, "%d", g_cfg_fail_ts); }
else { snprintf(b, sizeof b, "%d", meow_config_int(key, 0)); }
snprintf(out, (size_t)outsz, "%s", b);
return 0;
}
int meow_config_set(const char *key, const char *val) {
if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) g_cfg_fail_ts = atoi(val);
return 0;
}
void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; }
/* ---- harness ---- */
static int g_fail = 0;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
} while (0)
#define NOW_S (1'700'000'000ll) /* 基准秒 */
#define COOKIE "MUSIC_U=OLD_TOKEN; __csrf=cs; MUSIC_A=anon"
static void reset_state(int64_t last_refresh_ts)
{
memset(&g_st, 0, sizeof g_st);
g_st.logged_in = 1;
snprintf(g_st.ctx.cookie, sizeof g_st.ctx.cookie, "%s", COOKIE);
snprintf(g_st.ctx.device_id, sizeof g_st.ctx.device_id, "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00");
g_st.vip = NCM_VIP_NONE;
g_now_ms = NOW_S * 1000;
g_refresh_calls = 0; g_refresh_rc = NCM_OK; g_refresh_out = "MUSIC_U=NEW_TOKEN; __csrf=cs2; MUSIC_A=anon";
g_set_cookie_calls = 0; g_applied_cookie[0] = 0;
g_account_json[0] = 0; g_write_calls = 0;
g_cfg_hours = 12; g_cfg_fail_ts = 0;
/* mark_fresh 重置 account.c 内单飞/冷却/回登录标志(跨用例需手动清) */
account_cookie_mark_fresh();
g_st.cookie_last_refresh_ts = last_refresh_ts; /* 覆盖为用例指定年龄 */
g_st.logged_in = 1; /* mark_fresh 不碰登录态,显式恢复 */
}
int main(void) {
puts("== account_cookie_sim: 临期续期决策 ==");
/* ① 临期触发:年龄超阈值(12h)→ 后台静默刷一次,新 cookie 应用 + 落盘 last_refresh_ts */
reset_state(NOW_S - 13 * 3600); /* 13h 前刷过 → 已临期 */
int rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK, "临期: rc=0 (got %d)", rc);
CHECK(g_refresh_calls == 1, "临期: refresh 被调 1 次 (got %d)", g_refresh_calls);
CHECK(g_set_cookie_calls == 1, "临期: 新 cookie 已应用落盘 (got %d)", g_set_cookie_calls);
CHECK(strstr(g_applied_cookie, "NEW_TOKEN") != NULL, "临期: 应用的是新 MUSIC_U");
CHECK(g_st.cookie_last_refresh_ts == NOW_S, "临期: last_refresh_ts 更新为 now (got %lld)",
(long long)g_st.cookie_last_refresh_ts);
CHECK(strstr(g_account_json, "\"last_refresh_ts\":") != NULL, "临时: account.json 记录 last_refresh_ts");
CHECK(g_cfg_fail_ts == 0, "临期成功: 冷却已清 (got %d)", g_cfg_fail_ts);
/* ② 未临期:年龄 ≤ 阈值 → 不刷 */
reset_state(NOW_S - 5 * 3600); /* 5h 前刷过 → 未临期 */
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == 0, "未临期: 不刷 (calls=%d)", g_refresh_calls);
/* ③ 首次/旧会话:last_refresh_ts=0 → 视为临期刷一次 */
reset_state(0);
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == 1, "旧会话(last=0): 视为临期刷一次 (calls=%d)", g_refresh_calls);
/* ④ 失败退避:网络/TLS 瞬断 → 负错误码 + 冷却记录,随后冷却期内不重刷 */
reset_state(NOW_S - 13 * 3600);
g_refresh_rc = NCM_ERR_NET;
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_ERR_NET, "瞬断: rc=%d (期望 %d)", rc, NCM_ERR_NET);
CHECK(g_set_cookie_calls == 0, "瞬断: 未应用新 cookie");
CHECK(g_cfg_fail_ts == (int)NOW_S, "瞬断: 冷却时间已持久化 (got %d)", g_cfg_fail_ts);
/* 同 boot 再调(单飞)→ 不重刷;跨 boot 冷却:走新进程静态清零但 fail_ts 持久化在 config */
int c = g_refresh_calls;
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == c, "瞬断+单飞: 本 boot 不再重试 (calls=%d)", g_refresh_calls);
/* ⑤ 失效兜底:code301 → NCM_ERR_AUTH + 置「需回登录页」标志 */
reset_state(NOW_S - 13 * 3600);
g_refresh_rc = NCM_ERR_AUTH;
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_ERR_AUTH, "失效: rc=%d (期望 %d)", rc, NCM_ERR_AUTH);
CHECK(account_cookie_need_relogin() == 1, "失效: 置回登录页标志");
account_cookie_relogin_consume();
CHECK(account_cookie_need_relogin() == 0, "失效: 标志可被 UI 消费");
/* ⑥ 冷却(跨 boot):config fail_ts 为 10 分钟前,即使临期也不刷 */
reset_state(NOW_S - 13 * 3600);
g_cfg_fail_ts = (int)(NOW_S - 10 * 60); /* 上次失败 10 分钟前 < 30 分钟冷却 */
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == 0, "冷却(10min<30min): 不刷 (calls=%d)", g_refresh_calls);
/* 冷却过期(>30min)后再刷 */
reset_state(NOW_S - 13 * 3600);
g_cfg_fail_ts = (int)(NOW_S - 31 * 60); /* 31 分钟前 > 冷却 */
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == 1, "冷却过期(31min>30min): 重刷 (calls=%d)", g_refresh_calls);
/* ⑦ 登录重置:mark_fresh 后年龄=now、冷却清、单飞清 → 不再临期(默认阈值内) */
reset_state(NOW_S - 13 * 3600);
account_cookie_mark_fresh();
g_refresh_calls = 0;
rc = account_cookie_maybe_refresh();
CHECK(rc == NCM_OK && g_refresh_calls == 0, "登录后 mark_fresh: 视为全新不刷 (calls=%d)", g_refresh_calls);
printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS ==");
return g_fail ? 1 : 0;
}
+134
View File
@@ -0,0 +1,134 @@
/* bench/cookie_refresh_sim.c — MUSIC_U 登录续期端点语义验证(§NEW,0 真实 NCM 请求)。
*
* 用真实 core(ncm_login.c / ncm_req.c / ncm_crypto.c / ncm_json.c / ncm_common.c),
* 仅把网络层换成计数桩(canned eapi set-cookie 响应 / 网络错误),验证 ncm_login_refresh:
* ① 成功(code200 + Set-Cookie MUSIC_U/__csrf)→ NCM_OK,新 MUSIC_U patch 进 cookie、
* 旧 cookie 其它字段(MUSIC_A/__remember_me)保留(= 新 cookie 替换落盘的合并源);
* ② 会话失效(code301)→ NCM_ERR_AUTH(调用方据此退扫码重登);
* ③ 网络/TLS 瞬断 → 负错误码(调用方退避,保持旧 cookie);
* ④ code200 但无 set-cookie(服务端未轮换)→ NCM_OK,cookie 原样(不损坏)。
* 风控纪律:0 次真实 NCM 请求;桩按 mode 切换。
* 编译(宿主),在 ncm_app 目录:见 build_ncm_sims.sh 新增 cookie_refresh_sim 段。
* 运行:./cookie_refresh_sim
*/
#include "ncm.h"
#include "ncm_http.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static int g_mode; /* 0=成功+set-cookie; 1=code301; 2=网络错误; 3=code200 无 set-cookie */
static int g_http_calls; /* ncm_http_request 桩计数 = 真实网络调用数 */
static const char *HDR_OK =
"Content-Type: application/json\r\n"
"Set-Cookie: MUSIC_U=NEWTOKEN123; Path=/; Max-Age=7776000; HttpOnly\r\n"
"Set-Cookie: __csrf=newcsrf99; Domain=.music.163.com\r\n";
static const char *BODY_OK = "{\"code\":200,\"message\":\"ok\"}";
static const char *BODY_AUTH = "{\"code\":301,\"message\":\"需要登录\"}";
int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) {
(void)req; (void)buf_sz;
g_http_calls++;
memset(resp, 0, sizeof *resp);
if (g_mode == 2) return NCM_ERR_NET; /* 网络瞬断 */
const char *body = (g_mode == 1) ? BODY_AUTH : BODY_OK;
size_t bn = strlen(body);
memcpy(buf, body, bn);
resp->status = 200;
resp->body = buf;
resp->body_len = bn;
resp->content_length = bn;
if (g_mode == 0) { /* 带 Set-Cookie 轮换 */
size_t hn = strlen(HDR_OK);
memcpy(resp->headers, HDR_OK, hn);
resp->headers_len = hn;
}
return NCM_OK;
}
/* 复刻 ncm_http.c 的 set-cookie 提取(解析所有 Set-Cookie 名值,去属性,"; " 连接) */
int ncm_http_parse_set_cookie(const char *hdrs, size_t len, char *out, size_t out_sz) {
if (!hdrs || !out || out_sz == 0) return -1;
out[0] = 0;
size_t o = 0;
int count = 0;
const char *p = hdrs;
const char *end = hdrs + len;
while ((p = strstr(p, "Set-Cookie:")) != NULL && p < end) {
const char *val = p + strlen("Set-Cookie:");
while (*val == ' ' || *val == '\t') val++;
const char *semi = strchr(val, ';');
size_t nlen = semi ? (size_t)(semi - val) : strlen(val);
while (nlen && (val[nlen-1]==' '||val[nlen-1]=='\t')) nlen--;
if (nlen == 0) { p = val; continue; }
if (o > 0) { if (o + 2 >= out_sz) break; out[o++] = ';'; out[o++] = ' '; }
if (o + nlen >= out_sz) break;
memcpy(out + o, val, nlen);
o += nlen;
count++;
p = val;
}
out[o] = 0;
return count;
}
void ncm_http_set_verify_default(int verify, const char *ca_path) { (void)verify; (void)ca_path; }
static int g_fail = 0;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
} while (0)
static const char *g_device = "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00";
int main(void) {
/* 基础会话 cookie(MUSIC_U 在中间,__remember_me/MUSIC_A 在后——验证不丢失、不被重排破坏) */
static char cookie[NCM_COOKIE_MAX];
snprintf(cookie, sizeof cookie,
"__csrf=old_csrf; MUSIC_U=OLD_TOKEN_abc_123; MUSIC_A=anon_a; __remember_me=true");
char out[NCM_COOKIE_MAX];
char v[64];
puts("== cookie_refresh_sim: ncm_login_refresh endpoint semantics ==");
/* ① 成功:code200 + Set-Cookie 轮换 → 新 MUSIC_U 覆盖旧值,其余字段保留 */
g_mode = 0; g_http_calls = 0;
int rc = ncm_login_refresh(g_device, cookie, out, sizeof out);
CHECK(rc == NCM_OK, "success rc=0 (got %d)", rc);
CHECK(g_http_calls == 1, "exactly 1 http call (got %d)", g_http_calls);
CHECK(ncm_req_cookie_get(out, "MUSIC_U", v, sizeof v) == NCM_OK &&
strcmp(v, "NEWTOKEN123") == 0, "MUSIC_U replaced with NEWTOKEN123 (got %s)", v);
CHECK(ncm_req_cookie_get(out, "__csrf", v, sizeof v) == NCM_OK &&
strcmp(v, "newcsrf99") == 0, "__csrf replaced (got %s)", v);
CHECK(ncm_req_cookie_get(out, "MUSIC_A", v, sizeof v) == NCM_OK &&
strcmp(v, "anon_a") == 0, "MUSIC_A preserved (got %s)", v);
CHECK(ncm_req_cookie_get(out, "__remember_me", v, sizeof v) == NCM_OK &&
strcmp(v, "true") == 0, "__remember_me preserved (got %s)", v);
/* ② 会话失效:code301 → NCM_ERR_AUTH(退扫码重登) */
g_mode = 1; g_http_calls = 0;
rc = ncm_login_refresh(g_device, cookie, out, sizeof out);
CHECK(rc == NCM_ERR_AUTH, "auth-fail rc=%d (期望 %d)", rc, NCM_ERR_AUTH);
/* ③ 网络瞬断 → 负错误码(退避,保持旧 cookie 由调用方保证) */
g_mode = 2; g_http_calls = 0;
rc = ncm_login_refresh(g_device, cookie, out, sizeof out);
CHECK(rc == NCM_ERR_NET, "net-fail rc=%d (期望 %d)", rc, NCM_ERR_NET);
/* ④ code200 但无 set-cookie(未轮换)→ NCM_OK,cookie 原样(无害) */
g_mode = 3; g_http_calls = 0;
memset(out, 0, sizeof out);
rc = ncm_login_refresh(g_device, cookie, out, sizeof out);
CHECK(rc == NCM_OK, "code200-no-cookie rc=0 (got %d)", rc);
CHECK(strcmp(out, cookie) == 0, "no-cookie: cookie unchanged");
/* ⑤ 无 cookie(未登录)→ 参数允许空 body 字段但仍应优雅(eapi 头不注入 MUSIC_U) */
g_mode = 3; g_http_calls = 0;
rc = ncm_login_refresh(g_device, "", out, sizeof out);
CHECK(rc == NCM_OK, "empty-cookie still builds http ok (got %d)", rc);
printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS ==");
return g_fail ? 1 : 0;
}
+22
View File
@@ -91,4 +91,26 @@ gcc -O2 -o apicache_sim /tmp/ac.o \
-Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm
run_sim apicache_sim run_sim apicache_sim
echo "==> build cookie_refresh_sim (§NEW 续期端点语义;0 真实网络——ncm_http 计数桩)"
gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd \
-Ivendor/jsmn -Ivendor/mbedtls/include \
-c bench/cookie_refresh_sim.c -o /tmp/crs.o
gcc -O2 -o cookie_refresh_sim /tmp/crs.o \
$(ls out/core-host/ncm_login.o out/core-host/ncm_req.o out/core-host/ncm_crypto.o \
out/core-host/ncm_json.o out/core-host/ncm_common.o out/core-host/ncm_store.o \
out/core-host/v_qrcodegen.o) \
out/core-host/jsmn_impl.o \
-Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm
run_sim cookie_refresh_sim
echo "==> build account_cookie_sim (§NEW 临期续期决策;真实 account.c + 外围桩,0 真实网络)"
gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \
-Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \
-c app/logic/account.c -o /tmp/acs_account.o
gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \
-Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \
-c bench/account_cookie_sim.c -o /tmp/acs.o
gcc -O2 -o account_cookie_sim /tmp/acs.o /tmp/acs_account.o -lpthread -lm
run_sim account_cookie_sim
echo "===== all sims done =====" echo "===== all sims done ====="
+44
View File
@@ -187,6 +187,50 @@ int ncm_login_status_check(const char *device_id, const char *cookie,
return NCM_OK; return NCM_OK;
} }
int ncm_login_refresh(const char *device_id, const char *cookie,
char *out_cookie, size_t out_cookie_sz) {
if (!device_id || !cookie) return NCM_ERR_ARG;
char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX];
int rc = ncm_req_build(device_id, cookie, "/api/login/token/refresh", "{}",
NCM_CRYPT_EAPI,
body, sizeof body, hdrs, sizeof hdrs, url, sizeof url);
if (rc != NCM_OK) return rc;
ncm_http_req_t req;
memset(&req, 0, sizeof req);
req.url = url;
req.method = "POST";
req.body = body;
req.body_len = strlen(body);
req.extra_hdrs = hdrs;
req.verify_ca = NCM_HTTP_VERIFY_DEFAULT;
req.timeout_ms = 10000;
char respbuf[16384];
ncm_http_resp_t resp;
rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp);
if (rc != NCM_OK) return rc; /* 网络/TLS/超时 → 调用方退避 */
if (resp.status != 200) return NCM_ERR_HTTP; /* 异常 HTTP → 调用方退避 */
/* 提取响应 set-cookie(会话轮换:MUSIC_U/__csrf),patch 进现存 cookie */
char captured[4096] = "";
ncm_http_parse_set_cookie(resp.headers, resp.headers_len, captured, sizeof captured);
ncm_json_t j;
jsmntok_t toks[512];
int parent[512];
rc = ncm_json_parse(&j, resp.body, resp.body_len, toks, 512, parent);
if (rc < 0) return NCM_ERR_JSON;
long code = -1;
if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON;
if (code != 200) return NCM_ERR_AUTH; /* 301=未登录 / -460=风控 / 其它 → 会话失效,重登 */
if (out_cookie)
ncm_req_cookie_merge(cookie, captured, out_cookie, out_cookie_sz);
return NCM_OK;
}
int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie) { int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie) {
return ncm_store_save_session(store, cookie); return ncm_store_save_session(store, cookie);
} }
+7
View File
@@ -47,6 +47,13 @@ int ncm_login_poll(const char *device_id, const char *unikey, const char *cur_co
int ncm_login_status_check(const char *device_id, const char *cookie, int ncm_login_status_check(const char *device_id, const char *cookie,
char *uid, size_t usz, char *nickname, size_t nsz); char *uid, size_t usz, char *nickname, size_t nsz);
/* 登录续期(eapi /api/login/token/refresh,空体):带当前 cookie 换取新登录态。
* 成功(code==200)把响应 set-cookie 的新 MUSIC_U/__csrf patch 进 cookie 写 out_cookie 并返
* NCM_OK;code!=200(301/-460/会话失效)返 NCM_ERR_AUTH(调用方应清登录态进扫码);网络/TLS/
* 超时/HTTP 异常返负错误码(调用方退避,保持旧 cookie)。 */
int ncm_login_refresh(const char *device_id, const char *cookie,
char *out_cookie, size_t out_cookie_sz);
/* 会话 cookie 加密落盘/读回(机器绑定密钥,仅防裸读)。 */ /* 会话 cookie 加密落盘/读回(机器绑定密钥,仅防裸读)。 */
int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie); int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie);
int ncm_login_load_cookie(const ncm_store_t *store, char *cookie, size_t sz); int ncm_login_load_cookie(const ncm_store_t *store, char *cookie, size_t sz);
+40 -5
View File
@@ -100,6 +100,17 @@ int ncm_req_cookie_get(const char *cookie, const char *name, char *out, size_t o
return NCM_OK; return NCM_OK;
} }
/* 合并 cookie:先 parse base(字段指针指向 base 源串),再 parse patch——同名字段被 patch 值
* 覆盖(ck_set 替换值指针),新字段追加;ck_to_string 在调用期间同步使用,指针不外泄。 */
int ncm_req_cookie_merge(const char *base, const char *patch, char *out, size_t out_sz) {
if (!out || out_sz == 0) return NCM_ERR_ARG;
ckmap_t m; memset(&m, 0, sizeof m);
ck_parse(&m, base);
ck_parse(&m, patch); /* patch 覆盖 base(后值覆盖先值) */
ck_to_string(&m, out, out_sz);
return NCM_OK;
}
/* ---------- json 注入 ---------- */ /* ---------- json 注入 ---------- */
/* 把 json{...} 内部插入 ",<extra>"(尾部)。extra 形如 "\"csrf_token\":\"x\""(无前导逗号) */ /* 把 json{...} 内部插入 ",<extra>"(尾部)。extra 形如 "\"csrf_token\":\"x\""(无前导逗号) */
static int json_inject(const char *json, const char *extra, char *out, size_t out_sz) { static int json_inject(const char *json, const char *extra, char *out, size_t out_sz) {
@@ -155,6 +166,20 @@ static void hm_to_cookie(const hmap_tab *h, char *out, size_t out_sz) {
out[o < out_sz ? o : out_sz - 1] = 0; out[o < out_sz ? o : out_sz - 1] = 0;
} }
/* eapi 会话 cookie 锚点:把 cookie 中某个字段(MUSIC_U / MUSIC_A)urlencode 后追加到
* Cookie 头。MUSIC_U 值可达 ~800B 不进 hmap(val[256] 放不下),故单独附加。有界附加:
* 放不下则静默停止(不截断破坏 Cookie 头)。 */
static void eapi_append_cookie(const char *cookie, const char *name, char *hdr, size_t hdr_sz) {
char val[3072] = "";
if (ncm_req_cookie_get(cookie, name, val, sizeof val) != NCM_OK) return;
char ev[4096];
if (ncm_url_encode(val, ev, sizeof ev) < 0) return;
size_t o = strlen(hdr);
if (o >= hdr_sz) return;
int n = snprintf(hdr + o, hdr_sz - o, "%s%s=%s", (o > 0) ? "; " : "", name, ev);
if (n < 0 || (size_t)n >= hdr_sz - o) hdr[o] = 0; /* 放不下:还原,丢弃本次附加 */
}
/* head 注入(eapi):把 __csrf/deviceId/os/... 等填到 header map 与 cookie。 /* head 注入(eapi):把 __csrf/deviceId/os/... 等填到 header map 与 cookie。
* 同时产出 json.header 片段。 */ * 同时产出 json.header 片段。 */
static void eapi_fill_header(const char *device_id, const char *csrf, static void eapi_fill_header(const char *device_id, const char *csrf,
@@ -283,14 +308,24 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path,
char params[4096]; char params[4096];
if (ncm_crypto_eapi(path, full2, params, sizeof params) != 0) return NCM_ERR_CRYPTO; if (ncm_crypto_eapi(path, full2, params, sizeof params) != 0) return NCM_ERR_CRYPTO;
char cookie_hdr[2048]; char cookie_hdr[3072];
hm_to_cookie(&h, cookie_hdr, sizeof cookie_hdr); hm_to_cookie(&h, cookie_hdr, sizeof cookie_hdr);
/* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U 时补 MUSIC_A=anonymous_token /* 会话 cookie 锚点(对齐 request.rs):eapi Cookie 头需带 MUSIC_U(登录态识别——
* 到 eapi Cookie 头(weapi 分支同样注入);login 路径不注入。仅检测存在性 * 登录刷新/song_url 会员档等必须)与 MUSIC_A(游客)。MUSIC_U 值长不进 hmap,
* (MUSIC_U 值可能很长,无需拷出)。 */ * 单独 urlencode 追加。仅当 cookie 确有该字段才追加(游客/未登录不注入)。 */
{
char musicu[8];
if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) == NCM_OK)
eapi_append_cookie(cookie, "MUSIC_U", cookie_hdr, sizeof cookie_hdr);
if (ncm_req_cookie_get(cookie, "MUSIC_A", musicu, sizeof musicu) == NCM_OK)
eapi_append_cookie(cookie, "MUSIC_A", cookie_hdr, sizeof cookie_hdr);
}
/* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U/MUSIC_A 时补 MUSIC_A=anonymous_token
* 到 eapi Cookie 头;login 路径不注入(登录端点自身处理匿名)。 */
if (!strstr(path, "login")) { if (!strstr(path, "login")) {
char musicu[8]; char musicu[8];
if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) != NCM_OK) if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) != NCM_OK &&
ncm_req_cookie_get(cookie, "MUSIC_A", musicu, sizeof musicu) != NCM_OK)
ncm_strlcat(cookie_hdr, "; MUSIC_A=anonymous_token", sizeof cookie_hdr); ncm_strlcat(cookie_hdr, "; MUSIC_A=anonymous_token", sizeof cookie_hdr);
} }
+5
View File
@@ -44,6 +44,11 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path,
/* 从 cookie 串取某个字段值(如 __csrf)。找到返回 0。*/ /* 从 cookie 串取某个字段值(如 __csrf)。找到返回 0。*/
int ncm_req_cookie_get(const char *cookie, const char *name, char *out, size_t out_sz); int ncm_req_cookie_get(const char *cookie, const char *name, char *out, size_t out_sz);
/* 合并 cookie:patch 中每个 name=value 覆盖 base 同名字段(无则追加),其余 base 字段保留。
* 用于登录续期:把响应的新 MUSIC_U/__csrf patch 进现有会话 cookie(base 的 MUSIC_A/__remember_me
* 等其它 cookie 不丢失),输出为按名去重、"; " 连接的完整 cookie 串。返回 NCM_OK。 */
int ncm_req_cookie_merge(const char *base, const char *patch, char *out, size_t out_sz);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
Binary file not shown.
Binary file not shown.
+47
View File
@@ -189,6 +189,51 @@ static void test_crypto_vectors(void) {
CHECK(strcmp(a, b) == 0, "eapi deterministic for same input"); CHECK(strcmp(a, b) == 0, "eapi deterministic for same input");
} }
static void test_cookie_merge(void) {
/* §NEW 登录续期:patch 的 MUSIC_U/__csrf 覆盖 base 同名字段,其余 base 字段保留。 */
printf("== cookie merge (login refresh) ==\n");
char out[4096];
const char *base = "__csrf=old_csrf; MUSIC_A=anon; __remember_me=true; MUSIC_U=OLD_TOKEN_LONG_1234567890";
const char *patch = "MUSIC_U=NEW_TOKEN_abcdef; __csrf=new_csrf";
CHECKRC(ncm_req_cookie_merge(base, patch, out, sizeof out), "merge rc");
char v[64];
CHECK(ncm_req_cookie_get(out, "MUSIC_U", v, sizeof v) == NCM_OK &&
strcmp(v, "NEW_TOKEN_abcdef") == 0, "MUSIC_U replaced (got %s)", v);
CHECK(ncm_req_cookie_get(out, "__csrf", v, sizeof v) == NCM_OK &&
strcmp(v, "new_csrf") == 0, "__csrf replaced (got %s)", v);
CHECK(ncm_req_cookie_get(out, "MUSIC_A", v, sizeof v) == NCM_OK &&
strcmp(v, "anon") == 0, "MUSIC_A preserved (got %s)", v);
CHECK(ncm_req_cookie_get(out, "__remember_me", v, sizeof v) == NCM_OK &&
strcmp(v, "true") == 0, "__remember_me preserved (got %s)", v);
/* 空 patch(响应无 set-cookie)= 原样返回 base */
char out2[4096];
CHECKRC(ncm_req_cookie_merge(base, "", out2, sizeof out2), "merge with empty patch rc");
CHECK(strcmp(out2, base) == 0, "empty patch keeps base unchanged");
}
static void test_eapi_musicu_hdr(void) {
/* §NEW eapi Cookie 头须带 MUSIC_U(session 锚点,login_refresh/song_url 会员档依赖)。
* 这里只构建请求,不打网络——验证 Cookie 头包含 MUSIC_U=(值 urlencode 后附加)。 */
printf("== eapi cookie header carries MUSIC_U ==\n");
const char *device = "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00";
const char *cookie = "__csrf=c; MUSIC_U=MyMusicUToken1a2b; MUSIC_A=guestA";
char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX];
CHECKRC(ncm_req_build(device, cookie, "/api/login/token/refresh", "{}", NCM_CRYPT_EAPI,
body, sizeof body, hdrs, sizeof hdrs, url, sizeof url),
"eapi build rc");
CHECK(strstr(hdrs, "MUSIC_U=MyMusicUToken1a2b") != NULL, "Cookie header has MUSIC_U");
CHECK(strstr(hdrs, "MUSIC_A=guestA") != NULL, "Cookie header has MUSIC_A");
CHECK(strstr(url, "/eapi/login/token/refresh") != NULL, "url is eapi login/token/refresh");
/* 游客(无 MUSIC_U/A)+ 非 login 路径 → 补 anonymous_token 兜底 */
char h2[NCM_REQ_HDRS_MAX], b2[NCM_REQ_BODY_MAX], u2[NCM_REQ_URL_MAX];
CHECKRC(ncm_req_build(device, "", "/api/song/lyric", "{\"id\":1}", NCM_CRYPT_EAPI,
b2, sizeof b2, h2, sizeof h2, u2, sizeof u2), "guest eapi build rc");
CHECK(strstr(h2, "MUSIC_A=anonymous_token") != NULL, "guest eapi injected anonymous MUSIC_A");
CHECK(strstr(h2, "MUSIC_U=") == NULL, "guest eapi has no MUSIC_U");
}
static void test_network(void) { static void test_network(void) {
printf("== network (live) ==\n"); printf("== network (live) ==\n");
int net_ok = 0; int net_ok = 0;
@@ -468,6 +513,8 @@ int main(int argc, char **argv) {
test_store_cookie(); test_store_cookie();
test_cache(); test_cache();
test_qr(); test_qr();
test_cookie_merge();
test_eapi_musicu_hdr();
test_network(); test_network();
test_track_all_parse(); test_track_all_parse();
test_vip_parse(); test_vip_parse();
Binary file not shown.