feat(docgov): expose proposal HTTP and wire frontend decide flow

Add POST /api/v1/docgov/proposal for ProposeModification, harden decide
status mapping, and replace App pendingProposal local mock with real API calls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 14:07:52 +00:00
co-authored by Cursor
parent ba92a8b3a5
commit c0e7df5abf
7 changed files with 412 additions and 43 deletions
+1
View File
@@ -70,6 +70,7 @@ func main() {
fmt.Println(" • [GET] /api/v1/context/cases/recommend - 往期受控标杆案例与历史报价语义自推荐")
fmt.Println(" • [GET] /api/v1/docgov/document - 查询受控文档治理状态看板")
fmt.Println(" • [POST] /api/v1/docgov/claim - 查阅并认领受控文档 (正式稿方可认领)")
fmt.Println(" • [POST] /api/v1/docgov/proposal - 对已认领文档发起变更提案")
fmt.Println(" • [POST] /api/v1/docgov/proposal/decide - 编制负责人拍板同意/拒绝变更提案")
fmt.Println(" • [POST] /api/v1/docgov/submit-review - 编制完成提交审核 (文档正文锁定)")
fmt.Println(" • [POST] /api/v1/docgov/review-decision - 审核通过或驳回 (驳回必填原因且解锁)")
+73
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
@@ -735,6 +736,66 @@ func (s *Server) handleDocGovApproverDecision(w http.ResponseWriter, r *http.Req
writeJSONResponse(w, http.StatusOK, doc)
}
// ProposeModificationRequest 发起变更提案请求
type ProposeModificationRequest struct {
DocID string `json:"doc_id"`
TriggeredBy string `json:"triggered_by"`
ProposedChanges string `json:"proposed_changes"`
ImpactAnalysis string `json:"impact_analysis"`
RiskAssessment string `json:"risk_assessment"`
}
// handleDocGovProposeModification 对已认领文档发起变更提案(写库 + 审计)
func (s *Server) handleDocGovProposeModification(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
var req ProposeModificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if strings.TrimSpace(req.DocID) == "" {
writeSafeError(w, r, http.StatusBadRequest, "doc_id cannot be empty")
return
}
if strings.TrimSpace(req.ProposedChanges) == "" {
writeSafeError(w, r, http.StatusBadRequest, "proposed_changes cannot be empty")
return
}
if s.gov == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
return
}
triggeredBy := strings.TrimSpace(req.TriggeredBy)
if triggeredBy == "" {
triggeredBy = fmt.Sprintf("user:%d", user.ID)
}
proposal, err := s.gov.ProposeModification(
r.Context(),
req.DocID,
triggeredBy,
req.ProposedChanges,
req.ImpactAnalysis,
req.RiskAssessment,
)
if err != nil {
if errors.Is(err, docgov.ErrDocumentNotFound) {
writeSafeError(w, r, http.StatusNotFound, "document not found")
return
}
writeSafeError(w, r, http.StatusBadRequest, "propose modification failed: "+err.Error())
return
}
writeJSONResponse(w, http.StatusOK, proposal)
}
// DecideProposalRequest 拍板变更提案请求
type DecideProposalRequest struct {
ProposalID string `json:"proposal_id"`
@@ -755,6 +816,10 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
writeSafeError(w, r, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if strings.TrimSpace(req.ProposalID) == "" {
writeSafeError(w, r, http.StatusBadRequest, "proposal_id cannot be empty")
return
}
if s.gov == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "docgov engine not initialized")
@@ -763,6 +828,14 @@ func (s *Server) handleDocGovDecideProposal(w http.ResponseWriter, r *http.Reque
proposal, err := s.gov.DecideModificationProposal(r.Context(), req.ProposalID, user.ID, req.Approve, req.Reason)
if err != nil {
if errors.Is(err, docgov.ErrProposalNotFound) {
writeSafeError(w, r, http.StatusNotFound, "proposal not found")
return
}
if errors.Is(err, docgov.ErrUnauthorizedActor) {
writeSafeError(w, r, http.StatusForbidden, "decide proposal failed: "+err.Error())
return
}
writeSafeError(w, r, http.StatusBadRequest, "decide proposal failed: "+err.Error())
return
}
+1
View File
@@ -276,6 +276,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/docgov/submit-review", s.handleDocGovSubmitReview)
mux.HandleFunc("POST /api/v1/docgov/review-decision", s.handleDocGovReviewDecision)
mux.HandleFunc("POST /api/v1/docgov/approver-decision", s.handleDocGovApproverDecision)
mux.HandleFunc("POST /api/v1/docgov/proposal", s.handleDocGovProposeModification)
mux.HandleFunc("POST /api/v1/docgov/proposal/decide", s.handleDocGovDecideProposal)
mux.HandleFunc("GET /api/v1/docgov/comments", s.handleDocGovListComments)
mux.HandleFunc("POST /api/v1/docgov/comments", s.handleDocGovAddComment)
+61
View File
@@ -18,6 +18,7 @@ import (
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/chat"
"chat-agent-system/internal/config"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/stream"
)
@@ -461,3 +462,63 @@ func TestServer_ContextAndDocGovAPIs(t *testing.T) {
t.Fatalf("expected 200 from audit logs, got %d", w.Code)
}
}
func TestServer_DocGovProposeAndDecideHTTP(t *testing.T) {
srv := newTestServer(t)
token := loginTestAdmin(t, srv)
ctx := context.Background()
docID := "doc_proj1_proposal_http"
if _, err := srv.gov.CreateOrGetDocument(ctx, docID, 1, "feasibility_reg", "法规评估", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil {
t.Fatalf("CreateOrGetDocument: %v", err)
}
if _, err := srv.gov.RecordDocumentViewed(ctx, docID, 1, docgov.RoleAuthor, "admin"); err != nil {
t.Fatalf("claim: %v", err)
}
proposeBody, _ := json.Marshal(ProposeModificationRequest{
DocID: docID,
TriggeredBy: "web_ui",
ProposedChanges: "补充 EMC 滤波指标",
ImpactAnalysis: "影响原理图选型",
RiskAssessment: "低风险",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal", bytes.NewReader(proposeBody))
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("propose want 200 got %d %s", w.Code, w.Body.String())
}
var prop map[string]any
if err := json.NewDecoder(w.Body).Decode(&prop); err != nil {
t.Fatalf("decode propose: %v", err)
}
proposalID, _ := prop["proposal_id"].(string)
if proposalID == "" {
t.Fatalf("missing proposal_id: %#v", prop)
}
if status, _ := prop["status"].(string); status != "pending_author_approval" {
t.Fatalf("want pending_author_approval got %v", prop["status"])
}
decideBody, _ := json.Marshal(DecideProposalRequest{
ProposalID: proposalID,
Approve: true,
Reason: "同意补充",
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/docgov/proposal/decide", bytes.NewReader(decideBody))
withBearerAuth(req, token)
w = httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("decide want 200 got %d %s", w.Code, w.Body.String())
}
var decided map[string]any
if err := json.NewDecoder(w.Body).Decode(&decided); err != nil {
t.Fatalf("decode decide: %v", err)
}
if status, _ := decided["status"].(string); status != "approved_by_author" {
t.Fatalf("want approved_by_author got %v", decided["status"])
}
}
+158 -43
View File
@@ -55,6 +55,8 @@ import {
submitForReview,
reviewDecision,
approverDecision,
proposeModification,
decideModificationProposal,
mapLeaseToAstEditLease,
type DocGovRecord,
} from './api/docgov';
@@ -1557,11 +1559,13 @@ export default function App() {
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
const [pendingProposal, setPendingProposal] = useState<{
proposalId: string;
changes: string;
impact: string;
risk: string;
patchBlockId?: string;
} | null>(null);
const [proposalError, setProposalError] = useState<string | null>(null);
// 消息流:权威来源为服务端窗口;不再全量持久化到 localStorage
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -3188,24 +3192,56 @@ export default function App() {
if (text.includes('修改') || text.includes('更新') || text.includes('调整')) {
if (docAST.isClaimed && currentDocRole === 'author') {
setPendingProposal({
changes: '拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。',
impact: '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。',
risk: '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。',
});
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】。请您确认是否准许本次修改。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
const changes =
'拟在第2节「监管分类与强制性合规标准矩阵」中,强化补充高频电刀共模抑制比设计指标。';
const impact = '将直接影响电路原理图物料选型与型式检验 EMC 滤波网络参数。';
const risk = '低风险。符合 GB 9706.1-2020 强标,提升系统抗扰度,无侵权风险。';
const docId = activeDocIdRef.current;
void (async () => {
try {
setProposalError(null);
const prop = await proposeModification({
doc_id: docId,
triggered_by: 'web_ui',
proposed_changes: changes,
impact_analysis: impact,
risk_assessment: risk,
});
setPendingProposal({
proposalId: prop.proposal_id,
changes: prop.proposed_changes || changes,
impact: prop.impact_analysis || impact,
risk: prop.risk_assessment || risk,
});
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🛡️ 文档认领保护机制拦截',
content: `检测到《${docAST.title}》已处于【编制人已认领】状态。根据医疗器械质量体系规范,智能体小队不能擅自修改,已为您生成【变更方案提议】(${prop.proposal_id})。请您确认是否准许本次修改。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setProposalError(message);
setMessages((prev) => [
...prev,
{
id: 'ast-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '⚠️ 变更提案失败',
content: `未能创建变更提案:${message}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}
})();
return;
}
}
@@ -3244,32 +3280,106 @@ export default function App() {
return { accepted: true };
};
// 编制人接受变更提议
// 编制人接受变更提议(经 HTTP decide)
const handleAcceptProposal = () => {
setPendingProposal(null);
const children = docAST.rootBlock.children || [];
const pBlock = children.find((c) => c.type === 'paragraph');
if (pBlock) {
pBlock.content += '\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
pBlock.metadata = {
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
};
setDocAST({ ...docAST });
}
if (!pendingProposal?.proposalId) return;
const proposalId = pendingProposal.proposalId;
void (async () => {
try {
setProposalError(null);
await decideModificationProposal({
proposal_id: proposalId,
approve: true,
reason: '编制人确认采纳变更提案',
});
setPendingProposal(null);
const children = docAST.rootBlock.children || [];
const pBlock = children.find((c) => c.type === 'paragraph');
if (pBlock) {
pBlock.content +=
'\n\n【工程师确认增补】前端光路采集板已加装定制高频共模扼流圈,强标抗扰度提升至最高等级。';
pBlock.metadata = {
lastModifiedBy: '智能体协同小队 (经张工确认签署)',
lastModifiedAt: new Date().toISOString().replace('T', ' ').substring(0, 16),
};
setDocAST({ ...docAST });
}
setMessages((prev) => [
...prev,
{
id: 'ast-p-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '✅ 协同变更已生效',
content: `变更提议已成功执行并落盘至《${docAST.title}》,受控文档已同步更新。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
setMessages((prev) => [
...prev,
{
id: 'ast-p-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '✅ 协同变更已生效',
content: `变更提议 ${proposalId} 已批准并落盘至《${docAST.title}》,受控文档已同步更新。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setProposalError(message);
setMessages((prev) => [
...prev,
{
id: 'ast-p-err-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '⚠️ 批准提案失败',
content: `批准变更提案失败:${message}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}
})();
};
const handleRejectProposal = () => {
if (!pendingProposal?.proposalId) {
setPendingProposal(null);
return;
}
const proposalId = pendingProposal.proposalId;
void (async () => {
try {
setProposalError(null);
await decideModificationProposal({
proposal_id: proposalId,
approve: false,
reason: '编制人拒绝本次变更',
});
setPendingProposal(null);
setMessages((prev) => [
...prev,
{
id: 'ast-p-rej-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '🚫 变更提案已拒绝',
content: `已拒绝变更提议 ${proposalId},受控文档保持原状。`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setProposalError(message);
setMessages((prev) => [
...prev,
{
id: 'ast-p-rej-err-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '水木东方协同参谋',
intentTag: '⚠️ 拒绝提案失败',
content: `拒绝变更提案失败:${message}`,
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
}
})();
};
// 块操作:编排层先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
@@ -4064,12 +4174,17 @@ export default function App() {
</div>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
{proposalError && (
<div style={{ color: 'var(--danger, #b91c1c)', fontSize: 12, marginBottom: 8 }}>
{proposalError}
</div>
)}
<ProposalConfirmCard
proposedChanges={pendingProposal.changes}
impactAnalysis={pendingProposal.impact}
potentialRisk={pendingProposal.risk}
onAccept={handleAcceptProposal}
onReject={() => setPendingProposal(null)}
onReject={handleRejectProposal}
/>
</div>
)}
+77
View File
@@ -5,6 +5,8 @@ import {
submitForReview,
reviewDecision,
approverDecision,
proposeModification,
decideModificationProposal,
} from './docgov';
const sampleRecord = {
@@ -108,4 +110,79 @@ describe('docgov lifecycle API client', () => {
assert.equal(body.action, 'reject');
assert.equal(body.rollback_to, 'drafting');
});
it('proposeModification posts to /api/v1/docgov/proposal', async () => {
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
lastCall = { url: String(input), init };
return new Response(
JSON.stringify({
proposal_id: 'prop_1',
doc_id: 'doc-1',
proposed_changes: '改一处',
status: 'pending_author_approval',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;
globalThis.fetch = mockFetch;
(globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = {
fetch: mockFetch,
localStorage: {
getItem: () =>
JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }),
setItem: () => undefined,
removeItem: () => undefined,
},
};
const prop = await proposeModification({
doc_id: 'doc-1',
triggered_by: 'web_ui',
proposed_changes: '改一处',
impact_analysis: '影响小',
risk_assessment: '低风险',
});
assert.equal(lastCall?.url, '/api/v1/docgov/proposal');
assert.equal(lastCall?.init?.method, 'POST');
const body = JSON.parse(String(lastCall?.init?.body));
assert.equal(body.doc_id, 'doc-1');
assert.equal(body.proposed_changes, '改一处');
assert.equal(prop.proposal_id, 'prop_1');
});
it('decideModificationProposal posts to /api/v1/docgov/proposal/decide', async () => {
const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
lastCall = { url: String(input), init };
return new Response(
JSON.stringify({
proposal_id: 'prop_1',
doc_id: 'doc-1',
proposed_changes: '改一处',
status: 'approved_by_author',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;
globalThis.fetch = mockFetch;
(globalThis as { window: { fetch: typeof fetch; localStorage: unknown } }).window = {
fetch: mockFetch,
localStorage: {
getItem: () =>
JSON.stringify({ token: 'test-token', user: { id: 7, name: 'A' } }),
setItem: () => undefined,
removeItem: () => undefined,
},
};
const prop = await decideModificationProposal({
proposal_id: 'prop_1',
approve: true,
reason: '同意',
});
assert.equal(lastCall?.url, '/api/v1/docgov/proposal/decide');
const body = JSON.parse(String(lastCall?.init?.body));
assert.equal(body.proposal_id, 'prop_1');
assert.equal(body.approve, true);
assert.equal(prop.status, 'approved_by_author');
});
});
+41
View File
@@ -126,6 +126,47 @@ export async function approverDecision(payload: ApproverDecisionPayload): Promis
return postDocGov('/api/v1/docgov/approver-decision', payload);
}
export interface ProposeModificationPayload {
doc_id: string;
triggered_by?: string;
proposed_changes: string;
impact_analysis?: string;
risk_assessment?: string;
}
export interface DecideModificationProposalPayload {
proposal_id: string;
approve: boolean;
reason?: string;
}
export interface ChangeProposalRecord {
id?: number;
proposal_id: string;
doc_id: string;
project_id?: number;
triggered_by?: string;
proposed_changes: string;
impact_analysis?: string;
risk_assessment?: string;
status: string;
author_decision_reason?: string;
decided_at?: string | null;
created_at?: string;
}
export async function proposeModification(
payload: ProposeModificationPayload,
): Promise<ChangeProposalRecord> {
return postDocGov('/api/v1/docgov/proposal', payload);
}
export async function decideModificationProposal(
payload: DecideModificationProposalPayload,
): Promise<ChangeProposalRecord> {
return postDocGov('/api/v1/docgov/proposal/decide', payload);
}
export function mapLeaseToAstEditLease(doc: {
lease_holder_type?: string;
lease_holder_id?: string;