aaden-cli 1.6.117 → 1.6.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,8 @@
4
4
  #
5
5
  # light-update 只发 PHP/,AppCenter 以前只能跟 465MB 的 latest.zip 走;这里单独按应用同步。
6
6
  # - 灰度门:manifest 的 rollout = {"devices":[...], "percent":N},本机在名单里或 deviceId%100 < N 才动;没有 rollout = 全量
7
+ # - 按应用灰度:manifest 带 perAppRollout=true 时,每个应用按自己的 apps.<App>.rollout 判断(没有 = 全量),
8
+ # 顶层 rollout 只管 ensureNoCache;老版本 CLI 不认 perAppRollout,照旧整体按顶层 rollout
7
9
  # - ensureNoCache:manifest 打开时先确保 AppCenter 的 index.html 带 no-cache(ensureAppCenterNoCache.sh)
8
10
  # - manifest 门:本机 .appcenter-manifest 的 sha256 == manifest 的 treeSha256 且 `sha256sum -c` 通过 → 什么都不做,
9
11
  # 每天只花一次几 KB 的请求。被 latest.zip 全量覆盖过、文件对不上 → 校验失败 → 重新铺一遍(自愈)
@@ -19,7 +21,7 @@
19
21
  # APPCENTER_DEVICE_ID 本机 deviceId(CLI 会传);不传就问本机 PHP
20
22
  # APPCENTER_RESULT 结果行另存的文件(CLI 会传)
21
23
  # 结果行(同时写进 $RESULT 给 CLI 上报):APP_OK <App> <sha12> | APP_UP_TO_DATE <App> | APP_SKIP <App> <原因> |
22
- # APP_ROLLED_BACK <App> | APP_ABORT_* <App> | NOCACHE_* | SKIP_ROLLOUT | SKIP_DEPS | SKIP_LOCKED | ABORT_*
24
+ # APP_ROLLED_BACK <App> | APP_ABORT_* <App> | APP_SKIP_ROLLOUT <App> | NOCACHE_* | SKIP_ROLLOUT | SKIP_DEPS | SKIP_LOCKED | ABORT_*
23
25
  # 退出码:0=全部成功、无需更新或不在灰度内;1=有失败(失败的已回滚或未动线上目录)
24
26
  set -uo pipefail
25
27
  BASE="${APPCENTER_BASE:-https://aaden-backend.s3.eu-central-1.amazonaws.com/appcenter}"
@@ -48,21 +50,29 @@ flock -n 9 || { res "SKIP_LOCKED"; exit 0; }
48
50
 
49
51
  MANIFEST=$(curl -fsSL --max-time 30 "$BASE/$CHANNEL/manifest.json") \
50
52
  || { echo "取不到 $BASE/$CHANNEL/manifest.json"; res "ABORT_MANIFEST"; exit 1; }
51
- # 读 manifest:mf apps.Admin.zip / mf ensureNoCache / mf eligible <deviceId>
53
+ # 读 manifest:mf apps.Admin.zip / mf ensureNoCache / mf eligible <deviceId> / mf eligible_app <App> <deviceId>
52
54
  mf(){
53
55
  printf '%s' "$MANIFEST" | python3 -c '
54
56
  import json, sys
55
57
  m = json.load(sys.stdin)
56
- if sys.argv[1] == "eligible":
57
- r = m.get("rollout")
58
+ def gate(r, dev):
58
59
  if r is None:
59
- print("yes"); sys.exit()
60
+ return "yes"
60
61
  try:
61
- d = int(sys.argv[2])
62
+ d = int(dev)
62
63
  except ValueError:
63
- print("no"); sys.exit()
64
+ return "no"
64
65
  ok = d in [int(x) for x in r.get("devices", [])] or d % 100 < int(r.get("percent", 0))
65
- print("yes" if ok else "no"); sys.exit()
66
+ return "yes" if ok else "no"
67
+ if sys.argv[1] == "eligible":
68
+ print(gate(m.get("rollout"), sys.argv[2])); sys.exit()
69
+ if sys.argv[1] == "eligible_app":
70
+ if m.get("perAppRollout") is True:
71
+ app = (m.get("apps") or {}).get(sys.argv[2]) or {}
72
+ print(gate(app.get("rollout"), sys.argv[3]))
73
+ else:
74
+ print(gate(m.get("rollout"), sys.argv[3]))
75
+ sys.exit()
66
76
  v = m
67
77
  for k in sys.argv[1].split("."):
68
78
  v = v.get(k) if isinstance(v, dict) else None
@@ -76,13 +86,14 @@ if [ -z "$DEVICE_ID" ] || [ "$DEVICE_ID" = "-1" ]; then
76
86
  DEVICE_ID=$(curl -s --max-time 10 "http://localhost/PHP/AccessLog.php?op=deviceId" \
77
87
  | python3 -c 'import json,sys; print(json.load(sys.stdin).get("content",""))' 2>/dev/null)
78
88
  fi
79
- if [ "$(mf eligible "${DEVICE_ID:-x}")" != "yes" ]; then
89
+ TOP_ELIGIBLE=$(mf eligible "${DEVICE_ID:-x}")
90
+ if [ "$TOP_ELIGIBLE" != "yes" ] && [ "$(mf perAppRollout)" != "true" ]; then
80
91
  res "SKIP_ROLLOUT device=${DEVICE_ID:-?}"; exit 0
81
92
  fi
82
93
 
83
94
  FAILED=0
84
95
  # ---- index.html no-cache(先于换前端:让之后拿到的 index.html 都不再被启发式缓存)----
85
- if [ "$(mf ensureNoCache)" = "true" ]; then
96
+ if [ "$TOP_ELIGIBLE" = "yes" ] && [ "$(mf ensureNoCache)" = "true" ]; then
86
97
  NC=$(bash "$HERE/ensureAppCenterNoCache.sh"); NC_CODE=$?
87
98
  printf '%s\n' "$NC" | sed '$d'
88
99
  res "$(printf '%s\n' "$NC" | tail -1)"
@@ -101,6 +112,7 @@ sync_app(){
101
112
  local APP="$1" LIVE="$WEB/$1" NEW="$STAGE/$1" TREE ZIP ZSHA
102
113
  TREE=$(mf "apps.$APP.treeSha256"); ZIP=$(mf "apps.$APP.zip"); ZSHA=$(mf "apps.$APP.zipSha256")
103
114
  if [ -z "$TREE" ]; then res "APP_SKIP $APP 不在manifest里"; return 0; fi
115
+ if [ "$(mf eligible_app "$APP" "${DEVICE_ID:-x}")" != "yes" ]; then res "APP_SKIP_ROLLOUT $APP"; return 0; fi
104
116
  # manifest 来自网络:只接受预期格式,防止路径穿越
105
117
  if ! [[ "$TREE" =~ ^[0-9a-f]{64}$ && "$ZSHA" =~ ^[0-9a-f]{64}$ && "$ZIP" =~ ^blobs/[A-Za-z0-9._-]+\.zip$ ]]; then
106
118
  res "APP_ABORT_BAD_MANIFEST $APP"; return 1
package/dist/index.js CHANGED
@@ -694,6 +694,15 @@ const WINDOWS_TSE_DIR = 'Desktop/AadenTseOnWindows'
694
694
  const WINDOWS_DEFAULT_JAR = 'tse-0.0.3.jar'
695
695
  // 和 PHP 的 Api.php 一样给 3 秒
696
696
  const PROBE_TIMEOUT_MS = 3000
697
+ const PING_RETRY_DELAY_MS = 20000
698
+
699
+ function safeSync(fn, fallback = null) {
700
+ try {
701
+ return fn()
702
+ } catch (e) {
703
+ return fallback
704
+ }
705
+ }
697
706
 
698
707
  async function safe(fn, fallback = null) {
699
708
  try {
@@ -776,20 +785,67 @@ function servingJarPath() {
776
785
  return match ? match[1] : LINUX_DEFAULT_JAR
777
786
  }
778
787
 
779
- // 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
780
- function readCertFromDb() {
788
+ // 只读门店库,返回第一行按 tab 分开的各列(没有结果为 null)
789
+ function queryDb(sql) {
781
790
  const db = readIniSection('DB')
782
791
  const out = execFileSync('mysql', [
783
792
  '-h', db.host || 'localhost', '-u', db.user || 'aaden', '-N', '-B', '--raw', db.name || 'aaden',
784
- '-e', 'select serialNumber, certificate from RealTseInfo order by id desc limit 1',
793
+ '-e', sql,
785
794
  ], {
786
795
  encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
787
796
  // 密码走环境变量,不出现在进程列表里
788
797
  env: Object.assign({}, process.env, {MYSQL_PWD: db.password || ''}),
789
798
  }).trim()
790
799
  if (!out) return null
791
- const [serial, certificate] = out.split('\t')
792
- return {serial, certificate: certificate && certificate !== 'NULL' ? certificate : null}
800
+ return out.split('\n')[0].split('\t').map(v => (v === 'NULL' ? null : v))
801
+ }
802
+
803
+ // 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
804
+ function readCertFromDb() {
805
+ const row = queryDb('select serialNumber, certificate from RealTseInfo order by id desc limit 1')
806
+ if (!row) return null
807
+ const [serial, certificate] = row
808
+ return {serial, certificate: certificate || null}
809
+ }
810
+
811
+ // 结账后签名任务进 TseExecuteQueue(finish / finish-zero),后台几秒内执行;
812
+ // 结账超过 5 分钟还没有签名的就算漏签(任务报错、卡在队列里、或者 TSE 回了错)。
813
+ // 同一张单可能有多条任务(补签),按单算
814
+ const DUE_ORDERS = `select q.orderId, max(q.createdAt) as createdAt, max(q.id) as taskId,
815
+ exists (select 1 from RealTseLog l
816
+ where l.orderId = q.orderId and l.bestellungId is null and l.signature <> '') as signed
817
+ from TseExecuteQueue q
818
+ where q.executeType in ('finish', 'finish-zero')
819
+ and q.createdAt >= now() - interval 24 hour
820
+ and q.createdAt <= now() - interval 5 minute
821
+ group by q.orderId`
822
+
823
+ // 最近 24 小时结账的单有没有真的签上:这是 TSE 有没有坏的实际结果,比 ping 准——
824
+ // 关店、睡眠、开机自检时没人结账,不会算成故障;真坏了也一定有单签不上
825
+ function readSigningFromDb() {
826
+ const counts = queryDb(`select count(*), coalesce(sum(not signed), 0),
827
+ unix_timestamp(max(if(signed, createdAt, null))),
828
+ unix_timestamp(max(if(signed, null, createdAt)))
829
+ from (${DUE_ORDERS}) t`)
830
+ if (!counts) return null
831
+ const [due, unsigned, lastSigned, lastUnsigned] = counts
832
+ // 最近一张漏签的单为什么没签上:TSE 回的错(RealTseLog.tseError),没有就看队列任务的报错 / 还在排队
833
+ // 单独兜底:原因查不出来(比如两张表排序规则不一样)也不能连单数一起丢
834
+ const reason = Number(unsigned) > 0 ? safeSync(() => queryDb(`select coalesce(
835
+ (select l.tseError from RealTseLog l where l.orderId = t.orderId and l.tseError <> '' order by l.id desc limit 1),
836
+ nullif(q.note, ''),
837
+ if(q.finished = 0, '签名任务还在排队', null))
838
+ from (${DUE_ORDERS}) t join TseExecuteQueue q on q.id = t.taskId
839
+ where not t.signed
840
+ order by t.createdAt desc limit 1`)) : null
841
+ const toIso = sec => (sec ? new Date(Number(sec) * 1000).toISOString() : null)
842
+ return {
843
+ tseOrders24h: Number(due) || 0,
844
+ tseUnsigned24h: Number(unsigned) || 0,
845
+ tseLastSignedAt: toIso(lastSigned),
846
+ tseLastUnsignedAt: toIso(lastUnsigned),
847
+ tseLastUnsignedReason: reason && reason[0] ? reason[0].slice(0, 250) : null,
848
+ }
793
849
  }
794
850
 
795
851
  // TSE 自己的序列号、证书有效期和型号(dnQualifier:BSI-DSZ-CC-1121 是第一代,-V2 是第二代)
@@ -825,7 +881,12 @@ async function probeTse() {
825
881
  }
826
882
 
827
883
  // ok:TSE 正常;error:服务在但 TSE 在自检 / 没初始化 / 硬件报错;down:服务连不上
828
- const ping = await httpGet(`${tseHost}/ping`)
884
+ // 开机 / 唤醒时 TseHost 先自检约 15 秒,这期间 /ping 回 400:失败就等 20 秒再试一次,免得报成故障
885
+ let ping = await httpGet(`${tseHost}/ping`)
886
+ if (ping.status !== 200) {
887
+ await new Promise(resolve => setTimeout(resolve, PING_RETRY_DELAY_MS))
888
+ ping = await httpGet(`${tseHost}/ping`)
889
+ }
829
890
  const tseStatus = ping.status === 0 ? 'down' : (ping.status === 200 ? 'ok' : 'error')
830
891
 
831
892
  // 连不上时报 null,云端保留上次的版本;服务在但没有这个接口(2024-09 之前的构建)报空串
@@ -843,8 +904,10 @@ async function probeTse() {
843
904
  }
844
905
 
845
906
  const cert = await collectCert(tseHost, ping.status !== 0)
907
+ // 老版本门店库没有 TseExecuteQueue(2025-03 之前)就不报,云端保留上次的值
908
+ const signing = (await safe(readSigningFromDb)) || {}
846
909
 
847
- return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert}
910
+ return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert, ...signing}
848
911
  }
849
912
 
850
913
  // 整体出错就什么都不报,云端保留上次的值
@@ -19183,7 +19246,7 @@ module.exports = JSON.parse('{"name":"axios","version":"0.19.2","description":"P
19183
19246
  /***/ ((module) => {
19184
19247
 
19185
19248
  "use strict";
19186
- module.exports = {"i8":"1.6.117"};
19249
+ module.exports = {"i8":"1.6.119"};
19187
19250
 
19188
19251
  /***/ })
19189
19252
 
@@ -21,6 +21,15 @@ const WINDOWS_TSE_DIR = 'Desktop/AadenTseOnWindows'
21
21
  const WINDOWS_DEFAULT_JAR = 'tse-0.0.3.jar'
22
22
  // 和 PHP 的 Api.php 一样给 3 秒
23
23
  const PROBE_TIMEOUT_MS = 3000
24
+ const PING_RETRY_DELAY_MS = 20000
25
+
26
+ function safeSync(fn, fallback = null) {
27
+ try {
28
+ return fn()
29
+ } catch (e) {
30
+ return fallback
31
+ }
32
+ }
24
33
 
25
34
  async function safe(fn, fallback = null) {
26
35
  try {
@@ -103,20 +112,67 @@ function servingJarPath() {
103
112
  return match ? match[1] : LINUX_DEFAULT_JAR
104
113
  }
105
114
 
106
- // 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
107
- function readCertFromDb() {
115
+ // 只读门店库,返回第一行按 tab 分开的各列(没有结果为 null)
116
+ function queryDb(sql) {
108
117
  const db = readIniSection('DB')
109
118
  const out = execFileSync('mysql', [
110
119
  '-h', db.host || 'localhost', '-u', db.user || 'aaden', '-N', '-B', '--raw', db.name || 'aaden',
111
- '-e', 'select serialNumber, certificate from RealTseInfo order by id desc limit 1',
120
+ '-e', sql,
112
121
  ], {
113
122
  encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
114
123
  // 密码走环境变量,不出现在进程列表里
115
124
  env: Object.assign({}, process.env, {MYSQL_PWD: db.password || ''}),
116
125
  }).trim()
117
126
  if (!out) return null
118
- const [serial, certificate] = out.split('\t')
119
- return {serial, certificate: certificate && certificate !== 'NULL' ? certificate : null}
127
+ return out.split('\n')[0].split('\t').map(v => (v === 'NULL' ? null : v))
128
+ }
129
+
130
+ // 门店库里最新的那块 TSE(换过 TSE 会有多行);只读数据库,TSE 坏了也读得到
131
+ function readCertFromDb() {
132
+ const row = queryDb('select serialNumber, certificate from RealTseInfo order by id desc limit 1')
133
+ if (!row) return null
134
+ const [serial, certificate] = row
135
+ return {serial, certificate: certificate || null}
136
+ }
137
+
138
+ // 结账后签名任务进 TseExecuteQueue(finish / finish-zero),后台几秒内执行;
139
+ // 结账超过 5 分钟还没有签名的就算漏签(任务报错、卡在队列里、或者 TSE 回了错)。
140
+ // 同一张单可能有多条任务(补签),按单算
141
+ const DUE_ORDERS = `select q.orderId, max(q.createdAt) as createdAt, max(q.id) as taskId,
142
+ exists (select 1 from RealTseLog l
143
+ where l.orderId = q.orderId and l.bestellungId is null and l.signature <> '') as signed
144
+ from TseExecuteQueue q
145
+ where q.executeType in ('finish', 'finish-zero')
146
+ and q.createdAt >= now() - interval 24 hour
147
+ and q.createdAt <= now() - interval 5 minute
148
+ group by q.orderId`
149
+
150
+ // 最近 24 小时结账的单有没有真的签上:这是 TSE 有没有坏的实际结果,比 ping 准——
151
+ // 关店、睡眠、开机自检时没人结账,不会算成故障;真坏了也一定有单签不上
152
+ function readSigningFromDb() {
153
+ const counts = queryDb(`select count(*), coalesce(sum(not signed), 0),
154
+ unix_timestamp(max(if(signed, createdAt, null))),
155
+ unix_timestamp(max(if(signed, null, createdAt)))
156
+ from (${DUE_ORDERS}) t`)
157
+ if (!counts) return null
158
+ const [due, unsigned, lastSigned, lastUnsigned] = counts
159
+ // 最近一张漏签的单为什么没签上:TSE 回的错(RealTseLog.tseError),没有就看队列任务的报错 / 还在排队
160
+ // 单独兜底:原因查不出来(比如两张表排序规则不一样)也不能连单数一起丢
161
+ const reason = Number(unsigned) > 0 ? safeSync(() => queryDb(`select coalesce(
162
+ (select l.tseError from RealTseLog l where l.orderId = t.orderId and l.tseError <> '' order by l.id desc limit 1),
163
+ nullif(q.note, ''),
164
+ if(q.finished = 0, '签名任务还在排队', null))
165
+ from (${DUE_ORDERS}) t join TseExecuteQueue q on q.id = t.taskId
166
+ where not t.signed
167
+ order by t.createdAt desc limit 1`)) : null
168
+ const toIso = sec => (sec ? new Date(Number(sec) * 1000).toISOString() : null)
169
+ return {
170
+ tseOrders24h: Number(due) || 0,
171
+ tseUnsigned24h: Number(unsigned) || 0,
172
+ tseLastSignedAt: toIso(lastSigned),
173
+ tseLastUnsignedAt: toIso(lastUnsigned),
174
+ tseLastUnsignedReason: reason && reason[0] ? reason[0].slice(0, 250) : null,
175
+ }
120
176
  }
121
177
 
122
178
  // TSE 自己的序列号、证书有效期和型号(dnQualifier:BSI-DSZ-CC-1121 是第一代,-V2 是第二代)
@@ -152,7 +208,12 @@ async function probeTse() {
152
208
  }
153
209
 
154
210
  // ok:TSE 正常;error:服务在但 TSE 在自检 / 没初始化 / 硬件报错;down:服务连不上
155
- const ping = await httpGet(`${tseHost}/ping`)
211
+ // 开机 / 唤醒时 TseHost 先自检约 15 秒,这期间 /ping 回 400:失败就等 20 秒再试一次,免得报成故障
212
+ let ping = await httpGet(`${tseHost}/ping`)
213
+ if (ping.status !== 200) {
214
+ await new Promise(resolve => setTimeout(resolve, PING_RETRY_DELAY_MS))
215
+ ping = await httpGet(`${tseHost}/ping`)
216
+ }
156
217
  const tseStatus = ping.status === 0 ? 'down' : (ping.status === 200 ? 'ok' : 'error')
157
218
 
158
219
  // 连不上时报 null,云端保留上次的版本;服务在但没有这个接口(2024-09 之前的构建)报空串
@@ -170,8 +231,10 @@ async function probeTse() {
170
231
  }
171
232
 
172
233
  const cert = await collectCert(tseHost, ping.status !== 0)
234
+ // 老版本门店库没有 TseExecuteQueue(2025-03 之前)就不报,云端保留上次的值
235
+ const signing = (await safe(readSigningFromDb)) || {}
173
236
 
174
- return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert}
237
+ return {tseEnabled, tseHost, tseStatus, tseAdapterVersion, tseJarSize, ...cert, ...signing}
175
238
  }
176
239
 
177
240
  // 整体出错就什么都不报,云端保留上次的值
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aaden-cli",
3
- "version": "1.6.117",
3
+ "version": "1.6.119",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {