guanwei 1.3.2 → 1.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -12
- package/dist/assets/index-Bzt3QHIz.js +102 -0
- package/dist/index.html +1 -1
- package/package.json +17 -8
- package/packages/guanwei-api/package.json +26 -0
- package/packages/guanwei-api/src/mcp-http.ts +89 -0
- package/packages/guanwei-api/src/mcp.ts +108 -0
- package/packages/guanwei-api/src/routes/arts.ts +23 -0
- package/packages/guanwei-api/src/routes/chart.ts +24 -0
- package/packages/guanwei-api/src/server.ts +96 -0
- package/server/package.json +24 -0
- package/server/src/index.ts +72 -16
- package/server/src/routes/ai.ts +10 -22
- package/server/src/routes/divine.ts +34 -33
- package/server/src/routes/hour.ts +4 -0
- package/server/src/routes/tarot.ts +30 -7
- package/server/src/routes/users.ts +118 -123
- package/server/src/services/auth.ts +50 -0
- package/server/src/services/dataDir.ts +95 -0
- package/server/src/services/db.ts +131 -0
- package/server/src/services/divineStore.ts +9 -55
- package/server/src/services/duanyu.ts +3 -3
- package/server/src/services/llmProvider.ts +4 -3
- package/server/src/services/usersStore.ts +221 -0
- package/shared/core/data/gua64.ts +5 -5
- package/shared/core/data/qimen.ts +11 -9
- package/shared/core/data/ziwei.ts +16 -15
- package/shared/core/engine/bazi.ts +64 -13
- package/shared/core/engine/calendar.ts +18 -5
- package/shared/core/engine/chart.ts +3 -1
- package/shared/core/engine/liuren.ts +32 -13
- package/shared/core/engine/liuyao.ts +6 -1
- package/shared/core/engine/qimen.ts +102 -60
- package/shared/core/engine/trueSolarTime.ts +9 -6
- package/shared/core/engine/xiaoliuren.ts +6 -2
- package/shared/core/engine/ziwei.ts +4 -3
- package/shared/core/types.ts +7 -7
- package/src/components/arts/XiaoliurenArt.tsx +1 -1
- package/src/pages/AuthPage.tsx +3 -0
- package/src/pages/DemoPage.tsx +1 -1
- package/src/services/api.ts +38 -6
- package/src/utils/userStore.ts +7 -3
- package/vite.config.ts +7 -3
- package/dist/assets/index-BZTUsUN1.js +0 -102
- package/dist/assets/index-BZTUsUN1.js.map +0 -1
- package/scripts/release.sh +0 -81
- package/server/src/data/db.json +0 -7226
- package/server/src/data/guanwei.db +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// 鉴权统一入口:token → 用户(含过期校验)、请求来源判定
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 安全审查):
|
|
4
|
+
// - 三处路由各自实现 authedUsername,口径漂移:ai.ts 漏校验 tokenExpires(过期 token 在 AI 链路永久可用)
|
|
5
|
+
// - 旧记录可能没有 tokenExpires 字段(1.3.3 前签发)→ 原逻辑视作「永不过期」;
|
|
6
|
+
// 这些 token 已随 npm 1.3.2 包公开泄漏,故一律按失效处理(用户重新登录即可获新 token)
|
|
7
|
+
import { getUserByToken, type UserRow } from './usersStore.js';
|
|
8
|
+
|
|
9
|
+
export interface TokenUser { username: string; user: UserRow }
|
|
10
|
+
|
|
11
|
+
/** 请求携带的 token → 用户(无 token / 不匹配 / 已过期 → null) */
|
|
12
|
+
export function resolveTokenUser(req: any): TokenUser | null {
|
|
13
|
+
const tk = String(req.headers['x-guanwei-token'] || '');
|
|
14
|
+
if (!tk) return null;
|
|
15
|
+
try {
|
|
16
|
+
const user = getUserByToken(tk); // 存储:统一 SQLite(含恒时比较)
|
|
17
|
+
if (!user) return null;
|
|
18
|
+
// 过期即失效;无 tokenExpires 的旧记录(含已泄漏 token)同样视为失效
|
|
19
|
+
if (!user.tokenExpires || Date.now() > user.tokenExpires) return null;
|
|
20
|
+
return { username: user.username, user };
|
|
21
|
+
} catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 请求来源是否可信内网(回环 / RFC1918 / ULA):匿名建档与占位账号放行仅限此类来源 */
|
|
25
|
+
export function isTrustedOrigin(req: any): boolean {
|
|
26
|
+
const raw = String(req.ip || req.socket?.remoteAddress || '');
|
|
27
|
+
const ip = raw.replace(/^::ffff:/, '');
|
|
28
|
+
if (ip === '127.0.0.1' || ip === '::1') return true;
|
|
29
|
+
if (/^10\./.test(ip)) return true;
|
|
30
|
+
if (/^192\.168\./.test(ip)) return true;
|
|
31
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
|
32
|
+
if (/^f[cd][0-9a-f]{2}:/i.test(ip)) return true; // fc00::/7
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 显式放开匿名访问(公网部署且自担风险时设 GUANWEI_ALLOW_ANON=1) */
|
|
37
|
+
export function anonAllowed(): boolean {
|
|
38
|
+
return process.env.GUANWEI_ALLOW_ANON === '1';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 强制所有匿名访问携带 token(公网部署更严;GUANWEI_REQUIRE_TOKEN=1) */
|
|
42
|
+
export function requireTokenAlways(): boolean {
|
|
43
|
+
return process.env.GUANWEI_REQUIRE_TOKEN === '1';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 是否允许「无 token 的匿名操作」(建档/读占位账号) */
|
|
47
|
+
export function allowAnonymous(req: any): boolean {
|
|
48
|
+
if (requireTokenAlways()) return false;
|
|
49
|
+
return anonAllowed() || isTrustedOrigin(req);
|
|
50
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// 运行时数据路径解析(用户档案 JSON / 占卜 SQLite / 测试库)
|
|
2
|
+
//
|
|
3
|
+
// 设计(2026-09 安全修复):
|
|
4
|
+
// - 默认目录 **server/data/**(与 server/src 源码分离)——原 server/src/data 会被
|
|
5
|
+
// package.json 的 files 白名单("server/src")强制打进 npm tarball,导致用户档案
|
|
6
|
+
// (含 token/passHash/出生信息)与占卜记录随包公开(1.3.2 及更早版本已发生)。
|
|
7
|
+
// - 兼容旧路径:首次访问某文件时,若旧路径 server/src/data/<name> 存在且新路径不存在,
|
|
8
|
+
// 自动复制一份(幂等,不删除旧文件,便于回滚)。
|
|
9
|
+
// - 环境变量优先:GUANWEI_DATA_DIR(目录)、GUANWEI_USERS_DB / GUANWEI_DB_FILE(单文件,测试隔离用)。
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 运行时数据目录:默认 **用户主目录** `~/.guanwei/data`(可用 GUANWEI_DATA_DIR 覆盖)。
|
|
19
|
+
*
|
|
20
|
+
* 为什么放在项目目录之外(2026-09 事故后的硬门槛):
|
|
21
|
+
* 原默认 server/src/data 在项目树内,npm 的 `files` 白名单一旦包含它就会被 publish 打包公开
|
|
22
|
+
* (1.1.1–1.3.2 全部版本因此泄漏用户档案与占卜记录)。移出项目树后,
|
|
23
|
+
* 即使白名单再次写错,npm/Docker 构建上下文也**物理上取不到**这些文件。
|
|
24
|
+
*/
|
|
25
|
+
/** 选择数据目录:env > 用户主目录(推荐)> 项目内 server/data(受限环境回退,仍受三重发布守卫保护) */
|
|
26
|
+
function pickDataDir(): string {
|
|
27
|
+
const envDir = process.env.GUANWEI_DATA_DIR;
|
|
28
|
+
if (envDir) return envDir;
|
|
29
|
+
const home = path.join(os.homedir(), '.guanwei', 'data');
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(home, { recursive: true });
|
|
32
|
+
fs.accessSync(home, fs.constants.W_OK);
|
|
33
|
+
return home;
|
|
34
|
+
} catch {
|
|
35
|
+
const fallback = path.join(__dirname, '..', '..', 'data');
|
|
36
|
+
console.warn('[data] 用户主目录不可写,回退到项目内目录: ' + fallback + '(发布守卫仍会阻止其进入 npm 包)');
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const DATA_DIR = pickDataDir();
|
|
42
|
+
/** 兼容迁移源(按序尝试):曾经使用过的项目内目录 */
|
|
43
|
+
const LEGACY_DIRS = [
|
|
44
|
+
path.join(__dirname, '..', '..', 'data'), // server/data(1.3.3 过渡路径)
|
|
45
|
+
path.join(__dirname, '..', 'data'), // server/src/data(1.1.1–1.3.2 旧路径)
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** 解析数据文件路径(含旧目录一次性迁移) */
|
|
49
|
+
export function dataFile(name: string): string {
|
|
50
|
+
const target = path.join(DATA_DIR, name);
|
|
51
|
+
// SQLite WAL 模式下数据可能主要落在 -wal 文件里、主库文件本身很小;
|
|
52
|
+
// 比较体积时必须把 -wal/-shm 计入,否则会误判「目标库较小」而用旧库覆盖新库。
|
|
53
|
+
const sizeOf = (p: string): number => {
|
|
54
|
+
try {
|
|
55
|
+
let n = fs.statSync(p).size;
|
|
56
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
57
|
+
try { n += fs.statSync(p + suffix).size; } catch { /* 无副文件 */ }
|
|
58
|
+
}
|
|
59
|
+
return n;
|
|
60
|
+
} catch { return 0; }
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
const legacy = LEGACY_DIRS.map(d => path.join(d, name)).find(p => fs.existsSync(p) && fs.statSync(p).size > 0);
|
|
64
|
+
const legacyOk = !!legacy;
|
|
65
|
+
const targetExists = fs.existsSync(target);
|
|
66
|
+
const targetSize = sizeOf(target);
|
|
67
|
+
let needFill = !targetExists || targetSize === 0;
|
|
68
|
+
// SQLite 库启发式:目标存在但显著小于旧库(如历史遗留的空壳库)→ 也迁移,避免真实记录被空库遮蔽
|
|
69
|
+
if (!needFill && /\.(db|sqlite|sqlite3)$/.test(name) && legacyOk && targetSize < sizeOf(legacy!) / 2) {
|
|
70
|
+
needFill = true;
|
|
71
|
+
}
|
|
72
|
+
if (legacyOk && needFill) {
|
|
73
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
74
|
+
if (targetExists && targetSize > 0) {
|
|
75
|
+
const bak = target + '.bak-' + Date.now();
|
|
76
|
+
fs.copyFileSync(target, bak); // 覆盖前备份,可人工回滚
|
|
77
|
+
console.log('[data] 目标库较小,已备份: ' + bak);
|
|
78
|
+
}
|
|
79
|
+
fs.copyFileSync(legacy!, target);
|
|
80
|
+
// 只复制主库:旧库的 WAL 副文件若一并留用会与新主库不匹配
|
|
81
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
82
|
+
try { fs.rmSync(target + suffix, { force: true }); } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
console.log('[data] 已从旧路径迁移: ' + legacy + ' → ' + target + ' (' + fs.statSync(target).size + ' bytes)');
|
|
85
|
+
}
|
|
86
|
+
} catch (e: any) {
|
|
87
|
+
console.error('[data] 迁移失败(将按新路径处理):', e?.message || e);
|
|
88
|
+
}
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 旧用户档案库(JSON):仅作一次性导入来源(见 services/usersStore.ensureInit) */
|
|
93
|
+
export const USERS_DB = process.env.GUANWEI_USERS_DB || dataFile('db.json');
|
|
94
|
+
/** 统一数据库(SQLite):用户库 + 占卜记录 + 失败留档 */
|
|
95
|
+
export const DIVINE_DB = process.env.GUANWEI_DB_FILE || dataFile('guanwei.db');
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 统一 SQLite 句柄(单一数据源)
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09 W38 架构欠债 #4):用户库曾是 JSON(db.json)、占卜记录是 SQLite(guanwei.db),
|
|
4
|
+
// 两套存储两套写法:JSON 侧靠「进程内 Promise 串行 + 临时文件 rename」,SQLite 侧靠事务。
|
|
5
|
+
// 现合并为**同一个 SQLite 库**:users / divinations / ai_fail_logs / meta 四张表,
|
|
6
|
+
// 所有写操作走 BEGIN IMMEDIATE 事务,跨进程亦安全(busy_timeout + WAL)。
|
|
7
|
+
//
|
|
8
|
+
// 依赖:node:sqlite(Node 22 内置,零依赖)
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { DIVINE_DB } from './dataDir.js';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
// node:sqlite 用 createRequire 运行时加载:
|
|
15
|
+
// - 前端测试(vitest/jsdom)会沿 import 链把 server 模块也走 Vite 打包,静态 import 会被判为
|
|
16
|
+
// 「Cannot bundle built-in module "node:sqlite"」;require 形式可避免。运行时行为完全一致。
|
|
17
|
+
// - 顺带让「仅导入而不使用存储」的场景不加载 SQLite。
|
|
18
|
+
type DatabaseSync = import('node:sqlite').DatabaseSync;
|
|
19
|
+
type DatabaseSyncCtor = typeof import('node:sqlite').DatabaseSync;
|
|
20
|
+
|
|
21
|
+
let DatabaseSyncImpl: DatabaseSyncCtor | null = null;
|
|
22
|
+
function sqlite(): DatabaseSyncCtor {
|
|
23
|
+
if (!DatabaseSyncImpl) {
|
|
24
|
+
DatabaseSyncImpl = createRequire(import.meta.url)('node:sqlite').DatabaseSync as DatabaseSyncCtor;
|
|
25
|
+
}
|
|
26
|
+
return DatabaseSyncImpl;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let db: DatabaseSync | null = null;
|
|
30
|
+
|
|
31
|
+
/** 取库句柄(首次调用建表 + 迁移) */
|
|
32
|
+
export function getDb(): DatabaseSync {
|
|
33
|
+
if (db) return db;
|
|
34
|
+
fs.mkdirSync(path.dirname(DIVINE_DB), { recursive: true });
|
|
35
|
+
db = new (sqlite())(DIVINE_DB);
|
|
36
|
+
// WAL:读写不互斥(外部工具/测试可同时只读);busy_timeout:多进程时等待而非立刻 SQLITE_BUSY
|
|
37
|
+
try { db.exec('PRAGMA journal_mode = WAL'); } catch (e: any) { console.warn('[db] WAL 未启用:', e?.message || e); }
|
|
38
|
+
try { db.exec('PRAGMA busy_timeout = 5000'); } catch { /* 老版本忽略 */ }
|
|
39
|
+
try { db.exec('PRAGMA foreign_keys = ON'); } catch { /* 同上 */ }
|
|
40
|
+
db.exec(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
42
|
+
username TEXT PRIMARY KEY,
|
|
43
|
+
pass_hash TEXT NOT NULL DEFAULT '',
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
profile_json TEXT NOT NULL DEFAULT '{}',
|
|
46
|
+
samples_json TEXT NOT NULL DEFAULT '[]',
|
|
47
|
+
records_json TEXT NOT NULL DEFAULT '[]',
|
|
48
|
+
token TEXT,
|
|
49
|
+
token_expires INTEGER
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_users_token ON users(token);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS divinations (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
username TEXT NOT NULL,
|
|
56
|
+
art_id TEXT NOT NULL,
|
|
57
|
+
kind TEXT NOT NULL,
|
|
58
|
+
question TEXT,
|
|
59
|
+
profile_id TEXT NOT NULL DEFAULT 'main',
|
|
60
|
+
profile_json TEXT,
|
|
61
|
+
params_json TEXT,
|
|
62
|
+
result_raw_json TEXT NOT NULL,
|
|
63
|
+
display_json TEXT NOT NULL,
|
|
64
|
+
report_json TEXT,
|
|
65
|
+
report_quality TEXT,
|
|
66
|
+
status TEXT NOT NULL DEFAULT 'divined',
|
|
67
|
+
created_at INTEGER NOT NULL,
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_div_user_time ON divinations(username, created_at DESC);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS ai_fail_logs (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
art_id TEXT NOT NULL,
|
|
75
|
+
kind TEXT NOT NULL,
|
|
76
|
+
divine_id TEXT,
|
|
77
|
+
raw_output TEXT,
|
|
78
|
+
fail_reason TEXT,
|
|
79
|
+
created_at INTEGER NOT NULL
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
83
|
+
k TEXT PRIMARY KEY,
|
|
84
|
+
v TEXT NOT NULL
|
|
85
|
+
);
|
|
86
|
+
`);
|
|
87
|
+
// 老库迁移:补 divinations.profile_id 列(2026-08-20 档案隔离)——必须在引用该列的索引之前
|
|
88
|
+
try {
|
|
89
|
+
db.exec("ALTER TABLE divinations ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'main'");
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
if (!/duplicate column/i.test(e.message || '')) console.error('[db] 迁移 profile_id 失败:', e.message);
|
|
92
|
+
}
|
|
93
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_div_user_profile ON divinations(username, profile_id, created_at DESC)');
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 立即写事务:fn 内**不得 await**(node:sqlite 为同步 API)。
|
|
99
|
+
* BEGIN IMMEDIATE 立刻取写锁,避免「读后写」之间的竞态(并发注册/建档的唯一性由此保证)。
|
|
100
|
+
*/
|
|
101
|
+
export function withTx<T>(fn: () => T): T {
|
|
102
|
+
const d = getDb();
|
|
103
|
+
d.exec('BEGIN IMMEDIATE');
|
|
104
|
+
try {
|
|
105
|
+
const result = fn();
|
|
106
|
+
d.exec('COMMIT');
|
|
107
|
+
return result;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
try { d.exec('ROLLBACK'); } catch { /* 回滚失败时以原异常为准 */ }
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 读取元信息(迁移标记等) */
|
|
115
|
+
export function getMeta(k: string): string | null {
|
|
116
|
+
try {
|
|
117
|
+
const row = getDb().prepare('SELECT v FROM meta WHERE k = ?').get(k) as { v?: string } | undefined;
|
|
118
|
+
return row?.v ?? null;
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 写入元信息 */
|
|
123
|
+
export function setMeta(k: string, v: string): void {
|
|
124
|
+
getDb().prepare('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v').run(k, v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 仅测试/运维用:关闭句柄(下次调用重新打开) */
|
|
128
|
+
export function closeDb(): void {
|
|
129
|
+
try { db?.close(); } catch { /* ignore */ }
|
|
130
|
+
db = null;
|
|
131
|
+
}
|
|
@@ -1,58 +1,10 @@
|
|
|
1
1
|
// 占卜数据存储(SQLite):起占记录 / AI 报告 / 失败留档 / 占卜历史
|
|
2
|
-
//
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import path from 'path';
|
|
6
|
-
import { fileURLToPath } from 'url';
|
|
2
|
+
// 句柄与建表统一由 services/db 提供(与用户库同库,见 db.ts)
|
|
3
|
+
import { getDb, withTx } from './db.js';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
let db: DatabaseSync | null = null;
|
|
12
|
-
|
|
13
|
-
export function getDb(): DatabaseSync {
|
|
14
|
-
if (db) return db;
|
|
15
|
-
fs.mkdirSync(path.dirname(DB_FILE), { recursive: true });
|
|
16
|
-
db = new DatabaseSync(DB_FILE);
|
|
17
|
-
db.exec(`
|
|
18
|
-
CREATE TABLE IF NOT EXISTS divinations (
|
|
19
|
-
id TEXT PRIMARY KEY,
|
|
20
|
-
username TEXT NOT NULL,
|
|
21
|
-
art_id TEXT NOT NULL,
|
|
22
|
-
kind TEXT NOT NULL,
|
|
23
|
-
question TEXT,
|
|
24
|
-
profile_id TEXT NOT NULL DEFAULT 'main',
|
|
25
|
-
profile_json TEXT,
|
|
26
|
-
params_json TEXT,
|
|
27
|
-
result_raw_json TEXT NOT NULL,
|
|
28
|
-
display_json TEXT NOT NULL,
|
|
29
|
-
report_json TEXT,
|
|
30
|
-
report_quality TEXT,
|
|
31
|
-
status TEXT NOT NULL DEFAULT 'divined',
|
|
32
|
-
created_at INTEGER NOT NULL,
|
|
33
|
-
updated_at INTEGER NOT NULL
|
|
34
|
-
);
|
|
35
|
-
CREATE INDEX IF NOT EXISTS idx_div_user_time ON divinations(username, created_at DESC);
|
|
36
|
-
|
|
37
|
-
CREATE TABLE IF NOT EXISTS ai_fail_logs (
|
|
38
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
-
art_id TEXT NOT NULL,
|
|
40
|
-
kind TEXT NOT NULL,
|
|
41
|
-
divine_id TEXT,
|
|
42
|
-
raw_output TEXT,
|
|
43
|
-
fail_reason TEXT,
|
|
44
|
-
created_at INTEGER NOT NULL
|
|
45
|
-
);
|
|
46
|
-
`);
|
|
47
|
-
// 老库迁移:补 profile_id 列(2026-08-20 档案隔离)——必须在引用该列的索引之前
|
|
48
|
-
try {
|
|
49
|
-
db.exec("ALTER TABLE divinations ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'main'");
|
|
50
|
-
} catch (e: any) {
|
|
51
|
-
if (!/duplicate column/i.test(e.message || '')) console.error('[divineStore] 迁移 profile_id 失败:', e.message);
|
|
52
|
-
}
|
|
53
|
-
db.exec('CREATE INDEX IF NOT EXISTS idx_div_user_profile ON divinations(username, profile_id, created_at DESC)');
|
|
54
|
-
return db;
|
|
55
|
-
}
|
|
6
|
+
export { getDb };
|
|
7
|
+
export { withTx };
|
|
56
8
|
|
|
57
9
|
export interface DivineRecord {
|
|
58
10
|
id: string;
|
|
@@ -104,7 +56,8 @@ function toRecord(row: any): DivineRecord {
|
|
|
104
56
|
export function createDivination(args: CreateDivinationArgs): DivineRecord {
|
|
105
57
|
const d = getDb();
|
|
106
58
|
const now = Date.now();
|
|
107
|
-
|
|
59
|
+
// 原 Date.now()+Math.random() 可枚举(配合 403/404 差异可探测记录存在性)→ 改 CSPRNG UUID
|
|
60
|
+
const id = 'd_' + now + '_' + randomUUID();
|
|
108
61
|
const display = args.display !== undefined ? args.display : args.resultRaw;
|
|
109
62
|
d.prepare(`INSERT INTO divinations
|
|
110
63
|
(id, username, art_id, kind, question, profile_id, profile_json, params_json, result_raw_json, display_json, status, created_at, updated_at)
|
|
@@ -153,6 +106,7 @@ export function markAiFailed(id: string | null, artId: string, kind: string, rea
|
|
|
153
106
|
}
|
|
154
107
|
|
|
155
108
|
// 占卜历史分页(时间倒序,摘要字段)
|
|
109
|
+
// 同一毫秒内建立的记录(如冒烟/并发场景)以 rowid 兜底,保证分页顺序确定(原按 id 比较 uuid,顺序随机)
|
|
156
110
|
export function listDivinations(username: string, page = 1, pageSize = 20, profileId?: string): { list: { divineId: string; artId: string; question: string | null; createdAt: number; hasReport: boolean; status: string; profileId: string }[]; total: number } {
|
|
157
111
|
const d = getDb();
|
|
158
112
|
const pageN = Math.max(1, page);
|
|
@@ -160,7 +114,7 @@ export function listDivinations(username: string, page = 1, pageSize = 20, profi
|
|
|
160
114
|
const where = profileId ? 'WHERE username = ? AND profile_id = ?' : 'WHERE username = ?';
|
|
161
115
|
const params = profileId ? [username, profileId] : [username];
|
|
162
116
|
const totalRow = d.prepare('SELECT COUNT(*) AS c FROM divinations ' + where).get(...params) as any;
|
|
163
|
-
const rows = d.prepare('SELECT id, art_id, question, created_at, report_json, status, profile_id FROM divinations ' + where + ' ORDER BY created_at DESC,
|
|
117
|
+
const rows = d.prepare('SELECT id, art_id, question, created_at, report_json, status, profile_id FROM divinations ' + where + ' ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?')
|
|
164
118
|
.all(...params, size, (pageN - 1) * size) as any[];
|
|
165
119
|
return {
|
|
166
120
|
list: rows.map(r => ({
|
|
@@ -11,9 +11,9 @@ function extractKeywords(artId: string, resultRaw: unknown): string[] {
|
|
|
11
11
|
case 'bazi':
|
|
12
12
|
// 月令/格局/用神/调候/旺衰
|
|
13
13
|
if (r.monthZhi) kws.push('月令');
|
|
14
|
-
if (r.geju) kws.push('格局', String(r.geju));
|
|
15
|
-
if (r.yongshen) kws.push('用神', String(r.yongshen));
|
|
16
|
-
if (r.strength) kws.push(
|
|
14
|
+
if (r.geju) kws.push('格局', String(r.geju?.name || '')); // geju 为对象,须取名(原 String(obj) 恒为 [object Object])
|
|
15
|
+
if (r.yongshen) kws.push('用神', String(r.yongshen?.wx || r.yongshen?.shishen || ''));
|
|
16
|
+
if (r.strength) kws.push('旺衰', String(r.strength)); // 原三元两分支同值(死代码)
|
|
17
17
|
if (r.tiaohou) kws.push('调候');
|
|
18
18
|
break;
|
|
19
19
|
case 'liuyao':
|
|
@@ -72,7 +72,7 @@ async function callOpenAI(cfg: ProviderConfig, messages: ChatMessage[], stream:
|
|
|
72
72
|
|
|
73
73
|
async function callGoogle(cfg: ProviderConfig, messages: ChatMessage[], stream: boolean): Promise<Response> {
|
|
74
74
|
const model = process.env[cfg.modelEnv || ''] || cfg.defaultModel;
|
|
75
|
-
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent
|
|
75
|
+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
76
76
|
// 合并 messages 为 Google 格式:system → systemInstruction,其余 → contents
|
|
77
77
|
const system = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
|
|
78
78
|
const contents = messages.filter(m => m.role !== 'system').map(m => ({
|
|
@@ -81,7 +81,8 @@ async function callGoogle(cfg: ProviderConfig, messages: ChatMessage[], stream:
|
|
|
81
81
|
}));
|
|
82
82
|
return fetch(endpoint, {
|
|
83
83
|
method: 'POST',
|
|
84
|
-
|
|
84
|
+
// API Key 用 header 传递(原 ?key= 会进入 URL、代理日志与错误对象)
|
|
85
|
+
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': String(process.env[cfg.keyEnv] || '') },
|
|
85
86
|
body: JSON.stringify({
|
|
86
87
|
systemInstruction: system ? { parts: [{ text: system }] } : undefined,
|
|
87
88
|
contents,
|
|
@@ -99,7 +100,7 @@ async function withRetry<T>(fn: () => Promise<T>, label: string): Promise<T> {
|
|
|
99
100
|
return await fn();
|
|
100
101
|
} catch (e: any) {
|
|
101
102
|
lastErr = e;
|
|
102
|
-
if (!/AI_HTTP_(429|5\d\d
|
|
103
|
+
if (!/AI_HTTP_(429|5\d\d)/.test(e.message || '')) throw e; // 400 属请求体错误,重试必然再失败
|
|
103
104
|
if (attempt < 2) {
|
|
104
105
|
console.warn('[llm] ' + label + ' 第 ' + (attempt + 1) + ' 次失败(' + e.message + '),重试…');
|
|
105
106
|
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)));
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// 用户库(SQLite):账号 / 出生档案 / 示例档案 / 云同步记录 / 登录态 token
|
|
2
|
+
//
|
|
3
|
+
// 历史(2026-09):原为 JSON 文件(db.json)+ 进程内 Promise 串行锁。合并到统一 SQLite 后:
|
|
4
|
+
// - 写操作走 BEGIN IMMEDIATE 事务:并发注册/建档不再互相覆盖,也不再依赖进程内锁
|
|
5
|
+
// - 与占卜记录同库:跨进程一致(WAL + busy_timeout),外部工具可只读查看
|
|
6
|
+
// - 旧 db.json 在 users 表为空时**一次性导入**(原文件保留,不删除,便于回滚)
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { getDb, withTx, getMeta, setMeta } from './db.js';
|
|
10
|
+
import { USERS_DB } from './dataDir.js';
|
|
11
|
+
|
|
12
|
+
export interface Sample { id: string; name: string; profile: Record<string, unknown> }
|
|
13
|
+
export interface UserRow {
|
|
14
|
+
username: string;
|
|
15
|
+
passHash: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
profile: Record<string, unknown>;
|
|
18
|
+
samples: Sample[];
|
|
19
|
+
records: Record<string, unknown>[];
|
|
20
|
+
/** 登录态 token(注册/登录签发);占位账号为建档凭据 claimToken */
|
|
21
|
+
token?: string;
|
|
22
|
+
tokenExpires?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const COLS = 'username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires';
|
|
26
|
+
|
|
27
|
+
function parseJson<T>(s: unknown, fallback: T): T {
|
|
28
|
+
if (typeof s !== 'string' || !s) return fallback;
|
|
29
|
+
try {
|
|
30
|
+
const v = JSON.parse(s);
|
|
31
|
+
return (v ?? fallback) as T;
|
|
32
|
+
} catch { return fallback; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowToUser(r: any): UserRow {
|
|
36
|
+
return {
|
|
37
|
+
username: String(r.username),
|
|
38
|
+
passHash: String(r.pass_hash || ''),
|
|
39
|
+
createdAt: Number(r.created_at || 0),
|
|
40
|
+
profile: parseJson<Record<string, unknown>>(r.profile_json, {}),
|
|
41
|
+
samples: parseJson<Sample[]>(r.samples_json, []),
|
|
42
|
+
records: parseJson<Record<string, unknown>[]>(r.records_json, []),
|
|
43
|
+
token: r.token ? String(r.token) : undefined,
|
|
44
|
+
tokenExpires: r.token_expires == null ? undefined : Number(r.token_expires),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── token 工具(原 users.ts 逻辑,统一到此处,供 routes/auth 复用)───
|
|
49
|
+
export function newToken(): string {
|
|
50
|
+
return crypto.randomBytes(32).toString('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 恒时比较 token(含过期校验):false = 无效 */
|
|
54
|
+
export function tokenMatches(user: Pick<UserRow, 'token' | 'tokenExpires'> | null | undefined, token: string | undefined): boolean {
|
|
55
|
+
if (!user || !token || !user.token) return false;
|
|
56
|
+
if (user.tokenExpires && Date.now() > user.tokenExpires) return false; // 过期即失效
|
|
57
|
+
const a = Buffer.from(token), b = Buffer.from(user.token);
|
|
58
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── 读 ───
|
|
62
|
+
export function getUser(username: string): UserRow | null {
|
|
63
|
+
ensureInit();
|
|
64
|
+
if (!username) return null;
|
|
65
|
+
const row = getDb().prepare(`SELECT ${COLS} FROM users WHERE username = ?`).get(username) as any;
|
|
66
|
+
return row ? rowToUser(row) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function userCount(): number {
|
|
70
|
+
ensureInit();
|
|
71
|
+
return Number((getDb().prepare('SELECT COUNT(*) AS c FROM users').get() as any)?.c || 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** token → 用户(仅返回 token 完全匹配者;不匹配/不存在 → null) */
|
|
75
|
+
export function getUserByToken(token: string): UserRow | null {
|
|
76
|
+
ensureInit();
|
|
77
|
+
if (!token) return null;
|
|
78
|
+
const row = getDb().prepare(`SELECT ${COLS} FROM users WHERE token = ?`).get(token) as any;
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
const user = rowToUser(row);
|
|
81
|
+
// 索引命中后再做一次恒时比较(防御性:避免长度差异等边信道)
|
|
82
|
+
return tokenMatches(user, token) ? user : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── 写 ───
|
|
86
|
+
export interface NewUser {
|
|
87
|
+
username: string; passHash?: string; profile?: Record<string, unknown>;
|
|
88
|
+
token?: string; tokenExpires?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 新建账号(已存在 → false,不覆盖);占位账号亦经此建(passHash 为空串) */
|
|
92
|
+
export function createUserIfAbsent(u: NewUser): boolean {
|
|
93
|
+
ensureInit();
|
|
94
|
+
const res = getDb().prepare(
|
|
95
|
+
'INSERT OR IGNORE INTO users (username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
96
|
+
).run(
|
|
97
|
+
u.username, u.passHash ?? '', Date.now(),
|
|
98
|
+
JSON.stringify(u.profile ?? {}), '[]', '[]',
|
|
99
|
+
u.token ?? null, u.tokenExpires ?? null,
|
|
100
|
+
);
|
|
101
|
+
return Number(res.changes || 0) > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 占位账号升级为正式账号:写入密语哈希 + 轮换 token(须在事务内调用) */
|
|
105
|
+
export function upgradePlaceholder(username: string, passHash: string, token: string, tokenExpires: number, profilePatch?: Record<string, unknown>): void {
|
|
106
|
+
const merged = profilePatch ? { ...(getUser(username)?.profile || {}), ...profilePatch } : null;
|
|
107
|
+
getDb().prepare('UPDATE users SET pass_hash = ?, token = ?, token_expires = ? WHERE username = ?')
|
|
108
|
+
.run(passHash, token, tokenExpires, username);
|
|
109
|
+
if (merged) setProfileJson(username, merged);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 轮换 token(登录成功时) */
|
|
113
|
+
export function rotateToken(username: string, token: string, tokenExpires: number): void {
|
|
114
|
+
getDb().prepare('UPDATE users SET token = ?, token_expires = ? WHERE username = ?').run(token, tokenExpires, username);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 升级密语哈希(旧格式登录命中时) */
|
|
118
|
+
export function setPassHash(username: string, passHash: string): void {
|
|
119
|
+
getDb().prepare('UPDATE users SET pass_hash = ? WHERE username = ?').run(passHash, username);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setProfileJson(username: string, profile: Record<string, unknown>): void {
|
|
123
|
+
getDb().prepare('UPDATE users SET profile_json = ? WHERE username = ?').run(JSON.stringify(profile), username);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 档案合并写(浅合并);返回新档案,用户不存在 → null */
|
|
127
|
+
export function mergeProfile(username: string, patch: Record<string, unknown>): Record<string, unknown> | null {
|
|
128
|
+
ensureInit();
|
|
129
|
+
return withTx(() => {
|
|
130
|
+
const u = getUser(username);
|
|
131
|
+
if (!u) return null;
|
|
132
|
+
const merged = { ...u.profile, ...patch };
|
|
133
|
+
setProfileJson(username, merged);
|
|
134
|
+
return merged;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 示例档案:追加 */
|
|
139
|
+
export function addSample(username: string, sample: Sample): Sample[] | null {
|
|
140
|
+
ensureInit();
|
|
141
|
+
return withTx(() => {
|
|
142
|
+
const u = getUser(username);
|
|
143
|
+
if (!u) return null;
|
|
144
|
+
const samples = [...u.samples, sample];
|
|
145
|
+
getDb().prepare('UPDATE users SET samples_json = ? WHERE username = ?').run(JSON.stringify(samples), username);
|
|
146
|
+
return samples;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 示例档案:删除 */
|
|
151
|
+
export function removeSample(username: string, id: string): Sample[] | null {
|
|
152
|
+
ensureInit();
|
|
153
|
+
return withTx(() => {
|
|
154
|
+
const u = getUser(username);
|
|
155
|
+
if (!u) return null;
|
|
156
|
+
const samples = u.samples.filter(s => s.id !== id);
|
|
157
|
+
getDb().prepare('UPDATE users SET samples_json = ? WHERE username = ?').run(JSON.stringify(samples), username);
|
|
158
|
+
return samples;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 云同步记录:整表覆盖 */
|
|
163
|
+
export function setRecords(username: string, records: Record<string, unknown>[]): number | null {
|
|
164
|
+
ensureInit();
|
|
165
|
+
return withTx(() => {
|
|
166
|
+
const u = getUser(username);
|
|
167
|
+
if (!u) return null;
|
|
168
|
+
getDb().prepare('UPDATE users SET records_json = ? WHERE username = ?').run(JSON.stringify(records), username);
|
|
169
|
+
return records.length;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── 旧 JSON 库一次性导入 ───
|
|
174
|
+
const IMPORT_MARK = 'users_imported_from_json';
|
|
175
|
+
let inited = false;
|
|
176
|
+
|
|
177
|
+
/** 幂等初始化:users 表为空且存在旧 db.json → 导入(原文件保留) */
|
|
178
|
+
export function ensureInit(): void {
|
|
179
|
+
if (inited) return;
|
|
180
|
+
inited = true;
|
|
181
|
+
try {
|
|
182
|
+
const imported = getMeta(IMPORT_MARK);
|
|
183
|
+
if (imported) return; // 本库已导入过
|
|
184
|
+
if (userCountRaw() > 0) { setMeta(IMPORT_MARK, 'skip:users-exist'); return; }
|
|
185
|
+
let raw: string;
|
|
186
|
+
try { raw = fs.readFileSync(USERS_DB, 'utf-8'); } catch { return; } // 无旧库:全新安装
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
const users: any[] = Array.isArray(parsed?.users) ? parsed.users : [];
|
|
189
|
+
if (!users.length) { setMeta(IMPORT_MARK, 'skip:json-empty'); return; }
|
|
190
|
+
let n = 0;
|
|
191
|
+
withTx(() => {
|
|
192
|
+
for (const u of users) {
|
|
193
|
+
if (!u || typeof u.username !== 'string' || !u.username) continue;
|
|
194
|
+
const ok = insertLegacy(u);
|
|
195
|
+
if (ok) n++;
|
|
196
|
+
}
|
|
197
|
+
setMeta(IMPORT_MARK, `imported:${n}:${USERS_DB}`);
|
|
198
|
+
});
|
|
199
|
+
console.log(`[usersStore] 已从旧 JSON 库导入 ${n} 个账号: ${USERS_DB}`);
|
|
200
|
+
} catch (e: any) {
|
|
201
|
+
console.error('[usersStore] 旧 JSON 库导入失败(跳过,不影响新库):', e?.message || e);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function userCountRaw(): number {
|
|
206
|
+
return Number((getDb().prepare('SELECT COUNT(*) AS c FROM users').get() as any)?.c || 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function insertLegacy(u: any): boolean {
|
|
210
|
+
const res = getDb().prepare(
|
|
211
|
+
'INSERT OR IGNORE INTO users (username, pass_hash, created_at, profile_json, samples_json, records_json, token, token_expires) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
212
|
+
).run(
|
|
213
|
+
String(u.username), String(u.passHash || ''), Number(u.createdAt || Date.now()),
|
|
214
|
+
JSON.stringify(u.profile && typeof u.profile === 'object' ? u.profile : {}),
|
|
215
|
+
JSON.stringify(Array.isArray(u.samples) ? u.samples : []),
|
|
216
|
+
JSON.stringify(Array.isArray(u.records) ? u.records : []),
|
|
217
|
+
u.token ? String(u.token) : null,
|
|
218
|
+
u.tokenExpires == null ? null : Number(u.tokenExpires),
|
|
219
|
+
);
|
|
220
|
+
return Number(res.changes || 0) > 0;
|
|
221
|
+
}
|
|
@@ -21,11 +21,11 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
21
21
|
[1, 1, '乾为天', '元亨利贞。天行健,君子以自强不息。', '刚健中正,自强之象'],
|
|
22
22
|
[8, 8, '坤为地', '元亨,利牝马之贞。地势坤,君子以厚德载物。', '柔顺包容,厚德之象'],
|
|
23
23
|
[6, 4, '水雷屯', '元亨利贞,勿用有攸往,利建侯。', '始生维艰,草创之象'],
|
|
24
|
-
[7, 6, '山水蒙', '
|
|
24
|
+
[7, 6, '山水蒙', '亨。匪我求童蒙,童蒙求我。初筮告,再三渎,渎则不告。利贞。', '蒙昧待启,求教之象'],
|
|
25
25
|
[6, 1, '水天需', '有孚,光亨,贞吉,利涉大川。', '云上于天,待时之象'],
|
|
26
26
|
[1, 6, '天水讼', '有孚窒惕,中吉,终凶。利见大人,不利涉大川。', '争讼之象,和为贵'],
|
|
27
|
-
[
|
|
28
|
-
[
|
|
27
|
+
[8, 6, '地水师', '贞,丈人吉,无咎。', '行险而顺,统众之象'],
|
|
28
|
+
[6, 8, '水地比', '吉。原筮,元永贞,无咎。不宁方来,后夫凶。', '亲附之象,众心所归'],
|
|
29
29
|
[5, 1, '风天小畜', '亨。密云不雨,自我西郊。', '小有蓄积,待时而动'],
|
|
30
30
|
[1, 2, '天泽履', '履虎尾,不咥人,亨。', '谨慎履险,如履薄冰'],
|
|
31
31
|
[8, 1, '地天泰', '小往大来,吉亨。天地交而万物通。', '通泰之象,上下交心'],
|
|
@@ -41,7 +41,7 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
41
41
|
[3, 4, '火雷噬嗑', '亨。利用狱。雷电合而章。', '咬合之象,明罚敕法'],
|
|
42
42
|
[7, 3, '山火贲', '亨。小利有攸往。', '文饰之象,饰外修内'],
|
|
43
43
|
[7, 8, '山地剥', '不利有攸往。顺而止之。', '剥落之象,顺势而止'],
|
|
44
|
-
[8, 4, '地雷复', '
|
|
44
|
+
[8, 4, '地雷复', '亨。出入无疾,朋来无咎。反复其道,七日来复,利有攸往。', '复归之象,一阳来复'],
|
|
45
45
|
[1, 4, '天雷无妄', '元亨利贞。其匪正有眚,不利有攸往。', '无妄之象,守正勿妄'],
|
|
46
46
|
[7, 1, '山天大畜', '利贞。不家食吉,利涉大川。', '大蓄之象,厚积薄发'],
|
|
47
47
|
[7, 4, '山雷颐', '贞吉。观颐,自求口实。', '颐养之象,自养养人'],
|
|
@@ -57,7 +57,7 @@ const RAW: [number, number, string, string, string][] = [
|
|
|
57
57
|
[5, 3, '风火家人', '利女贞。', '一家之象,各正其位'],
|
|
58
58
|
[3, 2, '火泽睽', '小事吉。二女同居,其志不同行。', '乖离之象,求同存异'],
|
|
59
59
|
[6, 7, '水山蹇', '利西南,不利东北。利见大人,贞吉。', '艰难之象,见险知止'],
|
|
60
|
-
[4, 6, '雷水解', '
|
|
60
|
+
[4, 6, '雷水解', '利西南。无所往,其来复吉。有攸往,夙吉。', '解除之象,雷雨作而百果生'],
|
|
61
61
|
[7, 2, '山泽损', '有孚,元吉,无咎,可贞,利有攸往。', '减损之象,损下益上'],
|
|
62
62
|
[5, 4, '风雷益', '利有攸往,利涉大川。', '增益之象,损上益下'],
|
|
63
63
|
[2, 1, '泽天夬', '扬于王庭,孚号有厉。告自邑,不利即戎。', '决断之象,刚决柔也'],
|