feat(mcp): IssueSystemTokenWithTx for cross-module tx (spec §6.1)

- Extract issueSystemToken helper; pool-bound + tx-bound entries share impl
- IssueSystemTokenWithTx accepts pgx.Tx, uses repo.WithTx(tx) for DB writes
- Audit Record stays outside tx (audit failure doesn't roll back business path)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:44:37 +08:00
parent 33057e885f
commit 3a448cee5f
2 changed files with 36 additions and 7 deletions
+15 -7
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
@@ -20,6 +21,7 @@ import (
type TokenService interface {
IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error)
IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error)
IssueSystemTokenWithTx(ctx context.Context, tx pgx.Tx, in IssueSystemTokenInput) (IssueResult, error)
Authenticate(ctx context.Context, plaintext string) (*Token, error)
Revoke(ctx context.Context, id, byUserID uuid.UUID) error
RevokeBySession(ctx context.Context, sessionID uuid.UUID) error
@@ -137,15 +139,21 @@ func (s *tokenService) IssueAdmin(ctx context.Context, in IssueAdminInput) (Issu
// FK 校验失败。spec §6.1 明确建议同事务提交。本 service 不直接管事务边界(由
// ACP SessionService 在 Create 流程内编排),但调用顺序由调用方保证。
//
// TODO(Part 3): 当前 pgRepo 持有 *pgxpool.Pool 并忽略 ctx 中可能存在的 pgx tx,
// 因此严格意义上 IssueSystemToken 与 acp_sessions row 的写入并不在同一事务里 ——
// 调用顺序虽然能避开 FK 错误,但不是真正的原子提交。要兑现 spec §6.1 同事务
// 保证,Part 3 需要为 Repository 增加 `WithTx(tx pgx.Tx) Repository` 构造,
// 并在 acp.SessionService.Create 中以同一事务执行 acp_sessions 插入与本次签发。
//
// 默认 ExpiresIn = 24h;默认 Tools 为内置 PM 工具白名单(spec §6.2)。
// 写一条 audit `mcp.token.create_system`。
func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) {
return s.issueSystemToken(ctx, s.repo, in)
}
// IssueSystemTokenWithTx 与 IssueSystemToken 相同,但所有 DB 写操作走传入事务。
// 调用方(如 acp.SessionService.Create)负责在同一个 tx 里先写入 acp_sessions row,
// 再调本方法签发 token,实现 spec §6.1 同事务原子提交。
func (s *tokenService) IssueSystemTokenWithTx(ctx context.Context, tx pgx.Tx, in IssueSystemTokenInput) (IssueResult, error) {
return s.issueSystemToken(ctx, s.repo.WithTx(tx), in)
}
// issueSystemToken 是共享实现。repo 参数决定 DB 写入走 pool 还是 tx。
func (s *tokenService) issueSystemToken(ctx context.Context, repo Repository, in IssueSystemTokenInput) (IssueResult, error) {
if in.ACPSessionID == uuid.Nil {
return IssueResult{}, errs.New(errs.CodeInvalidInput, "acp_session_id is required for system token")
}
@@ -168,7 +176,7 @@ func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemToken
}
id := uuid.New()
created, err := s.repo.Create(ctx, &Token{
created, err := repo.Create(ctx, &Token{
ID: id,
TokenHash: hash,
UserID: in.UserID,
+21
View File
@@ -385,3 +385,24 @@ func TestIssueSystemToken_NilUserID(t *testing.T) {
// 编译期保证:类型实现接口
var _ = errors.New
func TestIssueSystemTokenWithTx_DelegatesToRepoWithTx(t *testing.T) {
t.Parallel()
repo := newFakeRepo()
au := &fakeAudit{}
svc := mcp.NewTokenService(repo, au, nil)
uid := uuid.New()
sid := uuid.New()
pid := uuid.New()
res, err := svc.IssueSystemTokenWithTx(context.Background(), nil, mcp.IssueSystemTokenInput{
UserID: uid, ACPSessionID: sid, ProjectIDs: []uuid.UUID{pid},
})
require.NoError(t, err)
assert.NotEmpty(t, res.Plaintext)
assert.Equal(t, []string{"mcp.token.create_system"}, au.actions())
got, err := repo.GetByID(context.Background(), res.ID)
require.NoError(t, err)
assert.Equal(t, mcp.IssuerSystem, got.Issuer)
}