Commit 34584a526081b0faaaf20e5b49c247ac317caf5e

Authored by yibo
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)
data/templates/_registry.json
@@ -6,7 +6,7 @@ @@ -6,7 +6,7 @@
6 "description": "公司战略规划、团队管理、OKR 制定、任务委派", 6 "description": "公司战略规划、团队管理、OKR 制定、任务委派",
7 "benchmarkScore": 97, 7 "benchmarkScore": 97,
8 "trainedAt": "2026-03-20", 8 "trainedAt": "2026-03-20",
9 - "usedCount": 2 9 + "usedCount": 3
10 }, 10 },
11 { 11 {
12 "id": "qa-judge", 12 "id": "qa-judge",
@@ -15,6 +15,6 @@ @@ -15,6 +15,6 @@
15 "description": "质量验收、OKR 进度审查、标准化仲裁", 15 "description": "质量验收、OKR 进度审查、标准化仲裁",
16 "benchmarkScore": 95, 16 "benchmarkScore": 95,
17 "trainedAt": "2026-03-25", 17 "trainedAt": "2026-03-25",
18 - "usedCount": 2 18 + "usedCount": 3
19 } 19 }
20 ] 20 ]
21 \ No newline at end of file 21 \ No newline at end of file
server/routes/company.js
@@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => { @@ -115,4 +115,81 @@ router.get('/:companyId/agents', (req, res) => {
115 res.json(agents); 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 module.exports = router; 195 module.exports = router;
server/scripts/test-company-e2e.js
@@ -18,6 +18,7 @@ const fs = require('fs'); @@ -18,6 +18,7 @@ const fs = require('fs');
18 18
19 // 直接引入 service 层 (不需要 HTTP) 19 // 直接引入 service 层 (不需要 HTTP)
20 const { CompanyService } = require('../services/company-service'); 20 const { CompanyService } = require('../services/company-service');
  21 +const { GoalsService } = require('../services/goals');
21 22
22 const DATA_DIR = path.resolve(__dirname, '../../data'); 23 const DATA_DIR = path.resolve(__dirname, '../../data');
23 const TEST_COMPANY_ID = 'test-media-co'; 24 const TEST_COMPANY_ID = 'test-media-co';
@@ -230,8 +231,69 @@ async function main() { @@ -230,8 +231,69 @@ async function main() {
230 const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); 231 const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8'));
231 assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); 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 const updated = svc.update(TEST_COMPANY_ID, { 297 const updated = svc.update(TEST_COMPANY_ID, {
236 status: 'running', 298 status: 'running',
237 heartbeat: { enabled: true }, 299 heartbeat: { enabled: true },
@@ -239,8 +301,8 @@ async function main() { @@ -239,8 +301,8 @@ async function main() {
239 assert(updated.status === 'running', '状态更新为 running'); 301 assert(updated.status === 'running', '状态更新为 running');
240 assert(updated.heartbeat.enabled === true, '心跳已开启'); 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 const shutdown = svc.shutdown(TEST_COMPANY_ID); 306 const shutdown = svc.shutdown(TEST_COMPANY_ID);
245 assert(shutdown.status === 'shutdown', '状态变为 shutdown'); 307 assert(shutdown.status === 'shutdown', '状态变为 shutdown');
246 assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); 308 assert(shutdown.heartbeat.enabled === false, '心跳已关闭');
@@ -262,7 +324,7 @@ async function main() { @@ -262,7 +324,7 @@ async function main() {
262 console.log(`\n${'═'.repeat(50)}`); 324 console.log(`\n${'═'.repeat(50)}`);
263 console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); 325 console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`);
264 if (failed === 0) { 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 } else { 328 } else {
267 console.log(` ⚠️ ${failed} 个测试未通过\n`); 329 console.log(` ⚠️ ${failed} 个测试未通过\n`);
268 process.exit(1); 330 process.exit(1);
server/services/goals.js
1 /** 1 /**
2 * GoalsService — OKR 目标管理 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 * ID 设计: 点分层级 11 * ID 设计: 点分层级
9 * G-001 → Goal (第1层) 12 * G-001 → Goal (第1层)
@@ -17,13 +20,21 @@ @@ -17,13 +20,21 @@
17 const fs = require('fs'); 20 const fs = require('fs');
18 const path = require('path'); 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 class GoalsService { 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,7 +63,7 @@ class GoalsService {
52 }; 63 };
53 64
54 // 写入 goal.json 65 // 写入 goal.json
55 - const goalDir = path.join(GOALS_DIR, id); 66 + const goalDir = path.join(this.goalsDir, id);
56 fs.mkdirSync(goalDir, { recursive: true }); 67 fs.mkdirSync(goalDir, { recursive: true });
57 this._saveGoal(goal); 68 this._saveGoal(goal);
58 69
@@ -73,7 +84,7 @@ class GoalsService { @@ -73,7 +84,7 @@ class GoalsService {
73 * 获取 Goal 完整数据 84 * 获取 Goal 完整数据
74 */ 85 */
75 get(goalId) { 86 get(goalId) {
76 - const fp = path.join(GOALS_DIR, goalId, 'goal.json'); 87 + const fp = path.join(this.goalsDir, goalId, 'goal.json');
77 if (!fs.existsSync(fp)) return null; 88 if (!fs.existsSync(fp)) return null;
78 return JSON.parse(fs.readFileSync(fp, 'utf-8')); 89 return JSON.parse(fs.readFileSync(fp, 'utf-8'));
79 } 90 }
@@ -82,7 +93,7 @@ class GoalsService { @@ -82,7 +93,7 @@ class GoalsService {
82 * 列出所有 Goal (轻量索引) 93 * 列出所有 Goal (轻量索引)
83 */ 94 */
84 list() { 95 list() {
85 - const indexPath = path.join(GOALS_DIR, '_index.json'); 96 + const indexPath = path.join(this.goalsDir, '_index.json');
86 if (!fs.existsSync(indexPath)) { 97 if (!fs.existsSync(indexPath)) {
87 this._rebuildIndex(); 98 this._rebuildIndex();
88 } 99 }
@@ -233,7 +244,7 @@ class GoalsService { @@ -233,7 +244,7 @@ class GoalsService {
233 // ═══════════════════════════════════════ 244 // ═══════════════════════════════════════
234 245
235 getProgress(goalId) { 246 getProgress(goalId) {
236 - const fp = path.join(GOALS_DIR, goalId, 'progress.json'); 247 + const fp = path.join(this.goalsDir, goalId, 'progress.json');
237 if (!fs.existsSync(fp)) { 248 if (!fs.existsSync(fp)) {
238 const goal = this.get(goalId); 249 const goal = this.get(goalId);
239 if (!goal) return null; 250 if (!goal) return null;
@@ -295,7 +306,7 @@ class GoalsService { @@ -295,7 +306,7 @@ class GoalsService {
295 * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点) 306 * 获取某 Agent 当前参与的活跃 Goal 上下文文本(汇总所有活跃节点)
296 */ 307 */
297 getActiveContextForAgent(agentId) { 308 getActiveContextForAgent(agentId) {
298 - if (!fs.existsSync(GOALS_DIR)) return ''; 309 + if (!fs.existsSync(this.goalsDir)) return '';
299 const index = this.list(); 310 const index = this.list();
300 let combinedText = ''; 311 let combinedText = '';
301 312
@@ -340,8 +351,8 @@ class GoalsService { @@ -340,8 +351,8 @@ class GoalsService {
340 351
341 _nextGoalId() { 352 _nextGoalId() {
342 // 扫描现有 G-xxx 目录 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 .filter(d => d.isDirectory() && d.name.startsWith('G-')) 356 .filter(d => d.isDirectory() && d.name.startsWith('G-'))
346 .map(d => parseInt(d.name.replace('G-', ''))) 357 .map(d => parseInt(d.name.replace('G-', '')))
347 .filter(n => !isNaN(n)); 358 .filter(n => !isNaN(n));
@@ -351,13 +362,13 @@ class GoalsService { @@ -351,13 +362,13 @@ class GoalsService {
351 } 362 }
352 363
353 _saveGoal(goal) { 364 _saveGoal(goal) {
354 - const goalDir = path.join(GOALS_DIR, goal.id); 365 + const goalDir = path.join(this.goalsDir, goal.id);
355 if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true }); 366 if (!fs.existsSync(goalDir)) fs.mkdirSync(goalDir, { recursive: true });
356 fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8'); 367 fs.writeFileSync(path.join(goalDir, 'goal.json'), JSON.stringify(goal, null, 2), 'utf-8');
357 } 368 }
358 369
359 _saveProgress(goalId, progress) { 370 _saveProgress(goalId, progress) {
360 - const goalDir = path.join(GOALS_DIR, goalId); 371 + const goalDir = path.join(this.goalsDir, goalId);
361 if (!fs.existsSync(goalDir)) return; 372 if (!fs.existsSync(goalDir)) return;
362 fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8'); 373 fs.writeFileSync(path.join(goalDir, 'progress.json'), JSON.stringify(progress, null, 2), 'utf-8');
363 } 374 }
@@ -399,8 +410,8 @@ class GoalsService { @@ -399,8 +410,8 @@ class GoalsService {
399 } 410 }
400 411
401 _rebuildIndex() { 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 .filter(d => d.isDirectory() && d.name.startsWith('G-')); 415 .filter(d => d.isDirectory() && d.name.startsWith('G-'));
405 416
406 const index = dirs.map(d => { 417 const index = dirs.map(d => {
@@ -421,14 +432,14 @@ class GoalsService { @@ -421,14 +432,14 @@ class GoalsService {
421 }).filter(Boolean); 432 }).filter(Boolean);
422 433
423 fs.writeFileSync( 434 fs.writeFileSync(
424 - path.join(GOALS_DIR, '_index.json'), 435 + path.join(this.goalsDir, '_index.json'),
425 JSON.stringify(index, null, 2), 436 JSON.stringify(index, null, 2),
426 'utf-8' 437 'utf-8'
427 ); 438 );
428 } 439 }
429 440
430 _log(goalId, message) { 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 const ts = new Date().toISOString(); 443 const ts = new Date().toISOString();
433 fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8'); 444 fs.appendFileSync(logPath, `[${ts}] ${message}\n`, 'utf-8');
434 } 445 }