From 34584a526081b0faaaf20e5b49c247ac317caf5e Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 25 Mar 2026 17:54:34 +0800 Subject: [PATCH] feat(company): implement Phase 3 - Company-scoped OKR + GoalsService refactor --- data/templates/_registry.json | 4 ++-- server/routes/company.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/scripts/test-company-e2e.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- server/services/goals.js | 51 +++++++++++++++++++++++++++++++-------------------- 4 files changed, 177 insertions(+), 27 deletions(-) diff --git a/data/templates/_registry.json b/data/templates/_registry.json index 588192a..6fb0694 100644 --- a/data/templates/_registry.json +++ b/data/templates/_registry.json @@ -6,7 +6,7 @@ "description": "公司战略规划、团队管理、OKR 制定、任务委派", "benchmarkScore": 97, "trainedAt": "2026-03-20", - "usedCount": 2 + "usedCount": 3 }, { "id": "qa-judge", @@ -15,6 +15,6 @@ "description": "质量验收、OKR 进度审查、标准化仲裁", "benchmarkScore": 95, "trainedAt": "2026-03-25", - "usedCount": 2 + "usedCount": 3 } ] \ No newline at end of file diff --git a/server/routes/company.js b/server/routes/company.js index 7580a31..6ad8412 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => { res.json(agents); }); +// ═══════════════════════════════════════ +// Company Goals / OKR (公司级 OKR) +// ═══════════════════════════════════════ + +const { GoalsService } = require('../services/goals'); + +// GET /api/company/:companyId/goals +router.get('/:companyId/goals', (req, res) => { + const goals = new GoalsService(req.params.companyId); + res.json(goals.list()); +}); + +// POST /api/company/:companyId/goals +router.post('/:companyId/goals', (req, res) => { + try { + const { title, description, owner } = req.body; + if (!title || !owner) { + return res.status(400).json({ error: 'title 和 owner 为必填项' }); + } + const goals = new GoalsService(req.params.companyId); + const goal = goals.create({ title, description, owner }); + res.status(201).json(goal); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/company/:companyId/goals/:goalId +router.get('/:companyId/goals/:goalId', (req, res) => { + const goals = new GoalsService(req.params.companyId); + const goal = goals.get(req.params.goalId); + if (!goal) return res.status(404).json({ error: 'Goal not found' }); + res.json(goal); +}); + +// PUT /api/company/:companyId/goals/:goalId +router.put('/:companyId/goals/:goalId', (req, res) => { + const goals = new GoalsService(req.params.companyId); + const result = goals.updateGoal(req.params.goalId, req.body); + if (!result) return res.status(404).json({ error: 'Goal not found' }); + res.json(result); +}); + +// POST /api/company/:companyId/goals/:goalId/nodes +router.post('/:companyId/goals/:goalId/nodes', (req, res) => { + try { + const { parentNodeId, title, owner, priority } = req.body; + if (!title || !owner) { + return res.status(400).json({ error: 'title 和 owner 为必填项' }); + } + const goals = new GoalsService(req.params.companyId); + const result = goals.addNode(req.params.goalId, parentNodeId || null, { + title, owner, priority, + }); + if (!result) return res.status(404).json({ error: 'Goal not found' }); + res.status(201).json({ nodeId: result.nodeId, node: result.goal.nodes[result.nodeId] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// PUT /api/company/:companyId/goals/:goalId/nodes/:nodeId +router.put('/:companyId/goals/:goalId/nodes/:nodeId', (req, res) => { + const goals = new GoalsService(req.params.companyId); + const result = goals.updateNode(req.params.goalId, req.params.nodeId, req.body); + if (!result) return res.status(404).json({ error: 'Node not found' }); + res.json({ nodeId: result.nodeId, node: result.node }); +}); + +// GET /api/company/:companyId/goals/:goalId/progress +router.get('/:companyId/goals/:goalId/progress', (req, res) => { + const goals = new GoalsService(req.params.companyId); + const progress = goals.getProgress(req.params.goalId); + if (!progress) return res.status(404).json({ error: 'Goal not found' }); + res.json(progress); +}); + module.exports = router; diff --git a/server/scripts/test-company-e2e.js b/server/scripts/test-company-e2e.js index 7c07d70..db9928e 100644 --- a/server/scripts/test-company-e2e.js +++ b/server/scripts/test-company-e2e.js @@ -18,6 +18,7 @@ const fs = require('fs'); // 直接引入 service 层 (不需要 HTTP) const { CompanyService } = require('../services/company-service'); +const { GoalsService } = require('../services/goals'); const DATA_DIR = path.resolve(__dirname, '../../data'); const TEST_COMPANY_ID = 'test-media-co'; @@ -230,8 +231,69 @@ async function main() { const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); - // ─── 13. 更新公司 ─── - console.log('\n✏️ Phase 13: 更新公司'); + // ─── 13. Phase 3: Company Goals (公司级 OKR) ─── + console.log('\n🎯 Phase 13: Company Goals (公司级 OKR)'); + + const companyGoals = new GoalsService(TEST_COMPANY_ID); + + // 13a: 创建 Goal + const goal = companyGoals.create({ + title: '30天粉丝破万', + description: '通过日更优质内容实现公众号粉丝增长', + owner: 'ceo', + }); + assert(goal.id === 'G-001', `Goal ID = G-001 (实际: ${goal.id})`); + assert(goal.title === '30天粉丝破万', 'Goal 标题正确'); + + // 13b: 检查数据存储在公司目录下 + const goalFilePath = path.join(TEST_COMPANY_DIR, 'goals', 'G-001', 'goal.json'); + assert(fs.existsSync(goalFilePath), 'Goal 存储在公司目录下'); + + // 13c: 添加节点 (Objective + Key Results) + const obj = companyGoals.addNode('G-001', null, { + title: '建立内容产出流水线', + owner: 'ceo', + priority: 'P0', + }); + assert(obj.nodeId === 'G-001.1', `Objective ID = G-001.1 (实际: ${obj.nodeId})`); + + const kr1 = companyGoals.addNode('G-001', 'G-001.1', { + title: '日更1篇高质量文章', + owner: 'content-writer', + priority: 'P0', + }); + assert(kr1.nodeId === 'G-001.1.1', `KR ID = G-001.1.1 (实际: ${kr1.nodeId})`); + + const kr2 = companyGoals.addNode('G-001', 'G-001.1', { + title: '建立选题数据库 (≥100 条)', + owner: 'hot-topic-hunter', + priority: 'P1', + }); + assert(kr2.nodeId === 'G-001.1.2', `KR2 ID = G-001.1.2 (实际: ${kr2.nodeId})`); + + // 13d: 更新节点状态 + companyGoals.updateNode('G-001', 'G-001.1.1', { status: 'in_progress' }); + const updatedNode = companyGoals.getNode('G-001', 'G-001.1.1'); + assert(updatedNode.status === 'in_progress', '节点状态更新为 in_progress'); + + // 13e: 进度计算 + const progress = companyGoals.getProgress('G-001'); + assert(progress.total === 2, `叶子节点总数 = 2 (实际: ${progress.total})`); + assert(progress.inProgress === 1, `进行中 = 1 (实际: ${progress.inProgress})`); + + // 13f: Goal list + const goalList = companyGoals.list(); + assert(goalList.length === 1, `Goal 列表长度 = 1 (实际: ${goalList.length})`); + assert(goalList[0].id === 'G-001', '列表中 Goal ID 正确'); + + // 13g: 验证 OKR 是公司隔离的 (全局 GoalsService 看不到公司的目标) + const globalGoals = new GoalsService(); + const globalList = globalGoals.list(); + const hasCompanyGoal = globalList.some(g => g.id === 'G-001' && g.title === '30天粉丝破万'); + assert(!hasCompanyGoal, '全局 GoalsService 看不到公司级 OKR (隔离)'); + + // ─── 14. 更新公司 ─── + console.log('\n✏️ Phase 14: 更新公司'); const updated = svc.update(TEST_COMPANY_ID, { status: 'running', heartbeat: { enabled: true }, @@ -239,8 +301,8 @@ async function main() { assert(updated.status === 'running', '状态更新为 running'); assert(updated.heartbeat.enabled === true, '心跳已开启'); - // ─── 14. 关停公司 ─── - console.log('\n🛑 Phase 14: 关停公司'); + // ─── 15. 关停公司 ─── + console.log('\n🛑 Phase 15: 关停公司'); const shutdown = svc.shutdown(TEST_COMPANY_ID); assert(shutdown.status === 'shutdown', '状态变为 shutdown'); assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); @@ -262,7 +324,7 @@ async function main() { console.log(`\n${'═'.repeat(50)}`); console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); if (failed === 0) { - console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 生产就绪!\n'); + console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 + Phase 3 生产就绪!\n'); } else { console.log(` ⚠️ ${failed} 个测试未通过\n`); process.exit(1); diff --git a/server/services/goals.js b/server/services/goals.js index 3178c51..5ecc064 100644 --- a/server/services/goals.js +++ b/server/services/goals.js @@ -1,9 +1,12 @@ /** * GoalsService — OKR 目标管理 * - * 数据目录: data/goals/ - * 每个目标: data/goals/G-xxx/goal.json - * 全局索引: data/goals/_index.json + * 数据目录: + * - 公司级: data/{companyId}/goals/ + * - 全局(向后兼容): data/goals/ + * + * 每个目标: {goalsDir}/G-xxx/goal.json + * 索引文件: {goalsDir}/_index.json * * ID 设计: 点分层级 * G-001 → Goal (第1层) @@ -17,13 +20,21 @@ const fs = require('fs'); const path = require('path'); -const GOALS_DIR = path.join(__dirname, '../../data/goals'); +const DATA_DIR = path.join(__dirname, '../../data'); class GoalsService { - constructor() { - if (!fs.existsSync(GOALS_DIR)) { - fs.mkdirSync(GOALS_DIR, { recursive: true }); + /** + * @param {string} [companyId] - 公司 ID。不传则使用 data/goals/ (向后兼容) + */ + constructor(companyId) { + this.companyId = companyId || null; + this.goalsDir = companyId + ? path.join(DATA_DIR, companyId, 'goals') + : path.join(DATA_DIR, 'goals'); + + if (!fs.existsSync(this.goalsDir)) { + fs.mkdirSync(this.goalsDir, { recursive: true }); } } @@ -52,7 +63,7 @@ class GoalsService { }; // 写入 goal.json - const goalDir = path.join(GOALS_DIR, id); + const goalDir = path.join(this.goalsDir, id); fs.mkdirSync(goalDir, { recursive: true }); this._saveGoal(goal); @@ -73,7 +84,7 @@ class GoalsService { * 获取 Goal 完整数据 */ get(goalId) { - const fp = path.join(GOALS_DIR, goalId, 'goal.json'); + const fp = path.join(this.goalsDir, goalId, 'goal.json'); if (!fs.existsSync(fp)) return null; return JSON.parse(fs.readFileSync(fp, 'utf-8')); } @@ -82,7 +93,7 @@ class GoalsService { * 列出所有 Goal (轻量索引) */ list() { - const indexPath = path.join(GOALS_DIR, '_index.json'); + const indexPath = path.join(this.goalsDir, '_index.json'); if (!fs.existsSync(indexPath)) { this._rebuildIndex(); } @@ -233,7 +244,7 @@ class GoalsService { // ═══════════════════════════════════════ getProgress(goalId) { - const fp = path.join(GOALS_DIR, goalId, 'progress.json'); + const fp = path.join(this.goalsDir, goalId, 'progress.json'); if (!fs.existsSync(fp)) { const goal = this.get(goalId); if (!goal) return null; @@ -295,7 +306,7 @@ class GoalsService { * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点) */ getActiveContextForAgent(agentId) { - if (!fs.existsSync(GOALS_DIR)) return ''; + if (!fs.existsSync(this.goalsDir)) return ''; const index = this.list(); let combinedText = ''; @@ -340,8 +351,8 @@ class GoalsService { _nextGoalId() { // 扫描现有 G-xxx 目录 - if (!fs.existsSync(GOALS_DIR)) return 'G-001'; - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) + if (!fs.existsSync(this.goalsDir)) return 'G-001'; + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.startsWith('G-')) .map(d => parseInt(d.name.replace('G-', ''))) .filter(n => !isNaN(n)); @@ -351,13 +362,13 @@ class GoalsService { } _saveGoal(goal) { - const goalDir = path.join(GOALS_DIR, goal.id); + const goalDir = path.join(this.goalsDir, goal.id); if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true }); fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8'); } _saveProgress(goalId, progress) { - const goalDir = path.join(GOALS_DIR, goalId); + const goalDir = path.join(this.goalsDir, goalId); if (!fs.existsSync(goalDir)) return; fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8'); } @@ -399,8 +410,8 @@ class GoalsService { } _rebuildIndex() { - if (!fs.existsSync(GOALS_DIR)) return; - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) + if (!fs.existsSync(this.goalsDir)) return; + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name.startsWith('G-')); const index = dirs.map(d => { @@ -421,14 +432,14 @@ class GoalsService { }).filter(Boolean); fs.writeFileSync( - path.join(GOALS_DIR, '_index.json'), + path.join(this.goalsDir, '_index.json'), JSON.stringify(index, null, 2), 'utf-8' ); } _log(goalId, message) { - const logPath = path.join(GOALS_DIR, goalId, 'history.log'); + const logPath = path.join(this.goalsDir, goalId, 'history.log'); const ts = new Date().toISOString(); fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8'); } -- libgit2 0.21.4