galaxy-opc-plugin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -45,7 +45,7 @@ function resolveDbPath(configured?: string): string {
45
45
  let db: OpcDatabase | null = null;
46
46
 
47
47
  const plugin = {
48
- id: "opc-platform",
48
+ id: "galaxy-opc-plugin",
49
49
  name: "OPC Platform",
50
50
  description: "星环OPC中心 — 一人公司孵化与赋能平台",
51
51
  configSchema: Type.Object({
@@ -101,11 +101,21 @@ const plugin = {
101
101
  // 注册上下文注入钩子
102
102
  registerContextInjector(api, db);
103
103
 
104
+ // 读取 gateway token 用于 API 认证
105
+ const gatewayToken = (() => {
106
+ try {
107
+ const cfg = api.config as Record<string, unknown>;
108
+ const gw = cfg?.gateway as Record<string, unknown> | undefined;
109
+ const auth = gw?.auth as Record<string, unknown> | undefined;
110
+ return auth?.token as string | undefined;
111
+ } catch { return undefined; }
112
+ })();
113
+
104
114
  // 注册 HTTP API
105
115
  registerHttpRoutes(api, db);
106
116
 
107
117
  // 注册 Web UI
108
- registerConfigUi(api, db);
118
+ registerConfigUi(api, db, gatewayToken);
109
119
  registerLandingPage(api);
110
120
 
111
121
  // 注册后台服务(数据库生命周期 + 自动提醒)
@@ -1,8 +1,8 @@
1
1
  {
2
- "id": "opc-platform",
2
+ "id": "galaxy-opc-plugin",
3
3
  "name": "OPC Platform",
4
4
  "description": "星环OPC中心 — 一人公司孵化与赋能平台",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "skills": ["./skills"],
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "galaxy-opc-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "星环 Galaxy OPC — 一人公司孵化与赋能平台 OpenClaw 插件",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -17,6 +17,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
17
17
  import type { OpcDatabase } from "../db/index.js";
18
18
  import { CompanyManager } from "../opc/company-manager.js";
19
19
  import type { OpcCompanyStatus } from "../opc/types.js";
20
+ import { authenticateRequest, apiRateLimiter } from "./middleware.js";
20
21
 
21
22
  const OPC_API_PREFIX = "/opc/api/companies";
22
23
 
@@ -55,7 +56,7 @@ function parseJson(body: string): Record<string, unknown> | null {
55
56
  }
56
57
  }
57
58
 
58
- export function registerCompanyRoutes(api: OpenClawPluginApi, db: OpcDatabase): void {
59
+ export function registerCompanyRoutes(api: OpenClawPluginApi, db: OpcDatabase, gatewayToken?: string): void {
59
60
  const manager = new CompanyManager(db);
60
61
 
61
62
  api.registerHttpHandler(async (req, res) => {
@@ -71,6 +72,16 @@ export function registerCompanyRoutes(api: OpenClawPluginApi, db: OpcDatabase):
71
72
  return false;
72
73
  }
73
74
 
75
+ // 限流检查
76
+ if (!apiRateLimiter.check(req, res)) {
77
+ return true;
78
+ }
79
+
80
+ // 认证检查
81
+ if (!authenticateRequest(req, res, gatewayToken)) {
82
+ return true;
83
+ }
84
+
74
85
  const subPath = pathname.slice(OPC_API_PREFIX.length);
75
86
 
76
87
  try {
@@ -7,11 +7,22 @@
7
7
 
8
8
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
9
9
  import type { OpcDatabase } from "../db/index.js";
10
+ import { authenticateRequest, apiRateLimiter } from "./middleware.js";
10
11
 
11
- export function registerDashboardRoutes(api: OpenClawPluginApi, db: OpcDatabase): void {
12
+ export function registerDashboardRoutes(api: OpenClawPluginApi, db: OpcDatabase, gatewayToken?: string): void {
12
13
  api.registerHttpRoute({
13
14
  path: "/opc/api/dashboard/stats",
14
- handler: (_req, res) => {
15
+ handler: (req, res) => {
16
+ // 限流检查
17
+ if (!apiRateLimiter.check(req, res)) {
18
+ return;
19
+ }
20
+
21
+ // 认证检查
22
+ if (!authenticateRequest(req, res, gatewayToken)) {
23
+ return;
24
+ }
25
+
15
26
  try {
16
27
  const stats = db.getDashboardStats();
17
28
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 星环OPC中心 — API 中间件(认证 + 限流)
3
+ *
4
+ * 独立模块,避免 routes.ts ↔ companies.ts/dashboard.ts 循环依赖。
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+ import { RateLimiter } from "./rate-limiter.js";
9
+
10
+ /**
11
+ * 验证请求的 Authorization header 中的 Bearer token。
12
+ * 返回 true 表示认证通过,false 表示认证失败(已发送 401 响应)。
13
+ */
14
+ export function authenticateRequest(
15
+ req: IncomingMessage,
16
+ res: ServerResponse,
17
+ expectedToken: string | undefined,
18
+ ): boolean {
19
+ // 如果未配置 token,跳过认证(开发模式)
20
+ if (!expectedToken) return true;
21
+
22
+ // 允许 OPTIONS 预检请求通过
23
+ if (req.method === "OPTIONS") return true;
24
+
25
+ const authHeader = req.headers["authorization"];
26
+ if (!authHeader) {
27
+ res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
28
+ res.end(JSON.stringify({ error: "未提供认证令牌", code: "AUTH_REQUIRED" }));
29
+ return false;
30
+ }
31
+
32
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
33
+ const token = match?.[1];
34
+ if (token !== expectedToken) {
35
+ res.writeHead(401, { "Content-Type": "application/json; charset=utf-8" });
36
+ res.end(JSON.stringify({ error: "认证令牌无效", code: "AUTH_INVALID" }));
37
+ return false;
38
+ }
39
+
40
+ return true;
41
+ }
42
+
43
+ // 共享限流器实例(100 req/min per IP)
44
+ export const apiRateLimiter = new RateLimiter(100, 60_000);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 星环OPC中心 — 内存滑动窗口请求限流器
3
+ *
4
+ * Per-IP 限流,默认 100 req/min。
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+
9
+ interface WindowEntry {
10
+ timestamps: number[];
11
+ }
12
+
13
+ export class RateLimiter {
14
+ private windows = new Map<string, WindowEntry>();
15
+ private readonly maxRequests: number;
16
+ private readonly windowMs: number;
17
+
18
+ constructor(maxRequests = 100, windowMs = 60_000) {
19
+ this.maxRequests = maxRequests;
20
+ this.windowMs = windowMs;
21
+
22
+ // 每 5 分钟清理过期条目,防止内存泄漏
23
+ setInterval(() => this.cleanup(), 300_000).unref();
24
+ }
25
+
26
+ /**
27
+ * 检查请求是否超过限流。
28
+ * 返回 true 表示允许通过,false 表示被限流(已发送 429 响应)。
29
+ */
30
+ check(req: IncomingMessage, res: ServerResponse): boolean {
31
+ const ip = this.getClientIp(req);
32
+ const now = Date.now();
33
+ const cutoff = now - this.windowMs;
34
+
35
+ let entry = this.windows.get(ip);
36
+ if (!entry) {
37
+ entry = { timestamps: [] };
38
+ this.windows.set(ip, entry);
39
+ }
40
+
41
+ // 移除窗口外的旧记录
42
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
43
+
44
+ if (entry.timestamps.length >= this.maxRequests) {
45
+ const retryAfter = Math.ceil((entry.timestamps[0] + this.windowMs - now) / 1000);
46
+ res.writeHead(429, {
47
+ "Content-Type": "application/json; charset=utf-8",
48
+ "Retry-After": String(retryAfter),
49
+ });
50
+ res.end(JSON.stringify({
51
+ error: "请求过于频繁,请稍后再试",
52
+ code: "RATE_LIMIT_EXCEEDED",
53
+ retryAfter,
54
+ }));
55
+ return false;
56
+ }
57
+
58
+ entry.timestamps.push(now);
59
+ return true;
60
+ }
61
+
62
+ private getClientIp(req: IncomingMessage): string {
63
+ const forwarded = req.headers["x-forwarded-for"];
64
+ if (typeof forwarded === "string") {
65
+ return forwarded.split(",")[0].trim();
66
+ }
67
+ return req.socket.remoteAddress ?? "unknown";
68
+ }
69
+
70
+ private cleanup(): void {
71
+ const cutoff = Date.now() - this.windowMs;
72
+ for (const [ip, entry] of this.windows) {
73
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
74
+ if (entry.timestamps.length === 0) {
75
+ this.windows.delete(ip);
76
+ }
77
+ }
78
+ }
79
+ }
package/src/api/routes.ts CHANGED
@@ -7,8 +7,25 @@ import type { OpcDatabase } from "../db/index.js";
7
7
  import { registerCompanyRoutes } from "./companies.js";
8
8
  import { registerDashboardRoutes } from "./dashboard.js";
9
9
 
10
+ /**
11
+ * 从 OpenClaw 配置中读取 gateway auth token。
12
+ * 配置路径: gateway.auth.token
13
+ */
14
+ function getGatewayToken(api: OpenClawPluginApi): string | undefined {
15
+ try {
16
+ const cfg = api.config as Record<string, unknown>;
17
+ const gateway = cfg?.gateway as Record<string, unknown> | undefined;
18
+ const auth = gateway?.auth as Record<string, unknown> | undefined;
19
+ return auth?.token as string | undefined;
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
10
25
  export function registerHttpRoutes(api: OpenClawPluginApi, db: OpcDatabase): void {
11
- registerCompanyRoutes(api, db);
12
- registerDashboardRoutes(api, db);
26
+ const gatewayToken = getGatewayToken(api);
27
+
28
+ registerCompanyRoutes(api, db, gatewayToken);
29
+ registerDashboardRoutes(api, db, gatewayToken);
13
30
  api.logger.info("opc: 已注册 HTTP API 路由");
14
31
  }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * 星环OPC中心 — SqliteAdapter 单元测试
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
6
+ import { SqliteAdapter } from "./sqlite-adapter.js";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ describe("SqliteAdapter", () => {
12
+ let db: SqliteAdapter;
13
+ let dbPath: string;
14
+
15
+ beforeEach(() => {
16
+ dbPath = path.join(os.tmpdir(), `opc-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
17
+ db = new SqliteAdapter(dbPath);
18
+ });
19
+
20
+ afterEach(() => {
21
+ db.close();
22
+ try { fs.unlinkSync(dbPath); } catch { /* ignore */ }
23
+ try { fs.unlinkSync(dbPath + "-wal"); } catch { /* ignore */ }
24
+ try { fs.unlinkSync(dbPath + "-shm"); } catch { /* ignore */ }
25
+ });
26
+
27
+ // ── Companies ──────────────────────────────────────────────
28
+ describe("Companies CRUD", () => {
29
+ it("should create and retrieve a company", () => {
30
+ const company = db.createCompany({
31
+ name: "测试公司",
32
+ industry: "科技",
33
+ owner_name: "张三",
34
+ owner_contact: "13800138000",
35
+ status: "pending",
36
+ registered_capital: 100000,
37
+ description: "测试描述",
38
+ });
39
+ expect(company.id).toBeTruthy();
40
+ expect(company.name).toBe("测试公司");
41
+
42
+ const fetched = db.getCompany(company.id);
43
+ expect(fetched).not.toBeNull();
44
+ expect(fetched!.name).toBe("测试公司");
45
+ });
46
+
47
+ it("should list companies and filter by status", () => {
48
+ db.createCompany({ name: "A", industry: "IT", owner_name: "X", owner_contact: "", status: "pending", registered_capital: 0, description: "" });
49
+ db.createCompany({ name: "B", industry: "IT", owner_name: "Y", owner_contact: "", status: "active", registered_capital: 0, description: "" });
50
+
51
+ expect(db.listCompanies().length).toBe(2);
52
+ expect(db.listCompanies("pending").length).toBe(1);
53
+ expect(db.listCompanies("active").length).toBe(1);
54
+ expect(db.listCompanies("terminated").length).toBe(0);
55
+ });
56
+
57
+ it("should update a company", () => {
58
+ const c = db.createCompany({ name: "Old", industry: "IT", owner_name: "X", owner_contact: "", status: "pending", registered_capital: 0, description: "" });
59
+ const updated = db.updateCompany(c.id, { name: "New", industry: "金融" });
60
+ expect(updated).not.toBeNull();
61
+ expect(updated!.name).toBe("New");
62
+ expect(updated!.industry).toBe("金融");
63
+ });
64
+
65
+ it("should return null when updating non-existent company", () => {
66
+ expect(db.updateCompany("fake-id", { name: "X" })).toBeNull();
67
+ });
68
+
69
+ it("should delete a company", () => {
70
+ const c = db.createCompany({ name: "Del", industry: "IT", owner_name: "X", owner_contact: "", status: "pending", registered_capital: 0, description: "" });
71
+ expect(db.deleteCompany(c.id)).toBe(true);
72
+ expect(db.getCompany(c.id)).toBeNull();
73
+ });
74
+
75
+ it("should return false when deleting non-existent company", () => {
76
+ expect(db.deleteCompany("fake-id")).toBe(false);
77
+ });
78
+ });
79
+
80
+ // ── Employees ──────────────────────────────────────────────
81
+ describe("Employees CRUD", () => {
82
+ it("should create and list employees", () => {
83
+ const c = db.createCompany({ name: "Emp Co", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
84
+ const emp = db.createEmployee({ company_id: c.id, name: "小明", role: "finance", skills: "会计", status: "active" });
85
+ expect(emp.id).toBeTruthy();
86
+ expect(emp.name).toBe("小明");
87
+
88
+ const list = db.listEmployees(c.id);
89
+ expect(list.length).toBe(1);
90
+ expect(list[0].role).toBe("finance");
91
+ });
92
+
93
+ it("should get employee by id", () => {
94
+ const c = db.createCompany({ name: "Emp2", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
95
+ const emp = db.createEmployee({ company_id: c.id, name: "小红", role: "hr", skills: "招聘", status: "active" });
96
+ const found = db.getEmployee(emp.id);
97
+ expect(found).not.toBeNull();
98
+ expect(found!.name).toBe("小红");
99
+ });
100
+
101
+ it("should return null for non-existent employee", () => {
102
+ expect(db.getEmployee("fake")).toBeNull();
103
+ });
104
+ });
105
+
106
+ // ── Transactions ───────────────────────────────────────────
107
+ describe("Transactions CRUD", () => {
108
+ let companyId: string;
109
+
110
+ beforeEach(() => {
111
+ const c = db.createCompany({ name: "Tx Co", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
112
+ companyId = c.id;
113
+ });
114
+
115
+ it("should create and retrieve a transaction", () => {
116
+ const tx = db.createTransaction({
117
+ company_id: companyId,
118
+ type: "income",
119
+ category: "service_income",
120
+ amount: 5000,
121
+ description: "咨询服务",
122
+ counterparty: "客户A",
123
+ transaction_date: "2025-01-15",
124
+ });
125
+ expect(tx.id).toBeTruthy();
126
+ expect(tx.amount).toBe(5000);
127
+ expect(tx.type).toBe("income");
128
+
129
+ const fetched = db.getTransaction(tx.id);
130
+ expect(fetched).not.toBeNull();
131
+ expect(fetched!.counterparty).toBe("客户A");
132
+ });
133
+
134
+ it("should list transactions with filters", () => {
135
+ db.createTransaction({ company_id: companyId, type: "income", category: "other", amount: 1000, description: "", counterparty: "", transaction_date: "2025-01-01" });
136
+ db.createTransaction({ company_id: companyId, type: "expense", category: "rent", amount: 2000, description: "", counterparty: "", transaction_date: "2025-02-01" });
137
+ db.createTransaction({ company_id: companyId, type: "income", category: "other", amount: 3000, description: "", counterparty: "", transaction_date: "2025-03-01" });
138
+
139
+ expect(db.listTransactions(companyId).length).toBe(3);
140
+ expect(db.listTransactions(companyId, { type: "income" }).length).toBe(2);
141
+ expect(db.listTransactions(companyId, { type: "expense" }).length).toBe(1);
142
+ expect(db.listTransactions(companyId, { startDate: "2025-02-01" }).length).toBe(2);
143
+ expect(db.listTransactions(companyId, { endDate: "2025-01-31" }).length).toBe(1);
144
+ expect(db.listTransactions(companyId, { limit: 1 }).length).toBe(1);
145
+ });
146
+
147
+ it("should return null for non-existent transaction", () => {
148
+ expect(db.getTransaction("fake")).toBeNull();
149
+ });
150
+ });
151
+
152
+ // ── Finance Summary ────────────────────────────────────────
153
+ describe("getFinanceSummary", () => {
154
+ it("should compute correct financial totals", () => {
155
+ const c = db.createCompany({ name: "Fin Co", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
156
+ db.createTransaction({ company_id: c.id, type: "income", category: "other", amount: 10000, description: "", counterparty: "", transaction_date: "2025-01-15" });
157
+ db.createTransaction({ company_id: c.id, type: "income", category: "other", amount: 5000, description: "", counterparty: "", transaction_date: "2025-01-20" });
158
+ db.createTransaction({ company_id: c.id, type: "expense", category: "rent", amount: 3000, description: "", counterparty: "", transaction_date: "2025-01-25" });
159
+
160
+ const summary = db.getFinanceSummary(c.id);
161
+ expect(summary.total_income).toBe(15000);
162
+ expect(summary.total_expense).toBe(3000);
163
+ expect(summary.net).toBe(12000);
164
+ expect(summary.count).toBe(3);
165
+ });
166
+
167
+ it("should filter by date range", () => {
168
+ const c = db.createCompany({ name: "Fin2", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
169
+ db.createTransaction({ company_id: c.id, type: "income", category: "other", amount: 1000, description: "", counterparty: "", transaction_date: "2025-01-15" });
170
+ db.createTransaction({ company_id: c.id, type: "income", category: "other", amount: 2000, description: "", counterparty: "", transaction_date: "2025-03-15" });
171
+
172
+ const jan = db.getFinanceSummary(c.id, "2025-01-01", "2025-01-31");
173
+ expect(jan.total_income).toBe(1000);
174
+
175
+ const all = db.getFinanceSummary(c.id);
176
+ expect(all.total_income).toBe(3000);
177
+ });
178
+
179
+ it("should return zeros for company with no transactions", () => {
180
+ const c = db.createCompany({ name: "Empty", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
181
+ const summary = db.getFinanceSummary(c.id);
182
+ expect(summary.total_income).toBe(0);
183
+ expect(summary.total_expense).toBe(0);
184
+ expect(summary.net).toBe(0);
185
+ expect(summary.count).toBe(0);
186
+ });
187
+ });
188
+
189
+ // ── Contacts ───────────────────────────────────────────────
190
+ describe("Contacts CRUD", () => {
191
+ let companyId: string;
192
+
193
+ beforeEach(() => {
194
+ const c = db.createCompany({ name: "Ct Co", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
195
+ companyId = c.id;
196
+ });
197
+
198
+ it("should create and retrieve a contact", () => {
199
+ const contact = db.createContact({
200
+ company_id: companyId,
201
+ name: "王经理",
202
+ phone: "13900139000",
203
+ email: "wang@example.com",
204
+ company_name: "客户公司",
205
+ tags: '["VIP"]',
206
+ notes: "重要客户",
207
+ last_contact_date: "2025-01-01",
208
+ });
209
+ expect(contact.id).toBeTruthy();
210
+ expect(contact.name).toBe("王经理");
211
+
212
+ const fetched = db.getContact(contact.id);
213
+ expect(fetched).not.toBeNull();
214
+ expect(fetched!.email).toBe("wang@example.com");
215
+ });
216
+
217
+ it("should list contacts and filter by tag", () => {
218
+ db.createContact({ company_id: companyId, name: "A", phone: "", email: "", company_name: "", tags: '["VIP"]', notes: "", last_contact_date: "" });
219
+ db.createContact({ company_id: companyId, name: "B", phone: "", email: "", company_name: "", tags: '["供应商"]', notes: "", last_contact_date: "" });
220
+
221
+ expect(db.listContacts(companyId).length).toBe(2);
222
+ expect(db.listContacts(companyId, "VIP").length).toBe(1);
223
+ expect(db.listContacts(companyId, "供应商").length).toBe(1);
224
+ });
225
+
226
+ it("should update a contact", () => {
227
+ const ct = db.createContact({ company_id: companyId, name: "Old", phone: "", email: "", company_name: "", tags: "[]", notes: "", last_contact_date: "" });
228
+ const updated = db.updateContact(ct.id, { name: "New", phone: "12345" });
229
+ expect(updated).not.toBeNull();
230
+ expect(updated!.name).toBe("New");
231
+ expect(updated!.phone).toBe("12345");
232
+ });
233
+
234
+ it("should return null when updating non-existent contact", () => {
235
+ expect(db.updateContact("fake", { name: "X" })).toBeNull();
236
+ });
237
+
238
+ it("should delete a contact", () => {
239
+ const ct = db.createContact({ company_id: companyId, name: "Del", phone: "", email: "", company_name: "", tags: "[]", notes: "", last_contact_date: "" });
240
+ expect(db.deleteContact(ct.id)).toBe(true);
241
+ expect(db.getContact(ct.id)).toBeNull();
242
+ });
243
+
244
+ it("should return false when deleting non-existent contact", () => {
245
+ expect(db.deleteContact("fake")).toBe(false);
246
+ });
247
+ });
248
+
249
+ // ── Dashboard Stats ────────────────────────────────────────
250
+ describe("getDashboardStats", () => {
251
+ it("should return correct aggregate stats", () => {
252
+ const c1 = db.createCompany({ name: "S1", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
253
+ db.createCompany({ name: "S2", industry: "IT", owner_name: "Y", owner_contact: "", status: "pending", registered_capital: 0, description: "" });
254
+ db.createTransaction({ company_id: c1.id, type: "income", category: "other", amount: 10000, description: "", counterparty: "", transaction_date: "2025-01-01" });
255
+ db.createTransaction({ company_id: c1.id, type: "expense", category: "rent", amount: 3000, description: "", counterparty: "", transaction_date: "2025-01-01" });
256
+ db.createContact({ company_id: c1.id, name: "Contact1", phone: "", email: "", company_name: "", tags: "[]", notes: "", last_contact_date: "" });
257
+
258
+ const stats = db.getDashboardStats();
259
+ expect(stats.total_companies).toBe(2);
260
+ expect(stats.active_companies).toBe(1);
261
+ expect(stats.total_transactions).toBe(2);
262
+ expect(stats.total_contacts).toBe(1);
263
+ expect(stats.total_revenue).toBe(10000);
264
+ expect(stats.total_expense).toBe(3000);
265
+ });
266
+
267
+ it("should return zeros for empty database", () => {
268
+ const stats = db.getDashboardStats();
269
+ expect(stats.total_companies).toBe(0);
270
+ expect(stats.active_companies).toBe(0);
271
+ expect(stats.total_transactions).toBe(0);
272
+ expect(stats.total_contacts).toBe(0);
273
+ });
274
+ });
275
+
276
+ // ── Generic query methods ──────────────────────────────────
277
+ describe("Generic query methods", () => {
278
+ it("genId should generate unique ids", () => {
279
+ const ids = new Set<string>();
280
+ for (let i = 0; i < 100; i++) {
281
+ ids.add(db.genId());
282
+ }
283
+ expect(ids.size).toBe(100);
284
+ });
285
+
286
+ it("query should return rows", () => {
287
+ db.createCompany({ name: "Q1", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
288
+ const rows = db.query("SELECT * FROM opc_companies WHERE status = ?", "active");
289
+ expect(rows.length).toBe(1);
290
+ });
291
+
292
+ it("queryOne should return single row", () => {
293
+ db.createCompany({ name: "Q2", industry: "IT", owner_name: "X", owner_contact: "", status: "active", registered_capital: 0, description: "" });
294
+ const row = db.queryOne("SELECT COUNT(*) as cnt FROM opc_companies") as { cnt: number };
295
+ expect(row.cnt).toBe(1);
296
+ });
297
+
298
+ it("execute should return changes count", () => {
299
+ db.createCompany({ name: "E1", industry: "IT", owner_name: "X", owner_contact: "", status: "pending", registered_capital: 0, description: "" });
300
+ const result = db.execute("UPDATE opc_companies SET industry = ? WHERE status = ?", "金融", "pending");
301
+ expect(result.changes).toBe(1);
302
+ });
303
+ });
304
+ });
@@ -335,7 +335,7 @@ export class SqliteAdapter implements OpcDatabase {
335
335
  .prepare(
336
336
  `SELECT
337
337
  COUNT(*) as total,
338
- SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active
338
+ COALESCE(SUM(CASE WHEN status='active' THEN 1 ELSE 0 END), 0) as active
339
339
  FROM opc_companies`,
340
340
  )
341
341
  .get() as { total: number; active: number };