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" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/infra/errs"
@@ -20,6 +21,7 @@ import (
type TokenService interface { type TokenService interface {
IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error) IssueAdmin(ctx context.Context, in IssueAdminInput) (IssueResult, error)
IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (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) Authenticate(ctx context.Context, plaintext string) (*Token, error)
Revoke(ctx context.Context, id, byUserID uuid.UUID) error Revoke(ctx context.Context, id, byUserID uuid.UUID) error
RevokeBySession(ctx context.Context, sessionID 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 不直接管事务边界(由 // FK 校验失败。spec §6.1 明确建议同事务提交。本 service 不直接管事务边界(由
// ACP SessionService 在 Create 流程内编排),但调用顺序由调用方保证。 // 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)。 // 默认 ExpiresIn = 24h;默认 Tools 为内置 PM 工具白名单(spec §6.2)。
// 写一条 audit `mcp.token.create_system`。 // 写一条 audit `mcp.token.create_system`。
func (s *tokenService) IssueSystemToken(ctx context.Context, in IssueSystemTokenInput) (IssueResult, error) { 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 { if in.ACPSessionID == uuid.Nil {
return IssueResult{}, errs.New(errs.CodeInvalidInput, "acp_session_id is required for system token") 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() id := uuid.New()
created, err := s.repo.Create(ctx, &Token{ created, err := repo.Create(ctx, &Token{
ID: id, ID: id,
TokenHash: hash, TokenHash: hash,
UserID: in.UserID, UserID: in.UserID,
+21
View File
@@ -385,3 +385,24 @@ func TestIssueSystemToken_NilUserID(t *testing.T) {
// 编译期保证:类型实现接口 // 编译期保证:类型实现接口
var _ = errors.New 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)
}