在这里粘贴文字 / 图片 / 文件
diff --git a/public/tools/threshing_ground/hell.html b/public/tools/threshing_ground/hell.html
new file mode 100644
index 0000000..74d7d1e
--- /dev/null
+++ b/public/tools/threshing_ground/hell.html
@@ -0,0 +1,2110 @@
+
+
+
+
+
+
打谷场 - 地狱版
+
+
+
+
+
+
+
下一波 3.0s
+
+
波次 0
+
+
金币 500
+
+
生命 20
+
+
击杀 0
+
+
速度 x1
+
+
编辑模式
+
+
+
+
+
+
+
+
+
+
+ 空格:暂停1-3:速度左键:操作右键:平移滚轮:缩放G:网格N:新路线
+
+
+
+
+
+
+
打谷场 地狱版 - 操作说明
+
游戏目标:在空白画布上自由绘制敌军行进路线,建造军事武器塔防,消灭所有进攻敌人,保护基地!
+
地狱版专属规则:
+
1. 节点必须落在网格十字交点(格子顶点),不能走斜线
+
2. 路线不能交叉重叠,只能在空白处绘制
+
编辑模式:
+
1. 点击画布添加路线节点,自动连线
+
2. 拖拽节点调整位置(受交叉规则限制)
+
3. 右键点击节点删除
+
4. N 新建路线,G 切换网格吸附
+
建造模式:
+
1. 先选武器类型,再点击路径旁空地放置
+
2. 点击已有塔可选中,再点升级/拆除
+
3. 塔不能放在路径上
+
快捷键:
+
Space 暂停/继续 1/2/3 速度切换
+
E 编辑模式 B 建造模式
+
滚轮 缩放 右键拖拽 平移画布
+
兵种克制:
+
机枪→轻型步兵 火炮→重装步兵 反坦克→装甲车/坦克 防空→直升机
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/threshing_ground/index.html b/public/tools/threshing_ground/index.html
new file mode 100644
index 0000000..bc23567
--- /dev/null
+++ b/public/tools/threshing_ground/index.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+
打谷场 - 塔防合集
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tools/threshing_ground/simple.html b/public/tools/threshing_ground/simple.html
new file mode 100644
index 0000000..de3419f
--- /dev/null
+++ b/public/tools/threshing_ground/simple.html
@@ -0,0 +1,2055 @@
+
+
+
+
+
+
打谷场 - 简单版
+
+
+
+
+
+
+
下一波 3.0s
+
+
波次 0
+
+
金币 500
+
+
生命 20
+
+
击杀 0
+
+
速度 x1
+
+
编辑模式
+
+
+
+
+
+
+
+
+
+
+ 空格:暂停1-3:速度左键:操作右键:平移滚轮:缩放G:网格N:新路线
+
+
+
+
+
+
+
打谷场 简单版 - 操作说明
+
游戏目标:在空白画布上自由绘制敌军行进路线,建造军事武器塔防,消灭所有进攻敌人,保护基地!
+
编辑模式:
+
1. 点击画布添加路线节点,自动连线
+
2. 拖拽节点调整位置
+
3. 右键点击节点删除
+
4. N 新建路线,G 切换网格吸附
+
建造模式:
+
1. 先选武器类型,再点击路径旁空地放置
+
2. 点击已有塔可选中,再点升级/拆除
+
3. 塔不能放在路径上
+
快捷键:
+
Space 暂停/继续 1/2/3 速度切换
+
E 编辑模式 B 建造模式
+
滚轮 缩放 右键拖拽 平移画布
+
兵种克制:
+
机枪→轻型步兵 火炮→重装步兵 反坦克→装甲车/坦克 防空→直升机
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/data_gateway/auth.js b/src/server/data_gateway/auth.js
new file mode 100644
index 0000000..9c208b1
--- /dev/null
+++ b/src/server/data_gateway/auth.js
@@ -0,0 +1,85 @@
+// ============================================================
+// data_gateway/auth.js - 对外 API 鉴权模块
+// 职责:校验 X-API-Id + X-API-Key,匹配 skill config.json 中的哈希
+// ============================================================
+const crypto = require('crypto')
+const fs = require('fs')
+const path = require('path')
+
+const SKILLS_DIR = path.join(__dirname, 'skills')
+
+// 缓存已加载的 skill 配置
+const skillConfigCache = new Map()
+
+const loadSkillConfig = (skillId) => {
+ if (skillConfigCache.has(skillId)) return skillConfigCache.get(skillId)
+ const cfgPath = path.join(SKILLS_DIR, skillId, 'config.json')
+ if (!fs.existsSync(cfgPath)) return null
+ try {
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
+ skillConfigCache.set(skillId, cfg)
+ return cfg
+ } catch {
+ return null
+ }
+}
+
+// 刷新缓存(用于配置热更新)
+const refreshCache = (skillId) => {
+ skillConfigCache.delete(skillId)
+ return loadSkillConfig(skillId)
+}
+
+// 枚举所有已启用的 skill
+const listEnabledSkills = () => {
+ const result = []
+ if (!fs.existsSync(SKILLS_DIR)) return result
+ const dirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
+ for (const d of dirs) {
+ if (!d.isDirectory()) continue
+ const cfg = loadSkillConfig(d.name)
+ if (cfg && cfg.enabled !== false) {
+ result.push({ id: cfg.id, name: cfg.name })
+ }
+ }
+ return result
+}
+
+// 校验外部 API 请求
+const verifyApiKey = (req) => {
+ const apiId = String(req.headers['x-api-id'] || '').trim()
+ const apiKey = String(req.headers['x-api-key'] || '').trim()
+ if (!apiId || !apiKey) return { ok: false, error: 'missing api id or key' }
+
+ const cfg = loadSkillConfig(apiId)
+ if (!cfg) return { ok: false, error: 'skill not found' }
+ if (cfg.enabled === false) return { ok: false, error: 'skill disabled' }
+
+ const expectedHash = String(cfg.api_key_hash || '')
+ if (!expectedHash) return { ok: false, error: 'skill not configured' }
+
+ const actualHash = 'sha256:' + crypto.createHash('sha256').update(apiKey).digest('hex')
+ if (actualHash !== expectedHash) return { ok: false, error: 'invalid api key' }
+
+ return { ok: true, skill: cfg }
+}
+
+// 校验内部 Tool 读取请求
+const verifyInternalToken = (req) => {
+ const token = String(req.headers['x-internal-token'] || '').trim()
+ const skillId = String(req.headers['x-skill-id'] || req.params?.skillId || '').trim()
+ if (!token || !skillId) return { ok: false, error: 'missing token or skill id' }
+
+ const cfg = loadSkillConfig(skillId)
+ if (!cfg) return { ok: false, error: 'skill not found' }
+
+ const expectedHash = String(cfg.read_token_hash || '')
+ if (!expectedHash) return { ok: false, error: 'skill not configured for internal read' }
+
+ const actualHash = 'sha256:' + crypto.createHash('sha256').update(token).digest('hex')
+ if (actualHash !== expectedHash) return { ok: false, error: 'invalid internal token' }
+
+ return { ok: true, skill: cfg }
+}
+
+module.exports = { loadSkillConfig, refreshCache, listEnabledSkills, verifyApiKey, verifyInternalToken }
\ No newline at end of file
diff --git a/src/server/data_gateway/index.js b/src/server/data_gateway/index.js
new file mode 100644
index 0000000..9835610
--- /dev/null
+++ b/src/server/data_gateway/index.js
@@ -0,0 +1,125 @@
+// ============================================================
+// data_gateway/index.js - 对外 API 数据网关主入口
+// 暴露两个路由:
+// POST /api/v1/ingest/:skillId 外部数据写入(公网,需 API Key)
+// GET /api/v1/data/:skillId/query 内部数据读取(仅 localhost,需 Internal Token)
+// ============================================================
+const { logJSON } = require('../logger')
+const { verifyApiKey, verifyInternalToken, listEnabledSkills } = require('./auth')
+const { insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count } = require('./store')
+const { loadSkillConfig } = require('./auth')
+const { bindRoutes: bindExternalStorageRoutes } = require('./skills/external_storage')
+
+const setNoCache = (res) => {
+ try {
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
+ res.set('Pragma', 'no-cache')
+ res.set('Expires', '0')
+ } catch {}
+}
+
+const bindRoutes = (app) => {
+ // ============================================================
+ // 外部存储 skill(第三方 OSS 读写)
+ // 路由挂载在 /api/v1/skills/external_storage/*
+ // ============================================================
+ try {
+ bindExternalStorageRoutes(app)
+ } catch (e) {
+ console.error(`[data_gateway] Failed to bind external_storage routes: ${e.message}`)
+ }
+
+ // ============================================================
+ // 外部写入接口(公网暴露)
+ // POST /api/v1/ingest/:skillId
+ // Headers: X-API-Id, X-API-Key
+ // Body: { "source": "...", "data": { ... } }
+ // ============================================================
+ app.post('/api/v1/ingest/:skillId', (req, res) => {
+ setNoCache(res)
+ const skillId = String(req.params.skillId || '').trim()
+ if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
+
+ const auth = verifyApiKey(req)
+ if (!auth.ok) {
+ try { logJSON('data_gateway.auth.fail', { skillId, error: auth.error, ip: req.ip }, 'data_gateway') } catch {}
+ return res.status(401).json({ ok: false, error: auth.error })
+ }
+
+ const { source, data } = req.body || {}
+ if (data === undefined && req.body && typeof req.body === 'object') {
+ // 如果没传 data 字段,将整个 body 视为 data
+ const bodyData = { ...req.body }
+ delete bodyData.source
+ const record = insert(skillId, { source: String(source || ''), data: Object.keys(bodyData).length > 0 ? bodyData : req.body })
+ try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {}
+ return res.json({ ok: true, id: record.id, created_at: record.created_at })
+ }
+
+ const record = insert(skillId, { source: String(source || ''), data: data || {} })
+ try { logJSON('data_gateway.ingest.ok', { skillId, source, recordId: record.id }, 'data_gateway') } catch {}
+ return res.json({ ok: true, id: record.id, created_at: record.created_at })
+ })
+
+ // ============================================================
+ // 内部读取接口(仅 localhost)
+ // GET /api/v1/data/:skillId/query?mode=latest|list&limit=100&offset=0
+ // Headers: X-Internal-Token, X-Skill-Id
+ // ============================================================
+ app.get('/api/v1/data/:skillId/query', (req, res) => {
+ setNoCache(res)
+ const skillId = String(req.params.skillId || '').trim()
+ if (!skillId) return res.status(400).json({ ok: false, error: 'missing skill id' })
+
+ const auth = verifyInternalToken(req)
+ if (!auth.ok) return res.status(401).json({ ok: false, error: auth.error })
+
+ const mode = String(req.query.mode || 'latest').trim()
+ const limit = parseInt(req.query.limit, 10) || 100
+ const offset = parseInt(req.query.offset, 10) || 0
+ const source = req.query.source || null
+ const start = req.query.start || null
+ const end = req.query.end || null
+
+ try {
+ if (mode === 'list') {
+ if (start || end) {
+ const result = queryByTimeRange(skillId, { start, end, limit })
+ return res.json({ ok: true, ...result })
+ }
+ const result = queryList(skillId, { limit, offset, source })
+ return res.json({ ok: true, ...result })
+ }
+
+ // mode === 'latest'(默认)
+ const row = queryLatest(skillId)
+ if (!row) return res.json({ ok: true, rows: [], total: 0 })
+ return res.json({ ok: true, rows: [row], total: 1 })
+ } catch (e) {
+ try { logJSON('data_gateway.query.error', { skillId, error: String(e.message || e) }, 'data_gateway') } catch {}
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 管理接口:列出所有已启用的 skill(仅 localhost)
+ // GET /api/v1/data/skills
+ // ============================================================
+ app.get('/api/v1/data/skills', (req, res) => {
+ setNoCache(res)
+ const skills = listEnabledSkills()
+ const result = skills.map(s => {
+ const cfg = loadSkillConfig(s.id)
+ return {
+ id: s.id,
+ name: s.name,
+ mode: String(cfg && cfg.mode || 'append'),
+ record_count: count(s.id),
+ db_path: require('./store').getDbPath(s.id)
+ }
+ })
+ res.json({ ok: true, skills: result })
+ })
+}
+
+module.exports = { bindRoutes, listEnabledSkills, loadSkillConfig }
\ No newline at end of file
diff --git a/src/server/data_gateway/skills/external_storage/config.json b/src/server/data_gateway/skills/external_storage/config.json
new file mode 100644
index 0000000..1c5af5b
--- /dev/null
+++ b/src/server/data_gateway/skills/external_storage/config.json
@@ -0,0 +1,14 @@
+{
+ "_comment": "外部存储 skill - 供第三方程序读写 OSS external_storage 区域",
+ "id": "external_storage",
+ "name": "外部存储",
+ "enabled": true,
+ "api_key_comment": "这个发给对方,API Key,用于上传文件到外部存储区域",
+ "api_key": "4a211084d059Pk0C6t8dlk3ew61d46",
+ "api_key_hash": "sha256:17eb4c895064b955d80d007b15af992d7bb7bd1525ce5726f2f63f06229857c9",
+ "oss_bucket": "android-o1-images",
+ "oss_endpoint_backup1": "oss-accelerate.aliyuncs.com 传输加速域名(全地域上传下载加速)",
+ "oss_endpoint_backup2": "oss-cn-shanghai.aliyuncs.com 上海区域",
+ "oss_endpoint": "oss-accelerate.aliyuncs.com",
+ "oss_prefix": "550e8400-e29b-41d4-a716-446655442082/external_storage"
+}
diff --git a/src/server/data_gateway/skills/external_storage/index.js b/src/server/data_gateway/skills/external_storage/index.js
new file mode 100644
index 0000000..1d9e8d5
--- /dev/null
+++ b/src/server/data_gateway/skills/external_storage/index.js
@@ -0,0 +1,322 @@
+// ============================================================
+// external_storage/index.js - 外部存储 skill 路由入口
+// 提供第三方程序读写 OSS external_storage 区域的 API
+// ============================================================
+const express = require('express')
+const multer = require('multer')
+const path = require('path')
+const archiver = require('archiver')
+const { createOssClient } = require('./oss_client')
+const { sanitizePath, createSizeLimitMiddleware } = require('./middleware')
+const { loadSkillConfig } = require('../../auth')
+const { verifyApiKey } = require('../../auth')
+const { logJSON } = require('../../../logger')
+
+const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 // 50MB
+
+// 内存存储上传文件
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 }
+})
+
+// MIME 类型推断(支持中文文件名 UTF-8)
+const contentTypeByExt = (filename) => {
+ const ext = path.extname(filename).toLowerCase().slice(1)
+ const map = {
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', avif: 'image/avif',
+ txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8',
+ js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json',
+ xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv',
+ pdf: 'application/pdf',
+ doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska',
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
+ zip: 'application/zip', rar: 'application/x-rar-compressed', '7z': 'application/x-7z-compressed',
+ tar: 'application/x-tar', gz: 'application/gzip',
+ exe: 'application/x-msdownload', dmg: 'application/x-apple-diskimage',
+ font: 'font/woff', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf'
+ }
+ return map[ext] || 'application/octet-stream'
+}
+
+// 安全文件名清理(保留中文,移除危险字符)
+const safeName = (name) => {
+ let s = String(name || 'untitled')
+ // 尝试修复编码(UTF-8 被误读为 latin1 的情况)
+ try {
+ const recovered = Buffer.from(s, 'latin1').toString('utf8')
+ if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
+ } catch {}
+ // 移除危险字符,但保留中文、英文、数字、常见符号
+ return s.replace(/[<>\":/\\|?*\x00-\x1F]/g, '_').trim() || 'untitled'
+}
+
+// 设置无缓存响应头
+const setNoCache = (res) => {
+ try {
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
+ res.set('Pragma', 'no-cache')
+ res.set('Expires', '0')
+ } catch {}
+}
+
+/**
+ * 绑定路由到 Express 应用
+ * @param {express.Application} app
+ */
+const bindRoutes = (app) => {
+ const skillId = 'external_storage'
+ const skillConfig = loadSkillConfig(skillId)
+ if (!skillConfig) {
+ console.warn(`[external_storage] config.json not found, skill disabled`)
+ return
+ }
+
+ let ossClient
+ try {
+ ossClient = createOssClient(skillConfig)
+ } catch (e) {
+ console.error(`[external_storage] Failed to init OSS client: ${e.message}`)
+ return
+ }
+
+ const router = express.Router()
+
+ // API Key 鉴权中间件
+ router.use((req, res, next) => {
+ const auth = verifyApiKey(req)
+ if (!auth.ok) {
+ try { logJSON('external_storage.auth.fail', { error: auth.error, ip: req.ip }, 'external_storage') } catch {}
+ return res.status(401).json({ ok: false, error: auth.error })
+ }
+ next()
+ })
+
+ // 全局无缓存
+ router.use((req, res, next) => {
+ setNoCache(res)
+ next()
+ })
+
+ // ============================================================
+ // 上传文件
+ // POST /api/v1/skills/external_storage/upload?path=subdir/
+ // Headers: X-API-Id, X-API-Key
+ // Body: multipart/form-data, field: "files"
+ // ============================================================
+ router.post('/upload', createSizeLimitMiddleware(MAX_UPLOAD_BYTES), upload.array('files', 20), async (req, res) => {
+ try {
+ const basePath = sanitizePath(req.query.path || '')
+ if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
+ return res.status(400).json({ ok: false, error: '未收到文件' })
+ }
+
+ const results = []
+ for (const f of req.files) {
+ const originalName = safeName(f.originalname || 'file')
+ const keyPath = basePath ? `${basePath}/${originalName}` : originalName
+ const contentType = f.mimetype || contentTypeByExt(originalName)
+
+ const { key, raw_url } = await ossClient.putObject(keyPath, f.buffer, contentType)
+ results.push({
+ path: keyPath,
+ oss_key: key,
+ raw_url,
+ name: originalName,
+ size: f.size,
+ content_type: contentType
+ })
+ }
+
+ res.json({ ok: true, count: results.length, files: results })
+ } catch (e) {
+ if (String(e.message || '').includes('File too large')) {
+ return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' })
+ }
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 下载文件(代理流)
+ // GET /api/v1/skills/external_storage/download?path=xxx
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.get('/download', async (req, res) => {
+ try {
+ const filePath = sanitizePath(req.query.path)
+ if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
+
+ const displayName = path.basename(filePath)
+ const { stream, headers } = await ossClient.getObjectStream(filePath)
+
+ // 使用 RFC 5987 格式支持 UTF-8 文件名
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`)
+ if (headers['content-type']) res.set('Content-Type', headers['content-type'])
+ if (headers['content-length']) res.set('Content-Length', headers['content-length'])
+
+ stream.pipe(res)
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 获取原始 URL
+ // GET /api/v1/skills/external_storage/raw_url?path=xxx
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.get('/raw_url', async (req, res) => {
+ try {
+ const filePath = sanitizePath(req.query.path)
+ if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
+
+ const raw_url = ossClient.getRawUrl(filePath)
+ res.json({ ok: true, path: filePath, raw_url })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 列出文件
+ // GET /api/v1/skills/external_storage/list?prefix=subdir/&recursive=true|false
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.get('/list', async (req, res) => {
+ try {
+ const prefix = sanitizePath(req.query.prefix || '')
+ const recursive = req.query.recursive === 'true'
+
+ let result
+ if (recursive) {
+ const files = await ossClient.listRecursive(prefix)
+ result = { files, folders: [] }
+ } else {
+ result = await ossClient.listObjects(prefix)
+ }
+
+ res.json({ ok: true, prefix, recursive, ...result })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 删除文件
+ // DELETE /api/v1/skills/external_storage/delete?path=xxx
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.delete('/delete', async (req, res) => {
+ try {
+ const filePath = sanitizePath(req.query.path)
+ if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
+
+ await ossClient.deleteObject(filePath)
+ res.json({ ok: true, path: filePath })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 文件夹打包下载
+ // GET /api/v1/skills/external_storage/folder/download?path=subdir/
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.get('/folder/download', async (req, res) => {
+ try {
+ const folderPath = sanitizePath(req.query.path)
+ if (!folderPath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
+
+ const folderKey = folderPath.endsWith('/') ? folderPath : folderPath + '/'
+ const folderName = path.basename(folderKey.replace(/\/+$/, '')) || 'folder'
+
+ const files = await ossClient.listRecursive(folderKey)
+
+ res.set('Content-Type', 'application/zip')
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(folderName + '.zip')}`)
+
+ const archive = archiver('zip', { zlib: { level: 6 } })
+ archive.on('error', err => {
+ try { res.status(500).end() } catch {}
+ })
+ archive.pipe(res)
+
+ const prefixClean = folderKey.replace(/\/+$/, '') + '/'
+ for (const f of files) {
+ try {
+ // 提取相对于文件夹前缀的路径
+ const fullRel = f.key
+ const relPart = fullRel.slice(prefixClean.length)
+ const { stream } = await ossClient.getObjectStream(relPart)
+ archive.append(stream, { name: relPart })
+ } catch {}
+ }
+
+ archive.finalize()
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ============================================================
+ // 获取文件信息
+ // GET /api/v1/skills/external_storage/info?path=xxx
+ // Headers: X-API-Id, X-API-Key
+ // ============================================================
+ router.get('/info', async (req, res) => {
+ try {
+ const filePath = sanitizePath(req.query.path)
+ if (!filePath) return res.status(400).json({ ok: false, error: '缺少 path 参数' })
+
+ const isFolder = filePath.endsWith('/')
+ const fullKey = ossClient.fullKey(filePath)
+ const raw_url = ossClient.getRawUrl(filePath)
+
+ let size = 0
+ let lastModified = 0
+
+ if (!isFolder) {
+ try {
+ const parent = filePath.includes('/') ? filePath.split('/').slice(0, -1).join('/') : ''
+ const result = await ossClient.listObjects(parent)
+ const targetKey = fullKey
+ const hit = result.files.find(f => f.key === targetKey)
+ if (hit) {
+ size = hit.size
+ lastModified = hit.lastModified
+ }
+ } catch {}
+ } else {
+ try {
+ const files = await ossClient.listRecursive(filePath)
+ size = files.reduce((s, f) => s + (Number(f.size) || 0), 0)
+ } catch {}
+ }
+
+ res.json({
+ ok: true,
+ info: {
+ path: filePath,
+ oss_key: fullKey,
+ raw_url,
+ isFolder,
+ size,
+ lastModified: lastModified ? new Date(lastModified).toISOString() : null
+ }
+ })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // 挂载路由
+ app.use('/api/v1/skills/external_storage', router)
+ console.log('[external_storage] Routes mounted at /api/v1/skills/external_storage')
+}
+
+module.exports = { bindRoutes }
diff --git a/src/server/data_gateway/skills/external_storage/middleware.js b/src/server/data_gateway/skills/external_storage/middleware.js
new file mode 100644
index 0000000..4f2c49c
--- /dev/null
+++ b/src/server/data_gateway/skills/external_storage/middleware.js
@@ -0,0 +1,83 @@
+// ============================================================
+// external_storage/middleware.js - 路径安全校验中间件
+// 职责:防止路径遍历攻击,确保所有操作限定在 external_storage 范围内
+// ============================================================
+
+/**
+ * 校验并清理路径
+ * - 拒绝包含 ../ 的路径遍历
+ * - 拒绝绝对路径
+ * - 拒绝控制字符
+ * - 统一使用 UTF-8 编码,正确处理中文
+ * @param {string} rawPath - 原始路径参数
+ * @returns {string} 清理后的安全路径
+ * @throws {Error} 如果路径不合法
+ */
+const sanitizePath = (rawPath) => {
+ if (rawPath === undefined || rawPath === null) return ''
+
+ // 确保使用 UTF-8 字符串
+ let p = String(rawPath)
+
+ // 拒绝控制字符(除了正常的换行和制表符)
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(p)) {
+ throw new Error('路径包含非法控制字符')
+ }
+
+ // 拒绝路径遍历
+ if (p.includes('..')) {
+ throw new Error('路径不允许包含 ".."')
+ }
+
+ // 拒绝绝对路径
+ if (p.startsWith('/') || p.startsWith('\\')) {
+ throw new Error('路径不允许以 "/" 或 "\\" 开头')
+ }
+
+ // 统一斜杠
+ p = p.replace(/[\\]+/g, '/')
+
+ // 去除连续斜杠
+ p = p.replace(/\/+/g, '/')
+
+ // 去除首尾斜杠
+ p = p.replace(/^\/+|\/+$/g, '')
+
+ return p
+}
+
+/**
+ * 校验路径是否在允许的前缀范围内
+ * @param {string} ossKey - 完整 OSS key
+ * @param {string} allowedPrefix - 允许的前缀
+ * @returns {boolean}
+ */
+const isPathWithinPrefix = (ossKey, allowedPrefix) => {
+ const key = String(ossKey || '')
+ const prefix = String(allowedPrefix || '').replace(/\/+$/, '')
+ return key === prefix || key.startsWith(prefix + '/')
+}
+
+/**
+ * 文件大小校验中间件
+ * @param {number} maxBytes - 最大字节数
+ * @returns {Function} express 中间件
+ */
+const createSizeLimitMiddleware = (maxBytes = 50 * 1024 * 1024) => {
+ return (req, res, next) => {
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10)
+ if (contentLength > maxBytes) {
+ return res.status(413).json({
+ ok: false,
+ error: `文件超过大小限制 (${Math.round(maxBytes / 1024 / 1024)}MB)`
+ })
+ }
+ next()
+ }
+}
+
+module.exports = {
+ sanitizePath,
+ isPathWithinPrefix,
+ createSizeLimitMiddleware
+}
diff --git a/src/server/data_gateway/skills/external_storage/oss_client.js b/src/server/data_gateway/skills/external_storage/oss_client.js
new file mode 100644
index 0000000..6553569
--- /dev/null
+++ b/src/server/data_gateway/skills/external_storage/oss_client.js
@@ -0,0 +1,321 @@
+// ============================================================
+// external_storage/oss_client.js - 阿里云 OSS 客户端封装
+// 职责:提供上传、下载、删除、列表、获取URL等基础操作
+// 所有操作限定在 config.json 配置的 oss_prefix 范围内
+// ============================================================
+const crypto = require('crypto')
+const https = require('https')
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+const { URLSearchParams } = require('url')
+
+/**
+ * 初始化 OSS 客户端配置
+ * @param {Object} skillConfig - skill 的 config.json 内容
+ * @returns {Object} 客户端实例
+ */
+const createOssClient = (skillConfig) => {
+ const bucket = skillConfig.oss_bucket || 'android-o1-images'
+ const endpoint = skillConfig.oss_endpoint || 'oss-cn-shanghai.aliyuncs.com'
+ const prefix = (skillConfig.oss_prefix || '550e8400-e29b-41d4-a716-446655442082/external_storage').replace(/\/+$/, '')
+
+ // 从 process.env 读取(index.js 启动时已从 Toolbox_local_creds.env.local 加载)
+ let accessKeyId = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_ID || ''
+ let accessKeySecret = process.env.EXTERNAL_STORAGE_OSS_ACCESS_KEY_SECRET || ''
+
+ if (!accessKeyId || !accessKeySecret) {
+ throw new Error('OSS credentials not found. Please set oss_access_key_id and oss_access_key_secret in config.json')
+ }
+
+ /**
+ * 规范化 OSS key:处理斜杠、去除危险字符
+ */
+ const normalizeKey = (relativePath) => {
+ let k = String(relativePath || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/')
+ if (k.startsWith('/')) k = k.slice(1)
+ return k
+ }
+
+ /**
+ * 拼接完整 OSS key(自动加前缀)
+ */
+ const fullKey = (relativePath) => {
+ const rel = normalizeKey(relativePath)
+ return rel ? `${prefix}/${rel}` : prefix
+ }
+
+ /**
+ * URL 编码 OSS key(用于 HTTP 路径)
+ */
+ const encodeKey = (key) => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/')
+
+ /**
+ * 构建 OSS 资源标识
+ */
+ const ossResource = (key) => `/${bucket}/${String(key || '').replace(/^\/+/, '')}`
+
+ /**
+ * OSS 请求签名(HMAC-SHA1)
+ */
+ const signRequest = ({ method, contentType = '', date, key, extraHeaders = {} }) => {
+ const canonicalHeaders = Object.keys(extraHeaders)
+ .filter(k => k && k.toLowerCase().startsWith('x-oss-'))
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
+ .map(k => `${k.toLowerCase()}:${String(extraHeaders[k] || '').trim()}`)
+ .join('\n')
+ const resource = ossResource(key)
+ const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
+ return crypto.createHmac('sha1', accessKeySecret).update(stringToSign).digest('base64')
+ }
+
+ /**
+ * 发起 HTTP 请求
+ */
+ const httpRequest = (options, body = null, timeoutMs = 60000) => new Promise((resolve, reject) => {
+ const lib = options.protocol === 'https:' ? https : http
+ const req = lib.request(options, (res) => {
+ const chunks = []
+ res.on('data', chunk => chunks.push(chunk))
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks)
+ resolve({ statusCode: res.statusCode, headers: res.headers, body: buf })
+ })
+ })
+ req.on('error', reject)
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('request timeout')))
+ if (body !== null && body !== undefined) {
+ if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body)
+ }
+ req.end()
+ })
+
+ /**
+ * 获取文件流
+ */
+ const getObjectStream = (relativePath) => new Promise((resolve, reject) => {
+ const key = fullKey(relativePath)
+ const date = new Date().toUTCString()
+ const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key })}`
+ const options = {
+ hostname: `${bucket}.${endpoint}`,
+ path: `/${encodeKey(key)}`,
+ method: 'GET',
+ headers: {
+ Host: `${bucket}.${endpoint}`,
+ Date: date,
+ Authorization: authorization
+ }
+ }
+ const req = https.request(options, (res) => {
+ if (res.statusCode < 200 || res.statusCode >= 300) {
+ const chunks = []
+ res.on('data', c => chunks.push(c))
+ res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 500)}`)))
+ return
+ }
+ resolve({ stream: res, headers: res.headers })
+ })
+ req.on('error', reject)
+ req.setTimeout(60000, () => req.destroy(new Error('request timeout')))
+ req.end()
+ })
+
+ /**
+ * 上传文件
+ * @param {string} relativePath - 相对路径(自动加前缀)
+ * @param {Buffer} body - 文件内容
+ * @param {string} contentType - MIME 类型
+ * @returns {Object} { key, raw_url }
+ */
+ const putObject = async (relativePath, body, contentType = 'application/octet-stream') => {
+ const key = fullKey(relativePath)
+ const date = new Date().toUTCString()
+ const extraHeaders = { 'x-oss-object-acl': 'public-read' }
+ const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'PUT', contentType, date, key, extraHeaders })}`
+ const options = {
+ hostname: `${bucket}.${endpoint}`,
+ path: `/${encodeKey(key)}`,
+ method: 'PUT',
+ headers: {
+ Host: `${bucket}.${endpoint}`,
+ Date: date,
+ 'Content-Type': contentType,
+ 'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''),
+ ...extraHeaders,
+ Authorization: authorization
+ }
+ }
+ const resp = await httpRequest(options, body, 120000)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
+ }
+ const raw_url = `https://${bucket}.${endpoint}/${encodeKey(key)}`
+ return { key, raw_url }
+ }
+
+ /**
+ * 删除文件
+ */
+ const deleteObject = async (relativePath) => {
+ const key = fullKey(relativePath)
+ const date = new Date().toUTCString()
+ const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'DELETE', date, key })}`
+ const options = {
+ hostname: `${bucket}.${endpoint}`,
+ path: `/${encodeKey(key)}`,
+ method: 'DELETE',
+ headers: {
+ Host: `${bucket}.${endpoint}`,
+ Date: date,
+ Authorization: authorization
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
+ }
+ return true
+ }
+
+ /**
+ * 解析 OSS ListObjects XML 响应
+ */
+ const parseListXml = (xml) => {
+ const extractTag = (tag, src) => {
+ const open = `<${tag}>`
+ const close = `${tag}>`
+ const result = []
+ let idx = 0
+ while (true) {
+ const s = src.indexOf(open, idx)
+ if (s < 0) break
+ const e = src.indexOf(close, s + open.length)
+ if (e < 0) break
+ result.push(src.slice(s + open.length, e))
+ idx = e + close.length
+ }
+ return result
+ }
+ const extractInSection = (tag, sectionOpen, sectionClose, src) => {
+ const result = []
+ let searchFrom = 0
+ while (true) {
+ const sOpen = src.indexOf(sectionOpen, searchFrom)
+ if (sOpen < 0) break
+ const sClose = src.indexOf(sectionClose, sOpen)
+ if (sClose < 0) break
+ const section = src.slice(sOpen, sClose)
+ result.push(...extractTag(tag, section))
+ searchFrom = sClose + sectionClose.length
+ }
+ return result
+ }
+ const folders = extractInSection('Prefix', '
', '', xml).map(p => ({
+ key: p,
+ name: p.split('/').filter(Boolean).pop() + '/',
+ isFolder: true,
+ size: 0
+ }))
+ const fileKeys = extractTag('Key', xml)
+ const fileSizes = extractTag('Size', xml)
+ const fileLms = extractTag('LastModified', xml)
+ const files = fileKeys.map((k, i) => ({
+ key: k,
+ name: k.split('/').filter(Boolean).pop(),
+ isFolder: false,
+ size: parseInt(fileSizes[i] || '0', 10) || 0,
+ lastModified: fileLms[i] ? Date.parse(fileLms[i]) : 0
+ })).filter(f => f.key && !f.key.endsWith('/'))
+ return { folders, files }
+ }
+
+ /**
+ * 列出文件(递归)
+ * @param {string} relativePrefix - 相对前缀
+ * @returns {Array} 文件列表
+ */
+ const listRecursive = async (relativePrefix = '') => {
+ const all = []
+ const stack = [relativePrefix]
+ while (stack.length > 0) {
+ const current = stack.pop()
+ const keyPrefix = fullKey(current)
+ const date = new Date().toUTCString()
+ const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
+ const query = new URLSearchParams()
+ query.set('prefix', keyPrefix)
+ query.set('delimiter', '/')
+ query.set('max-keys', '1000')
+ const options = {
+ hostname: `${bucket}.${endpoint}`,
+ path: `/?${query.toString()}`,
+ method: 'GET',
+ headers: {
+ Host: `${bucket}.${endpoint}`,
+ Date: date,
+ Authorization: authorization
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
+ }
+ const parsed = parseListXml(resp.body.toString('utf8'))
+ all.push(...parsed.files)
+ for (const f of parsed.folders) {
+ stack.push(f.key.slice(keyPrefix.length > 0 ? (fullKey('').length) : 0))
+ }
+ }
+ return all
+ }
+
+ /**
+ * 列出对象(单层)
+ */
+ const listObjects = async (relativePrefix = '') => {
+ const keyPrefix = fullKey(relativePrefix)
+ const date = new Date().toUTCString()
+ const authorization = `OSS ${accessKeyId}:${signRequest({ method: 'GET', date, key: '' })}`
+ const query = new URLSearchParams()
+ query.set('prefix', keyPrefix)
+ query.set('delimiter', '/')
+ query.set('max-keys', '1000')
+ const options = {
+ hostname: `${bucket}.${endpoint}`,
+ path: `/?${query.toString()}`,
+ method: 'GET',
+ headers: {
+ Host: `${bucket}.${endpoint}`,
+ Date: date,
+ Authorization: authorization
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 500)}`)
+ }
+ return parseListXml(resp.body.toString('utf8'))
+ }
+
+ /**
+ * 获取文件原始 URL(公共读可直接访问)
+ */
+ const getRawUrl = (relativePath) => {
+ const key = fullKey(relativePath)
+ return `https://${bucket}.${endpoint}/${encodeKey(key)}`
+ }
+
+ return {
+ fullKey,
+ normalizeKey,
+ putObject,
+ deleteObject,
+ getObjectStream,
+ listObjects,
+ listRecursive,
+ getRawUrl
+ }
+}
+
+module.exports = { createOssClient }
diff --git a/src/server/data_gateway/skills/weather/config.json b/src/server/data_gateway/skills/weather/config.json
new file mode 100644
index 0000000..b710548
--- /dev/null
+++ b/src/server/data_gateway/skills/weather/config.json
@@ -0,0 +1,10 @@
+{
+ "_comment": "天气数据 skill - 由外部 Hermes Agent 每30分钟写入一次",
+ "id": "weather",
+ "name": "天气数据",
+ "enabled": true,
+ "mode": "append",
+ "mode_comment": "append: 增量追加保留所有历史;latest: 只保留最新一条",
+ "api_key_hash": "sha256:7b4035f766bf66e39cff1a58a5e8c73f2fcbf75f3f2a368bf0003e1110a56255",
+ "read_token_hash": "sha256:aa40833cd71a76b2fc558edb1384f4d993c9e8d966a730005f7c99d56ab19037"
+}
\ No newline at end of file
diff --git a/src/server/data_gateway/store.js b/src/server/data_gateway/store.js
new file mode 100644
index 0000000..9976d5f
--- /dev/null
+++ b/src/server/data_gateway/store.js
@@ -0,0 +1,123 @@
+// ============================================================
+// data_gateway/store.js - 数据存储模块
+// 职责:动态创建/管理每个 skill 的独立 SQLite 数据库
+// 表结构:id | source | data(JSON) | created_at
+// 支持 mode: "append"(增量追加)和 "latest"(覆盖最新)
+// ============================================================
+const Database = require('better-sqlite3')
+const fs = require('fs')
+const path = require('path')
+
+const DATA_DIR = path.join(process.cwd(), 'data', 'ingest')
+if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
+
+// 已打开的数据库连接缓存
+const dbCache = new Map()
+
+const getDbPath = (skillId) => path.join(DATA_DIR, `${skillId}.db`)
+
+const getOrOpenDb = (skillId) => {
+ if (dbCache.has(skillId)) return dbCache.get(skillId)
+ const dbPath = getDbPath(skillId)
+ const db = new Database(dbPath)
+ // 启用 WAL 模式提高并发写入性能
+ db.pragma('journal_mode = WAL')
+ db.pragma('synchronous = NORMAL')
+ db.exec(
+ `CREATE TABLE IF NOT EXISTS records (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL DEFAULT '',
+ data TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )`
+ )
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at DESC)`)
+ dbCache.set(skillId, db)
+ return db
+}
+
+// 写入一条记录
+const insert = (skillId, { source = '', data = {} }) => {
+ const db = getOrOpenDb(skillId)
+ const dataStr = JSON.stringify(data)
+ const info = db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
+ String(source || ''), dataStr
+ )
+ return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
+}
+
+// 覆盖写入(mode: "latest")- 先删后插,保证只有一条
+const upsertLatest = (skillId, { source = '', data = {} }) => {
+ const db = getOrOpenDb(skillId)
+ const dataStr = JSON.stringify(data)
+ const tx = db.transaction(() => {
+ db.prepare(`DELETE FROM records`).run()
+ return db.prepare(`INSERT INTO records (source, data) VALUES (?, ?)`).run(
+ String(source || ''), dataStr
+ )
+ })
+ const info = tx()
+ return { id: info.lastInsertRowid, source, data, created_at: new Date().toISOString() }
+}
+
+// 查询最新一条记录
+const queryLatest = (skillId) => {
+ const db = getOrOpenDb(skillId)
+ const row = db.prepare(`SELECT * FROM records ORDER BY id DESC LIMIT 1`).get()
+ if (!row) return null
+ try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
+ return row
+}
+
+// 查询记录列表(分页)
+const queryList = (skillId, { limit = 100, offset = 0, source = null } = {}) => {
+ const db = getOrOpenDb(skillId)
+ let sql = `SELECT * FROM records`
+ const params = []
+ if (source) {
+ sql += ` WHERE source = ?`
+ params.push(String(source))
+ }
+ sql += ` ORDER BY id DESC LIMIT ? OFFSET ?`
+ params.push(Math.min(Number(limit) || 100, 1000), Math.max(0, Number(offset) || 0))
+ const rows = db.prepare(sql).all(...params)
+ for (const row of rows) {
+ try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
+ }
+ const total = db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
+ return { rows, total }
+}
+
+// 按时间范围查询
+const queryByTimeRange = (skillId, { start, end, limit = 100 } = {}) => {
+ const db = getOrOpenDb(skillId)
+ let sql = `SELECT * FROM records WHERE 1=1`
+ const params = []
+ if (start) { sql += ` AND created_at >= ?`; params.push(String(start)) }
+ if (end) { sql += ` AND created_at <= ?`; params.push(String(end)) }
+ sql += ` ORDER BY id DESC LIMIT ?`
+ params.push(Math.min(Number(limit) || 100, 1000))
+ const rows = db.prepare(sql).all(...params)
+ for (const row of rows) {
+ try { row.data = JSON.parse(row.data) } catch { /* keep as string */ }
+ }
+ return { rows, total: rows.length }
+}
+
+// 获取记录总数
+const count = (skillId) => {
+ const db = getOrOpenDb(skillId)
+ return db.prepare(`SELECT COUNT(*) as cnt FROM records`).get().cnt
+}
+
+// 关闭所有数据库连接
+const closeAll = () => {
+ for (const [skillId, db] of dbCache) {
+ try { db.close() } catch {}
+ dbCache.delete(skillId)
+ }
+}
+
+module.exports = {
+ insert, upsertLatest, queryLatest, queryList, queryByTimeRange, count, closeAll, getDbPath, getOrOpenDb
+}
\ No newline at end of file
diff --git a/src/server/index.js b/src/server/index.js
index 0ab9e50..656b5be 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -7,7 +7,9 @@ const Database = require('better-sqlite3')
const crypto = require('crypto')
// ===== 从 ~/Toolbox_local_creds.env.local 加载变量到 process.env =====
-// 所有模块的 process.env.XXX fallback 依赖此加载器
+// ★ 规则:所有密码、密钥、Token、用户名等高危内容,禁止硬编码在源码里。
+// 一律放在 ~/Toolbox_local_creds.env.local 中,在此处自动加载到 process.env
+// 各模块通过 process.env.XXX 读取,不要直接 import 或 require 凭证。
// 优先级:已存在的 process.env > env 文件 > 模块内 '' fallback
try {
const os = require('os')
@@ -76,6 +78,8 @@ const plantHome = require('./plant_home')
const globalNews = require('./global_news')
const boxBackup = require('./box_backup')
const yuanzhupai = require('./yuanzhupai')
+const dataGateway = require('./data_gateway')
+const ossFileCabinet = require('./oss_file_cabinet')
yuanzhupai.initDb()
const app = express()
@@ -1335,6 +1339,7 @@ const mapSystemIdToUrl = (systemId) => {
'FitnessDiary': '/tools/fitness_diary',
'Tools-doc_cloud_keeper': '/tools/doc_cloud_keeper',
'Tools-yuanzhupai': '/tools/yuanzhupai',
+ 'Tools-oss_file_cabinet': '/tools/oss_file_cabinet',
'Tools-plant_home': '/tools/plant_home',
'Tools-apple_watch': '/tools/apple_watch',
'Tools-private_clipboard': '/tools/private_clipboard',
@@ -2078,6 +2083,145 @@ app.use('/api/private_clipboard', (req, res, next) => {
return res.status(503).json({ ok: false, error: 'service_not_ready' })
})
+const OSS_FILE_CABINET_AUTH_COOKIE = 'oss_file_cabinet_gate'
+const readOssFileCabinetAuthConfig = () => {
+ try {
+ const p = path.join(process.cwd(), 'public', 'tools', 'oss_file_cabinet', 'auth_config.json')
+ if (fs.existsSync(p)) {
+ const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'))
+ if (cfg && typeof cfg === 'object') return cfg
+ }
+ } catch {}
+ return {}
+}
+const readOssFileCabinetJwk = () => {
+ try {
+ const p = path.join(process.cwd(), 'config', 'oss_file_cabinet.jwk.json')
+ if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
+ } catch {}
+ return { kty: 'RSA', n: '', e: '' }
+}
+const getOssFileCabinetCookieName = () => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ const n = String(cfg.cookieName || '')
+ if (n) return n
+ } catch {}
+ return OSS_FILE_CABINET_AUTH_COOKIE
+}
+const getOssFileCabinetMaxAge = () => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ const days = Number(cfg.max_age_days || 0)
+ if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
+ } catch {}
+ return 30 * 24 * 3600 * 1000
+}
+const getOssFileCabinetGuidConfig = () => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
+ const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
+ return { key: keyRaw || 'guid', value: valueRaw }
+ } catch { return { key: 'guid', value: '' } }
+}
+const hasValidOssFileCabinetGuid = (req) => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ if (cfg.enable_auth === false) return false
+ const { key, value } = getOssFileCabinetGuidConfig()
+ if (!value) return false
+ const q = req && req.query ? req.query : {}
+ const raw = q[key]
+ if (raw === undefined || raw === null) return false
+ const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
+ return incoming === value
+ } catch { return false }
+}
+const hasOssFileCabinetAuth = (req) => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ if (cfg.enable_auth === false) return true
+ const cookies = parseCookie(req.headers.cookie || '')
+ const name = getOssFileCabinetCookieName()
+ return cookies[name] === '1'
+ } catch { return false }
+}
+const isOssFileCabinetForceGuid = () => {
+ try {
+ const cfg = readOssFileCabinetAuthConfig()
+ if (cfg.enable_auth === false) return false
+ if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
+ if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
+ return true
+ } catch { return true }
+}
+app.get('/tools/oss_file_cabinet', (req, res, next) => {
+ setNoCache(res)
+ try {
+ const forceGuid = isOssFileCabinetForceGuid()
+ if (hasOssFileCabinetAuth(req)) return next()
+ const guidKey = getOssFileCabinetGuidConfig().key
+ const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
+ if (hasValidOssFileCabinetGuid(req)) {
+ const maxAgeOfc = getOssFileCabinetMaxAge()
+ const cookieName = getOssFileCabinetCookieName()
+ res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
+ return res.redirect('/tools/oss_file_cabinet/index.html')
+ }
+ if (hasGuidInput) return res.status(401).send('未授权')
+ const token = String(req.query.token || '')
+ if (!token) {
+ if (forceGuid) return res.status(401).send('未授权')
+ return next()
+ }
+ const payload = verifyJwtWithKeys(token, jwkKeys(readOssFileCabinetJwk()))
+ if (!payload) return res.status(401).send('未授权')
+ {
+ const iss = String(payload.iss || '')
+ const navIss = getNavIssFromFlags()
+ if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权')
+ }
+ const maxAgeOfc = getOssFileCabinetMaxAge()
+ const cookieName = getOssFileCabinetCookieName()
+ res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
+ return res.redirect('/tools/oss_file_cabinet/index.html')
+ } catch { return res.status(401).send('未授权') }
+})
+app.use('/tools/oss_file_cabinet', (req, res, next) => {
+ const forceGuid = isOssFileCabinetForceGuid()
+ if (hasOssFileCabinetAuth(req)) return next()
+ const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
+ const guidKey = getOssFileCabinetGuidConfig().key
+ const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
+ if (isHandshake && hasValidOssFileCabinetGuid(req)) {
+ const maxAgeOfc = getOssFileCabinetMaxAge()
+ const cookieName = getOssFileCabinetCookieName()
+ res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
+ return res.redirect('/tools/oss_file_cabinet/index.html')
+ }
+ if (hasGuidInput) return res.status(401).send('未授权')
+ if (isHandshake && String(req.query.token || '')) {
+ const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readOssFileCabinetJwk()))
+ if (payload) {
+ const iss = String(payload.iss || '')
+ const navIss = getNavIssFromFlags()
+ if (iss === navIss && !audMatch(payload, 'Tools-oss_file_cabinet')) return res.status(401).send('未授权')
+ const maxAgeOfc = getOssFileCabinetMaxAge()
+ const cookieName = getOssFileCabinetCookieName()
+ res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge: maxAgeOfc, path: '/' })
+ return res.redirect('/tools/oss_file_cabinet/index.html')
+ }
+ }
+ if (isHandshake && forceGuid) return res.status(401).send('未授权')
+ return res.status(401).send('未授权')
+})
+app.use('/api/oss_file_cabinet', (req, res, next) => {
+ if (req.path.startsWith('/share/access/')) return next()
+ if (hasOssFileCabinetAuth(req)) return next()
+ return res.status(401).json({ ok: false, error: '未授权' })
+})
+
const WEEKLY_AUTH_COOKIE = 'weekly_gate'
const readWeeklyJwk = () => {
try {
@@ -5842,6 +5986,8 @@ expense.bindRoutes(app)
investmentLedger.bindRoutes(app)
styleCheck.bindRoutes(app)
globalNews.bindRoutes(app)
+dataGateway.bindRoutes(app)
+ossFileCabinet.bindRoutes(app)
const marketsCfgAtBoot = getMarketsCfg()
const currentPort = Number(process.env.PORT || '8976') || 8976
diff --git a/src/server/market_skills/brazil_ibovespa/config.json b/src/server/market_skills/brazil_ibovespa/config.json
index 55696f0..b150a00 100644
--- a/src/server/market_skills/brazil_ibovespa/config.json
+++ b/src/server/market_skills/brazil_ibovespa/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "巴西IBOVESPA",
"card_order": 18,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(iShares MSCI Brazil ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usEWZ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWZ",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/bse50/config.json b/src/server/market_skills/bse50/config.json
index 2902ae1..efac55f 100644
--- a/src/server/market_skills/bse50/config.json
+++ b/src/server/market_skills/bse50/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "北证50",
"card_order": 6,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": false,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "bj899050",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/bj899050",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/buffett_index/config.json b/src/server/market_skills/buffett_index/config.json
index dfc8daa..97cb7ec 100644
--- a/src/server/market_skills/buffett_index/config.json
+++ b/src/server/market_skills/buffett_index/config.json
@@ -1,17 +1,17 @@
{
- "name_comment": "显示的名ç§?,
- "name": "巴菲特指æ•?,
- "card_order_comment": "卡片排序序号,越大越靠后(当前最大å€?87,设ä¸?88 排最后)",
+ "name_comment": "显示的名",
+ "name": "巴菲特指",
+ "card_order_comment": "卡片排序序号,越大越靠后(当前最大",
"card_order": 88,
"card_type_comment": "卡片类型:domestic(国内)/ overseas(海外)/ enhanced(增强)/ defensive(防御)",
"card_type": "overseas",
- "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列",
+ "card_span_comment": "卡片横向占位列数",
"card_span": 2,
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?1h 表示1小时)",
+ "schedule_comment": "刷新频率 (",
"schedule": "1h",
"symbol_comment": "监控的代码(技能内部定义)",
"symbol": "us_buffett_index",
@@ -21,9 +21,9 @@
"source": "fred",
"alerts_comment": "预警阈值配置(基于S&P500/GDP比率,非Wilshire5000原值)",
"alerts": {
- "ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预è?,
+ "ratio_high_warning_comment": "S&P500/GDP 比率高于此值触发估值偏高预",
"ratio_high_warning": 0.19,
- "ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告è?,
+ "ratio_critical_comment": "S&P500/GDP 比率高于此值触发极端高估严重告",
"ratio_critical": 0.21,
"change_percent_down_comment": "单日跌幅告警阈值(比率变化百分比)",
"change_percent_down": -5,
@@ -31,20 +31,20 @@
"change_percent_up": 5,
"urgent_abs_change_percent_comment": "严重告警阈值(绝对波动百分比,日变动超过此值触发紧急推送)",
"urgent_abs_change_percent": 8,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -55,19 +55,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"enabled": false
}
],
- "market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状æ€?,
+ "market_status_comment": "市场时区与交易时段定义,用于前端显示开休市状",
"market_status": {
"timezone": "America/New_York",
"sessions": [
diff --git a/src/server/market_skills/china_korea_semiconductor/config.json b/src/server/market_skills/china_korea_semiconductor/config.json
index 9fd36bc..5c1cea1 100644
--- a/src/server/market_skills/china_korea_semiconductor/config.json
+++ b/src/server/market_skills/china_korea_semiconductor/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
- "name": "中韩半导体指æ•?,
+ "name_comment": "显示的名",
+ "name": "中韩半导体指",
"card_order": 19,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(科创50,原中韩半导体指数无ETF替代)",
+ "symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
"source_comment": "数据来源",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"end": "15:30"
}
],
- "display_label": "中韩半导ä½?
- },
+ "display_label": "中韩半导"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/chinext/config.json b/src/server/market_skills/chinext/config.json
index 93dd44b..31250b2 100644
--- a/src/server/market_skills/chinext/config.json
+++ b/src/server/market_skills/chinext/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "创业板指",
"card_order": 5,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sz399006",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399006",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -80,7 +80,7 @@
"end": "15:00"
}
],
- "display_label": "创业æ�?
- },
+ "display_label": "创业"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/comex_gold/config.json b/src/server/market_skills/comex_gold/config.json
index 0589dce..0c99837 100644
--- a/src/server/market_skills/comex_gold/config.json
+++ b/src/server/market_skills/comex_gold/config.json
@@ -1,52 +1,52 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "COMEX黄金",
"card_order": 15,
"card_type": "defensive",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "hf_GC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/GC00Y.html",
- "silver_symbol_comment": "白银代码 (用于计算金银æ¯?",
+ "silver_symbol_comment": "白银代码 (用于计算金银",
"silver_symbol": "hf_SI",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "price_min_comment": "最低价格预è?,
+ "price_min_comment": "最低价格预",
"price_min": 4800,
- "price_max_comment": "最高价格预è?,
+ "price_max_comment": "最高价格预",
"price_max": 5500,
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3,
- "gold_silver_ratio_min_comment": "金银比最低预è?,
+ "gold_silver_ratio_min_comment": "金银比最低预",
"gold_silver_ratio_min": 60,
- "gold_silver_ratio_max_comment": "金银比最高预è?,
+ "gold_silver_ratio_max_comment": "金银比最高预",
"gold_silver_ratio_max": 65
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -57,19 +57,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -86,7 +86,7 @@
"end": "17:00"
}
],
- "display_label": "纽约é‡?
- },
+ "display_label": "纽约"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/comex_silver/config.json b/src/server/market_skills/comex_silver/config.json
index 8465fff..eda7383 100644
--- a/src/server/market_skills/comex_silver/config.json
+++ b/src/server/market_skills/comex_silver/config.json
@@ -1,46 +1,46 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "COMEX白银",
"card_order": 15,
"card_type": "defensive",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "hf_SI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/globalfuture/SI00Y.html",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "price_min_comment": "最低价格预è?,
+ "price_min_comment": "最低价格预",
"price_min": 75,
- "price_max_comment": "最高价格预è?,
+ "price_max_comment": "最高价格预",
"price_max": 95,
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 3
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -51,19 +51,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -80,7 +80,7 @@
"end": "17:00"
}
],
- "display_label": "纽约é“?
- },
+ "display_label": "纽约"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/csi300/config.json b/src/server/market_skills/csi300/config.json
index 6ca5cf6..f0d6ae3 100644
--- a/src/server/market_skills/csi300/config.json
+++ b/src/server/market_skills/csi300/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "沪深300",
"card_order": 3,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sh000300",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000300",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/dow_jones/config.json b/src/server/market_skills/dow_jones/config.json
index e4c4cf6..1286c55 100644
--- a/src/server/market_skills/dow_jones/config.json
+++ b/src/server/market_skills/dow_jones/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
- "name": "道琼斯工业指æ•?,
+ "name_comment": "显示的名",
+ "name": "道琼斯工业指",
"card_order": 13.5,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "usDJI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usDJI",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -76,7 +76,7 @@
"end": "16:00"
}
],
- "display_label": "道琼æ–?
- },
+ "display_label": "道琼"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/germany_etf/config.json b/src/server/market_skills/germany_etf/config.json
index 706767d..40ecf6b 100644
--- a/src/server/market_skills/germany_etf/config.json
+++ b/src/server/market_skills/germany_etf/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "德国DAX30",
"card_order": 10,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(iShares MSCI Germany ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usEWG",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWG",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/gold_silver_nav/config.json b/src/server/market_skills/gold_silver_nav/config.json
index 7bf3e2d..f795bed 100644
--- a/src/server/market_skills/gold_silver_nav/config.json
+++ b/src/server/market_skills/gold_silver_nav/config.json
@@ -1,9 +1,9 @@
{
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
"schedule": "5m",
- "name": "黄金白银净值跟è¸?,
+ "name": "黄金白银净值跟",
"card_order": 16,
"card_type": "defensive",
"gold_fund_code": "518880",
@@ -33,7 +33,7 @@
{
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled": true,
- \"key\": \"\"
+ "key": ""
},
{
"url": "http://localhost:{{PORT}}/api/calendar/events",
diff --git a/src/server/market_skills/hsi/config.json b/src/server/market_skills/hsi/config.json
index d2a2b8d..867d7f4 100644
--- a/src/server/market_skills/hsi/config.json
+++ b/src/server/market_skills/hsi/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "恒生指数",
"card_order": 8,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "hkHSI",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/hkHSI",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/india_etf/config.json b/src/server/market_skills/india_etf/config.json
index 343e445..fa940c9 100644
--- a/src/server/market_skills/india_etf/config.json
+++ b/src/server/market_skills/india_etf/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
- "name": "印度NFTY(ç¾?ETF",
+ "name_comment": "显示的名",
+ "name": "印度NFTY(",
"card_order": 11,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(India Nifty 50 ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usNFTY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usNFTY",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/korea_kospi/config.json b/src/server/market_skills/korea_kospi/config.json
index 5933e37..f0ca62e 100644
--- a/src/server/market_skills/korea_kospi/config.json
+++ b/src/server/market_skills/korea_kospi/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "韩国综合指数",
"card_order": 86,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(iShares MSCI South Korea ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usEWY",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWY",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/liquidity_state_machine/config.json b/src/server/market_skills/liquidity_state_machine/config.json
index 7fb7250..5123849 100644
--- a/src/server/market_skills/liquidity_state_machine/config.json
+++ b/src/server/market_skills/liquidity_state_machine/config.json
@@ -1,44 +1,44 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "流动性状态机",
"card_order": 6,
"card_type": "overseas",
- "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列",
+ "card_span_comment": "卡片横向占位列数",
"card_span": 2,
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "6h",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "us_liquidity_state",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/FEDFUNDS",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "fred",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -20,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 40,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 240,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 20
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -49,19 +49,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/nasdaq/config.json b/src/server/market_skills/nasdaq/config.json
index ad2a83b..429d7bc 100644
--- a/src/server/market_skills/nasdaq/config.json
+++ b/src/server/market_skills/nasdaq/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "纳斯达克",
"card_order": 13,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "usIXIC",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usIXIC",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/nikkei_etf/config.json b/src/server/market_skills/nikkei_etf/config.json
index 6c49809..434d00b 100644
--- a/src/server/market_skills/nikkei_etf/config.json
+++ b/src/server/market_skills/nikkei_etf/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "日经225指数",
"card_order": 9,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(iShares MSCI Japan ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usEWJ",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWJ",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/saudi_ftse/config.json b/src/server/market_skills/saudi_ftse/config.json
index 87a9521..3d7f897 100644
--- a/src/server/market_skills/saudi_ftse/config.json
+++ b/src/server/market_skills/saudi_ftse/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "沙特ETF(富时沙特)",
"card_order": 17,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(Franklin FTSE Saudi Arabia ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usFLSA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usFLSA",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/sh_index/config.json b/src/server/market_skills/sh_index/config.json
index d1e7ff7..f8777ea 100644
--- a/src/server/market_skills/sh_index/config.json
+++ b/src/server/market_skills/sh_index/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "上证指数",
"card_order": 1,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sh000001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000001",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/shenhua_299/config.json b/src/server/market_skills/shenhua_299/config.json
index 15b057f..8b8db6e 100644
--- a/src/server/market_skills/shenhua_299/config.json
+++ b/src/server/market_skills/shenhua_299/config.json
@@ -1,33 +1,33 @@
{
- "name_comment": "显示的名ç§?,
- "name": "中国神华-299æ³?,
+ "name_comment": "显示的名",
+ "name": "中国神华-299",
"card_order": 1,
"card_type": "299",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": false,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sh601088_299",
"source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601088",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601088.html",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -38,19 +38,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -71,7 +71,7 @@
"end": "15:00"
}
],
- "display_label": "中国Aè‚?
- },
+ "display_label": "中国A"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/southeast_asia_tech/config.json b/src/server/market_skills/southeast_asia_tech/config.json
index 42f7738..d487a2c 100644
--- a/src/server/market_skills/southeast_asia_tech/config.json
+++ b/src/server/market_skills/southeast_asia_tech/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "东南亚科技(海峡时报)",
"card_order": 20,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(Global X 东南äº?ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usASEA",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usASEA",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/sp500/config.json b/src/server/market_skills/sp500/config.json
index a61c1c7..3014cb7 100644
--- a/src/server/market_skills/sp500/config.json
+++ b/src/server/market_skills/sp500/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "标普500",
"card_order": 12,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "usINX",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usINX",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/star50/config.json b/src/server/market_skills/star50/config.json
index 3c73e37..13a153c 100644
--- a/src/server/market_skills/star50/config.json
+++ b/src/server/market_skills/star50/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "科创50",
"card_order": 4,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sh000688",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/sz_index/config.json b/src/server/market_skills/sz_index/config.json
index bde0bea..9f99c27 100644
--- a/src/server/market_skills/sz_index/config.json
+++ b/src/server/market_skills/sz_index/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "深证成指",
"card_order": 2,
"card_type": "domestic",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sz399001",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sz399001",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/taiwan_twii/config.json b/src/server/market_skills/taiwan_twii/config.json
index b966aa9..742ca49 100644
--- a/src/server/market_skills/taiwan_twii/config.json
+++ b/src/server/market_skills/taiwan_twii/config.json
@@ -1,42 +1,42 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "宝岛加权指数",
"card_order": 87,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(iShares MSCI Taiwan ETF)",
+ "symbol_comment": "监控的代",
"symbol": "usEWT",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/usEWT",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "yahoo_probe",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
- "change_percent_down_comment": "跌幅预警阈å€?(负数,如 -2 表示è·?2%)",
+ "change_percent_down_comment": "跌幅预警阈",
"change_percent_down": -2,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120,
- "change_percent_up_comment": "涨幅预警阈å€?(正数,如 2 表示æ¶?2%)",
+ "change_percent_up_comment": "涨幅预警阈",
"change_percent_up": 2
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": true,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -47,19 +47,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": true,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": true,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": true,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/us_war_index/config.json b/src/server/market_skills/us_war_index/config.json
index b705220..1c26d9d 100644
--- a/src/server/market_skills/us_war_index/config.json
+++ b/src/server/market_skills/us_war_index/config.json
@@ -1,15 +1,15 @@
{
- "name_comment": "显示的名ç§?,
+ "name_comment": "显示的名",
"name": "美国战争指数",
"card_order": 14,
"card_type": "overseas",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(技能内部定ä¹?",
+ "symbol_comment": "监控的代",
"symbol": "us_war_pizzint",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://fred.stlouisfed.org/series/VIXCLS",
@@ -21,34 +21,34 @@
"fail_alert_consecutive": 3,
"source_comment": "数据来源",
"source": "fred_vix",
- "alerts_comment": "预警阈值配ç½?,
+ "alerts_comment": "预警阈值配",
"alerts": {
"doughcon_critical_max_comment": "DOUGHCON 小于等于该值触发高风险预警",
"doughcon_critical_max": 2,
- "doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预è?,
+ "doughcon_warning_max_comment": "DOUGHCON 小于等于该值触发风险抬升预",
"doughcon_warning_max": 3,
- "vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预è?,
+ "vix_warning_min_comment": "VIX 大于等于该值触发风险抬升预",
"vix_warning_min": 22,
"vix_critical_min_comment": "VIX 大于等于该值触发高风险预警",
"vix_critical_min": 30,
- "index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预è?,
+ "index_jump_threshold_comment": "战争指数单次上跳达到该值触发快速升温预",
"index_jump_threshold": 20,
- "urgent_abs_change_percent_comment": "严重告警阈å€?(绝对波动百分æ¯?",
+ "urgent_abs_change_percent_comment": "严重告警阈",
"urgent_abs_change_percent": 5,
- "urgent_cooldown_minutes_comment": "严重告警冷却分钟æ•?,
+ "urgent_cooldown_minutes_comment": "严重告警冷却分钟",
"urgent_cooldown_minutes": 120
},
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -59,19 +59,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
diff --git a/src/server/market_skills/zhonghang_299/config.json b/src/server/market_skills/zhonghang_299/config.json
index 8bc1118..57e2201 100644
--- a/src/server/market_skills/zhonghang_299/config.json
+++ b/src/server/market_skills/zhonghang_299/config.json
@@ -1,45 +1,45 @@
{
- "name_comment": "显示的名ç§?,
- "name": "中国银行-299æ³?,
+ "name_comment": "显示的名",
+ "name": "中国银行-299",
"card_order": 2,
"card_type": "299",
- "card_span_comment": "卡片横向占位列数ï¼?为默认,2为跨两列",
+ "card_span_comment": "卡片横向占位列数",
"card_span": 2,
"card_custom_class_comment": "中国银行卡片专属样式类名,仅用于前端独立展示",
"card_custom_class": "card-zhonghang-299",
- "enabled_comment": "是否启用该监æŽ?(true/false)",
+ "enabled_comment": "是否启用该监",
"enabled": true,
- "display_comment": "是否显示该卡ç‰?(false 则不拉取数据也不封存)",
+ "display_comment": "是否显示该卡",
"display": true,
- "schedule_comment": "刷新频率 (å¦?10m 表示10分钟)",
+ "schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代ç ?(腾讯财经或其他源)",
+ "symbol_comment": "监控的代",
"symbol": "sh601988_299",
"source_symbol_comment": "用于拉取K线的真实代码",
"source_symbol": "sh601988",
"url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://quote.eastmoney.com/sh601988.html",
- "source_comment": "数据来源 (å¦?tencent)",
+ "source_comment": "数据来源 (",
"source": "tencent",
- "box_window_comment": "收盘价箱体窗口,仅支æŒ?0/30/40",
+ "box_window_comment": "收盘价箱体窗口,仅支",
"box_window": 30,
"box_confirm_days_comment": "箱体突破确认天数",
"box_confirm_days": 2,
"box_volume_ma_window_comment": "突破日成交量均量窗口",
"box_volume_ma_window": 5,
- "box_volume_expand_ratio_comment": "突破日放量倍数阈å€?,
+ "box_volume_expand_ratio_comment": "突破日放量倍数阈",
"box_volume_expand_ratio": 1.2,
"email_comment": "邮件通知配置",
"email": {
"enabled_comment": "是否启用邮件通知",
"enabled": false,
- "from_comment": "发件人邮ç®?,
+ "from_comment": "发件人邮",
"from": "yangdafe@gmail.com",
- "to_comment": "收件人邮箱列è¡?,
+ "to_comment": "收件人邮箱列",
"to": [
"yangxiangyuan@umer.com.cn"
],
- "smtp_comment": "SMTP 服务器配ç½?,
+ "smtp_comment": "SMTP 服务器配",
"smtp": {
"host": "smtp.gmail.com",
"port": 465,
@@ -50,19 +50,19 @@
}
}
},
- "email_reminder_enabled_comment": "是否发送邮件提é†?,
+ "email_reminder_enabled_comment": "是否发送邮件提",
"email_reminder_enabled": false,
"calendar_reminder_enabled_comment": "是否写入日历提醒",
"calendar_reminder_enabled": false,
- "push_targets_comment": "推送目标列è¡?(支持多路推é€?",
+ "push_targets_comment": "推送目标列",
"push_targets": [
{
"url_comment": "推送接口地址",
"url": "http://localhost:{{PORT}}/api/weekly/ext/event",
"enabled_comment": "是否启用",
"enabled": false,
- "key_comment": "Weekly 工具çš?Key",
- \"key\": \"\"
+ "key_comment": "Weekly 工具",
+ "key": ""
},
{
"url_comment": "推送接口地址",
@@ -83,7 +83,7 @@
"end": "15:00"
}
],
- "display_label": "中国Aè‚?
- },
+ "display_label": "中国A"
+},
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/markets.js b/src/server/markets.js
index b3bcd59..f8b968d 100644
--- a/src/server/markets.js
+++ b/src/server/markets.js
@@ -27,7 +27,7 @@ if (!fs.existsSync(cfgPath)) {
enabled_comment: '是否启用 Yahoo 探针',
enabled: true,
base_url_comment: 'Yahoo 探针基础地址(不带结尾斜杠)',
- base_url: 'http://47.252.30.127:19071',
+ base_url: 'https://yang-probe.umersoft.com',
token_comment: 'Yahoo 探针 token,仅后端使用,需在 config/markets.json 中配置',
token: '',
endpoint_comment: 'Yahoo 探针统一路由',
diff --git a/src/server/marketsClient.js b/src/server/marketsClient.js
index 89d0f11..aef8133 100644
--- a/src/server/marketsClient.js
+++ b/src/server/marketsClient.js
@@ -175,10 +175,10 @@ const readMarketsConfig = () => {
const getYahooProbeConfig = () => {
const cfg = readMarketsConfig()
const raw = cfg && typeof cfg.yahoo_probe === 'object' ? cfg.yahoo_probe : {}
- const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '')
+ const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '')
return {
enabled: raw.enabled !== false,
- base_url: baseUrl || 'http://47.252.30.127:19071',
+ base_url: baseUrl || 'https://yang-probe.umersoft.com',
token: String(raw.token || '').trim(),
quote_endpoint: String(raw.quote_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
chart_endpoint: String(raw.chart_endpoint || raw.endpoint || '/probe/yahoo').trim() || '/probe/yahoo',
diff --git a/src/server/markets_weekly_report.js b/src/server/markets_weekly_report.js
index a3f9ec7..0103e6d 100644
--- a/src/server/markets_weekly_report.js
+++ b/src/server/markets_weekly_report.js
@@ -228,10 +228,10 @@ function getConfig() {
function getAlphaVantageConfig() {
const cfg = getConfig()
const raw = cfg && typeof cfg.alphavantage_probe === 'object' ? cfg.alphavantage_probe : {}
- const baseUrl = String(raw.base_url || 'http://47.252.30.127:19071').trim().replace(/\/$/, '')
+ const baseUrl = String(raw.base_url || 'https://yang-probe.umersoft.com').trim().replace(/\/$/, '')
return {
enabled: raw.enabled !== false,
- base_url: baseUrl || 'http://47.252.30.127:19071',
+ base_url: baseUrl || 'https://yang-probe.umersoft.com',
token: String(raw.token || '').trim(),
candle_endpoint: String(raw.candle_endpoint || '/probe/alphavantage/candle').trim() || '/probe/alphavantage/candle',
request_timeout_ms: Math.max(5000, Number(raw.request_timeout_ms || 20000)),
diff --git a/src/server/oss_file_cabinet.js b/src/server/oss_file_cabinet.js
new file mode 100644
index 0000000..a8f85da
--- /dev/null
+++ b/src/server/oss_file_cabinet.js
@@ -0,0 +1,1096 @@
+const express = require('express')
+const fs = require('fs')
+const path = require('path')
+const crypto = require('crypto')
+const Database = require('better-sqlite3')
+const multer = require('multer')
+const archiver = require('archiver')
+
+const DATA_DIR = path.join(process.cwd(), 'data')
+const DB_PATH = process.env.OSS_FILE_CABINET_DB_PATH || path.join(DATA_DIR, 'oss_file_cabinet.db')
+const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
+
+const OSS_CONFIG = {
+ bucket: process.env.OSS_FILE_CABINET_OSS_BUCKET || process.env.DOC_CLOUD_KEEPER_OSS_BUCKET || 'android-o1-images',
+ endpoint: process.env.OSS_FILE_CABINET_OSS_ENDPOINT || process.env.DOC_CLOUD_KEEPER_OSS_ENDPOINT || 'oss-cn-shanghai.aliyuncs.com',
+ root_prefix: process.env.OSS_FILE_CABINET_OSS_ROOT_PREFIX || '550e8400-e29b-41d4-a716-446655442082/file_cabinet',
+ access_key_id: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_ID || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || '',
+ access_key_secret: process.env.OSS_FILE_CABINET_OSS_ACCESS_KEY_SECRET || process.env.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || ''
+}
+
+const AUTH_COOKIE = 'oss_file_cabinet_auth'
+const AUTH_MAX_AGE_MS = 24 * 3600 * 1000
+const AUTH_HMAC_SECRET = process.env.OSS_FILE_CABINET_AUTH_SECRET || 'ofc-auth-secret-change-me'
+
+if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
+
+let db = null
+
+const getDb = () => {
+ if (db) return db
+ db = new Database(DB_PATH)
+ db.pragma('journal_mode = WAL')
+ db.pragma('synchronous = NORMAL')
+ db.pragma('busy_timeout = 4000')
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS trash_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ oss_key TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ is_folder INTEGER NOT NULL DEFAULT 0,
+ original_path TEXT NOT NULL,
+ size INTEGER DEFAULT 0,
+ deleted_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_trash_items_deleted_at ON trash_items(deleted_at);
+ CREATE TABLE IF NOT EXISTS share_links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token TEXT NOT NULL UNIQUE,
+ oss_key TEXT NOT NULL,
+ display_name TEXT NOT NULL,
+ is_folder INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token);
+ CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
+ `)
+ return db
+}
+
+const safeName = name => {
+ let s = String(name || 'untitled')
+ try {
+ const recovered = Buffer.from(s, 'latin1').toString('utf8')
+ if (recovered !== s && !/\ufffd/.test(recovered)) s = recovered
+ } catch {}
+ return s.replace(/[<>\":/\\|?*\u0000-\u001F]/g, '_').trim() || 'untitled'
+}
+const normalizeOssKey = key => {
+ let k = String(key || '').replace(/[\\]+/g, '/').replace(/\/+/g, '/')
+ const endsSlash = k.endsWith('/')
+ if (k.startsWith('/')) k = k.slice(1)
+ if (endsSlash && !k.endsWith('/') && k) k += '/'
+ return k
+}
+const fullKey = relKey => {
+ const r = normalizeOssKey(relKey)
+ const p = OSS_CONFIG.root_prefix ? `${OSS_CONFIG.root_prefix.replace(/\/+$/, '')}/` : ''
+ return p + r
+}
+const displayNameFromKey = key => {
+ const k = normalizeOssKey(key)
+ if (!k) return ''
+ const parts = k.split('/').filter(Boolean)
+ const last = parts[parts.length - 1] || ''
+ if (k.endsWith('/') && last) return last
+ return last
+}
+const encodeOssKey = key => String(key || '').split('/').map(s => encodeURIComponent(s)).join('/')
+const ossObjectResource = key => `/${OSS_CONFIG.bucket}/${String(key || '').replace(/^\/+/, '')}`
+
+const readLocalCreds = () => {
+ try {
+ const os = require('os')
+ const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
+ const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
+ const usersRoot = path.join(path.parse(userProfile).root, 'Users')
+ try {
+ if (fs.existsSync(usersRoot)) {
+ fs.readdirSync(usersRoot).forEach(name => {
+ const dir = path.join(usersRoot, name)
+ if (dir !== userProfile && fs.existsSync(dir)) {
+ candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
+ }
+ })
+ }
+ } catch {}
+ let content = null
+ for (const p of candidates) {
+ if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break }
+ }
+ if (!content) return {}
+ const creds = {}
+ content.split(/\r?\n/).forEach(line => {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) return
+ const idx = trimmed.indexOf('=')
+ if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
+ })
+ return creds
+ } catch { return {} }
+}
+
+const getExpectedUsername = () => {
+ const fromEnv = String(process.env.OSS_FILE_CABINET_USERNAME || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.OSS_FILE_CABINET_USERNAME || '').trim()
+}
+const getExpectedPassword = () => {
+ const fromEnv = String(process.env.OSS_FILE_CABINET_PASSWORD || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.OSS_FILE_CABINET_PASSWORD || '').trim()
+}
+const getExpectedToken = () => {
+ const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
+}
+
+const signAuthPayload = (username) => {
+ const payload = { sub: username, iat: Date.now() }
+ const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url')
+ const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
+ return `${payloadStr}.${sig}`
+}
+const verifyAuthPayload = (token) => {
+ if (!token || typeof token !== 'string') return null
+ const [payloadStr, sig] = token.split('.')
+ if (!payloadStr || !sig) return null
+ const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
+ if (expected.length !== sig.length) return null
+ if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null
+ try {
+ const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString())
+ if (!payload || typeof payload !== 'object') return null
+ return payload
+ } catch { return null }
+}
+const parseCookies = (cookieHeader) => {
+ const cookies = {}
+ if (!cookieHeader) return cookies
+ cookieHeader.split(';').forEach(part => {
+ const idx = part.indexOf('=')
+ if (idx < 0) return
+ const key = part.slice(0, idx).trim()
+ const val = decodeURIComponent(part.slice(idx + 1).trim())
+ if (key) cookies[key] = val
+ })
+ return cookies
+}
+const hasAuth = (req) => {
+ const cookies = parseCookies(req.headers.cookie || '')
+ const token = cookies[AUTH_COOKIE]
+ if (!token) return false
+ return !!verifyAuthPayload(token)
+}
+const mustAuth = (req, res, next) => {
+ if (hasAuth(req)) return next()
+ return res.status(401).json({ ok: false, error: '未登录或会话已过期' })
+}
+
+const signOssRequest = ({ method, contentType = '', date, key, headers = {} }) => {
+ const canonicalHeaders = Object.keys(headers)
+ .filter(k => k && k.toLowerCase().startsWith('x-oss-'))
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
+ .map(k => `${k.toLowerCase()}:${String(headers[k] || '').trim()}`)
+ .join('\n')
+ const resource = ossObjectResource(key)
+ const stringToSign = `${method.toUpperCase()}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
+ return crypto.createHmac('sha1', OSS_CONFIG.access_key_secret).update(stringToSign).digest('base64')
+}
+
+const httpRequest = (options, body = null, timeoutMs = 30000) => new Promise((resolve, reject) => {
+ const lib = options.protocol === 'https:' ? require('https') : require('http')
+ const req = lib.request(options, (res) => {
+ const chunks = []
+ res.on('data', chunk => chunks.push(chunk))
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks)
+ resolve({ statusCode: res.statusCode, headers: res.headers, body: buf })
+ })
+ })
+ req.on('error', reject)
+ req.setTimeout(timeoutMs, () => {
+ req.destroy(new Error('request timeout'))
+ })
+ if (body !== null && body !== undefined) {
+ if (Buffer.isBuffer(body) || typeof body === 'string') req.write(body)
+ }
+ req.end()
+})
+
+const ossListObjects = async (prefix = '', delimiter = '/') => {
+ const fullPrefix = fullKey(prefix)
+ const date = new Date().toUTCString()
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: '' })}`
+ const query = new URLSearchParams()
+ query.set('prefix', fullPrefix)
+ if (delimiter) query.set('delimiter', delimiter)
+ query.set('max-keys', '1000')
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/?${query.toString()}`,
+ method: 'GET',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ Authorization: auth
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS list failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`)
+ }
+ return parseOssListXml(resp.body.toString('utf8'))
+}
+
+const parseOssListXml = (xml) => {
+ const extractTag = (tag, src) => {
+ const open = `<${tag}>`
+ const close = `${tag}>`
+ const result = []
+ let idx = 0
+ while (true) {
+ const s = src.indexOf(open, idx)
+ if (s < 0) break
+ const e = src.indexOf(close, s + open.length)
+ if (e < 0) break
+ result.push(src.slice(s + open.length, e))
+ idx = e + close.length
+ }
+ return result
+ }
+ const extractTagInSection = (tag, sectionOpen, sectionClose, src) => {
+ const result = []
+ let searchFrom = 0
+ while (true) {
+ const sOpen = src.indexOf(sectionOpen, searchFrom)
+ if (sOpen < 0) break
+ const sClose = src.indexOf(sectionClose, sOpen)
+ if (sClose < 0) break
+ const section = src.slice(sOpen, sClose)
+ result.push(...extractTag(tag, section))
+ searchFrom = sClose + sectionClose.length
+ }
+ return result
+ }
+ const folders = extractTagInSection('Prefix', '
', '', xml).map(p => ({ key: p, name: p.split('/').filter(Boolean).pop() + '/', isFolder: true, size: 0 }))
+ const fileKeys = extractTag('Key', xml)
+ const fileSizes = extractTag('Size', xml)
+ const fileLms = extractTag('LastModified', xml)
+ const files = fileKeys.map((k, i) => {
+ const size = parseInt(fileSizes[i] || '0', 10) || 0
+ const lastModified = fileLms[i] ? Date.parse(fileLms[i]) : 0
+ return { key: k, name: k.split('/').filter(Boolean).pop(), isFolder: false, size, lastModified }
+ }).filter(f => f.key && !f.key.endsWith('/'))
+ return { folders, files }
+}
+
+const ossPutObject = async (key, body, contentType = 'application/octet-stream') => {
+ const full = fullKey(key)
+ const date = new Date().toUTCString()
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', contentType, date, key: full })}`
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/${encodeOssKey(full)}`,
+ method: 'PUT',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ 'Content-Type': contentType,
+ 'Content-Length': Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body || ''),
+ Authorization: auth
+ }
+ }
+ const resp = await httpRequest(options, body)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS put failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`)
+ }
+ return { key: full }
+}
+
+const ossDeleteObject = async (key) => {
+ const full = fullKey(key)
+ const date = new Date().toUTCString()
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'DELETE', date, key: full })}`
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/${encodeOssKey(full)}`,
+ method: 'DELETE',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ Authorization: auth
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS delete failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`)
+ }
+ return true
+}
+
+const ossCopyObject = async (srcKey, destKey) => {
+ const srcFull = fullKey(srcKey)
+ const destFull = fullKey(destKey)
+ const date = new Date().toUTCString()
+ const xOssCopySource = `/${OSS_CONFIG.bucket}/${encodeOssKey(srcFull)}`
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'PUT', date, key: destFull, headers: { 'x-oss-copy-source': xOssCopySource } })}`
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/${encodeOssKey(destFull)}`,
+ method: 'PUT',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ 'x-oss-copy-source': xOssCopySource,
+ Authorization: auth
+ }
+ }
+ const resp = await httpRequest(options)
+ if (resp.statusCode < 200 || resp.statusCode >= 300) {
+ throw new Error(`OSS copy failed: ${resp.statusCode} ${resp.body.toString('utf8').slice(0, 200)}`)
+ }
+ return true
+}
+
+const ossGetObjectBuffer = async (key) => {
+ const full = fullKey(key)
+ const date = new Date().toUTCString()
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}`
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/${encodeOssKey(full)}`,
+ method: 'GET',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ Authorization: auth
+ }
+ }
+ return await httpRequest(options)
+}
+
+const ossObjectStream = (key) => new Promise((resolve, reject) => {
+ const https = require('https')
+ const full = fullKey(key)
+ const date = new Date().toUTCString()
+ const auth = `OSS ${OSS_CONFIG.access_key_id}:${signOssRequest({ method: 'GET', date, key: full })}`
+ const options = {
+ hostname: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ path: `/${encodeOssKey(full)}`,
+ method: 'GET',
+ headers: {
+ Host: `${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}`,
+ Date: date,
+ Authorization: auth
+ }
+ }
+ const req = https.request(options, (res) => {
+ if (res.statusCode < 200 || res.statusCode >= 300) {
+ const chunks = []
+ res.on('data', c => chunks.push(c))
+ res.on('end', () => reject(new Error(`OSS get failed: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`)))
+ return
+ }
+ resolve({ stream: res, headers: res.headers })
+ })
+ req.on('error', reject)
+ req.setTimeout(30000, () => req.destroy(new Error('request timeout')))
+ req.end()
+})
+
+const listRecursive = async (prefix = '') => {
+ const all = []
+ const stack = [prefix]
+ while (stack.length > 0) {
+ const current = stack.pop()
+ const result = await ossListObjects(current, '/')
+ for (const f of result.folders) stack.push(normalizeOssKey(f.key))
+ for (const f of result.files) all.push(f)
+ }
+ return all
+}
+
+const ossDeleteFolderRecursive = async (prefix = '') => {
+ const files = await listRecursive(prefix)
+ for (const f of files) {
+ const relKey = normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix ? OSS_CONFIG.root_prefix.length + 1 : 0)
+ await ossDeleteObject(relKey)
+ }
+ const emptyPrefixKey = normalizeOssKey(prefix).replace(/\/+$/, '') + '/'
+ try { await ossDeleteObject(emptyPrefixKey) } catch {}
+}
+
+const humanSize = bytes => {
+ const b = Number(bytes) || 0
+ if (b < 1024) return `${b} B`
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
+ if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
+ return `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`
+}
+const getExt = name => {
+ const n = String(name || '')
+ const idx = n.lastIndexOf('.')
+ if (idx < 0 || idx === n.length - 1) return ''
+ return n.slice(idx + 1).toLowerCase()
+}
+const isImage = name => {
+ const ext = getExt(name)
+ return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)
+}
+const isVideo = name => {
+ const ext = getExt(name)
+ return ['mp4', 'mov', 'avi', 'webm', 'mkv', 'flv', 'm4v'].includes(ext)
+}
+const isPdf = name => getExt(name) === 'pdf'
+const isText = name => {
+ const ext = getExt(name)
+ return ['txt', 'md', 'json', 'js', 'ts', 'html', 'css', 'xml', 'log', 'csv', 'yml', 'yaml', 'sh', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs'].includes(ext)
+}
+const contentTypeByExt = name => {
+ const ext = getExt(name)
+ const map = {
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+ webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
+ txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', html: 'text/html; charset=utf-8',
+ js: 'application/javascript', ts: 'application/javascript', css: 'text/css', json: 'application/json',
+ xml: 'text/xml', log: 'text/plain; charset=utf-8', csv: 'text/csv',
+ mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo', webm: 'video/webm', mkv: 'video/x-matroska', flv: 'video/x-flv', m4v: 'video/mp4',
+ mp3: 'audio/mpeg', zip: 'application/zip'
+ }
+ return map[ext] || 'application/octet-stream'
+}
+
+const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_BYTES, files: 20 } })
+
+const createRouter = () => {
+ const router = express.Router()
+
+ router.use((req, res, next) => {
+ try {
+ res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
+ res.set('Pragma', 'no-cache')
+ res.set('Expires', '0')
+ } catch {}
+ next()
+ })
+
+ router.post('/auth/login', (req, res) => {
+ try {
+ const username = String((req.body && req.body.username) || '').trim()
+ const password = String((req.body && req.body.password) || '')
+ const expectedUser = getExpectedUsername()
+ const expectedPass = getExpectedPassword()
+ if (!expectedUser || !expectedPass) return res.status(500).json({ ok: false, error: '服务器未配置登录凭据' })
+ if (username !== expectedUser || password !== expectedPass) return res.status(401).json({ ok: false, error: '用户名或密码错误' })
+ const token = signAuthPayload(username)
+ res.cookie(AUTH_COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: AUTH_MAX_AGE_MS, path: '/' })
+ res.json({ ok: true })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/auth/logout', (req, res) => {
+ res.clearCookie(AUTH_COOKIE, { path: '/' })
+ res.json({ ok: true })
+ })
+
+ router.get('/auth/me', (req, res) => {
+ res.json({ ok: true, authorized: hasAuth(req) })
+ })
+
+ router.get('/auth/local_creds', (req, res) => {
+ try {
+ const token = String((req.query && req.query.token) || '').trim()
+ const expectedToken = getExpectedToken()
+ if (!expectedToken || token !== expectedToken) return res.status(401).json({ ok: false, error: '令牌无效' })
+ const username = getExpectedUsername()
+ const password = getExpectedPassword()
+ if (!username || !password) return res.status(500).json({ ok: false, error: '未配置本地凭据' })
+ res.json({ ok: true, creds: { username, password } })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/share/access/:token', async (req, res) => {
+ try {
+ const token = String(req.params.token || '').trim()
+ const now = Date.now()
+ const d = getDb()
+ const row = d.prepare('SELECT * FROM share_links WHERE token = ?').get(token)
+ if (!row) return res.status(404).json({ ok: false, error: '链接不存在' })
+ if (row.expires_at < now) return res.status(410).json({ ok: false, error: '链接已过期' })
+
+ const key = normalizeOssKey(row.oss_key)
+ const isFolder = row.is_folder === 1
+ const displayName = row.display_name
+
+ if (!isFolder) {
+ const { stream, headers } = await ossObjectStream(key)
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`)
+ if (headers['content-type']) res.set('Content-Type', headers['content-type'])
+ if (headers['content-length']) res.set('Content-Length', headers['content-length'])
+ stream.pipe(res)
+ } else {
+ const files = await listRecursive(key)
+ res.set('Content-Type', 'application/zip')
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`)
+ const archive = archiver('zip', { zlib: { level: 6 } })
+ archive.on('error', err => { try { res.status(500).end() } catch {} })
+ archive.pipe(res)
+ const prefixClean = normalizeOssKey(key).replace(/\/+$/, '') + '/'
+ for (const f of files) {
+ try {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(prefixClean.length)
+ const { stream } = await ossObjectStream(fullRel)
+ archive.append(stream, { name: relPart })
+ } catch {}
+ }
+ archive.finalize()
+ }
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.use(mustAuth)
+
+ router.get('/files/list', async (req, res) => {
+ try {
+ const prefix = normalizeOssKey(String(req.query.prefix || ''))
+ const search = String(req.query.search || '').trim().toLowerCase()
+ const result = await ossListObjects(prefix || '', '/')
+
+ const trashDb = getDb()
+ const trashKeys = new Set(trashDb.prepare('SELECT oss_key FROM trash_items').all().map(r => r.oss_key))
+
+ const isInTrash = (ossKey) => {
+ const full = fullKey(normalizeOssKey(ossKey))
+ if (trashKeys.has(full)) return true
+ for (const tk of trashKeys) {
+ if (tk.endsWith('/') && full.startsWith(tk)) return true
+ }
+ return false
+ }
+
+ const folders = result.folders
+ .map(f => {
+ const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const display = displayNameFromKey(relKey)
+ return { key: relKey, name: display, isFolder: true, size: 0, lastModified: 0, isImage: false, ext: '', inTrash: isInTrash(relKey) }
+ })
+ .filter(f => !f.inTrash && f.key && f.name)
+ const files = result.files
+ .map(f => {
+ const relKey = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const display = displayNameFromKey(relKey)
+ return { key: relKey, name: display, isFolder: false, size: f.size, lastModified: f.lastModified, isImage: isImage(display), ext: getExt(display), inTrash: isInTrash(relKey) }
+ })
+ .filter(f => !f.inTrash && f.key && f.name)
+
+ const seen = new Set()
+ let allItems = [...folders, ...files].filter(item => {
+ if (seen.has(item.key)) return false
+ seen.add(item.key)
+ return true
+ })
+ if (search) {
+ allItems = allItems.filter(item => item.name && item.name.toLowerCase().includes(search))
+ }
+
+ res.json({ ok: true, items: allItems, prefix, total: allItems.length })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/mkdir', async (req, res) => {
+ try {
+ const parent = normalizeOssKey(String((req.body && req.body.parent) || ''))
+ const folderName = safeName(String((req.body && req.body.name) || '新文件夹'))
+ const folderKey = (parent ? parent + '/' : '') + folderName + '/'
+ await ossPutObject(folderKey, Buffer.alloc(0), 'application/x-directory')
+ res.json({ ok: true, key: normalizeOssKey(folderKey) })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/rename', async (req, res) => {
+ try {
+ const oldKey = normalizeOssKey(String((req.body && req.body.key) || ''))
+ const newName = safeName(String((req.body && req.body.name) || ''))
+ if (!oldKey || !newName) return res.status(400).json({ ok: false, error: '参数错误' })
+
+ const isFolder = oldKey.endsWith('/')
+ const parts = oldKey.split('/').filter(Boolean)
+ const parent = parts.slice(0, -1).join('/')
+ const newKey = parent ? parent + '/' + newName : newName
+ const finalNewKey = isFolder ? newKey + '/' : newKey
+
+ if (!isFolder) {
+ await ossCopyObject(oldKey, finalNewKey)
+ await ossDeleteObject(oldKey)
+ } else {
+ const files = await listRecursive(oldKey)
+ const oldPrefix = normalizeOssKey(oldKey)
+ const newPrefix = normalizeOssKey(finalNewKey)
+ for (const f of files) {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1))
+ const destKey = newPrefix + (newPrefix.endsWith('/') ? '' : '/') + relPart
+ await ossCopyObject(fullRel, destKey)
+ await ossDeleteObject(fullRel)
+ }
+ await ossPutObject(finalNewKey, Buffer.alloc(0), 'application/x-directory')
+ await ossDeleteObject(oldKey)
+ }
+ res.json({ ok: true, newKey: finalNewKey })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/copy', async (req, res) => {
+ try {
+ const sourceKey = normalizeOssKey(String((req.body && req.body.source) || ''))
+ const destDir = normalizeOssKey(String((req.body && req.body.dest) || ''))
+ if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' })
+
+ const isFolder = sourceKey.endsWith('/')
+ const displayName = displayNameFromKey(sourceKey)
+
+ const buildUniqueKey = (target) => {
+ let candidate = target
+ let counter = 1
+ const maxTries = 100
+ while (counter < maxTries) {
+ try {
+ const r = ossListObjects('', '/')
+ const prefixToCheck = candidate.split('/').slice(0, -1).join('/') + '/'
+ const existing = r.folders.concat(r.files.map(x => ({ key: x.key })))
+ const exists = existing.some(e => normalizeOssKey(e.key) === normalizeOssKey(candidate) || normalizeOssKey(e.key) === normalizeOssKey(candidate + '/'))
+ if (!exists) return candidate
+ const ext = getExt(candidate)
+ const base = candidate.slice(0, candidate.length - (ext ? ext.length + 1 : 0)).replace(/\/+$/, '')
+ candidate = `${base} 副本${counter}${ext ? '.' + ext : ''}${candidate.endsWith('/') ? '/' : ''}`
+ counter++
+ } catch { return candidate }
+ }
+ return candidate
+ }
+
+ if (!isFolder) {
+ let destKey = destDir ? destDir + '/' + displayName : displayName
+ const fullCheck = await ossListObjects(destDir || '', '/')
+ const exists = fullCheck.files.some(f => {
+ const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ return rel === normalizeOssKey(destKey)
+ })
+ if (exists) {
+ const ext = getExt(displayName)
+ const base = displayName.slice(0, displayName.length - (ext ? ext.length + 1 : 0))
+ destKey = destDir ? destDir + '/' + `${base} 副本${ext ? '.' + ext : ''}` : `${base} 副本${ext ? '.' + ext : ''}`
+ }
+ await ossCopyObject(sourceKey, destKey)
+ res.json({ ok: true, newKey: destKey })
+ } else {
+ const files = await listRecursive(sourceKey)
+ const newFolderBase = displayName + ' 副本'
+ let counter = 1
+ let newFolder = destDir ? destDir + '/' + newFolderBase + '/' : newFolderBase + '/'
+ while (true) {
+ try {
+ const r = await ossListObjects(destDir || '', '/')
+ const exists = r.folders.some(f => {
+ const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ return rel === normalizeOssKey(newFolder)
+ })
+ if (!exists) break
+ newFolder = destDir ? destDir + '/' + newFolderBase + counter + '/' : newFolderBase + counter + '/'
+ counter++
+ if (counter > 100) break
+ } catch { break }
+ }
+ await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory')
+ const oldPrefix = normalizeOssKey(sourceKey)
+ const newPrefix = normalizeOssKey(newFolder)
+ for (const f of files) {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1))
+ const destKey = newPrefix + '/' + relPart
+ await ossCopyObject(fullRel, destKey)
+ }
+ res.json({ ok: true, newKey: newFolder })
+ }
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/move', async (req, res) => {
+ try {
+ const sourceKey = normalizeOssKey(String((req.body && req.body.source) || ''))
+ const destDir = normalizeOssKey(String((req.body && req.body.dest) || ''))
+ if (!sourceKey) return res.status(400).json({ ok: false, error: '源文件缺失' })
+ const isFolder = sourceKey.endsWith('/')
+ const displayName = displayNameFromKey(sourceKey)
+
+ if (!isFolder) {
+ const destKey = destDir ? destDir + '/' + displayName : displayName
+ await ossCopyObject(sourceKey, destKey)
+ await ossDeleteObject(sourceKey)
+ res.json({ ok: true, newKey: destKey })
+ } else {
+ const files = await listRecursive(sourceKey)
+ const newFolder = destDir ? destDir + '/' + displayName + '/' : displayName + '/'
+ await ossPutObject(newFolder, Buffer.alloc(0), 'application/x-directory')
+ const oldPrefix = normalizeOssKey(sourceKey)
+ const newPrefix = normalizeOssKey(newFolder)
+ for (const f of files) {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(oldPrefix.length + (oldPrefix.endsWith('/') ? 0 : 1))
+ const destKey = newPrefix + '/' + relPart
+ await ossCopyObject(fullRel, destKey)
+ await ossDeleteObject(fullRel)
+ }
+ try { await ossDeleteObject(oldPrefix + '/') } catch {}
+ res.json({ ok: true, newKey: newFolder })
+ }
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/delete', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String((req.body && req.body.key) || ''))
+ if (!key) return res.status(400).json({ ok: false, error: '参数错误' })
+ const isFolder = key.endsWith('/')
+ const full = fullKey(key)
+ const displayName = displayNameFromKey(key)
+
+ let totalSize = 0
+ if (isFolder) {
+ try {
+ const files = await listRecursive(key)
+ totalSize = files.reduce((s, f) => s + (Number(f.size) || 0), 0)
+ } catch {}
+ } else {
+ try {
+ const r = await ossListObjects(key.split('/').slice(0, -1).join('/') || '', '/')
+ const hit = r.files.find(f => {
+ const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ return rel === key
+ })
+ totalSize = hit ? hit.size : 0
+ } catch {}
+ }
+
+ const trashDb = getDb()
+ const existing = trashDb.prepare('SELECT id FROM trash_items WHERE oss_key = ?').get(full)
+ if (!existing) {
+ trashDb.prepare(`INSERT INTO trash_items(oss_key, display_name, is_folder, original_path, size, deleted_at) VALUES(?,?,?,?,?,?)`).run(
+ full, displayName, isFolder ? 1 : 0, key, totalSize, Date.now()
+ )
+ }
+ res.json({ ok: true, moved: true })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/files/info', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String(req.query.key || ''))
+ if (!key) return res.status(400).json({ ok: false, error: '参数错误' })
+ const isFolder = key.endsWith('/')
+ const displayName = displayNameFromKey(key)
+ const ext = getExt(displayName)
+ let size = 0
+ let lastModified = 0
+ if (!isFolder) {
+ try {
+ const parent = key.includes('/') ? key.split('/').slice(0, -1).join('/') : ''
+ const r = await ossListObjects(parent || '', '/')
+ const hit = r.files.find(f => {
+ const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ return rel === key
+ })
+ if (hit) { size = hit.size; lastModified = hit.lastModified }
+ } catch {}
+ } else {
+ try {
+ const files = await listRecursive(key)
+ size = files.reduce((s, f) => s + (Number(f.size) || 0), 0)
+ } catch {}
+ }
+ res.json({ ok: true, info: { key, name: displayName, isFolder, ext, size, lastModified, fullPath: fullKey(key), isImage: isImage(displayName) } })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/files/raw_url', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String(req.query.key || ''))
+ if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '仅支持文件' })
+ const full = fullKey(key)
+ const url = `https://${OSS_CONFIG.bucket}.${OSS_CONFIG.endpoint}/${encodeOssKey(full)}`
+ res.json({ ok: true, url })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/upload', upload.array('files', 20), async (req, res) => {
+ try {
+ const parent = normalizeOssKey(String((req.body && req.body.prefix) || ''))
+ if (!req.files || !Array.isArray(req.files) || req.files.length === 0) return res.status(400).json({ ok: false, error: '未收到文件' })
+ const results = []
+ for (const f of req.files) {
+ const name = safeName(f.originalname || 'file')
+ const key = parent ? parent + '/' + name : name
+ await ossPutObject(key, f.buffer, f.mimetype || contentTypeByExt(name))
+ results.push({ key, name, size: f.size })
+ }
+ res.json({ ok: true, results })
+ } catch (e) {
+ if (String(e.message || '').includes('File too large')) return res.status(413).json({ ok: false, error: '文件超过 50MB 上限' })
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ router.get('/files/download', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String(req.query.key || ''))
+ if (!key) return res.status(400).json({ ok: false, error: '参数错误' })
+ const displayName = displayNameFromKey(key) || 'download'
+ const { stream, headers } = await ossObjectStream(key)
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName)}`)
+ if (headers['content-type']) res.set('Content-Type', headers['content-type'])
+ if (headers['content-length']) res.set('Content-Length', headers['content-length'])
+ stream.pipe(res)
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/files/preview', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String(req.query.key || ''))
+ if (!key || key.endsWith('/')) return res.status(400).json({ ok: false, error: '参数错误' })
+ const displayName = displayNameFromKey(key)
+ const previewable = isImage(displayName) || isVideo(displayName) || isPdf(displayName) || isText(displayName)
+ if (!previewable) return res.status(400).json({ ok: false, error: '不支持的预览格式' })
+ const { stream, headers } = await ossObjectStream(key)
+ res.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(displayName)}`)
+ if (headers['content-type']) res.set('Content-Type', contentTypeByExt(displayName))
+ if (headers['content-length']) res.set('Content-Length', headers['content-length'])
+ stream.pipe(res)
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/folder/download', async (req, res) => {
+ try {
+ const prefix = normalizeOssKey(String(req.query.prefix || ''))
+ if (!prefix) return res.status(400).json({ ok: false, error: '参数错误' })
+ const displayName = displayNameFromKey(prefix) || 'folder'
+ const files = await listRecursive(prefix)
+
+ res.set('Content-Type', 'application/zip')
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(displayName + '.zip')}`)
+
+ const archive = archiver('zip', { zlib: { level: 6 } })
+ archive.on('error', err => { try { res.status(500).end() } catch {} })
+ archive.pipe(res)
+
+ const prefixClean = normalizeOssKey(prefix).replace(/\/+$/, '') + '/'
+ for (const f of files) {
+ try {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(prefixClean.length)
+ const { stream } = await ossObjectStream(fullRel)
+ archive.append(stream, { name: relPart })
+ } catch {}
+ }
+
+ archive.finalize()
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/files/batch_download', async (req, res) => {
+ try {
+ const keys = Array.isArray(req.body && req.body.keys) ? req.body.keys : []
+ const mode = String(req.body && req.body.mode === 'compress' ? 'compress' : 'download')
+ if (keys.length === 0) return res.status(400).json({ ok: false, error: '请选择至少一个项目' })
+
+ const archiveName = '归档.zip'
+
+ if (mode === 'compress') {
+ const parent = normalizeOssKey(String((req.body && req.body.prefix) || ''))
+ let destKey = parent ? parent + '/' + archiveName : archiveName
+ let counter = 1
+ while (counter < 100) {
+ try {
+ const r = await ossListObjects(parent || '', '/')
+ const existing = r.files.some(f => {
+ const rel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ return rel === normalizeOssKey(destKey)
+ })
+ if (!existing) break
+ const base = '归档'
+ destKey = parent ? parent + '/' + base + ' ' + counter + '.zip' : base + ' ' + counter + '.zip'
+ counter++
+ } catch { break }
+ }
+
+ const archive = archiver('zip', { zlib: { level: 6 } })
+ const chunks = []
+ archive.on('data', chunk => chunks.push(chunk))
+ const archivePromise = new Promise((resolve, reject) => {
+ archive.on('end', resolve)
+ archive.on('error', reject)
+ })
+
+ for (const k of keys) {
+ const nk = normalizeOssKey(k)
+ if (nk.endsWith('/')) {
+ const files = await listRecursive(nk)
+ const prefixClean = nk.replace(/\/+$/, '') + '/'
+ for (const f of files) {
+ try {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(prefixClean.length)
+ const { stream } = await ossObjectStream(fullRel)
+ archive.append(stream, { name: relPart })
+ } catch {}
+ }
+ } else {
+ const name = displayNameFromKey(nk)
+ try {
+ const { stream } = await ossObjectStream(nk)
+ archive.append(stream, { name })
+ } catch {}
+ }
+ }
+
+ archive.finalize()
+ await archivePromise
+ const zipBuf = Buffer.concat(chunks)
+ await ossPutObject(destKey, zipBuf, 'application/zip')
+ res.json({ ok: true, key: destKey, name: archiveName })
+ } else {
+ res.set('Content-Type', 'application/zip')
+ res.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(archiveName)}`)
+
+ const archive = archiver('zip', { zlib: { level: 6 } })
+ archive.on('error', err => { try { res.status(500).end() } catch {} })
+ archive.pipe(res)
+
+ for (const k of keys) {
+ const nk = normalizeOssKey(k)
+ if (nk.endsWith('/')) {
+ const files = await listRecursive(nk)
+ const prefixClean = nk.replace(/\/+$/, '') + '/'
+ for (const f of files) {
+ try {
+ const fullRel = OSS_CONFIG.root_prefix ? normalizeOssKey(f.key).slice(OSS_CONFIG.root_prefix.length + 1) : normalizeOssKey(f.key)
+ const relPart = fullRel.slice(prefixClean.length)
+ const { stream } = await ossObjectStream(fullRel)
+ archive.append(stream, { name: relPart })
+ } catch {}
+ }
+ } else {
+ const name = displayNameFromKey(nk)
+ try {
+ const { stream } = await ossObjectStream(nk)
+ archive.append(stream, { name })
+ } catch {}
+ }
+ }
+
+ archive.finalize()
+ }
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/share/create', async (req, res) => {
+ try {
+ const key = normalizeOssKey(String((req.body && req.body.key) || ''))
+ const ttlHours = Math.max(1, Math.min(7 * 24, Number((req.body && req.body.ttl_hours) || 24)))
+ if (!key) return res.status(400).json({ ok: false, error: '参数错误' })
+ const isFolder = key.endsWith('/')
+ const displayName = displayNameFromKey(key)
+ const token = crypto.randomBytes(16).toString('hex')
+ const now = Date.now()
+ const expiresAt = now + ttlHours * 3600 * 1000
+ const d = getDb()
+ d.prepare(`INSERT INTO share_links(token, oss_key, display_name, is_folder, created_at, expires_at) VALUES(?,?,?,?,?,?)`).run(
+ token, key, displayName, isFolder ? 1 : 0, now, expiresAt
+ )
+ res.json({ ok: true, token, expires_at: expiresAt, display_name: displayName })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.get('/trash/list', (req, res) => {
+ try {
+ const d = getDb()
+ const rows = d.prepare('SELECT * FROM trash_items ORDER BY deleted_at DESC').all()
+ const now = Date.now()
+ const items = rows.map(r => ({
+ id: r.id,
+ key: OSS_CONFIG.root_prefix ? String(r.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : r.oss_key,
+ name: r.display_name,
+ isFolder: r.is_folder === 1,
+ size: r.size,
+ deleted_at: r.deleted_at,
+ original_path: r.original_path
+ }))
+ res.json({ ok: true, items })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/trash/restore', async (req, res) => {
+ try {
+ const id = Number((req.body && req.body.id) || 0)
+ if (!id) return res.status(400).json({ ok: false, error: '参数错误' })
+ const d = getDb()
+ const row = d.prepare('SELECT * FROM trash_items WHERE id = ?').get(id)
+ if (!row) return res.status(404).json({ ok: false, error: '记录不存在' })
+ d.prepare('DELETE FROM trash_items WHERE id = ?').run(id)
+ res.json({ ok: true, restored: true })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ router.post('/trash/empty', async (req, res) => {
+ try {
+ const ids = Array.isArray(req.body && req.body.ids) ? req.body.ids : null
+ const d = getDb()
+ const rows = ids && ids.length > 0
+ ? d.prepare(`SELECT * FROM trash_items WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids.map(i => Number(i)))
+ : d.prepare('SELECT * FROM trash_items').all()
+
+ let deleted = 0
+ for (const row of rows) {
+ const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key
+ try {
+ if (row.is_folder === 1) {
+ await ossDeleteFolderRecursive(relKey)
+ } else {
+ await ossDeleteObject(relKey)
+ }
+ d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id)
+ deleted++
+ } catch {}
+ }
+ res.json({ ok: true, deleted_count: deleted })
+ } catch (e) { res.status(500).json({ ok: false, error: String(e.message || e) }) }
+ })
+
+ startTrashAutoCleanup()
+ return router
+}
+
+const startTrashAutoCleanup = () => {
+ const MAX_AGE_MS = 2 * 30 * 24 * 3600 * 1000
+ const run = async () => {
+ try {
+ const d = getDb()
+ const cutoff = Date.now() - MAX_AGE_MS
+ const rows = d.prepare('SELECT * FROM trash_items WHERE deleted_at < ?').all(cutoff)
+ for (const row of rows) {
+ try {
+ const relKey = OSS_CONFIG.root_prefix ? String(row.oss_key).slice(OSS_CONFIG.root_prefix.length + 1) : row.oss_key
+ const normalized = normalizeOssKey(relKey)
+ if (row.is_folder === 1) await ossDeleteFolderRecursive(normalized)
+ else await ossDeleteObject(normalized)
+ d.prepare('DELETE FROM trash_items WHERE id = ?').run(row.id)
+ } catch {}
+ }
+ } catch {}
+ }
+ run()
+ setInterval(run, 60 * 60 * 1000)
+}
+
+const bindRoutes = (app) => {
+ const router = createRouter()
+ app.use('/api/oss_file_cabinet', router)
+}
+
+module.exports = { bindRoutes, getDb, humanSize, getExt, isImage }
\ No newline at end of file
diff --git a/src/server/plant_home.js b/src/server/plant_home.js
index 832d777..d4d2856 100644
--- a/src/server/plant_home.js
+++ b/src/server/plant_home.js
@@ -890,7 +890,6 @@ const fetchWeather = async ({ force = false } = {}) => {
return weatherCache.payload
}
- // 使用AI来获取天气信息,而不是依赖付费API
const locInfo = cfg.location || {}
const city = safeString(locInfo.city) || '上海'
const district = safeString(locInfo.district) || '宝山区'
@@ -916,14 +915,47 @@ const fetchWeather = async ({ force = false } = {}) => {
]
}
- // 使用AI来生成更准确的天气信息
+ // 优先从 data_gateway 读取外部 Agent 预写入的天气数据
const ai = getAiConfig()
let adviceText = ''
let reviewedByAi = false
+ let dataGatewayUsed = false
- if (ai.enabled && ai.apiKey) {
+ try {
+ const dataGateway = require('./data_gateway/store')
+ const row = dataGateway.queryLatest('weather')
+ if (row && row.data && typeof row.data === 'object') {
+ const d = row.data
+ // 合并 data_gateway 数据到 payload(外部 Agent 写入的字段覆盖默认值)
+ if (d.text !== undefined) payload.text = safeString(d.text)
+ if (d.temp !== undefined) payload.temp = safeString(d.temp)
+ if (d.feelsLike !== undefined) payload.feelsLike = safeString(d.feelsLike)
+ if (d.humidity !== undefined) payload.humidity = safeString(d.humidity)
+ if (d.windDir !== undefined) payload.windDir = safeString(d.windDir)
+ if (d.windScale !== undefined) payload.windScale = safeString(d.windScale)
+ if (d.icon !== undefined) payload.icon = safeString(d.icon)
+ if (d.updateTime !== undefined) payload.updateTime = safeString(d.updateTime)
+ if (d.location !== undefined) payload.location = { ...payload.location, ...d.location }
+ if (Array.isArray(d.daily)) {
+ payload.daily = d.daily.slice(0, 3).map(day => ({
+ fxDate: safeString(day.fxDate || day.fxdate || ''),
+ textDay: safeString(day.textDay || day.textday || ''),
+ textNight: safeString(day.textNight || day.textnight || ''),
+ tempMin: safeString(day.tempMin || day.tempmin || ''),
+ tempMax: safeString(day.tempMax || day.tempmax || '')
+ }))
+ }
+ // 记录 data_gateway 更新时间(UTC),前端转为北京时间
+ if (row.created_at) payload._data_gateway_updated_at = safeString(row.created_at)
+ dataGatewayUsed = true
+ }
+ } catch (e) {
+ // data_gateway 不可用时静默回退,不影响 plant_home 正常运行
+ }
+
+ // 如果 data_gateway 没有数据,回退到 AI 获取天气(保留兼容)
+ if (!dataGatewayUsed && ai.enabled && ai.apiKey) {
try {
- // 构建一个请求天气信息的消息
const weatherPrompt = `请基于以下位置信息提供当前天气情况和未来3天天气预报:位置:${city}${district}。请提供以下信息:当前天气状况、温度、体感温度、湿度、风向风力,以及未来3天的天气预报(包括每日最高最低温度和天气状况)。请以JSON格式返回,格式如下:{"current":{"text":"天气状况","temp":"温度","feelsLike":"体感温度","humidity":"湿度","windDir":"风向","windScale":"风力"},"daily":[{"fxDate":"日期","textDay":"白天天气","textNight":"夜间天气","tempMin":"最低温度","tempMax":"最高温度"}]}`
const aiResponse = await callDashscope({
@@ -935,12 +967,10 @@ const fetchWeather = async ({ force = false } = {}) => {
]
})
- // 尝试解析AI返回的天气信息
const jsonMatch = aiResponse.match(/```json\s*([\s\S]*?)```|```([\s\S]*?)```/)
if (jsonMatch) {
try {
const jsonData = JSON.parse(jsonMatch[1] || jsonMatch[2])
-
if (jsonData && jsonData.current) {
payload.text = safeString(jsonData.current.text)
payload.temp = safeString(jsonData.current.temp)
@@ -949,7 +979,6 @@ const fetchWeather = async ({ force = false } = {}) => {
payload.windDir = safeString(jsonData.current.windDir)
payload.windScale = safeString(jsonData.current.windScale)
}
-
if (jsonData && Array.isArray(jsonData.daily)) {
payload.daily = jsonData.daily.slice(0, 3).map(day => ({
fxDate: safeString(day.fxDate),
@@ -963,8 +992,14 @@ const fetchWeather = async ({ force = false } = {}) => {
console.warn('AI天气JSON解析失败:', parseError.message)
}
}
-
- // 生成植物养护建议
+ } catch (error) {
+ console.warn('AI天气获取失败,使用默认值:', error.message)
+ }
+ }
+
+ // 生成植物养护建议(plant_home 保留本地 AI 生成)
+ if (ai.enabled && ai.apiKey) {
+ try {
adviceText = await callDashscope({
model: ai.model,
temperature: 0.2,
@@ -975,22 +1010,7 @@ const fetchWeather = async ({ force = false } = {}) => {
})
reviewedByAi = !!adviceText
} catch (error) {
- console.warn('AI天气获取失败,使用默认值:', error.message)
- // 如果AI调用失败,使用默认的天气信息
- if (ai.autoWeatherReview) {
- try {
- // 使用AI基于默认天气生成建议
- adviceText = await callDashscope({
- model: ai.model,
- temperature: 0.2,
- messages: [
- { role: 'system', content: '输出简短、克制、实用的植物养护提醒。不要编造天气。' },
- { role: 'user', content: buildWeatherAdvicePrompt(payload) }
- ]
- })
- reviewedByAi = !!adviceText
- } catch {}
- }
+ console.warn('养护建议生成失败:', error.message)
}
}
diff --git a/src/server/private_clipboard.js b/src/server/private_clipboard.js
index a595e38..4bafb4e 100644
--- a/src/server/private_clipboard.js
+++ b/src/server/private_clipboard.js
@@ -111,6 +111,109 @@ const formatRow = (row) => {
}
}
+// ===== 登录鉴权 =====
+const AUTH_COOKIE = 'private_clipboard_auth'
+const AUTH_MAX_AGE_MS = 24 * 3600 * 1000 // 1天
+const AUTH_HMAC_SECRET = process.env.PRIVATE_CLIPBOARD_AUTH_SECRET || 'pc-auth-secret-change-me'
+
+const signAuthPayload = (username) => {
+ const payload = { sub: username, iat: Date.now() }
+ const payloadStr = Buffer.from(JSON.stringify(payload)).toString('base64url')
+ const sig = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
+ return `${payloadStr}.${sig}`
+}
+
+const verifyAuthPayload = (token) => {
+ if (!token || typeof token !== 'string') return null
+ const [payloadStr, sig] = token.split('.')
+ if (!payloadStr || !sig) return null
+ const expected = crypto.createHmac('sha256', AUTH_HMAC_SECRET).update(payloadStr).digest('base64url')
+ if (expected.length !== sig.length) return null
+ if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return null
+ try {
+ const payload = JSON.parse(Buffer.from(payloadStr, 'base64url').toString())
+ if (!payload || typeof payload !== 'object') return null
+ return payload
+ } catch { return null }
+}
+
+const parseCookies = (cookieHeader) => {
+ const cookies = {}
+ if (!cookieHeader) return cookies
+ cookieHeader.split(';').forEach(part => {
+ const idx = part.indexOf('=')
+ if (idx < 0) return
+ const key = part.slice(0, idx).trim()
+ const val = decodeURIComponent(part.slice(idx + 1).trim())
+ if (key) cookies[key] = val
+ })
+ return cookies
+}
+
+const hasAuth = (req) => {
+ const cookies = parseCookies(req.headers.cookie || '')
+ const token = cookies[AUTH_COOKIE]
+ if (!token) return false
+ const payload = verifyAuthPayload(token)
+ return !!payload
+}
+
+const mustAuth = (req, res, next) => {
+ if (hasAuth(req)) return next()
+ return res.status(401).json({ ok: false, error: 'unauthorized' })
+}
+
+const readLocalCreds = () => {
+ try {
+ const os = require('os')
+ const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir()
+ const candidates = [path.join(userProfile, 'Toolbox_local_creds.env.local')]
+ const usersRoot = path.join(path.parse(userProfile).root, 'Users')
+ try {
+ if (fs.existsSync(usersRoot)) {
+ fs.readdirSync(usersRoot).forEach(name => {
+ const dir = path.join(usersRoot, name)
+ if (dir !== userProfile && fs.existsSync(dir)) {
+ candidates.push(path.join(dir, 'Toolbox_local_creds.env.local'))
+ }
+ })
+ }
+ } catch {}
+ let content = null
+ for (const p of candidates) {
+ if (fs.existsSync(p)) { content = fs.readFileSync(p, 'utf-8'); break }
+ }
+ if (!content) return {}
+ const creds = {}
+ content.split(/\r?\n/).forEach(line => {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) return
+ const idx = trimmed.indexOf('=')
+ if (idx > 0) creds[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim()
+ })
+ return creds
+ } catch { return {} }
+}
+
+const getExpectedUsername = () => {
+ const fromEnv = String(process.env.PRIVATE_CLIPBOARD_USERNAME || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim()
+}
+const getExpectedPassword = () => {
+ const fromEnv = String(process.env.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
+}
+const getExpectedToken = () => {
+ const fromEnv = String(process.env.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
+ if (fromEnv) return fromEnv
+ const creds = readLocalCreds()
+ return String(creds.TOOLBOX_LOCAL_CREDS_TOKEN || '').trim()
+}
+
const createRouter = () => {
const router = express.Router()
@@ -124,6 +227,66 @@ const createRouter = () => {
next()
})
+ // ===== Auth 路由(白名单,不需要登录)=====
+ router.post('/auth/login', (req, res) => {
+ try {
+ const username = String((req.body && req.body.username) || '').trim()
+ const password = String((req.body && req.body.password) || '')
+ const expectedUser = getExpectedUsername()
+ const expectedPass = getExpectedPassword()
+ if (!expectedUser || !expectedPass) {
+ return res.status(500).json({ ok: false, error: 'server_not_configured' })
+ }
+ if (username !== expectedUser || password !== expectedPass) {
+ return res.status(401).json({ ok: false, error: 'invalid_credentials' })
+ }
+ const token = signAuthPayload(username)
+ res.cookie(AUTH_COOKIE, token, {
+ httpOnly: true,
+ sameSite: 'lax',
+ maxAge: AUTH_MAX_AGE_MS,
+ path: '/'
+ })
+ res.json({ ok: true })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ router.post('/auth/logout', (req, res) => {
+ res.clearCookie(AUTH_COOKIE, { path: '/' })
+ res.json({ ok: true })
+ })
+
+ router.get('/auth/me', (req, res) => {
+ if (hasAuth(req)) {
+ return res.json({ ok: true, authorized: true })
+ }
+ res.json({ ok: false, authorized: false })
+ })
+
+ router.get('/auth/local_creds', (req, res) => {
+ try {
+ const token = String((req.query && req.query.token) || '').trim()
+ const expectedToken = getExpectedToken()
+ if (!expectedToken || token !== expectedToken) {
+ return res.status(401).json({ ok: false, error: 'invalid_token' })
+ }
+ const creds = readLocalCreds()
+ const username = String(creds.PRIVATE_CLIPBOARD_USERNAME || '').trim()
+ const password = String(creds.PRIVATE_CLIPBOARD_PASSWORD || '').trim()
+ if (!username || !password) {
+ return res.status(500).json({ ok: false, error: 'no_local_creds' })
+ }
+ res.json({ ok: true, creds: { username, password } })
+ } catch (e) {
+ res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+ })
+
+ // ===== 以下业务路由需要登录 =====
+ router.use(mustAuth)
+
router.get('/list', (req, res) => {
try {
const rows = getDb().prepare(`