diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 6779b7a..37b2e7f 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -3187,3 +3187,64 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进 防止同 incident 二次兜底(新图也死时不再回 song/detail)。 - **歌单封面兜底语义未变**:兜底链两级都失败后仍清目标、保留当前显示封面(歌单封面在进歌单时已由 `cover_request(pcur->cover, NULL)` 取到)。未新增对 `g_st.cover_url` 的主动重取。 + +## 36. 风控加固批:接口级响应短 TTL 缓存 + 游客 MUSIC_A + track/all 死代码(2026-09-02) + +> 依据:NCMAPI_NOTES(319 权威接口地图)§2.1/§4/§6。仅改 `patch_v2/ncm_app/`。 +> 三项:①位只读接口响应缓存(对齐参考 apicache 2 分钟);②游客 `MUSIC_A=anonymous_token`;③清理死代码。 + +### 36.1 ① 接口级响应短 TTL 缓存(user_playlist / playlist_detail / song_detail / cloudsearch) + +- 新增 **`core/ncm_apicache.{c,h}`**(自包含:仅 libc+pthread+clock_gettime,不依赖 ncm_store/ncm_crypto/ncm_common 函数,可独立链接)。 + - **内存**:4 槽 LRU,单槽 ≤128KB,TTL 默认 120s;键 = md5(`path` + `\x01` + 参数 json)。超 TTL 过期、超容量逐 LRU 最旧。 + - **盘(可选)**:`/ncm_api_cache/.json`,文件头 8B 墙钟毫秒 + body;超 TTL 视为 miss(并删除);写入按 30s 节流清过期文件,磁盘占用有界。 + - 失效:登出/换账号 `ncm_apicache_clear` 全清;盘超 TTL 自然过期。 +- **`core/ncm_api.c`**:`api_post` 拆为「请求(ncm_http)+解析」两段;新增 `api_post_cached`(先查缓存、miss/过期/手动刷新→真网络、成功后写缓存)。四个只读接口路由到 `api_post_cached`:`user_playlist`(weapi) / `playlist_detail`(eapi,v6) / `song_detail`(eapi,v3) / `cloudsearch`(weapi)。`song_url`/`lyric`/`vip` **不入缓存**(逐曲/需新鲜/低频)。 +- **手动刷新绕过**:`ncm_api_cache_refresh_path(path)` 标记某路径下一次调用绕过缓存(一次性消费)。接入 home topbar ↻(`/api/user/playlist`) 与歌单 topbar ↻(`/api/v6/playlist/detail`);`playlist_open`/`search` 不标记(走缓存)。 +- 盘层挂载:`appstate_init` → `ncm_apicache_init(g_st.data_dir)`;`appstate_logout` → `ncm_apicache_clear()`。 + +### 36.2 ② 游客 MUSIC_A(未登录 cookie 伪装,NCMAPI_NOTES §4) + +- `core/ncm_req.c`:weapi 分支(无 `MUSIC_U` 且非 login 路径)补 `MUSIC_A=anonymous_token` 进会话 cookie;eapi 分支同样(无 `MUSIC_U` 且非 login 路径)追加到 eapi Cookie 头。与参考「常驻 cookie 伪装」对齐,降低未登录需登录接口被服务端判定异常(401/400 需验证)概率。 + +### 36.3 ③ track/all 死代码清理(NCMAPI_NOTES §1.1 实锤 404) + +- `core/ncm_api.c`/`.h`:**移除**网络封装 `ncm_api_playlist_track_all`(真实服务器无 `/api/playlist/track/all`,8 形态全 404;无任何调用点)。 +- `ncm_api_parse_track_all`(离线解析器)**保留**供 `offline_track_all`/`probe_track_all`/`test_ncm_core` 离线重放/单测(0 请求),注释标注「已验证端点不存在,仅离线单测用,勿用于真实请求」。 + +### 36.4 验证(宿主 + qemu;新缓存用例 0 真实网络请求) + +| 项 | 结果 | +|---|---| +| 新增 `bench/apicache_sim.c`(真实 core + ncm_http 计数桩,0 网络) | **ALL PASS**:TTL 内不重复请求 / 过期重拉 / 手动刷新绕过 / cloudsearch 按键区分 / 清空重拉 / 盘缓存写入+clear | +| `stream_sim` / `player_flow_sim` / `window_sim` / `search_sim` | ALL PASS | +| `cache_evict_sim`(1-9)/ `pagination_sim` / `playlist_race_sim` | PASS | +| `test_ncm_core_host` + `qemu test_ncm_core_mipsel` | host + mipsel **PASS** | +| `build_ncm.sh`(-Wall -Wextra) | **0 warning**;`text 1026914 / data 1664 / bss 1363460` | +| `build_ncm_host.sh` | 0 warning(仅 SDK `meow_ui_vol.c` 存量 `system()` 告警,非本批) | +| UI 渲染 `NCM_PAGE=home MEOW_UI_DUMP` | exit=0,dump 正常 | + +改动文件(本批,仅这些;未动 sdk/、devdocs/、其它 App): +``` +修改: + core/ncm_api.c # api_post 拆分 + api_post_cached + 四接口接线 + 刷新路径 + 移除 track_all 网络版 + 解析器注释 + core/ncm_api.h # 移除 ncm_api_playlist_track_all 声明 + parse_track_all 注释 + ncm_api_cache_refresh_path + core/ncm_req.c # weapi/eapi 游客 MUSIC_A 注入 + core/ncm.h # 伞头加 ncm_apicache.h + app/logic/appstate.c # init/logout 接 ncm_apicache + app/ui/home.c # 手动刷新标记 /api/user/playlist + app/ui/playlist.c # 手动刷新标记 /api/v6/playlist/detail + build_core.sh / build_ncm.sh / build_ncm_sims.sh # 加 ncm_apicache.c;window/search sim 链 ncm_apicache.o + ncm / ncm_host / test/test_ncm_core_host # 产物重链 +新增: + core/ncm_apicache.c / core/ncm_apicache.h # 接口级响应短 TTL 缓存(内存+盘) + bench/apicache_sim.c # 缓存用例(0 网络) + REPORT.md # 本批记录(§36) +``` + +### 36.5 遗留 / 诚实出入 + +- **缓存 TTL=120s 的内存槽上限 4×128KB**:大歌单 `playlist_detail`(trackIds >128KB)不入内存、仅盘缓存(内存瘦身口径 REPORT §32);命中读盘+重解析,避免把上千首 trackIds 常驻内存。 +- **手刷只标详情接口**:歌单手动刷新只绕过 `v6/playlist/detail`(列表结构),`song_detail` 批 2 分钟内走缓存(改动歌曲为新键自然 miss,元数据 ≤2min 旧可接受);home 刷新绕过 `user/playlist`。 +- **eapi 分支未注入 MUSIC_U**(登录态 eapi 播放接口仍走原有的非-cookie 鉴权路径)——本次仅按任务补游客 MUSIC_A;MUSIC_U 头注入留后续(超范围)。 +- **盘缓存读路径未在真机/宿主整链实测**(内存命中优先,盘仅在内存 miss/大体时兜底)——代码审查 + 文件存在性断言验证;真机可观察 `ncm_api_cache/` 落盘与热更后命中。 diff --git a/patch_v2/ncm_app/app/logic/appstate.c b/patch_v2/ncm_app/app/logic/appstate.c index 196b042..6518afe 100644 --- a/patch_v2/ncm_app/app/logic/appstate.c +++ b/patch_v2/ncm_app/app/logic/appstate.c @@ -87,6 +87,10 @@ int appstate_init(void) else ncm_ctx_default_cache_dir(g_st.data_dir, g_st.cache_dir, sizeof g_st.cache_dir); + /* 风控加固 §1:接口级 JSON 响应短 TTL 缓存的盘层挂到 /ncm_api_cache/。 + * (内存层由 core 初始化默认启用;盘层跨进程/热更保留,启动清一次过期盘文件。) */ + ncm_apicache_init(g_st.data_dir); + appstate_load_settings(); g_st.logged_in = (g_st.ctx.cookie[0] != 0); g_st.active_playlist = -1; @@ -156,6 +160,9 @@ void appstate_set_logged_in(const char *uid, const char *nickname) void appstate_logout(void) { ncm_ctx_clear_cookie(&g_st.ctx); + /* 风控加固 §1:登出/换账号清接口级响应缓存(防上一账号缓存的 user/playlist/cloudsearch + * 被下一账号复用;盘缓存目录一并清)。 */ + ncm_apicache_clear(); g_st.logged_in = 0; g_st.uid[0] = 0; g_st.nickname[0] = 0; diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index babe286..d75f528 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -257,7 +257,12 @@ int home_on_tap(int x, int y) if (g_tb) { int ah = meow_topappbar_hit(g_tb, x, y); if (ah == MEOW_TOPBAR_BACK) { home_drag_reset(); return 1; } - if (ah == 0) { home_drag_reset(); home_reload(); return 1; } /* 刷新 */ + if (ah == 0) { /* 刷新 */ + home_drag_reset(); + ncm_api_cache_refresh_path("/api/user/playlist"); /* 手动刷新绕过接口缓存 */ + home_reload(); + return 1; + } if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */ if (ah >= 2) { home_drag_reset(); search_open(); return 1; } /* 搜索(最右) */ } diff --git a/patch_v2/ncm_app/app/ui/playlist.c b/patch_v2/ncm_app/app/ui/playlist.c index 4078960..8ebf1dc 100644 --- a/patch_v2/ncm_app/app/ui/playlist.c +++ b/patch_v2/ncm_app/app/ui/playlist.c @@ -427,7 +427,12 @@ int playlist_on_tap(int x, int y) if (g_tb) { int ah = meow_topappbar_hit(g_tb, x, y); if (ah == MEOW_TOPBAR_BACK) { pl_drag_reset(); meow_view_page_pop(); return 1; } - if (ah >= 0) { pl_drag_reset(); playlist_refresh(); return 1; } + if (ah >= 0) { /* topbar ↻ 手动刷新:绕过歌单详情接口缓存 */ + pl_drag_reset(); + ncm_api_cache_refresh_path("/api/v6/playlist/detail"); + playlist_refresh(); + return 1; + } } if (x >= r_plall.x && x < r_plall.x + r_plall.w && y >= r_plall.y && y < r_plall.y + r_plall.h) { pl_drag_reset(); player_play_song(0); return 1; } diff --git a/patch_v2/ncm_app/bench/apicache_sim.c b/patch_v2/ncm_app/bench/apicache_sim.c new file mode 100644 index 0000000..f120a7a --- /dev/null +++ b/patch_v2/ncm_app/bench/apicache_sim.c @@ -0,0 +1,132 @@ +/* bench/apicache_sim.c — 接口级响应短 TTL 缓存验证(风控加固 §1,0 真实 NCM 请求)。 + * + * 用**真实** core(ncm_api.c / ncm_req.c / ncm_crypto.c / ncm_json.c / ncm_common.c / + * ncm_apicache.c),仅把网络层 ncm_http_request 换成计数桩(canned JSON 响应),验证: + * ① TTL 内重复调用不重复请求(缓存命中); + * ② TTL 过期后重拉(expired re-fetch); + * ③ 手动刷新(ncm_api_cache_refresh_path)绕过缓存; + * ④ cloudsearch 缓存按键(关键词/分页)区分,不同关键词各拉一次; + * ⑤ 清空(登出)后重拉; + * ⑥ 盘缓存写入/清除(文件存在性)。 + * 风控纪律:0 次真实 NCM 请求(网络层计数桩);sleep 仅用于 TTL 过期(短 TTL=1s)。 + * + * 编译(宿主),在 ncm_app 目录:见 build_ncm_sims.sh 新增 apicache_sim 段。 + * 运行:./apicache_sim + */ +#include "ncm.h" +#include "ncm_http.h" +#include +#include +#include +#include +#include + +static int g_http_calls = 0; /* ncm_http_request 桩计数 = 真实网络调用数 */ + +/* 桩:不联网,按 url 返回 canned JSON,并计数。 */ +static const char *BODY_USER_PL = + "{\"code\":200,\"playlist\":[{\"id\":\"1\",\"name\":\"P1\",\"coverImgUrl\":\"http://x/c.jpg\",\"trackCount\":3,\"subscribed\":false}]}"; +static const char *BODY_CLOUD_SEARCH = + "{\"code\":200,\"result\":{\"songCount\":1,\"songs\":[{\"id\":\"100\",\"name\":\"S\",\"dt\":200000,\"fee\":0," + "\"ar\":[{\"name\":\"A1\"}],\"al\":{\"name\":\"AL1\",\"picUrl\":\"http://x/1.jpg\"}}]}}"; + +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++; + const char *body = (strstr(req->url, "cloudsearch") != NULL) ? BODY_CLOUD_SEARCH : BODY_USER_PL; + size_t n = strlen(body); + memcpy(buf, body, n); + memset(resp, 0, sizeof *resp); + resp->status = 200; + resp->body = buf; + resp->body_len = n; + resp->content_length = n; + return NCM_OK; +} +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) { + ncm_apicache_init(NULL); /* 内存缓存;盘层本轮单独测(临时目录) */ + ncm_apicache_ttl_set(NCM_APICACHE_TTL_S); + + ncm_playlist_t pls[8]; int pn = 0; + + /* ① TTL 内不重复请求(user/playlist) */ + int c0 = g_http_calls; + int rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(rc == NCM_OK && pn == 1, "user_playlist rc=%d n=%d (first = real)", rc, pn); + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c0 + 1, "TTL 内不重复请求: calls=%d (期望 %d)", g_http_calls, c0 + 1); + CHECK(rc == NCM_OK && pn == 1, "cached user_playlist rc=%d n=%d", rc, pn); + + /* ② TTL 过期后重拉(短 TTL=1s;sleep 2s 确保严格超 1000ms 触发过期) */ + ncm_apicache_ttl_set(1); + sleep(2); /* 等上一条(默认 120s 存的)过期 */ + c0 = g_http_calls; + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c0 + 1, "过期重拉: calls=%d (期望 +1)", g_http_calls); + CHECK(rc == NCM_OK && pn == 1, "refetch after expiry rc=%d n=%d", rc, pn); + /* 刚存了 1s TTL 的新条目,立即再取应命中 */ + int c1 = g_http_calls; + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c1, "重拉后 TTL 内命中: calls=%d (期望不增)", g_http_calls); + ncm_apicache_ttl_set(NCM_APICACHE_TTL_S); /* 还原默认 TTL */ + + /* ③ 手动刷新绕过缓存(一次性消费) */ + c0 = g_http_calls; + ncm_api_cache_refresh_path("/api/user/playlist"); + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c0 + 1, "手动刷新绕过: calls=%d (期望 +1)", g_http_calls); + CHECK(rc == NCM_OK && pn == 1, "refresh bypass rc=%d n=%d", rc, pn); + c1 = g_http_calls; + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c1, "刷新后(路径已消费)回缓存: calls=%d", g_http_calls); + + /* ④ cloudsearch 缓存:同关键词命中、不同关键词各自请求 */ + ncm_song_t songs[8]; int sn = 0; int64_t total = -1; + c0 = g_http_calls; + rc = ncm_api_cloudsearch_song(g_device, "", "abc", 0, 30, songs, 8, &sn, &total); + CHECK(rc == NCM_OK && sn == 1, "cloudsearch abc rc=%d n=%d", rc, sn); + int c2 = g_http_calls; + rc = ncm_api_cloudsearch_song(g_device, "", "abc", 0, 30, songs, 8, &sn, &total); + CHECK(g_http_calls == c2, "cloudsearch 同关键词命中: calls=%d", g_http_calls); + rc = ncm_api_cloudsearch_song(g_device, "", "def", 0, 30, songs, 8, &sn, &total); + CHECK(g_http_calls == c2 + 1, "cloudsearch 不同关键词各拉一次: calls=%d (期望 %d)", g_http_calls, c2 + 1); + CHECK(rc == NCM_OK && sn == 1, "cloudsearch def rc=%d n=%d", rc, sn); + + /* ⑤ 清空(登出/换账号)后重拉 */ + c0 = g_http_calls; + ncm_apicache_clear(); + rc = ncm_api_user_playlist(g_device, "", "0", 30, 0, pls, 8, &pn); + CHECK(g_http_calls == c0 + 1, "清空后重拉: calls=%d (期望 +1)", g_http_calls); + + /* ⑥ 盘缓存写入/清除(临时目录,默认 TTL) */ + char tmpdir[128]; + snprintf(tmpdir, sizeof tmpdir, "/tmp/apicache_sim_%ld", (long)getpid()); + mkdir(tmpdir, 0777); + ncm_apicache_init(tmpdir); + ncm_apicache_ttl_set(NCM_APICACHE_TTL_S); + ncm_apicache_put("0123456789abcdef0123456789abcdef", "hello-disk", 10); + char dp[512]; + snprintf(dp, sizeof dp, "%s/ncm_api_cache/0123456789abcdef0123456789abcdef.json", tmpdir); + struct stat st; + CHECK(stat(dp, &st) == 0, "盘缓存写入文件存在: %s", dp); + /* 命中盘缓存(内存若也存了会先命中内存;这里单独验证 clear 后文件被删) */ + ncm_apicache_clear(); + CHECK(stat(dp, &st) != 0, "盘缓存 clear 后文件删除"); + /* 清掉临时目录(内容已由 clear 删除,rmdir) */ + char cmd[160]; + snprintf(cmd, sizeof cmd, "rm -rf \"%s\"", tmpdir); + if (system(cmd) != 0) { /* 忽略 */ } + + printf("\n%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS"); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/build_core.sh b/patch_v2/ncm_app/build_core.sh index dd23063..ffe61e2 100755 --- a/patch_v2/ncm_app/build_core.sh +++ b/patch_v2/ncm_app/build_core.sh @@ -7,7 +7,7 @@ INC_CORE="-Icore" INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c" CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \ - core/ncm_cache.c core/ncm_ctx.c" + core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c" VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译 JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU diff --git a/patch_v2/ncm_app/build_ncm.sh b/patch_v2/ncm_app/build_ncm.sh index 391e703..6022a3c 100755 --- a/patch_v2/ncm_app/build_ncm.sh +++ b/patch_v2/ncm_app/build_ncm.sh @@ -16,7 +16,7 @@ STRICT="-Wall -Wextra -Os -DJSMN_HEADER" CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \ - core/ncm_cache.c core/ncm_ctx.c" + core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c" APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ app/ui/settings.c app/ui/search.c app/ui/ncm_colors.c app/ui/chrome.c \ app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \ diff --git a/patch_v2/ncm_app/build_ncm_sims.sh b/patch_v2/ncm_app/build_ncm_sims.sh index df35497..95053c6 100755 --- a/patch_v2/ncm_app/build_ncm_sims.sh +++ b/patch_v2/ncm_app/build_ncm_sims.sh @@ -59,7 +59,8 @@ gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -c app/logic/appstate.c -o /tmp/ws_app.o gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -c app/logic/songmeta.c -o /tmp/ws_meta.o -gcc -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o -lpthread +gcc -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o \ + out/core-host/ncm_apicache.o -lpthread # appstate.c 调 ncm_apicache_init/clear(自包含模块) run_sim window_sim echo "==> build search_sim" @@ -67,7 +68,8 @@ gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icor -c bench/search_sim.c -o /tmp/sh.o gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ -Icore -Ivendor/jsmn -c app/logic/appstate.c -o /tmp/sh_app.o -gcc -O2 -Wl,--gc-sections -o search_sim /tmp/sh.o /tmp/sh_app.o -lpthread +gcc -O2 -Wl,--gc-sections -o search_sim /tmp/sh.o /tmp/sh_app.o \ + out/core-host/ncm_apicache.o -lpthread # appstate.c 调 ncm_apicache_init/clear run_sim search_sim echo "==> build cache_evict_sim" @@ -76,4 +78,17 @@ gcc -O2 -DJSMN_HEADER -I. -Icore -Ivendor/mbedtls/include -c core/ncm_cache.c -o gcc -O2 -o cache_evict_sim /tmp/ce.o /tmp/ce_cache.o -lpthread run_sim cache_evict_sim +echo "==> build apicache_sim (风控加固 §1 接口级响应缓存;0 真实网络——ncm_http 计数桩)" +gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd \ + -Ivendor/jsmn -Ivendor/mbedtls/include \ + -c bench/apicache_sim.c -o /tmp/ac.o +# 真实 core(api/req/crypto/json/common/apicache)+ 网络层计数桩(apicache_sim 已含 ncm_http_request 桩, +# 故不链 ncm_http.o)+ jsmn + 宿主 mbedtls;核心对象从 out/core-host/*.o 取(含 ncm_apicache.o)。 +gcc -O2 -o apicache_sim /tmp/ac.o \ + $(ls out/core-host/ncm_api.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_apicache.o) \ + out/core-host/jsmn_impl.o \ + -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm +run_sim apicache_sim + echo "===== all sims done =====" diff --git a/patch_v2/ncm_app/core/ncm.h b/patch_v2/ncm_app/core/ncm.h index f29af6d..e02260d 100644 --- a/patch_v2/ncm_app/core/ncm.h +++ b/patch_v2/ncm_app/core/ncm.h @@ -15,6 +15,7 @@ #include "ncm_login.h" #include "ncm_api.h" #include "ncm_cache.h" +#include "ncm_apicache.h" #define NCM_CTX_CA_PATH_MAX 256 diff --git a/patch_v2/ncm_app/core/ncm_api.c b/patch_v2/ncm_app/core/ncm_api.c index f74190a..ac8a495 100644 --- a/patch_v2/ncm_app/core/ncm_api.c +++ b/patch_v2/ncm_app/core/ncm_api.c @@ -1,5 +1,6 @@ /* ncm_api.c — 歌单/播放数据接口实现(响应/token 走堆,避免大缓冲驻栈) */ #include "ncm_api.h" +#include "ncm_apicache.h" #include "ncm_common.h" #include "ncm_http.h" #include "ncm_json.h" @@ -11,7 +12,7 @@ /* 响应缓冲上界(内存瘦身,OOM 修复):多数接口响应远小于 1MB,按需给尺寸避免每次调用 * 都 malloc 1MB 堆峰值(播放中 song_url/lyric/song_detail 并发,几 × 1MB 是可砍的瞬态)。 - * 仅 playlist detail / track_all(全量 trackIds,最多 NCM_TRACKID_MAX 条)保留传统 1MB。 */ + * 仅 playlist detail(全量 trackIds,最多 NCM_TRACKID_MAX 条)保留传统 2MB。 */ #define RESP_SMALL (64 * 1024) /* song_url:url+字段,实际 ~1KB */ #define RESP_MED (256 * 1024) /* lyric/song_detail/playlist/cloudsearch:实际 ≤100KB */ @@ -62,6 +63,34 @@ typedef struct api_json { static void api_free(api_json_t *c); +/* 解析 c->rbuf/c->resp.body 为 JSON + 业务码判定(api_fetch 与缓存命中共用): + * 前置:c->rbuf 指向响应体缓冲、c->resp.body/body_len 已就位。 */ +static int api_parse_body(api_json_t *c) { + jsmn_parser p; + jsmn_init(&p); + int n = jsmn_parse(&p, c->resp.body, c->resp.body_len, NULL, 0); + if (n <= 0) { free(c->rbuf); c->rbuf = NULL; return NCM_ERR_JSON; } + c->toks = (jsmntok_t *)malloc(sizeof(jsmntok_t) * (size_t)n); + c->parent = (int *)malloc(sizeof(int) * (size_t)n); + if (!c->toks || !c->parent) { + free(c->rbuf); free(c->toks); free(c->parent); + c->rbuf = NULL; c->toks = NULL; c->parent = NULL; + return NCM_ERR_OOM; + } + int rc = ncm_json_parse(&c->j, c->resp.body, c->resp.body_len, c->toks, n, c->parent); + if (rc < 0) { api_free(c); return NCM_ERR_JSON; } + c->ntoks = n; + + long code; + if (ncm_json_path_int(&c->j, "code", &code) == NCM_OK && code != 200) { + int ec = NCM_ERR_HTTP; + if (code == 301 || code == -460 || code == 2000 || code == 201) ec = NCM_ERR_AUTH; + api_free(c); + return ec; + } + return NCM_OK; +} + static int api_post(const char *device_id, const char *cookie, const char *path, const char *json, int kind, size_t resp_cap, api_json_t *c) { char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX]; @@ -78,30 +107,66 @@ static int api_post(const char *device_id, const char *cookie, const char *path, if (!c->rbuf) return NCM_ERR_OOM; rc = ncm_http_request(&req, c->rbuf, resp_cap, &c->resp); if (rc != NCM_OK) { free(c->rbuf); c->rbuf = NULL; return rc; } + return api_parse_body(c); +} - jsmn_parser p; - jsmn_init(&p); - int n = jsmn_parse(&p, c->resp.body, c->resp.body_len, NULL, 0); - if (n <= 0) { free(c->rbuf); c->rbuf = NULL; return NCM_ERR_JSON; } - c->toks = (jsmntok_t *)malloc(sizeof(jsmntok_t) * (size_t)n); - c->parent = (int *)malloc(sizeof(int) * (size_t)n); - if (!c->toks || !c->parent) { - free(c->rbuf); free(c->toks); free(c->parent); - c->rbuf = NULL; c->toks = NULL; c->parent = NULL; - return NCM_ERR_OOM; - } - rc = ncm_json_parse(&c->j, c->resp.body, c->resp.body_len, c->toks, n, c->parent); - if (rc < 0) { api_free(c); return NCM_ERR_JSON; } - c->ntoks = n; +/* ---- 接口级响应短 TTL 缓存(风控加固 §1)---- + * 目标接口:user_playlist / playlist_detail / song_detail / cloudsearch(只读幂等)。 + * 其余(song_url/lyric/vip)不经此路径——逐曲、需新鲜、或低频。 + * 手动刷新语义:ncm_api_cache_refresh_path 标记的路径下一次调用绕过缓存(不读不写)。 + * 键 = md5(path + \x01 + json),内存+可选盘都由 ncm_apicache 管。 */ +static char g_refresh_path[64]; /* 待绕过的路径(一次性消费) */ - long code; - if (ncm_json_path_int(&c->j, "code", &code) == NCM_OK && code != 200) { - int ec = NCM_ERR_HTTP; - if (code == 301 || code == -460 || code == 2000 || code == 201) ec = NCM_ERR_AUTH; - api_free(c); - return ec; +void ncm_api_cache_refresh_path(const char *path) { + if (!path) return; + ncm_strlcpy(g_refresh_path, path, sizeof g_refresh_path); +} + +/* 命中「手动刷新」目标路径则消费并返回 1(此调用绕过缓存)。 */ +static int api_refresh_match(const char *path) { + if (!g_refresh_path[0]) return 0; + if (ncm_streq(g_refresh_path, path)) { g_refresh_path[0] = 0; return 1; } + return 0; +} + +/* 缓存键(内存+盘):md5(path + '\x01' + json)。堆拼避免大 json 驻栈。 */ +static void api_cache_key(const char *path, const char *json, char *out, size_t out_sz) { + size_t pl = strlen(path), jl = strlen(json); + char *buf = (char *)malloc(pl + jl + 2); + if (!buf) { out[0] = 0; return; } + memcpy(buf, path, pl); + buf[pl] = '\x01'; + memcpy(buf + pl + 1, json, jl + 1); + ncm_md5_hex(buf, pl + jl + 1, out); /* 32 hex + NUL */ + free(buf); + if (out_sz) out[out_sz - 1] = 0; +} + +/* 带缓存的 POST:先查内存/盘缓存(TTL 内命中则直接解析回填);miss/过期/手动刷新 → 真网络, + * 成功且非手动刷新 → 写缓存。 */ +static int api_post_cached(const char *device_id, const char *cookie, const char *path, + const char *json, int kind, size_t resp_cap, api_json_t *c) { + int refresh = api_refresh_match(path); + char key[NCM_APICACHE_KEY_MAX]; + api_cache_key(path, json, key, sizeof key); + + if (!refresh && key[0]) { + char *buf = (char *)malloc(resp_cap); + if (buf) { + size_t len = 0; + if (ncm_apicache_get(key, buf, resp_cap, &len) == NCM_OK) { + c->rbuf = buf; + c->resp.body = buf; /* 缓存的 body 从缓冲头开始 */ + c->resp.body_len = len; + return api_parse_body(c); + } + free(buf); /* miss/过期 → 走真网络 */ + } } - return NCM_OK; + int rc = api_post(device_id, cookie, path, json, kind, resp_cap, c); + if (rc == NCM_OK && key[0] && !refresh) + ncm_apicache_put(key, c->resp.body, c->resp.body_len); + return rc; } static void api_free(api_json_t *c) { @@ -131,7 +196,7 @@ static int obj_bool(const ncm_json_t *j, int tok, const char *key) { return (strcmp(s, "true") == 0); } -/* 解析一处「歌曲对象数组」(song_detail / cloudsearch / playlist_track_all 共用)。 +/* 解析一处「歌曲对象数组」(song_detail / cloudsearch / track_all 离线解析 共用)。 * 元素字段:id/name/ar[0].name/al.name/al.picUrl/dt/fee。id 可为数字或字符串(均以文本拷贝, * 兼容 >2^31 的歌曲 id)。返回 NCM_OK,*out_n=写入数。 */ static int parse_songs_array(const ncm_json_t *j, int arr, ncm_song_t *out, int max, int *out_n) @@ -173,8 +238,8 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char uid, limit > 0 ? limit : 30, offset > 0 ? offset : 0); api_json_t c; memset(&c, 0, sizeof c); - int rc = api_post(device_id, cookie, "/api/user/playlist", json, NCM_CRYPT_WEAPI, - RESP_MED, &c); + int rc = api_post_cached(device_id, cookie, "/api/user/playlist", json, NCM_CRYPT_WEAPI, + RESP_MED, &c); if (rc != NCM_OK) return rc; int arr = ncm_json_path(&c.j, "playlist"); @@ -209,8 +274,8 @@ int ncm_api_playlist_detail(const char *device_id, const char *cookie, const cha snprintf(json, sizeof json, "{\"id\":\"%s\",\"n\":1,\"s\":8}", playlist_id); api_json_t c; memset(&c, 0, sizeof c); - int rc = api_post(device_id, cookie, "/api/v6/playlist/detail", json, NCM_CRYPT_EAPI, - 2 * 1024 * 1024, &c); + int rc = api_post_cached(device_id, cookie, "/api/v6/playlist/detail", json, NCM_CRYPT_EAPI, + 2 * 1024 * 1024, &c); if (rc != NCM_OK) return rc; int pl = ncm_json_path(&c.j, "playlist"); @@ -253,8 +318,8 @@ int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t snprintf(json, sizeof json, "{\"c\":\"%s\"}", clist); api_json_t c; memset(&c, 0, sizeof c); - int rc = api_post(device_id, cookie, "/api/v3/song/detail", json, NCM_CRYPT_EAPI, - RESP_MED, &c); + int rc = api_post_cached(device_id, cookie, "/api/v3/song/detail", json, NCM_CRYPT_EAPI, + RESP_MED, &c); if (rc != NCM_OK) return rc; int arr = ncm_json_path(&c.j, "songs"); @@ -532,8 +597,8 @@ static int cloudsearch_post(const char *device_id, const char *cookie, const cha esc[ei] = 0; snprintf(json, sizeof json, "{\"s\":\"%s\",\"type\":%d,\"offset\":%d,\"limit\":%d,\"total\":true}", esc, type, offset > 0 ? offset : 0, limit > 0 ? limit : 30); - return api_post(device_id, cookie, "/api/cloudsearch/pc", json, NCM_CRYPT_WEAPI, - RESP_MED, c); + return api_post_cached(device_id, cookie, "/api/cloudsearch/pc", json, NCM_CRYPT_WEAPI, + RESP_MED, c); } /* 解析一处「歌单对象数组」(cloudsearch type=1000)。id 可为数字/字符串(均文本拷贝)。 */ @@ -609,9 +674,13 @@ int ncm_api_cloudsearch(const char *device_id, const char *cookie, const char *k return ncm_api_cloudsearch_song(device_id, cookie, keyword, 0, max, out, max, out_n, NULL); } -/* 从一段 track/all 响应 JSON 串解析歌曲数组 + total(离线样本/单元测试用,不打网络)。 +/* 从一段「track/all 形态」响应 JSON 串解析歌曲数组 + total(离线样本/单测用,不打网络)。 * 数组取「songs → playlist.tracks → data.songs」首个命中;total 取「total → count → - * playlist.trackCount → musicCount」,全缺则 0。 */ + * playlist.trackCount → musicCount」,全缺则 0。 + * ⚠ 接口核实:`/api/playlist/track/all` 在网易云真实服务器**不存在**(NCMAPI_NOTES §1.1, + * 8 形态全 404)——真实取全量曲目走 v6/playlist/detail(trackIds) + v3/song/detail 分批。 + * 本函数**保留仅为离线单测/样本回放**,勿用于任何真实网络请求(原网络封装 + * ncm_api_playlist_track_all 已随端点核实为不存在而移除)。 */ int ncm_api_parse_track_all(const char *json, size_t len, ncm_song_t *out, int max, int *out_n, int64_t *total) { if (out_n) *out_n = 0; @@ -646,27 +715,3 @@ int ncm_api_parse_track_all(const char *json, size_t len, ncm_song_t *out, int m free(toks); free(parent); return rc; } - -int ncm_api_playlist_track_all(const char *device_id, const char *cookie, const char *playlist_id, - int offset, int limit, ncm_song_t *out, int max, int *out_n, - int64_t *total) { - if (out_n) *out_n = 0; - if (limit <= 0) limit = 50; - if (limit > max) limit = max; - if (offset < 0) offset = 0; - - char json[256]; - snprintf(json, sizeof json, "{\"id\":\"%s\",\"offset\":%d,\"limit\":%d,\"n\":%d,\"s\":8}", - playlist_id, offset, limit, limit); - - api_json_t c; memset(&c, 0, sizeof c); - int rc = api_post(device_id, cookie, "/api/playlist/track/all", json, NCM_CRYPT_EAPI, - NCM_RESP_MAX, &c); - if (rc != NCM_OK) return rc; - - int64_t tot = 0; - rc = ncm_api_parse_track_all(c.resp.body, c.resp.body_len, out, max, out_n, &tot); - if (rc == NCM_OK && total) *total = tot; - api_free(&c); - return rc; -} diff --git a/patch_v2/ncm_app/core/ncm_api.h b/patch_v2/ncm_app/core/ncm_api.h index 127e465..b3ecaf5 100644 --- a/patch_v2/ncm_app/core/ncm_api.h +++ b/patch_v2/ncm_app/core/ncm_api.h @@ -158,20 +158,22 @@ int ncm_api_cloudsearch_playlist(const char *device_id, const char *cookie, cons int ncm_api_cloudsearch(const char *device_id, const char *cookie, const char *keyword, ncm_song_t *out, int max, int *out_n); -/* 歌单曲目服务端分页(eapi /api/playlist/track/all,REPORT §11 二期)。 - * 相对 /v6/playlist/detail 的「全量 trackIds(5191 首 ≈900KB)」改进:本端点按 - * offset/limit 直接返回该页的歌曲对象(含 al.picUrl,无需再 song_detail 二次请求)。 - * 首屏 offset=0 limit=50;滚动到底继续 offset+=limit。out 填 ncm_song_t。 - * *total 返回歌单总曲数(0=未知;调用方可退化为 playlist.trackCount)。 */ -int ncm_api_playlist_track_all(const char *device_id, const char *cookie, const char *playlist_id, - int offset, int limit, ncm_song_t *out, int max, int *out_n, - int64_t *total); - -/* 离线解析:从一段 track/all 响应 JSON 串解析歌曲数组(含 al.picUrl)+ total。 - * 网络版内部同一解析器;供「宿主离线样本重放」与单元测试用,不打网络。 */ +/* 离线解析:从一段「track/all 形态」响应 JSON 串解析歌曲数组(含 al.picUrl)+ total。 + * 供「宿主离线样本重放」与单元测试用(bench/offline_track_all.c / bench/probe_track_all.c / + * test_ncm_core.c),不打网络。 + * ⚠ 接口核实:`/api/playlist/track/all` 在网易云真实服务器**不存在**(NCMAPI_NOTES §1.1, + * 8 形态全 404)——真实取全量曲目走 v6/playlist/detail(trackIds) + v3/song/detail 分批。 + * 本函数**保留仅为离线单测/样本回放**,勿用于任何真实网络请求(原网络封装 + * ncm_api_playlist_track_all 已随端点核实为不存在而移除)。 */ int ncm_api_parse_track_all(const char *json, size_t len, ncm_song_t *out, int max, int *out_n, int64_t *total); +/* 手动刷新语义(风控加固 §1 缓存):标记某个 API 路径的**下一次**调用绕过响应缓存 + * (不读不写;用完即消费)。用于「手动刷新/下拉刷新」——home topbar ↻(user/playlist)、 + * 歌单 topbar ↻(v6/playlist/detail)。cloudsearch 无手动刷新概念(换关键词即新意图), + * 不标记。非目标路径不消费(可并发各标记各的)。 */ +void ncm_api_cache_refresh_path(const char *path); + #ifdef __cplusplus } #endif diff --git a/patch_v2/ncm_app/core/ncm_apicache.c b/patch_v2/ncm_app/core/ncm_apicache.c new file mode 100644 index 0000000..d4548ef --- /dev/null +++ b/patch_v2/ncm_app/core/ncm_apicache.c @@ -0,0 +1,297 @@ +/* ncm_apicache.c — 接口级 JSON 响应短 TTL 缓存实现(见 ncm_apicache.h) + * + * 自包含:只用 libc + pthread + dirent/stat + clock_gettime,不依赖 ncm_store/ncm_crypto/ + * ncm_common 的任何**函数**(独立单测,可独立链接到任何 sim,无符号冲突);仅取 + * ncm_common.h 的错误码枚举(NCM_OK/NCM_ERR_*,编译期常量,不产生链接引用)。 + * 时间:内存条目以单调钟(CLOCK_MONOTONIC)判 TTL;盘条目以文件头墙钟毫秒(CLOCK_REALTIME) + * 判 TTL(需跨进程/重启存活)。 + */ +#include "ncm_apicache.h" +#include "ncm_common.h" /* 仅错误码枚举;不调用其函数,故无链接依赖 */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 内存表:固定槽数 LRU(每槽一个响应体)。槽数×单槽上限共同约束常驻内存: + * 4 × 128KB = 512KB 峰值,低于现有瞬态缓冲预算(REPORT §32 内存瘦身口径)。 */ +#define MAX_ENTRIES 4 +#define BODY_MAX (128 * 1024) +#define DISK_DIR_MAX 512 +#define DISK_SUBDIR "ncm_api_cache" +#define DISK_PATH_MAX 1024 /* 目录+文件名拼接缓冲(≥ dir 512 + / + name 255 + nul,免 -Wformat-truncation) */ +#define DISK_PURGE_MS (30 * 1000) /* 盘过期清理节流(30s) */ + +typedef struct { + char key[NCM_APICACHE_KEY_MAX]; /* md5 hex + NUL;空串=空槽 */ + char *body; /* 堆上响应体快照 */ + size_t body_len; + int64_t mono_ms; /* 存槽单调钟(TTL 判定) */ + int64_t last_ms; /* 最近命中(LRU 淘汰) */ +} entry_t; + +static entry_t g_e[MAX_ENTRIES]; +static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; /* 内存表锁 */ +static pthread_mutex_t g_disk_mu = PTHREAD_MUTEX_INITIALIZER; /* 盘缓存锁(串行 I/O) */ +static char g_disk_dir[DISK_DIR_MAX]; +static int g_disk_on = 0; +static int g_ttl_s = NCM_APICACHE_TTL_S; /* 默认 120s */ +static int64_t g_last_purge_ms = 0; /* 盘过期清理节流(单调钟)*/ + +static int64_t clock_ms(clockid_t id) { + struct timespec ts; + if (clock_gettime(id, &ts) != 0) return 0; + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} +static int64_t now_mono(void) { return clock_ms(CLOCK_MONOTONIC); } +static int64_t now_wall(void) { return clock_ms(CLOCK_REALTIME); } + +void ncm_apicache_ttl_set(int seconds) +{ + pthread_mutex_lock(&g_mu); + g_ttl_s = (seconds < 0) ? NCM_APICACHE_TTL_S : seconds; + pthread_mutex_unlock(&g_mu); +} + +static void slot_clear_locked(int i) +{ + free(g_e[i].body); + g_e[i].body = NULL; + g_e[i].body_len = 0; + g_e[i].mono_ms = 0; + g_e[i].last_ms = 0; + g_e[i].key[0] = 0; +} + +int ncm_apicache_mem_count(void) +{ + pthread_mutex_lock(&g_mu); + int c = 0; + for (int i = 0; i < MAX_ENTRIES; i++) if (g_e[i].body) c++; + pthread_mutex_unlock(&g_mu); + return c; +} + +/* ---------- 盘缓存(可选) ---------- */ + +static void disk_path(const char *key, char *out, size_t out_sz) +{ + snprintf(out, out_sz, "%s/%s/%s.json", g_disk_dir, DISK_SUBDIR, key); +} + +/* mkdir data_dir/ncm_api_cache:目录已存在(EEXIST)静默成功。 */ +static void ensure_disk_dir(void) +{ + char p[DISK_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", g_disk_dir, DISK_SUBDIR); + if (mkdir(p, 0777) != 0 && errno != EEXIST) { /* 静默:后续写失败自然返回 */ } +} + +/* 删除盘缓存目录内所有文件(清空)。 */ +static void disk_purge_all(void) +{ + char d[DISK_DIR_MAX + 32]; + snprintf(d, sizeof d, "%s/%s", g_disk_dir, DISK_SUBDIR); + DIR *dir = opendir(d); + if (!dir) return; + struct dirent *de; + while ((de = readdir(dir)) != NULL) { + if (de->d_name[0] == '.') continue; + char p[DISK_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", d, de->d_name); + unlink(p); + } + closedir(dir); +} + +/* 删除过期盘文件(以文件 mtime 判 TTL,写入即刷新)。用于启动 + 写入节流清理,磁盘有界。 */ +static void disk_purge_expired(void) +{ + char d[DISK_DIR_MAX + 32]; + snprintf(d, sizeof d, "%s/%s", g_disk_dir, DISK_SUBDIR); + DIR *dir = opendir(d); + if (!dir) return; + int64_t now = now_wall(); + int64_t ttl = (int64_t)g_ttl_s * 1000; + struct dirent *de; + while ((de = readdir(dir)) != NULL) { + if (de->d_name[0] == '.') continue; + size_t nl = strlen(de->d_name); + if (nl < 5 || strcmp(de->d_name + nl - 5, ".json") != 0) continue; + char p[DISK_PATH_MAX]; + snprintf(p, sizeof p, "%s/%s", d, de->d_name); + struct stat st; + if (stat(p, &st) != 0) { unlink(p); continue; } + if (ttl > 0 && now - (int64_t)st.st_mtime * 1000 > ttl) unlink(p); + } + closedir(dir); +} + +/* 盘 get:读 8B 墙钟头 + body。过期 → 删文件、miss。调用方已持 g_disk_mu。 */ +static int disk_get_locked(const char *key, char *buf, size_t buf_sz, size_t *len) +{ + char p[DISK_PATH_MAX]; + disk_path(key, p, sizeof p); + FILE *f = fopen(p, "rb"); + if (!f) return NCM_ERR_NOENT; + int64_t ts = 0; + if (fread(&ts, 1, sizeof ts, f) != sizeof ts) { fclose(f); unlink(p); return NCM_ERR_IO; } + int64_t now = now_wall(); + int64_t ttl = (int64_t)g_ttl_s * 1000; + if (ttl > 0 && now - ts > ttl) { fclose(f); unlink(p); return NCM_ERR_NOENT; } + if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return NCM_ERR_IO; } + long fl = ftell(f); + if (fl <= (long)sizeof ts) { fclose(f); unlink(p); return NCM_ERR_IO; } + if (fseek(f, (long)sizeof ts, SEEK_SET) != 0) { fclose(f); return NCM_ERR_IO; } + size_t blen = (size_t)(fl - (long)sizeof ts); + if (blen > buf_sz) { fclose(f); return NCM_ERR_FULL; } + size_t rd = fread(buf, 1, blen, f); + fclose(f); + if (rd != blen) return NCM_ERR_IO; + if (len) *len = rd; + return NCM_OK; +} + +static void disk_put_locked(const char *key, const char *body, size_t len) +{ + ensure_disk_dir(); + int64_t now = now_mono(); + if (now - g_last_purge_ms > DISK_PURGE_MS) { g_last_purge_ms = now; disk_purge_expired(); } + char p[DISK_PATH_MAX]; + disk_path(key, p, sizeof p); + FILE *f = fopen(p, "wb"); + if (!f) return; + int64_t ts = now_wall(); + fwrite(&ts, 1, sizeof ts, f); + fwrite(body, 1, len, f); + fclose(f); +} + +static void disk_put(const char *key, const char *body, size_t len) +{ + if (!g_disk_on) return; + pthread_mutex_lock(&g_disk_mu); + disk_put_locked(key, body, len); + pthread_mutex_unlock(&g_disk_mu); +} + +static int disk_get(const char *key, char *buf, size_t buf_sz, size_t *len) +{ + if (!g_disk_on) return NCM_ERR_NOENT; + pthread_mutex_lock(&g_disk_mu); + int rc = disk_get_locked(key, buf, buf_sz, len); + pthread_mutex_unlock(&g_disk_mu); + return rc; +} + +/* ---------- 内存表 ---------- */ + +int ncm_apicache_get(const char *key, char *buf, size_t buf_sz, size_t *len) +{ + if (!key || !buf || buf_sz == 0) return NCM_ERR_ARG; + if (len) *len = 0; + int64_t now = now_mono(); + int64_t ttl = (int64_t)g_ttl_s * 1000; + + pthread_mutex_lock(&g_mu); + int idx = -1; + for (int i = 0; i < MAX_ENTRIES; i++) + if (g_e[i].body && strcmp(g_e[i].key, key) == 0) { idx = i; break; } + if (idx >= 0) { + if (ttl > 0 && now - g_e[idx].mono_ms > ttl) { + slot_clear_locked(idx); /* 过期 → 槽位作废,落盘兜底 */ + } else { + size_t n = g_e[idx].body_len; + if (n > buf_sz) { pthread_mutex_unlock(&g_mu); return NCM_ERR_FULL; } + memcpy(buf, g_e[idx].body, n); + g_e[idx].last_ms = now; /* LRU touch */ + if (len) *len = n; + pthread_mutex_unlock(&g_mu); + return NCM_OK; + } + } + pthread_mutex_unlock(&g_mu); + + /* 内存 miss → 盘缓存兜底(命中回填内存)。 */ + if (g_disk_on) { + int rc = disk_get(key, buf, buf_sz, len); + if (rc == NCM_OK) ncm_apicache_put(key, buf, *len); + return rc; + } + return NCM_ERR_NOENT; +} + +void ncm_apicache_put(const char *key, const char *body, size_t len) +{ + if (!key || !body || len == 0) return; + int64_t now = now_mono(); + + disk_put(key, body, len); /* 盘层(范围外,避免持内存锁期间做 I/O) */ + + pthread_mutex_lock(&g_mu); + int idx = -1; + for (int i = 0; i < MAX_ENTRIES; i++) + if (g_e[i].body && strcmp(g_e[i].key, key) == 0) { idx = i; break; } + if (idx < 0) { + for (int i = 0; i < MAX_ENTRIES; i++) if (!g_e[i].body) { idx = i; break; } + if (idx < 0) { /* 满 → 逐 LRU 最旧 */ + int64_t mn = g_e[0].last_ms; idx = 0; + for (int i = 1; i < MAX_ENTRIES; i++) + if (g_e[i].last_ms < mn) { mn = g_e[i].last_ms; idx = i; } + slot_clear_locked(idx); + } + } + if (len > (size_t)BODY_MAX) { + /* 超大响应(大歌单 trackIds >128KB):不入内存(内存瘦身),仅盘缓存(若开启)。 + * 本槽若曾存本 key 旧值,一并作废,改由盘兜底。 */ + slot_clear_locked(idx); + pthread_mutex_unlock(&g_mu); + return; + } + char *nb = (char *)malloc(len); + if (!nb) { pthread_mutex_unlock(&g_mu); return; } + memcpy(nb, body, len); + slot_clear_locked(idx); + strncpy(g_e[idx].key, key, sizeof g_e[idx].key - 1); + g_e[idx].key[sizeof g_e[idx].key - 1] = 0; + g_e[idx].body = nb; + g_e[idx].body_len = len; + g_e[idx].mono_ms = now; + g_e[idx].last_ms = now; + pthread_mutex_unlock(&g_mu); +} + +void ncm_apicache_clear(void) +{ + pthread_mutex_lock(&g_mu); + for (int i = 0; i < MAX_ENTRIES; i++) slot_clear_locked(i); + pthread_mutex_unlock(&g_mu); + if (g_disk_on) { + pthread_mutex_lock(&g_disk_mu); + disk_purge_all(); + pthread_mutex_unlock(&g_disk_mu); + } +} + +void ncm_apicache_init(const char *data_dir) +{ + if (data_dir && data_dir[0]) { + strncpy(g_disk_dir, data_dir, sizeof g_disk_dir - 1); + g_disk_dir[sizeof g_disk_dir - 1] = 0; + pthread_mutex_lock(&g_disk_mu); + g_disk_on = 1; + ensure_disk_dir(); + disk_purge_expired(); /* 启动清一次过期盘文件 */ + pthread_mutex_unlock(&g_disk_mu); + } else { + g_disk_dir[0] = 0; + g_disk_on = 0; + } +} diff --git a/patch_v2/ncm_app/core/ncm_apicache.h b/patch_v2/ncm_app/core/ncm_apicache.h new file mode 100644 index 0000000..af20575 --- /dev/null +++ b/patch_v2/ncm_app/core/ncm_apicache.h @@ -0,0 +1,61 @@ +/* ncm_apicache.h — 接口级 JSON 响应短 TTL 缓存(风控加固 §1) + * + * 对齐参考 NeteaseCloudMusicApi 的 express 层 apicache「2 分钟全局缓存」,防高频拉取 + * 触发网易服务器「ip 高频错误 / 503」。目标接口:user_playlist / playlist_detail / + * song_detail / cloudsearch(只读、幂等)。song_url / lyric / vip 等**不入缓存** + * (逐曲/低频/需新鲜)。 + * + * 两级: + * - 内存:固定 4 槽 LRU(每槽 ≤128KB,TTL 120s)。键=接口+参数 md5(32hex)。 + * 超 TTL 过期;超容量逐最旧;登录/换账号可 ncm_apicache_clear 全清。 + * - 盘(可选):/ncm_api_cache/.json。文件头 8B 墙钟毫秒 + body。 + * 超 TTL 视为 miss(并删除);写入时按 30s 节流清过期文件,磁盘占用有界。 + * 盘缓存跨进程/热更保留,是本机防高频的持久层(songmeta 同类,接口级 JSON 新加)。 + * + * 失效纪律:手动刷新/下拉刷新经 ncm_api_cache_refresh_path 标记 → 该路径下一次调用 + * 绕过缓存(不读不写);登出/换账号经 ncm_apicache_clear 全清(防串账号)。盘缓存超 TTL + * 自然过期,刷新不写盘。 + * + * 模块自包含(仅 libc + pthread),不依赖 ncm_store/ncm_crypto,便于独立单测。 + */ +#ifndef NCM_APICACHE_H +#define NCM_APICACHE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 默认 TTL(秒):对接参考 apicache 2 分钟。 */ +#define NCM_APICACHE_TTL_S 120 + +/* 内存表键最大长度(md5 hex 32 + NUL)。 */ +#define NCM_APICACHE_KEY_MAX 33 + +/* 初始化:设数据目录用于盘缓存 /ncm_api_cache/。dir 为 NULL 或空 → 仅内存缓存 + * (盘缓存关闭;test_ncm_core/离线单测用)。非空则在启动时清一次过期盘文件。 */ +void ncm_apicache_init(const char *data_dir); + +/* 取一份新鲜响应。key=接口+参数 hash(调用方管)。命中:*buf 填响应体、*len 填长度、返 NCM_OK; + * 未命中/过期 → NCM_ERR_NOENT(过期项删除,盘文件留给后续清理)。buf_sz 不足 → NCM_ERR_FULL。 */ +int ncm_apicache_get(const char *key, char *buf, size_t buf_sz, size_t *len); + +/* 存一份响应体到内存(≤BODY_MAX 才入内存;更大仅入盘)+ 盘(若开启)。 + * body==NULL/len==0 忽略。 */ +void ncm_apicache_put(const char *key, const char *body, size_t len); + +/* 清空(内存全槽 + 盘缓存目录所有文件)。登出/换账号调用,防串账号数据。 */ +void ncm_apicache_clear(void); + +/* 覆盖 TTL(秒;<0 还原默认)。仅测试/内部用,运行期可留默认。 */ +void ncm_apicache_ttl_set(int seconds); + +/* 当前内存条目数(诊断/测试)。 */ +int ncm_apicache_mem_count(void); + +#ifdef __cplusplus +} +#endif +#endif /* NCM_APICACHE_H */ diff --git a/patch_v2/ncm_app/core/ncm_req.c b/patch_v2/ncm_app/core/ncm_req.c index 9e63e6f..96224d9 100644 --- a/patch_v2/ncm_app/core/ncm_req.c +++ b/patch_v2/ncm_app/core/ncm_req.c @@ -231,6 +231,10 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path, unsigned char r[4]; ncm_random_bytes(r, 4); ncm_hex_encode(r, 4, nmtid, 1); /* 大写 */ ck_set_default(&m, "NMTID", nmtid); + /* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U 时补 MUSIC_A=anonymous_token, + * 对齐参考「常驻 cookie 伪装」;需登录接口未登录会 301 而非 400(reduce 风控误判)。 + * 不覆盖已存在的 MUSIC_A(若会话 cookie 自带),也不在 login 路径注入。 */ + if (ck_find(&m, "MUSIC_U") < 0) ck_set_default(&m, "MUSIC_A", "anonymous_token"); } char cookie_full[4096]; ck_to_string(&m, cookie_full, sizeof cookie_full); @@ -281,6 +285,14 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path, char cookie_hdr[2048]; hm_to_cookie(&h, cookie_hdr, sizeof cookie_hdr); + /* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U 时补 MUSIC_A=anonymous_token + * 到 eapi Cookie 头(weapi 分支同样注入);login 路径不注入。仅检测存在性 + * (MUSIC_U 值可能很长,无需拷出)。 */ + if (!strstr(path, "login")) { + char musicu[8]; + if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) != NCM_OK) + ncm_strlcat(cookie_hdr, "; MUSIC_A=anonymous_token", sizeof cookie_hdr); + } int n = snprintf(hdrs, hdrs_sz, "User-Agent: %s\r\nContent-Type: application/x-www-form-urlencoded\r\nCookie: %s", diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index 91f232c..e4a1297 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index cbcab02..b492ee5 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ diff --git a/patch_v2/ncm_app/test/test_ncm_core_host b/patch_v2/ncm_app/test/test_ncm_core_host index da6bb5a..d135b44 100755 Binary files a/patch_v2/ncm_app/test/test_ncm_core_host and b/patch_v2/ncm_app/test/test_ncm_core_host differ