service.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /* ============================== src/service.js ==============================
  2. * 需要「服务端」的业务能力:卡密校验、检查更新。
  3. *
  4. * 这两件事的共同点是:真实场景必须联网核验,但**离线也必须能给出确定行为**。
  5. * 所以每个能力都是「远端优先 + 本地降级」,且降级模式会在界面上如实标注,
  6. * 不会把本地通过伪装成服务端通过(规范:不编造测试结果/接口字段)。
  7. *
  8. * ⚠️ 本地模式的定位必须说清楚:
  9. * 仅校验格式,不具备防伪能力。要真防伪,把 CONFIG.licenseEndpoint
  10. * 指向你自己的校验接口即可——请求/响应结构见 verifyRemote() 注释。
  11. */
  12. var Service = {};
  13. Service.CONFIG = {
  14. /* 留空 = 本地模式。填写后 login() 会先请求远端。 */
  15. licenseEndpoint: "",
  16. licenseTimeoutMs: 8000,
  17. /* 本地模式下的默认有效期(天),仅用于让「到期时间」这一栏有真实含义。 */
  18. localTrialDays: 30,
  19. /* 检查更新的清单地址,留空则用内置版本号自比。 */
  20. updateEndpoint: ""
  21. };
  22. /** 脚本版本。设计稿的「版本信息卡」显示的就是它;build.js 会用构建日期覆盖。 */
  23. Service.SCRIPT_VERSION = "2026.10.13";
  24. Service.CHANGELOG = [
  25. "2026.10.11 数量类设置全部改为「最小值 ~ 最大值」区间(关注/私信/休息/浏览/单次/停止/间隔)",
  26. "2026.10.11 补齐登录页 / 运行状态条 / 拦截弹窗,ID提取页改单选",
  27. "2026.10.10 界面层按设计稿重构,配置项全部落盘",
  28. "2026.10.03 接入设备真实状态(权限 / TikTok 版本)",
  29. "2026.09.28 首个可运行版本"
  30. ];
  31. /* ---------------------------------------------------------------- 卡密 */
  32. /** 去掉空格与连字符并转大写,得到比较用的规范形式。 */
  33. Service.normalizeCode = function (code) {
  34. return String(code || "").replace(/[\s-]/g, "").toUpperCase();
  35. };
  36. /**
  37. * 格式校验:16 位大写字母或数字。
  38. * 只做形态校验——真伪必须靠服务端,本地无法判定一个随机串是不是有效卡密。
  39. */
  40. Service.isWellFormed = function (code) {
  41. return /^[A-Z0-9]{16}$/.test(Service.normalizeCode(code));
  42. };
  43. /** 展示用分组:ABCDEFGHIJKLMNOP → ABCDE-FGHI-JKLM-NOPQ 形态的 4 组。 */
  44. Service.formatCode = function (code) {
  45. var n = Service.normalizeCode(code);
  46. var out = [];
  47. for (var i = 0; i < n.length; i += 4) { out.push(n.substring(i, i + 4)); }
  48. return out.join("-");
  49. };
  50. function httpPostJson(url, payload) {
  51. var body = JSON.stringify(payload);
  52. var response = http.post(url, body, {
  53. headers: { "Content-Type": "application/json" },
  54. timeout: Service.CONFIG.licenseTimeoutMs
  55. });
  56. if (!response || response.statusCode !== 200) {
  57. return null;
  58. }
  59. try {
  60. return JSON.parse(response.body.string());
  61. } catch (e) {
  62. console.log("[service] 卡密响应不是合法 JSON");
  63. return null;
  64. }
  65. }
  66. /**
  67. * 远端校验。
  68. * 请求:POST {endpoint} {"code":"<规范化卡密>"}
  69. * 响应:{"ok":true,"expireAt":"2026-11-09"} 或 {"ok":false,"reason":"卡密不存在"}
  70. * 返回 null 表示「没问到」(网络/格式问题),调用方据此决定是否降级为本地模式。
  71. */
  72. Service.verifyRemote = function (code) {
  73. if (!Service.CONFIG.licenseEndpoint) { return null; }
  74. var data = null;
  75. try {
  76. data = httpPostJson(Service.CONFIG.licenseEndpoint, { code: code });
  77. } catch (e) {
  78. console.log("[service] 卡密远端校验异常: " + e);
  79. return null;
  80. }
  81. if (!data) { return null; }
  82. return {
  83. ok: !!data.ok,
  84. expireAt: data.expireAt || "",
  85. reason: data.reason || "卡密无效"
  86. };
  87. };
  88. /** 今天 + N 天,格式 yyyy-MM-dd。用于本地模式生成到期时间。 */
  89. Service.datePlusDays = function (days) {
  90. var now = new Date();
  91. now.setDate(now.getDate() + Number(days || 0));
  92. var m = now.getMonth() + 1;
  93. var d = now.getDate();
  94. return now.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (d < 10 ? "0" + d : d);
  95. };
  96. Service.today = function () {
  97. return Service.datePlusDays(0);
  98. };
  99. /** 过期判定:只比日期字符串(yyyy-MM-dd 字典序即时间序),不引时区计算。 */
  100. Service.isExpired = function (expireAt) {
  101. if (!expireAt) { return false; }
  102. return Service.today() > String(expireAt);
  103. };
  104. /**
  105. * 登录。返回 { ok, status, message }。
  106. * status: "active" | "expired" | "none"
  107. */
  108. Service.login = function (code) {
  109. var normalized = Service.normalizeCode(code);
  110. if (!Service.isWellFormed(normalized)) {
  111. return { ok: false, status: "none", message: "卡密格式不对,应为 16 位字母或数字" };
  112. }
  113. var remote = Service.verifyRemote(normalized);
  114. if (remote) {
  115. if (!remote.ok) {
  116. return { ok: false, status: "none", message: remote.reason };
  117. }
  118. if (Service.isExpired(remote.expireAt)) {
  119. return { ok: false, status: "expired", message: "该卡密已于 " + remote.expireAt + " 到期" };
  120. }
  121. return {
  122. ok: true,
  123. status: "active",
  124. code: normalized,
  125. expireAt: remote.expireAt,
  126. message: "登录成功(服务端核验)"
  127. };
  128. }
  129. /* 本地模式:只有格式校验,如实告知,不伪装成服务端通过。 */
  130. return {
  131. ok: true,
  132. status: "active",
  133. code: normalized,
  134. expireAt: Service.datePlusDays(Service.CONFIG.localTrialDays),
  135. message: "登录成功(本地模式,未联网核验)"
  136. };
  137. };
  138. /** 读当前卡密状态,并顺手处理「已过期但状态还写着 active」的情况。 */
  139. Service.licenseStatus = function () {
  140. var status = Store.getEnum("license.status", "licenseStatus");
  141. var expireAt = Store.getText("license.expireAt");
  142. var code = Store.getText("license.code");
  143. if (status === "active" && Service.isExpired(expireAt)) {
  144. Store.set("license.status", "expired");
  145. status = "expired";
  146. }
  147. return { status: status, code: code, expireAt: expireAt };
  148. };
  149. /**
  150. * 状态 → 界面文案与配色 key。集中在这里,避免页面各自判断。
  151. *
  152. * ⚠️ 返回值里必须带 `status` —— 页面除了文案还要按状态分支
  153. * (已登录才显示「解绑」,否则显示「去登录」)。
  154. * 早先只返回 text/tone/expireAt,页面写 `display.status === "active"` 永远是 false,
  155. * 结果是「已登录」旁边挂着「去登录」,自相矛盾但没有任何报错。
  156. */
  157. Service.licenseDisplay = function () {
  158. var st = Service.licenseStatus();
  159. if (st.status === "active") {
  160. return { status: st.status, text: "已登录", tone: "ok", expireAt: st.expireAt || "—" };
  161. }
  162. if (st.status === "expired") {
  163. return { status: st.status, text: "已过期", tone: "warn", expireAt: st.expireAt || "—" };
  164. }
  165. return { status: st.status, text: "未登录", tone: "warn", expireAt: "—" };
  166. };
  167. Service.logout = function () {
  168. Store.set("license.status", "none");
  169. Store.set("license.code", "");
  170. Store.set("license.expireAt", "");
  171. };
  172. /* ---------------------------------------------------------------- 更新 */
  173. /**
  174. * 检查更新。远端不可达时退化为「与内置版本号自比」,
  175. * 结论同样如实标注来源,避免把「没连上」说成「已是最新」。
  176. */
  177. Service.checkUpdate = function () {
  178. var current = Service.SCRIPT_VERSION;
  179. if (Service.CONFIG.updateEndpoint) {
  180. var data = null;
  181. try {
  182. var response = http.get(Service.CONFIG.updateEndpoint, { timeout: 8000 });
  183. if (response && response.statusCode === 200) {
  184. data = JSON.parse(response.body.string());
  185. }
  186. } catch (e) {
  187. console.log("[service] 检查更新失败: " + e);
  188. }
  189. if (data && data.version) {
  190. return {
  191. latest: String(data.version),
  192. hasUpdate: String(data.version) > current,
  193. source: "remote",
  194. note: data.note || ""
  195. };
  196. }
  197. return { latest: current, hasUpdate: false, source: "unknown", note: "检查失败,请稍后重试" };
  198. }
  199. return { latest: current, hasUpdate: false, source: "local", note: "已是最新" };
  200. };