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 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 }
... ...