Commit 34584a526081b0faaaf20e5b49c247ac317caf5e
1 parent
2c2cde85
feat(company): implement Phase 3 - Company-scoped OKR + GoalsService refactor
- GoalsService refactored: constructor accepts optional companyId
- With companyId: goals stored in data/{companyId}/goals/
- Without: backward-compatible data/goals/ (global)
- Company Goals REST API: full OKR CRUD under /api/company/:id/goals
- OKR isolation verified: different companies have independent goal spaces
- E2E test: 81/81 passed (Phase 1 + 2 + 3)
Showing
4 changed files
with
177 additions
and
27 deletions
data/templates/_registry.json
| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | "description": "公司战略规划、团队管理、OKR 制定、任务委派", |
| 7 | 7 | "benchmarkScore": 97, |
| 8 | 8 | "trainedAt": "2026-03-20", |
| 9 | - "usedCount": 2 | |
| 9 | + "usedCount": 3 | |
| 10 | 10 | }, |
| 11 | 11 | { |
| 12 | 12 | "id": "qa-judge", |
| ... | ... | @@ -15,6 +15,6 @@ |
| 15 | 15 | "description": "质量验收、OKR 进度审查、标准化仲裁", |
| 16 | 16 | "benchmarkScore": 95, |
| 17 | 17 | "trainedAt": "2026-03-25", |
| 18 | - "usedCount": 2 | |
| 18 | + "usedCount": 3 | |
| 19 | 19 | } |
| 20 | 20 | ] |
| 21 | 21 | \ No newline at end of file | ... | ... |
server/routes/company.js
| ... | ... | @@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => { |
| 115 | 115 | res.json(agents); |
| 116 | 116 | }); |
| 117 | 117 | |
| 118 | +// ═══════════════════════════════════════ | |
| 119 | +// Company Goals / OKR (公司级 OKR) | |
| 120 | +// ═══════════════════════════════════════ | |
| 121 | + | |
| 122 | +const { GoalsService } = require('../services/goals'); | |
| 123 | + | |
| 124 | +// GET /api/company/:companyId/goals | |
| 125 | +router.get('/:companyId/goals', (req, res) => { | |
| 126 | + const goals = new GoalsService(req.params.companyId); | |
| 127 | + res.json(goals.list()); | |
| 128 | +}); | |
| 129 | + | |
| 130 | +// POST /api/company/:companyId/goals | |
| 131 | +router.post('/:companyId/goals', (req, res) => { | |
| 132 | + try { | |
| 133 | + const { title, description, owner } = req.body; | |
| 134 | + if (!title || !owner) { | |
| 135 | + return res.status(400).json({ error: 'title 和 owner 为必填项' }); | |
| 136 | + } | |
| 137 | + const goals = new GoalsService(req.params.companyId); | |
| 138 | + const goal = goals.create({ title, description, owner }); | |
| 139 | + res.status(201).json(goal); | |
| 140 | + } catch (err) { | |
| 141 | + res.status(500).json({ error: err.message }); | |
| 142 | + } | |
| 143 | +}); | |
| 144 | + | |
| 145 | +// GET /api/company/:companyId/goals/:goalId | |
| 146 | +router.get('/:companyId/goals/:goalId', (req, res) => { | |
| 147 | + const goals = new GoalsService(req.params.companyId); | |
| 148 | + const goal = goals.get(req.params.goalId); | |
| 149 | + if (!goal) return res.status(404).json({ error: 'Goal not found' }); | |
| 150 | + res.json(goal); | |
| 151 | +}); | |
| 152 | + | |
| 153 | +// PUT /api/company/:companyId/goals/:goalId | |
| 154 | +router.put('/:companyId/goals/:goalId', (req, res) => { | |
| 155 | + const goals = new GoalsService(req.params.companyId); | |
| 156 | + const result = goals.updateGoal(req.params.goalId, req.body); | |
| 157 | + if (!result) return res.status(404).json({ error: 'Goal not found' }); | |
| 158 | + res.json(result); | |
| 159 | +}); | |
| 160 | + | |
| 161 | +// POST /api/company/:companyId/goals/:goalId/nodes | |
| 162 | +router.post('/:companyId/goals/:goalId/nodes', (req, res) => { | |
| 163 | + try { | |
| 164 | + const { parentNodeId, title, owner, priority } = req.body; | |
| 165 | + if (!title || !owner) { | |
| 166 | + return res.status(400).json({ error: 'title 和 owner 为必填项' }); | |
| 167 | + } | |
| 168 | + const goals = new GoalsService(req.params.companyId); | |
| 169 | + const result = goals.addNode(req.params.goalId, parentNodeId || null, { | |
| 170 | + title, owner, priority, | |
| 171 | + }); | |
| 172 | + if (!result) return res.status(404).json({ error: 'Goal not found' }); | |
| 173 | + res.status(201).json({ nodeId: result.nodeId, node: result.goal.nodes[result.nodeId] }); | |
| 174 | + } catch (err) { | |
| 175 | + res.status(500).json({ error: err.message }); | |
| 176 | + } | |
| 177 | +}); | |
| 178 | + | |
| 179 | +// PUT /api/company/:companyId/goals/:goalId/nodes/:nodeId | |
| 180 | +router.put('/:companyId/goals/:goalId/nodes/:nodeId', (req, res) => { | |
| 181 | + const goals = new GoalsService(req.params.companyId); | |
| 182 | + const result = goals.updateNode(req.params.goalId, req.params.nodeId, req.body); | |
| 183 | + if (!result) return res.status(404).json({ error: 'Node not found' }); | |
| 184 | + res.json({ nodeId: result.nodeId, node: result.node }); | |
| 185 | +}); | |
| 186 | + | |
| 187 | +// GET /api/company/:companyId/goals/:goalId/progress | |
| 188 | +router.get('/:companyId/goals/:goalId/progress', (req, res) => { | |
| 189 | + const goals = new GoalsService(req.params.companyId); | |
| 190 | + const progress = goals.getProgress(req.params.goalId); | |
| 191 | + if (!progress) return res.status(404).json({ error: 'Goal not found' }); | |
| 192 | + res.json(progress); | |
| 193 | +}); | |
| 194 | + | |
| 118 | 195 | module.exports = router; | ... | ... |
server/scripts/test-company-e2e.js
| ... | ... | @@ -18,6 +18,7 @@ const fs = require('fs'); |
| 18 | 18 | |
| 19 | 19 | // 直接引入 service 层 (不需要 HTTP) |
| 20 | 20 | const { CompanyService } = require('../services/company-service'); |
| 21 | +const { GoalsService } = require('../services/goals'); | |
| 21 | 22 | |
| 22 | 23 | const DATA_DIR = path.resolve(__dirname, '../../data'); |
| 23 | 24 | const TEST_COMPANY_ID = 'test-media-co'; |
| ... | ... | @@ -230,8 +231,69 @@ async function main() { |
| 230 | 231 | const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); |
| 231 | 232 | assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); |
| 232 | 233 | |
| 233 | - // ─── 13. 更新公司 ─── | |
| 234 | - console.log('\n✏️ Phase 13: 更新公司'); | |
| 234 | + // ─── 13. Phase 3: Company Goals (公司级 OKR) ─── | |
| 235 | + console.log('\n🎯 Phase 13: Company Goals (公司级 OKR)'); | |
| 236 | + | |
| 237 | + const companyGoals = new GoalsService(TEST_COMPANY_ID); | |
| 238 | + | |
| 239 | + // 13a: 创建 Goal | |
| 240 | + const goal = companyGoals.create({ | |
| 241 | + title: '30天粉丝破万', | |
| 242 | + description: '通过日更优质内容实现公众号粉丝增长', | |
| 243 | + owner: 'ceo', | |
| 244 | + }); | |
| 245 | + assert(goal.id === 'G-001', `Goal ID = G-001 (实际: ${goal.id})`); | |
| 246 | + assert(goal.title === '30天粉丝破万', 'Goal 标题正确'); | |
| 247 | + | |
| 248 | + // 13b: 检查数据存储在公司目录下 | |
| 249 | + const goalFilePath = path.join(TEST_COMPANY_DIR, 'goals', 'G-001', 'goal.json'); | |
| 250 | + assert(fs.existsSync(goalFilePath), 'Goal 存储在公司目录下'); | |
| 251 | + | |
| 252 | + // 13c: 添加节点 (Objective + Key Results) | |
| 253 | + const obj = companyGoals.addNode('G-001', null, { | |
| 254 | + title: '建立内容产出流水线', | |
| 255 | + owner: 'ceo', | |
| 256 | + priority: 'P0', | |
| 257 | + }); | |
| 258 | + assert(obj.nodeId === 'G-001.1', `Objective ID = G-001.1 (实际: ${obj.nodeId})`); | |
| 259 | + | |
| 260 | + const kr1 = companyGoals.addNode('G-001', 'G-001.1', { | |
| 261 | + title: '日更1篇高质量文章', | |
| 262 | + owner: 'content-writer', | |
| 263 | + priority: 'P0', | |
| 264 | + }); | |
| 265 | + assert(kr1.nodeId === 'G-001.1.1', `KR ID = G-001.1.1 (实际: ${kr1.nodeId})`); | |
| 266 | + | |
| 267 | + const kr2 = companyGoals.addNode('G-001', 'G-001.1', { | |
| 268 | + title: '建立选题数据库 (≥100 条)', | |
| 269 | + owner: 'hot-topic-hunter', | |
| 270 | + priority: 'P1', | |
| 271 | + }); | |
| 272 | + assert(kr2.nodeId === 'G-001.1.2', `KR2 ID = G-001.1.2 (实际: ${kr2.nodeId})`); | |
| 273 | + | |
| 274 | + // 13d: 更新节点状态 | |
| 275 | + companyGoals.updateNode('G-001', 'G-001.1.1', { status: 'in_progress' }); | |
| 276 | + const updatedNode = companyGoals.getNode('G-001', 'G-001.1.1'); | |
| 277 | + assert(updatedNode.status === 'in_progress', '节点状态更新为 in_progress'); | |
| 278 | + | |
| 279 | + // 13e: 进度计算 | |
| 280 | + const progress = companyGoals.getProgress('G-001'); | |
| 281 | + assert(progress.total === 2, `叶子节点总数 = 2 (实际: ${progress.total})`); | |
| 282 | + assert(progress.inProgress === 1, `进行中 = 1 (实际: ${progress.inProgress})`); | |
| 283 | + | |
| 284 | + // 13f: Goal list | |
| 285 | + const goalList = companyGoals.list(); | |
| 286 | + assert(goalList.length === 1, `Goal 列表长度 = 1 (实际: ${goalList.length})`); | |
| 287 | + assert(goalList[0].id === 'G-001', '列表中 Goal ID 正确'); | |
| 288 | + | |
| 289 | + // 13g: 验证 OKR 是公司隔离的 (全局 GoalsService 看不到公司的目标) | |
| 290 | + const globalGoals = new GoalsService(); | |
| 291 | + const globalList = globalGoals.list(); | |
| 292 | + const hasCompanyGoal = globalList.some(g => g.id === 'G-001' && g.title === '30天粉丝破万'); | |
| 293 | + assert(!hasCompanyGoal, '全局 GoalsService 看不到公司级 OKR (隔离)'); | |
| 294 | + | |
| 295 | + // ─── 14. 更新公司 ─── | |
| 296 | + console.log('\n✏️ Phase 14: 更新公司'); | |
| 235 | 297 | const updated = svc.update(TEST_COMPANY_ID, { |
| 236 | 298 | status: 'running', |
| 237 | 299 | heartbeat: { enabled: true }, |
| ... | ... | @@ -239,8 +301,8 @@ async function main() { |
| 239 | 301 | assert(updated.status === 'running', '状态更新为 running'); |
| 240 | 302 | assert(updated.heartbeat.enabled === true, '心跳已开启'); |
| 241 | 303 | |
| 242 | - // ─── 14. 关停公司 ─── | |
| 243 | - console.log('\n🛑 Phase 14: 关停公司'); | |
| 304 | + // ─── 15. 关停公司 ─── | |
| 305 | + console.log('\n🛑 Phase 15: 关停公司'); | |
| 244 | 306 | const shutdown = svc.shutdown(TEST_COMPANY_ID); |
| 245 | 307 | assert(shutdown.status === 'shutdown', '状态变为 shutdown'); |
| 246 | 308 | assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); |
| ... | ... | @@ -262,7 +324,7 @@ async function main() { |
| 262 | 324 | console.log(`\n${'═'.repeat(50)}`); |
| 263 | 325 | console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); |
| 264 | 326 | if (failed === 0) { |
| 265 | - console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 生产就绪!\n'); | |
| 327 | + console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 + Phase 3 生产就绪!\n'); | |
| 266 | 328 | } else { |
| 267 | 329 | console.log(` ⚠️ ${failed} 个测试未通过\n`); |
| 268 | 330 | process.exit(1); | ... | ... |
server/services/goals.js
| 1 | 1 | /** |
| 2 | 2 | * GoalsService — OKR 目标管理 |
| 3 | 3 | * |
| 4 | - * 数据目录: data/goals/ | |
| 5 | - * 每个目标: data/goals/G-xxx/goal.json | |
| 6 | - * 全局索引: data/goals/_index.json | |
| 4 | + * 数据目录: | |
| 5 | + * - 公司级: data/{companyId}/goals/ | |
| 6 | + * - 全局(向后兼容): data/goals/ | |
| 7 | + * | |
| 8 | + * 每个目标: {goalsDir}/G-xxx/goal.json | |
| 9 | + * 索引文件: {goalsDir}/_index.json | |
| 7 | 10 | * |
| 8 | 11 | * ID 设计: 点分层级 |
| 9 | 12 | * G-001 → Goal (第1层) |
| ... | ... | @@ -17,13 +20,21 @@ |
| 17 | 20 | const fs = require('fs'); |
| 18 | 21 | const path = require('path'); |
| 19 | 22 | |
| 20 | -const GOALS_DIR = path.join(__dirname, '../../data/goals'); | |
| 23 | +const DATA_DIR = path.join(__dirname, '../../data'); | |
| 21 | 24 | |
| 22 | 25 | class GoalsService { |
| 23 | 26 | |
| 24 | - constructor() { | |
| 25 | - if (!fs.existsSync(GOALS_DIR)) { | |
| 26 | - fs.mkdirSync(GOALS_DIR, { recursive: true }); | |
| 27 | + /** | |
| 28 | + * @param {string} [companyId] - 公司 ID。不传则使用 data/goals/ (向后兼容) | |
| 29 | + */ | |
| 30 | + constructor(companyId) { | |
| 31 | + this.companyId = companyId || null; | |
| 32 | + this.goalsDir = companyId | |
| 33 | + ? path.join(DATA_DIR, companyId, 'goals') | |
| 34 | + : path.join(DATA_DIR, 'goals'); | |
| 35 | + | |
| 36 | + if (!fs.existsSync(this.goalsDir)) { | |
| 37 | + fs.mkdirSync(this.goalsDir, { recursive: true }); | |
| 27 | 38 | } |
| 28 | 39 | } |
| 29 | 40 | |
| ... | ... | @@ -52,7 +63,7 @@ class GoalsService { |
| 52 | 63 | }; |
| 53 | 64 | |
| 54 | 65 | // 写入 goal.json |
| 55 | - const goalDir = path.join(GOALS_DIR, id); | |
| 66 | + const goalDir = path.join(this.goalsDir, id); | |
| 56 | 67 | fs.mkdirSync(goalDir, { recursive: true }); |
| 57 | 68 | this._saveGoal(goal); |
| 58 | 69 | |
| ... | ... | @@ -73,7 +84,7 @@ class GoalsService { |
| 73 | 84 | * 获取 Goal 完整数据 |
| 74 | 85 | */ |
| 75 | 86 | get(goalId) { |
| 76 | - const fp = path.join(GOALS_DIR, goalId, 'goal.json'); | |
| 87 | + const fp = path.join(this.goalsDir, goalId, 'goal.json'); | |
| 77 | 88 | if (!fs.existsSync(fp)) return null; |
| 78 | 89 | return JSON.parse(fs.readFileSync(fp, 'utf-8')); |
| 79 | 90 | } |
| ... | ... | @@ -82,7 +93,7 @@ class GoalsService { |
| 82 | 93 | * 列出所有 Goal (轻量索引) |
| 83 | 94 | */ |
| 84 | 95 | list() { |
| 85 | - const indexPath = path.join(GOALS_DIR, '_index.json'); | |
| 96 | + const indexPath = path.join(this.goalsDir, '_index.json'); | |
| 86 | 97 | if (!fs.existsSync(indexPath)) { |
| 87 | 98 | this._rebuildIndex(); |
| 88 | 99 | } |
| ... | ... | @@ -233,7 +244,7 @@ class GoalsService { |
| 233 | 244 | // ═══════════════════════════════════════ |
| 234 | 245 | |
| 235 | 246 | getProgress(goalId) { |
| 236 | - const fp = path.join(GOALS_DIR, goalId, 'progress.json'); | |
| 247 | + const fp = path.join(this.goalsDir, goalId, 'progress.json'); | |
| 237 | 248 | if (!fs.existsSync(fp)) { |
| 238 | 249 | const goal = this.get(goalId); |
| 239 | 250 | if (!goal) return null; |
| ... | ... | @@ -295,7 +306,7 @@ class GoalsService { |
| 295 | 306 | * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点) |
| 296 | 307 | */ |
| 297 | 308 | getActiveContextForAgent(agentId) { |
| 298 | - if (!fs.existsSync(GOALS_DIR)) return ''; | |
| 309 | + if (!fs.existsSync(this.goalsDir)) return ''; | |
| 299 | 310 | const index = this.list(); |
| 300 | 311 | let combinedText = ''; |
| 301 | 312 | |
| ... | ... | @@ -340,8 +351,8 @@ class GoalsService { |
| 340 | 351 | |
| 341 | 352 | _nextGoalId() { |
| 342 | 353 | // 扫描现有 G-xxx 目录 |
| 343 | - if (!fs.existsSync(GOALS_DIR)) return 'G-001'; | |
| 344 | - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) | |
| 354 | + if (!fs.existsSync(this.goalsDir)) return 'G-001'; | |
| 355 | + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) | |
| 345 | 356 | .filter(d => d.isDirectory() && d.name.startsWith('G-')) |
| 346 | 357 | .map(d => parseInt(d.name.replace('G-', ''))) |
| 347 | 358 | .filter(n => !isNaN(n)); |
| ... | ... | @@ -351,13 +362,13 @@ class GoalsService { |
| 351 | 362 | } |
| 352 | 363 | |
| 353 | 364 | _saveGoal(goal) { |
| 354 | - const goalDir = path.join(GOALS_DIR, goal.id); | |
| 365 | + const goalDir = path.join(this.goalsDir, goal.id); | |
| 355 | 366 | if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true }); |
| 356 | 367 | fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8'); |
| 357 | 368 | } |
| 358 | 369 | |
| 359 | 370 | _saveProgress(goalId, progress) { |
| 360 | - const goalDir = path.join(GOALS_DIR, goalId); | |
| 371 | + const goalDir = path.join(this.goalsDir, goalId); | |
| 361 | 372 | if (!fs.existsSync(goalDir)) return; |
| 362 | 373 | fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8'); |
| 363 | 374 | } |
| ... | ... | @@ -399,8 +410,8 @@ class GoalsService { |
| 399 | 410 | } |
| 400 | 411 | |
| 401 | 412 | _rebuildIndex() { |
| 402 | - if (!fs.existsSync(GOALS_DIR)) return; | |
| 403 | - const dirs = fs.readdirSync(GOALS_DIR, { withFileTypes: true }) | |
| 413 | + if (!fs.existsSync(this.goalsDir)) return; | |
| 414 | + const dirs = fs.readdirSync(this.goalsDir, { withFileTypes: true }) | |
| 404 | 415 | .filter(d => d.isDirectory() && d.name.startsWith('G-')); |
| 405 | 416 | |
| 406 | 417 | const index = dirs.map(d => { |
| ... | ... | @@ -421,14 +432,14 @@ class GoalsService { |
| 421 | 432 | }).filter(Boolean); |
| 422 | 433 | |
| 423 | 434 | fs.writeFileSync( |
| 424 | - path.join(GOALS_DIR, '_index.json'), | |
| 435 | + path.join(this.goalsDir, '_index.json'), | |
| 425 | 436 | JSON.stringify(index, null, 2), |
| 426 | 437 | 'utf-8' |
| 427 | 438 | ); |
| 428 | 439 | } |
| 429 | 440 | |
| 430 | 441 | _log(goalId, message) { |
| 431 | - const logPath = path.join(GOALS_DIR, goalId, 'history.log'); | |
| 442 | + const logPath = path.join(this.goalsDir, goalId, 'history.log'); | |
| 432 | 443 | const ts = new Date().toISOString(); |
| 433 | 444 | fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8'); |
| 434 | 445 | } | ... | ... |