feat(ncm): 风控加固——接口响应短 TTL 缓存 + 游客 MUSIC_A + track/all 死代码

三(按 NCMAPI_NOTES §2.1/§4/§6):
① 接口级响应缓存(对齐参考 apicache 2 分钟,防风控高频 ip 错误):
   - 新增 core/ncm_apicache.{c,h}——内存 4 槽 LRU(单槽≤128KB,TTL 120s,
     键=md5(path+\x01+json))+ 可选盘缓存 <data_dir>/ncm_api_cache/.json
     (8B 墙钟头+body,超 TTL miss 并删,30s 节流清过期)。自包含模块
     (仅 libc+pthread+clock_gettime),可独立链接无符号冲突。
   - ncm_api.c:api_post 拆「请求+解析」两段,新增 api_post_cached;四个只读
     接口接线:user_playlist/playlist_detail/song_detail/cloudsearch。
     song_url/lyric/vip 不入缓存。
   - 手动刷新绕过:ncm_api_cache_refresh_path(path) 一次性标记;home/歌单
     topbar ↻ 接入;playlist_open/search 不标记(走缓存)。
   - 盘层挂载 appstate_init;登出 appstate_logout 全清(防串账号)。
② 游客 MUSIC_A:ncm_req.c weapi+eapi 无 MUSIC_U 且非 login 路径补
   MUSIC_A=anonymous_token(对齐参考 cookie 伪装)。
③ track/all 死代码清理:移除 ncm_api_playlist_track_all(真实服务器 8 形态全
   404,无调用点);ncm_api_parse_track_all 保留(离线重放/单测),注释标注
   「已验证不存在,勿用于真实请求」。

验证:新 bench/apicache_sim.c(0 网络)①TTL 内不重复请求/②过期重拉/③手动
刷新绕过/④cloudsearch 按键区分/⑤清空重拉/⑥盘缓存写入+clear 全 PASS;stream/
player_flow/window/search/cache_evict(1-9)/pagination/playlist_race sim 全
PASS;test_ncm_core host+mipsel(qemu) PASS;build_ncm.sh -Wall -Wextra 0
warning(text 1026914/data 1664/bss 1363460);0 高频真实请求。新 ncm md5
a69c93538920eba9d42a2ebb42f8fdca(留主会话上卡)。
This commit is contained in:
AskaEth committed 2026-09-02 12:48:34 +08:00
1 parent 9d5301f931
commit ea645ce2a6
17 files changed
+717 -74

No files matched your search

+1
View File
@@ -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
+102 -57
View File
@@ -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;
}
+13 -11
View File
@@ -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
+297
View File
@@ -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 <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <dirent.h>
/* 内存表:固定槽数 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;
}
}
+61
View File
@@ -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 全清。
* - 盘(可选):<data_dir>/ncm_api_cache/<md5>.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 <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* 默认 TTL(秒):对接参考 apicache 2 分钟。 */
#define NCM_APICACHE_TTL_S 120
/* 内存表键最大长度(md5 hex 32 + NUL)。 */
#define NCM_APICACHE_KEY_MAX 33
/* 初始化:设数据目录用于盘缓存 <data_dir>/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 */
+12
View File
@@ -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",