@blod-nm/1kuan-act-01-cli 0.0.0-stage → 0.1.0
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.
- package/README.md +68 -2
- package/bin/act-01.js +8 -0
- package/config-helper.html +220 -0
- package/package.json +38 -4
- package/src/main.js +506 -0
- package/src/mcp.js +197 -0
- package/src/redpacket.js +124 -0
- package/src/store.js +151 -0
- package/src/tcb.js +71 -0
package/src/redpacket.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 字段校验(与服务端 normalizeCreateRedpacket 规则一致)
|
|
3
|
+
* + SQL 组装(调用库函数 manage_act_01_redpacket_create,$1..$11 客户端绑定)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ValidationError extends Error {}
|
|
7
|
+
|
|
8
|
+
function positiveInteger(value, label) {
|
|
9
|
+
const n = Number(value)
|
|
10
|
+
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n))
|
|
11
|
+
throw new ValidationError(`${label}必须是正整数`)
|
|
12
|
+
return n
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function optionalText(value, label, maxLen) {
|
|
16
|
+
if (value === undefined || value === null || String(value).trim() === '') return undefined
|
|
17
|
+
const text = String(value).trim()
|
|
18
|
+
if (text.length > maxLen) throw new ValidationError(`${label}不能超过 ${maxLen} 个字符`)
|
|
19
|
+
return text
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 完整复刻 server/utils/act-01-manage.ts 的 normalizeCreateRedpacket */
|
|
23
|
+
export function normalizeCreateRedpacket(body) {
|
|
24
|
+
const mode = String(body.mode ?? '')
|
|
25
|
+
if (mode !== 'random' && mode !== 'equal')
|
|
26
|
+
throw new ValidationError('type must be random or equal')
|
|
27
|
+
|
|
28
|
+
const totalPoints = positiveInteger(body.total_points, '总金额')
|
|
29
|
+
const count = positiveInteger(body.count, '份数')
|
|
30
|
+
const perUserLimit = positiveInteger(body.per_user_limit, '每人领取次数')
|
|
31
|
+
if (perUserLimit > count) throw new ValidationError('per-user limit cannot exceed count')
|
|
32
|
+
if (mode === 'equal' && totalPoints < count)
|
|
33
|
+
throw new ValidationError('total must be at least count')
|
|
34
|
+
|
|
35
|
+
const shareTitle = optionalText(body.share_title, '分享标题', 60)
|
|
36
|
+
const shareCover = optionalText(body.share_cover, '分享封面', 500)
|
|
37
|
+
if (shareCover && !/^https?:\/\//i.test(shareCover))
|
|
38
|
+
throw new ValidationError('分享封面地址不合法(需 http(s) 开头)')
|
|
39
|
+
const note = optionalText(body.note, '备注', 20)
|
|
40
|
+
const requireGroup = body.require_group === true
|
|
41
|
+
|
|
42
|
+
if (mode === 'equal') {
|
|
43
|
+
return {
|
|
44
|
+
mode,
|
|
45
|
+
totalPoints,
|
|
46
|
+
count,
|
|
47
|
+
perUserLimit,
|
|
48
|
+
minPoints: Math.floor(totalPoints / count),
|
|
49
|
+
maxPoints: Math.ceil(totalPoints / count),
|
|
50
|
+
shareTitle,
|
|
51
|
+
shareCover,
|
|
52
|
+
note,
|
|
53
|
+
requireGroup,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const minPoints = positiveInteger(body.min_points, '单份最小金额')
|
|
58
|
+
const maxPoints = positiveInteger(body.max_points, '单份最大金额')
|
|
59
|
+
if (maxPoints < minPoints) throw new ValidationError('单份最大金额不能小于最小金额')
|
|
60
|
+
if (totalPoints < minPoints * count || totalPoints > maxPoints * count)
|
|
61
|
+
throw new ValidationError('total is outside the min..max × count range')
|
|
62
|
+
if (totalPoints % count !== 0)
|
|
63
|
+
throw new ValidationError('total must be divisible by count')
|
|
64
|
+
|
|
65
|
+
return { mode, totalPoints, count, perUserLimit, minPoints, maxPoints, shareTitle, shareCover, note, requireGroup }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---------- $N 字面量绑定(exec-pgsql 只收整条 SQL) ---------- */
|
|
69
|
+
|
|
70
|
+
function lit(value) {
|
|
71
|
+
if (value === null || value === undefined) return 'NULL'
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
if (!Number.isFinite(value)) throw new ValidationError('数值不合法')
|
|
74
|
+
return String(value)
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
|
|
77
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CREATE_FN_SQL =
|
|
81
|
+
'SELECT public.manage_act_01_redpacket_create($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) AS result'
|
|
82
|
+
|
|
83
|
+
export function buildCreateSql(input) {
|
|
84
|
+
const positional = [
|
|
85
|
+
null, // $1 operator:服务端同样传 null
|
|
86
|
+
input.mode,
|
|
87
|
+
input.totalPoints,
|
|
88
|
+
input.count,
|
|
89
|
+
input.perUserLimit,
|
|
90
|
+
input.minPoints,
|
|
91
|
+
input.maxPoints,
|
|
92
|
+
input.shareTitle ?? null,
|
|
93
|
+
input.shareCover ?? null,
|
|
94
|
+
input.note ?? null,
|
|
95
|
+
input.requireGroup,
|
|
96
|
+
]
|
|
97
|
+
let missing = ''
|
|
98
|
+
const sql = CREATE_FN_SQL.replace(/\$(\d+)/g, (whole, num) => {
|
|
99
|
+
const idx = Number(num) - 1
|
|
100
|
+
if (idx < 0 || idx >= positional.length) {
|
|
101
|
+
missing = missing || `$${num}`
|
|
102
|
+
return whole
|
|
103
|
+
}
|
|
104
|
+
return lit(positional[idx])
|
|
105
|
+
})
|
|
106
|
+
if (missing) throw new ValidationError(`占位符 ${missing} 没有对应的参数`)
|
|
107
|
+
return sql
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 确认卡摘要(提交前逐项列出) */
|
|
111
|
+
export function confirmSummary(input) {
|
|
112
|
+
const lines = [
|
|
113
|
+
`模式: ${input.mode === 'random' ? '随机' : '均分'}`,
|
|
114
|
+
`总金额: ${input.totalPoints}(整数积分)`,
|
|
115
|
+
`份数: ${input.count}`,
|
|
116
|
+
`每人限领: ${input.perUserLimit}`,
|
|
117
|
+
`单份区间: ${input.minPoints} ~ ${input.maxPoints}`,
|
|
118
|
+
]
|
|
119
|
+
if (input.shareTitle) lines.push(`分享标题: ${input.shareTitle}`)
|
|
120
|
+
if (input.shareCover) lines.push(`分享封面: ${input.shareCover}`)
|
|
121
|
+
if (input.note) lines.push(`备注: ${input.note}`)
|
|
122
|
+
lines.push(`群专属: ${input.requireGroup ? '是' : '否'}`)
|
|
123
|
+
return lines
|
|
124
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置层:~/.config/act-01/config.json(0600,原子写)。
|
|
3
|
+
*
|
|
4
|
+
* v1 配置只有两项(按需求收敛):
|
|
5
|
+
* envId 环境 ID —— 网关固定国内 api.tcloudbasegateway.com
|
|
6
|
+
* apiKey 腾讯云开发 API Key(需管理员权限;可被环境变量 ACT01_API_KEY 覆盖)
|
|
7
|
+
*
|
|
8
|
+
* 角色固定 cloudbase_postgres(写命令必需),不是配置项。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, scryptSync } from 'node:crypto'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export function configDir() {
|
|
17
|
+
const xdg = process.env.XDG_CONFIG_HOME
|
|
18
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), '.config')
|
|
19
|
+
return join(base, 'act-01')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return join(configDir(), 'config.json')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ensureDir() {
|
|
27
|
+
if (!existsSync(configDir())) {
|
|
28
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 })
|
|
29
|
+
chmodSync(configDir(), 0o700)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 同目录临时文件 + rename 原子写,显式 0600(不依赖 umask) */
|
|
34
|
+
export function atomicWriteText(file, text) {
|
|
35
|
+
ensureDir()
|
|
36
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
37
|
+
writeFileSync(tmp, text, { mode: 0o600 })
|
|
38
|
+
chmodSync(tmp, 0o600)
|
|
39
|
+
renameSync(tmp, file)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadConfig() {
|
|
43
|
+
const file = configPath()
|
|
44
|
+
if (!existsSync(file)) return {}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
47
|
+
} catch {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function saveConfig(config) {
|
|
53
|
+
atomicWriteText(configPath(), `${JSON.stringify(config, null, 2)}\n`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** --file 类参数支持相对路径与 ~ 展开 */
|
|
57
|
+
export function expandPath(p) {
|
|
58
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2))
|
|
59
|
+
return isAbsolute(p) ? p : resolve(p)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ---------- 配置串:口令加密的一串传递格式 ----------
|
|
63
|
+
act01.v1. <b64url(salt[16]|iv[12]|tag[16]|ct)> scrypt 派生(CLI 原生格式)
|
|
64
|
+
act01w.v1. <b64url(salt[16]|iv[12]|ct+tag[16])> PBKDF2-SHA256 310k(网页生成格式)
|
|
65
|
+
act01plain.<b64url(json)> 明文便捷串
|
|
66
|
+
传输链上没有口令,加密串就是乱码。明文只在解密后的内存里。 */
|
|
67
|
+
|
|
68
|
+
const CFG_PREFIX = 'act01.v1.'
|
|
69
|
+
const CFG_WEB_PREFIX = 'act01w.v1.'
|
|
70
|
+
const CFG_PLAIN_PREFIX = 'act01plain.'
|
|
71
|
+
|
|
72
|
+
const CFG_PREFIXES = [CFG_PREFIX, CFG_WEB_PREFIX, CFG_PLAIN_PREFIX]
|
|
73
|
+
|
|
74
|
+
export function encryptConfig(obj, passphrase) {
|
|
75
|
+
const salt = randomBytes(16)
|
|
76
|
+
const iv = randomBytes(12)
|
|
77
|
+
const key = scryptSync(passphrase, salt, 32)
|
|
78
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
79
|
+
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()])
|
|
80
|
+
const tag = cipher.getAuthTag()
|
|
81
|
+
return CFG_PREFIX + Buffer.concat([salt, iv, tag, ct]).toString('base64url')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scopedBody(str) {
|
|
85
|
+
const body = String(str).trim()
|
|
86
|
+
const prefix = CFG_PREFIXES.find((p) => body.startsWith(p))
|
|
87
|
+
if (!prefix) {
|
|
88
|
+
const err = new Error('无法识别的配置串前缀')
|
|
89
|
+
err.code = 'bad-format'
|
|
90
|
+
throw err
|
|
91
|
+
}
|
|
92
|
+
const data = Buffer.from(body.slice(prefix.length), 'base64url')
|
|
93
|
+
if (data.length < 44) {
|
|
94
|
+
const err = new Error('配置串过短或已损坏')
|
|
95
|
+
err.code = 'bad-format'
|
|
96
|
+
throw err
|
|
97
|
+
}
|
|
98
|
+
return { prefix, data }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** act01.v1.(CLI scrypt 格式) */
|
|
102
|
+
function decryptCli(data, passphrase) {
|
|
103
|
+
const salt = data.subarray(0, 16)
|
|
104
|
+
const iv = data.subarray(16, 28)
|
|
105
|
+
const tag = data.subarray(28, 44)
|
|
106
|
+
const ct = data.subarray(44)
|
|
107
|
+
const key = scryptSync(passphrase, salt, 32)
|
|
108
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
109
|
+
decipher.setAuthTag(tag)
|
|
110
|
+
return JSON.parse(Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** act01w.v1.(网页 PBKDF2 格式,WebCrypto tag 附在密文尾) */
|
|
114
|
+
function decryptWeb(data, passphrase) {
|
|
115
|
+
const salt = data.subarray(0, 16)
|
|
116
|
+
const iv = data.subarray(16, 28)
|
|
117
|
+
const rest = data.subarray(28)
|
|
118
|
+
const key = pbkdf2Sync(passphrase, salt, 310_000, 32, 'sha256')
|
|
119
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
120
|
+
decipher.setAuthTag(rest.subarray(rest.length - 16))
|
|
121
|
+
return JSON.parse(
|
|
122
|
+
Buffer.concat([decipher.update(rest.subarray(0, rest.length - 16)), decipher.final()]).toString('utf8'),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function decryptConfig(str, passphrase) {
|
|
127
|
+
const { prefix, data } = scopedBody(str)
|
|
128
|
+
if (prefix === CFG_WEB_PREFIX) return decryptWeb(data, passphrase)
|
|
129
|
+
if (prefix === CFG_PREFIX) return decryptCli(data, passphrase)
|
|
130
|
+
const err = new Error('不是加密配置串(缺少 act01.v1. / act01w.v1. 前缀)')
|
|
131
|
+
err.code = 'bad-format'
|
|
132
|
+
throw err
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 明文单串(便捷导入):act01plain.<base64url(json)> */
|
|
136
|
+
export function encodePlain(obj) {
|
|
137
|
+
return CFG_PLAIN_PREFIX + Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function decodeConfigString(str) {
|
|
141
|
+
const body = String(str).trim()
|
|
142
|
+
if (body.startsWith(CFG_PREFIX) || body.startsWith(CFG_WEB_PREFIX))
|
|
143
|
+
return { encrypted: true, config: null, decode: (pass) => decryptConfig(body, pass) }
|
|
144
|
+
if (body.startsWith(CFG_PLAIN_PREFIX)) {
|
|
145
|
+
const json = Buffer.from(body.slice(CFG_PLAIN_PREFIX.length), 'base64url').toString('utf8')
|
|
146
|
+
return { encrypted: false, config: JSON.parse(json), decode: null }
|
|
147
|
+
}
|
|
148
|
+
const err = new Error('无法识别的配置串(支持 act01.v1. / act01w.v1. 加密串 / act01plain. 明文串)')
|
|
149
|
+
err.code = 'bad-format'
|
|
150
|
+
throw err
|
|
151
|
+
}
|
package/src/tcb.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 腾讯云开发网关传输层。
|
|
3
|
+
*
|
|
4
|
+
* 端点:POST https://{envId}.api.tcloudbasegateway.com/v1/rdb/exec-pgsql
|
|
5
|
+
* 鉴权:Authorization: Bearer <apiKey>
|
|
6
|
+
* 请求体:{ sql } —— exec-pgsql 只收一整条 SQL(无 parameters 通道),
|
|
7
|
+
* $1..$11 由客户端绑定成带类型的字面量(文本单引号翻倍,数值/布尔直书)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadConfig } from './store.js'
|
|
11
|
+
|
|
12
|
+
export const GATEWAY_DOMAIN = 'api.tcloudbasegateway.com'
|
|
13
|
+
|
|
14
|
+
export function resolveApikey(config, flagValue) {
|
|
15
|
+
const raw = flagValue ?? process.env.ACT01_API_KEY ?? config.apiKey
|
|
16
|
+
return raw ? String(raw).trim() : ''
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function gatewayBase(config) {
|
|
20
|
+
const envId = (config.envId ?? '').trim()
|
|
21
|
+
if (!envId) {
|
|
22
|
+
const err = new Error('缺少 envId:先用 act-01 config set envId <环境ID>')
|
|
23
|
+
err.exitCode = 2
|
|
24
|
+
throw err
|
|
25
|
+
}
|
|
26
|
+
return `https://${envId}.${GATEWAY_DOMAIN}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** account row query */
|
|
30
|
+
export const ACCOUNT_SQL = `
|
|
31
|
+
SELECT id, data, updated_at
|
|
32
|
+
FROM public."tmp-act-01"
|
|
33
|
+
WHERE deleted_at IS NULL AND tag @> ARRAY['account','openid:activity']::text[]
|
|
34
|
+
LIMIT 1
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
export async function execPgsql(config, apikey, sql) {
|
|
38
|
+
const base = gatewayBase(config)
|
|
39
|
+
let res
|
|
40
|
+
try {
|
|
41
|
+
res = await fetch(`${base}/v1/rdb/exec-pgsql`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json',
|
|
45
|
+
Authorization: `Bearer ${apikey}`,
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ sql }),
|
|
48
|
+
})
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`网关连接失败:${err instanceof Error ? err.message : String(err)}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = await res.text()
|
|
54
|
+
let raw = null
|
|
55
|
+
try {
|
|
56
|
+
raw = text ? JSON.parse(text) : null
|
|
57
|
+
} catch {
|
|
58
|
+
raw = text
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
let detail = res.statusText
|
|
63
|
+
if (raw && typeof raw === 'object' && 'message' in raw) detail = String(raw.message)
|
|
64
|
+
else if (typeof raw === 'string' && raw) detail = raw.slice(0, 200)
|
|
65
|
+
return { ok: false, status: res.status, raw, message: `HTTP ${res.status}: ${detail}` }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rows =
|
|
69
|
+
raw && typeof raw === 'object' && Array.isArray(raw.rows) ? raw.rows : undefined
|
|
70
|
+
return { ok: true, status: res.status, rows, raw }
|
|
71
|
+
}
|