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:
1 parent
9d5301f931
commit
ea645ce2a6
17 files changed
+717
-74
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user