OpenSpec 规格驱动开发

OpenSpec Workflow

引言

在 HotelByte 项目的早期,我们遇到了一个典型问题:需求和实现经常脱节。产品经理提出需求,开发者理解需求,但最终交付的功能与预期不符。此外,代码审查经常变成”为什么这样做”的争论,而不是”如何做得更好”的讨论。

为了解决这个问题,我们引入了 OpenSpec —— 一个规格驱动开发的框架。本文将深入探讨 OpenSpec 的工作原理、在 HotelByte 项目中的应用,以及它如何帮助我们建立更规范的开发流程。

OpenSpec 概述

什么是 OpenSpec?

OpenSpec 是一个轻量级的规格驱动开发框架,核心思想是:

“先定义规格,再实施代码”

OpenSpec 强制在编写任何代码之前,必须:

  1. 创建变更提案(Proposal)
  2. 定义明确的规格(Spec)
  3. 列出实施清单(Tasks)
  4. 经过批准后才能开始实施

核心价值

价值维度 传统开发 OpenSpec 开发
需求理解 口头传达,容易误解 书面规格,明确清晰
开发质量 实施后才发现问题 规格阶段即发现
代码审查 讨论”为什么” 讨论如何做得更好
测试覆盖 事后补充 规格中定义
知识沉淀 分散在代码和文档 集中在规格文件
团队协作 个人英雄主义 集体决策

三阶段工作流

OpenSpec 定义了清晰的三阶段工作流:

Stage 1: Creating Changes (创建变更)
    ↓
Stage 2: Implementing Changes (实施变更)
    ↓
Stage 3: Archiving Changes (归档变更)

Stage 1: Creating Changes

触发条件

创建变更提案的场景:

  • ✅ 添加新功能或能力
  • ✅ 破坏性变更(API、数据结构)
  • ✅ 架构或模式变更
  • ✅ 性能优化(影响行为)
  • ✅ 安全模式更新

不需要提案的场景:

  • ❌ Bug 修复(恢复预期行为)
  • ❌ 拼写、格式、注释
  • ❌ 非破坏性依赖更新
  • ❌ 配置变更
  • ❌ 现有行为的测试

工作流程

graph TD
    A[收到需求] --> B{是否需要提案?}
    B -->|是| C[创建变更目录]
    B -->|否| D[直接实施]
    C --> E[编写 proposal.md]
    C --> F[编写 tasks.md]
    C --> G[编写 design.md 可选]
    C --> H[编写 spec deltas]
    E --> I[运行 openspec validate]
    F --> I
    G --> I
    H --> I
    I --> J{验证通过?}
    J -->|否| I
    J -->|是| K[提交审查]
    K --> L{批准?}
    L -->|否| M[修改提案]
    M --> I
    L -->|是| N[进入 Stage 2]

创建提案

步骤 1: 选择唯一的 change-id

命名规范:kebab-case,动词引导(add-, update-, remove-, refactor-

示例:

  • add-two-factor-auth
  • update-booking-flow
  • remove-legacy-cache
  • refactor-order-service

步骤 2: 创建目录结构

openspec/changes/add-two-factor-auth/
├── proposal.md         # 为什么,做什么,影响
├── tasks.md            # 实施清单
├── design.md           # 技术决策(可选)
└── specs/              # Delta 变更
    ├── auth/
    │   └── spec.md     # ADDED/MODIFIED/REMOVED
    └── notifications/
        └── spec.md     # ADDED/MODIFIED/REMOVED

步骤 3: 编写 proposal.md

# 提案:添加双因素认证

## Why(为什么)
当前系统仅使用用户名密码登录,存在安全隐患。需要增加第二层认证以提高安全性。

## What Changes(做什么)

- [ ] 添加双因素认证选项
- [ ] 支持 TOTP(基于时间的一次性密码)
- [ ] 支持短信验证码
- [ ] 更新登录流程
- [ ] **BREAKING**: 修改登录 API 响应格式

## Impact(影响)

### 影响的规格
- `specs/auth/spec.md` - 认证功能
- `specs/notifications/spec.md` - 通知功能

### 影响的代码
- `user/service/auth.go` - 认证服务
- `user/protocol/auth.go` - 认证协议
- `api/handlers/auth.go` - API 处理器
- `hotel-fe/src/auth/` - 前端认证页面

### 影响的数据库
- `user` 表 - 添加 `totp_secret`- `verification_codes` 表 - 新建

### 影响的用户
- 所有用户需要重新登录
- 管理员用户强制启用双因素认证

## 风险评估

- **高风险**: 破坏性变更,需要协调迁移
- **缓解措施**: 提供迁移脚本,逐步推出

## 估算

- 开发时间:2 天
- 测试时间:1 天
- 上线时间:1 天

步骤 4: 编写 tasks.md

# 实施任务清单

## 1. 数据库变更
- [ ] 1.1 添加 `totp_secret` 列到 `user`- [ ] 1.2 创建 `verification_codes`- [ ] 1.3 编写数据库迁移脚本
- [ ] 1.4 测试迁移脚本(开发环境)

## 2. 领域层(domain)
- [ ] 2.1 创建 `TOTP` 值对象
- [ ] 2.2 创建 `VerificationCode` 实体
- [ ] 2.3 更新 `User` 实体
- [ ] 2.4 实现双因素验证逻辑

## 3. 协议层(protocol)
- [ ] 3.1 添加 `EnableTOTPRequest` 协议
- [ ] 3.2 添加 `VerifyTOTPRequest` 协议
- [ ] 3.3 更新 `LoginRequest` 协议
- [ ] 3.4 更新 `LoginResponse` 协议

## 4. 数据访问层(mysql)
- [ ] 4.1 创建 `VerificationCodeDAO`
- [ ] 4.2 更新 `UserDAO`
- [ ] 4.3 实现数据库操作方法

## 5. 服务层(service)
- [ ] 5.1 实现 `AuthService.EnableTOTP`
- [ ] 5.2 实现 `AuthService.VerifyTOTP`
- [ ] 5.3 更新 `AuthService.Login`

## 6. API 层
- [ ] 6.1 添加 `POST /auth/totp/enable` 端点
- [ ] 6.2 添加 `POST /auth/totp/verify` 端点
- [ ] 6.3 更新 `POST /auth/login` 端点

## 7. 前端(hotel-fe)
- [ ] 7.1 创建双因素设置页面
- [ ] 7.2 更新登录页面
- [ ] 7.3 添加 QR 码生成
- [ ] 7.4 实现验证码输入

## 8. 测试
- [ ] 8.1 编写领域层单元测试
- [ ] 8.2 编写服务层单元测试
- [ ] 8.3 编写 API 层单元测试
- [ ] 8.4 编写 E2E 测试
- [ ] 8.5 运行测试覆盖率检查

## 9. 文档
- [ ] 9.1 更新 API 文档
- [ ] 9.2 编写用户指南
- [ ] 9.3 更新部署文档

## 10. 验收
- [ ] 10.1 所有测试通过
- [ ] 10.2 测试覆盖率 ≥ 50%
- [ ] 10.3 代码审查通过
- [ ] 10.4 UAT 测试通过

步骤 5: 编写 spec deltas

specs/auth/spec.md

## ADDED Requirements

### Requirement: TOTP Configuration
用户可以配置 TOTP(基于时间的一次性密码)作为双因素认证方法。

#### Scenario: Enable TOTP
- **GIVEN** 用户已登录
- **WHEN** 用户请求启用 TOTP
- **THEN** 系统生成 TOTP 密钥
- **AND** 返回 QR 码用于配置验证器应用
- **AND** 保存 TOTP 密钥到数据库

#### Scenario: Verify TOTP Setup
- **GIVEN** 用户已启用 TOTP
- **WHEN** 用户输入 6 位验证码
- **THEN** 系统验证验证码有效性
- **AND** 验证通过则激活 TOTP
- **AND** 验证失败则返回错误

## MODIFIED Requirements

### Requirement: User Login
用户登录时,如果已启用双因素认证,必须提供第二因素验证。

#### Scenario: Login with TOTP
- **GIVEN** 用户已启用 TOTP
- **WHEN** 用户提供用户名、密码和 TOTP 验证码
- **THEN** 系统验证用户名和密码
- **AND** 验证 TOTP 验证码
- **AND** 两者都通过则返回 JWT token
- **AND** 任一失败则返回错误

#### Scenario: Login without TOTP
- **GIVEN** 用户未启用 TOTP
- **WHEN** 用户提供用户名和密码
- **THEN** 系统验证用户名和密码
- **AND** 验证通过则返回 JWT token
- **AND** 验证失败则返回错误

## REMOVED Requirements

### Requirement: Simple Password Login
**Reason**: 已不再支持仅密码登录,所有用户必须使用双因素认证

**Migration**: 现有用户需要首次登录时设置 TOTP

步骤 6: 运行验证

openspec validate add-two-factor-auth --strict

步骤 7: 提交审查

创建 PR 到 openspec/changes/add-two-factor-auth/,等待批准。

Stage 2: Implementing Changes

实施流程

  1. 阅读提案
    • 理解变更的目标和范围
    • 识别影响范围
  2. 阅读设计(如有)
    • 理解技术决策
    • 了解架构变更
  3. 阅读任务清单
    • 获取实施步骤
    • 估算时间
  4. 顺序实施
    • 按照任务清单顺序完成
    • 每个任务完成后打勾
  5. 确认完成
    • 确保所有任务完成
    • 运行测试验证
  6. 更新清单
    • 将所有任务标记为完成
    • 确保清单反映实际状态

完成定义

关键原则:

需求完成 = 功能代码 + 单元测试 (UT) + E2E 测试,全部通过!

禁止的行为:

  • ❌ “代码已完成” —— 如果测试未通过就不算完成
  • ❌ “功能已实现” —— 没有测试的功能不算实现
  • ❌ “可以提交了” —— 未运行测试就不能提交

正确的做法:

  • ✅ “代码已生成,正在运行测试验证…”
  • ✅ “单元测试覆盖率 65%,E2E 测试通过,可以提交 PR”
  • ✅ “检测到测试覆盖率不足 50%,需要补充以下测试…”

实施示例

// 任务 4.1: 创建 VerificationCodeDAO

// domain/verification_code.go
package domain

import (
    "context"
    "time"
)

type VerificationCode struct {
    ID        int64
    Code      string
    UserID    int64
    Type      string // "totp", "sms"
    ExpiresAt time.Time
    Used      bool
    CreatedAt time.Time
}

// mysql/verification_code_dao.go
package mysql

import (
    "context"
    "hotel/user/domain"
)

type VerificationCodeDAO struct {
    db *sqlx.DB
}

func NewVerificationCodeDAO(db *sqlx.DB) *VerificationCodeDAO {
    return &VerificationCodeDAO{db: db}
}

func (d *VerificationCodeDAO) Create(ctx context.Context, code *domain.VerificationCode) error {
    query := `INSERT INTO verification_codes (code, user_id, type, expires_at, used, created_at)
              VALUES (?, ?, ?, ?, ?, ?)`

    result, err := d.db.ExecContext(ctx, query,
        code.Code, code.UserID, code.Type, code.ExpiresAt, code.Used, code.CreatedAt)

    if err != nil {
        return err
    }

    id, err := result.LastInsertId()
    if err != nil {
        return err
    }

    code.ID = id
    return nil
}

func (d *VerificationCodeDAO) GetValidCode(ctx context.Context, userID int64, code string, codeType string) (*domain.VerificationCode, error) {
    query := `SELECT id, code, user_id, type, expires_at, used, created_at
              FROM verification_codes
              WHERE user_id = ? AND code = ? AND type = ? AND used = false AND expires_at > ?`

    var vc domain.VerificationCode
    err := d.db.GetContext(ctx, &vc, query, userID, code, codeType, time.Now())

    if err != nil {
        return nil, err
    }

    return &vc, nil
}

func (d *VerificationCodeDAO) MarkUsed(ctx context.Context, id int64) error {
    query := `UPDATE verification_codes SET used = true WHERE id = ?`
    _, err := d.db.ExecContext(ctx, query, id)
    return err
}
// 测试:mysql/verification_code_dao_test.go
package mysql_test

import (
    "context"
    "testing"
    "time"

    "github.com/Danceiny/mockey"
    "github.com/smartystreets/goconvey/convey"

    "hotel/user/domain"
    "hotel/user/mysql"
)

func TestVerificationCodeDAO_Create(t *testing.T) {
    mockey.PatchConvey("Create 验证码", t, func() {
        ctx := context.Background()
        dao := mysql.NewVerificationCodeDAO(testDB)

        // 测试数据
        code := &domain.VerificationCode{
            Code:      "123456",
            UserID:    1001,
            Type:      "totp",
            ExpiresAt: time.Now().Add(5 * time.Minute),
            Used:      false,
            CreatedAt: time.Now(),
        }

        // 执行
        err := dao.Create(ctx, code)

        // 断言
        convey.So(err, convey.ShouldBeNil)
        convey.So(code.ID, convey.ShouldBeGreaterThan, int64(0))
    })
}

func TestVerificationCodeDAO_GetValidCode(t *testing.T) {
    mockey.PatchConvey("获取有效验证码", t, func() {
        ctx := context.Background()
        dao := mysql.NewVerificationCodeDAO(testDB)

        // 准备数据
        code := &domain.VerificationCode{
            Code:      "123456",
            UserID:    1001,
            Type:      "totp",
            ExpiresAt: time.Now().Add(5 * time.Minute),
            Used:      false,
            CreatedAt: time.Now(),
        }
        err := dao.Create(ctx, code)
        convey.So(err, convey.ShouldBeNil)

        // 执行
        result, err := dao.GetValidCode(ctx, 1001, "123456", "totp")

        // 断言
        convey.So(err, convey.ShouldBeNil)
        convey.So(result, convey.ShouldNotBeNil)
        convey.So(result.ID, convey.ShouldEqual, code.ID)
        convey.So(result.Used, convey.ShouldBeFalse)
    })
}

func TestVerificationCodeDAO_GetValidCode_Expired(t *testing.T) {
    mockey.PatchConvey("获取过期验证码", t, func() {
        ctx := context.Background()
        dao := mysql.NewVerificationCodeDAO(testDB)

        // 准备过期数据
        code := &domain.VerificationCode{
            Code:      "123456",
            UserID:    1001,
            Type:      "totp",
            ExpiresAt: time.Now().Add(-5 * time.Minute), // 已过期
            Used:      false,
            CreatedAt: time.Now(),
        }
        err := dao.Create(ctx, code)
        convey.So(err, convey.ShouldBeNil)

        // 执行
        result, err := dao.GetValidCode(ctx, 1001, "123456", "totp")

        // 断言
        convey.So(err, convey.ShouldBeNil)
        convey.So(result, convey.ShouldBeNil) // 过期验证码不应该返回
    })
}

Stage 3: Archiving Changes

部署完成后,需要归档变更。

归档流程

# 创建新的 PR 来归档变更
# 1. 移动 changes/[name]/ → changes/archive/YYYY-MM-DD-[name]/
# 2. 更新 specs/(如功能模块变更)
# 3. 运行验证

openspec archive add-two-factor-auth --yes

归档后目录结构

openspec/
├── changes/
│   └── archive/
│       └── 2026-02-15-add-two-factor-auth/
│           ├── proposal.md
│           ├── tasks.md
│           ├── design.md
│           └── specs/
│               ├── auth/
│               │   └── spec.md
│               └── notifications/
│                   └── spec.md
└── specs/
    ├── auth/
    │   └── spec.md          # 已更新
    └── notifications/
        └── spec.md          # 已更新

DDD 对齐

DDD 目录结构

OpenSpec 与 DDD 架构完美对齐:

hotel/
├── domain/           # 领域层(核心业务逻辑)
│   ├── order/
│   │   ├── order.go
│   │   └── order_test.go
├── protocol/         # 协议层(API 定义)
│   └── order.go
├── mysql/            # 数据访问层
│   └── order_dao.go
└── service/          # 服务层
    └── order_service.go

开发顺序

OpenSpec 强制按照 DDD 顺序开发:

1. domain/      ← 先写领域逻辑
   ↓
2. protocol/   ← 再定义协议
   ↓
3. mysql/      ← 然后写 DAO
   ↓
4. service/    ← 最后写服务
   ↓
5. 注册到 httpdispatcher

示例:订单功能

domain/order/order.go

package order

import (
    "context"
    "errors"
    "time"

    "hotel/common/log"
    "hotel/common/idgen"
    "hotel/user/domain"
)

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusCancelled OrderStatus = "cancelled"
    OrderStatusCompleted OrderStatus = "completed"
)

type Order struct {
    ID          int64
    OrderID     string
    CustomerID  int64
    SupplierID  int64
    HotelID     int64
    Status      OrderStatus
    CheckIn     time.Time
    CheckOut    time.Time
    Rooms       int
    TotalPrice  int64 // fils
    Currency    string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

type OrderItem struct {
    ID       int64
    OrderID  int64
    RoomType string
    Quantity int
    Price    int64
}

type Service struct {
    dao    OrderDAO
    wallet domain.WalletService
}

func NewService(dao OrderDAO, wallet domain.WalletService) *Service {
    return &Service{
        dao:    dao,
        wallet: wallet,
    }
}

func (s *Service) CreateOrder(ctx context.Context, customerID, supplierID, hotelID int64, checkIn, checkOut time.Time, rooms int) (*Order, error) {
    // 1. 验证参数
    if customerID == 0 || supplierID == 0 || hotelID == 0 {
        return nil, errors.New("invalid parameters")
    }

    if checkIn.After(checkOut) {
        return nil, errors.New("check-in date must be before check-out date")
    }

    if rooms <= 0 {
        return nil, errors.New("rooms must be greater than 0")
    }

    // 2. 生成订单 ID
    orderID := idgen.GenID()

    // 3. 创建订单
    order := &Order{
        OrderID:    orderID,
        CustomerID: customerID,
        SupplierID: supplierID,
        HotelID:    hotelID,
        Status:     OrderStatusPending,
        CheckIn:    checkIn,
        CheckOut:   checkOut,
        Rooms:      rooms,
        CreatedAt:  time.Now(),
        UpdatedAt:  time.Now(),
    }

    // 4. 保存订单
    if err := s.dao.Create(ctx, order); err != nil {
        log.Error("create order failed", log.Field("error", err))
        return nil, err
    }

    log.Info("order created", log.Field("order_id", orderID))

    return order, nil
}

func (s *Service) ConfirmOrder(ctx context.Context, orderID string) error {
    // 1. 获取订单
    order, err := s.dao.GetByOrderID(ctx, orderID)
    if err != nil {
        return err
    }

    // 2. 验证状态
    if order.Status != OrderStatusPending {
        return errors.New("order is not pending")
    }

    // 3. 扣减钱包余额
    if err := s.wallet.Deduct(ctx, order.CustomerID, order.SupplierID, order.TotalPrice, order.Currency); err != nil {
        return err
    }

    // 4. 更新订单状态
    order.Status = OrderStatusConfirmed
    order.UpdatedAt = time.Now()

    if err := s.dao.Update(ctx, order); err != nil {
        log.Error("update order status failed", log.Field("error", err))
        return err
    }

    log.Info("order confirmed", log.Field("order_id", orderID))

    return nil
}

protocol/order.go

package protocol

type CreateOrderRequest struct {
    CustomerID int64     `json:"customer_id"`
    SupplierID int64     `json:"supplier_id"`
    HotelID    int64     `json:"hotel_id"`
    CheckIn    time.Time `json:"check_in"`
    CheckOut   time.Time `json:"check_out"`
    Rooms      int       `json:"rooms"`
}

type CreateOrderResponse struct {
    OrderID   string `json:"order_id"`
    Status    string `json:"status"`
    TotalPrice int64  `json:"total_price"`
    Currency  string `json:"currency"`
}

type ConfirmOrderRequest struct {
    OrderID string `json:"order_id"`
}

type ConfirmOrderResponse struct {
    Status string `json:"status"`
}

mysql/order_dao.go

package mysql

import (
    "context"
    "hotel/order/domain"
)

type OrderDAO interface {
    Create(ctx context.Context, order *domain.Order) error
    GetByOrderID(ctx context.Context, orderID string) (*domain.Order, error)
    Update(ctx context.Context, order *domain.Order) error
}

type orderDAO struct {
    db *sqlx.DB
}

func NewOrderDAO(db *sqlx.DB) OrderDAO {
    return &orderDAO{db: db}
}

func (d *orderDAO) Create(ctx context.Context, order *domain.Order) error {
    query := `INSERT INTO orders (order_id, customer_id, supplier_id, hotel_id, status, check_in, check_out, rooms, total_price, currency, created_at, updated_at)
              VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`

    result, err := d.db.ExecContext(ctx, query,
        order.OrderID, order.CustomerID, order.SupplierID, order.HotelID,
        order.Status, order.CheckIn, order.CheckOut, order.Rooms,
        order.TotalPrice, order.Currency, order.CreatedAt, order.UpdatedAt)

    if err != nil {
        return err
    }

    id, err := result.LastInsertId()
    if err != nil {
        return err
    }

    order.ID = id
    return nil
}

func (d *orderDAO) GetByOrderID(ctx context.Context, orderID string) (*domain.Order, error) {
    query := `SELECT id, order_id, customer_id, supplier_id, hotel_id, status, check_in, check_out, rooms, total_price, currency, created_at, updated_at
              FROM orders WHERE order_id = ?`

    var order domain.Order
    err := d.db.GetContext(ctx, &order, query, orderID)

    if err != nil {
        return nil, err
    }

    return &order, nil
}

func (d *orderDAO) Update(ctx context.Context, order *domain.Order) error {
    query := `UPDATE orders SET status = ?, updated_at = ? WHERE id = ?`
    _, err := d.db.ExecContext(ctx, query, order.Status, order.UpdatedAt, order.ID)
    return err
}

测试标准

覆盖率要求

层级 覆盖率目标 说明
domain/ 100% 所有领域逻辑必须有测试
mysql/ (DAO) 80%+ 所有 CRUD 必须有测试
service/ 70%+ 核心业务逻辑必须有测试
convert/ 90%+ 所有转换函数必须有测试
protocol/ 不要求 仅数据结构,无需测试

PR 强制检查: 增量代码测试覆盖率必须 ≥ 50%

单元测试框架

框架: github.com/bytedance/mockey + github.com/smartystreets/goconvey

测试策略:

  • 自底向上编写测试(domain → DAO → service)
  • 跨层调用使用 mock,单层内部调用使用真实实现
  • 每个测试必须有明确断言,不只是打印日志

测试模板:

func TestService_Method_Success(t *testing.T) {
    mockey.PatchConvey("描述", t, func() {
        // 1. Setup - 准备测试数据
        ctx := context.Background()

        // 2. Mock - Mock 外部依赖
        mockey.Mock((*DAO).Method).Return(expected, nil).Build()

        // 3. Execute - 执行被测试函数
        result, err := service.Method(ctx, input)

        // 4. Assert - 验证结果
        convey.So(err, convey.ShouldBeNil)
        convey.So(result, convey.ShouldEqual, expected)
    })
}

E2E 测试

测试位置: api/tests/

测试工具: 使用 sdk/go 调用真实 API

测试场景: 必须覆盖核心业务流程

// api/tests/order_e2e_test.go
package tests

import (
    "context"
    "testing"
    "time"

    "github.com/smartystreets/goconvey/convey"

    "sdk/go/client"
    "sdk/go/model"
)

func TestOrderE2E_CreateAndConfirm(t *testing.T) {
    convey.Convey("订单 E2E 测试:创建和确认", t, func() {
        ctx := context.Background()
        apiClient := client.NewClient("http://localhost:8080")

        // 1. 创建订单
        createReq := &model.CreateOrderRequest{
            CustomerID: 1001,
            SupplierID: 6,
            HotelID:    20001,
            CheckIn:    time.Now(),
            CheckOut:   time.Now().AddDate(0, 0, 3),
            Rooms:      2,
        }

        createResp, err := apiClient.CreateOrder(ctx, createReq)
        convey.So(err, convey.ShouldBeNil)
        convey.So(createResp, convey.ShouldNotBeNil)
        convey.So(createResp.OrderID, convey.ShouldNotBeEmpty)

        // 2. 确认订单
        confirmReq := &model.ConfirmOrderRequest{
            OrderID: createResp.OrderID,
        }

        confirmResp, err := apiClient.ConfirmOrder(ctx, confirmReq)
        convey.So(err, convey.ShouldBeNil)
        convey.So(confirmResp, convey.ShouldNotBeNil)
        convey.So(confirmResp.Status, convey.ShouldEqual, "confirmed")
    })
}

测试命令

# 单元测试
make test

# 覆盖率检查
make test-coverage

# E2E 测试
make test-e2e

# 特定包测试
go test -v -cover ./order/service/...

代码即文档:httpdispatcher + make doc

前瞻性布局的核心创新

在 OpenSpec 工作流中,我们发现一个重要的瓶颈:文档编写和维护成本高,且容易与代码脱节

为了解决这个问题,我们构建了一个独特的”代码即文档”系统,通过 httpdispatcher + make doc 实现了代码、文档、路由的全自动同步。

核心理念

“代码即文档”不是简单的”代码包含注释”,而是通过元数据驱动,实现代码、文档、测试、路由的全自动同步。

工作流程

┌─────────────────────────────────────────────────────────────┐
│                     开发者写代码                              │
│  ┌──────────────┬──────────────┬──────────────┐          │
│  │  业务逻辑     │  元数据注释    │  类型定义     │          │
│  └──────────────┴──────────────┴──────────────┘          │
└─────────────────────────────────────────────────────────────┘
                           ↓
┌─────────────────────────────────────────────────────────────┐
│              AST 解析(build/api/asthelper/)                │
│  ┌──────────────┬──────────────┬──────────────┐          │
│  │  comment.go  │ method_meta  │ visibility_  │          │
│  │  注释提取    │  元数据提取   │  control.go  │          │
│  └──────────────┴──────────────┴──────────────┘          │
└─────────────────────────────────────────────────────────────┘
                           ↓
        ┌──────────────────┴──────────────────┐
        ↓                                      ↓
┌──────────────────────┐        ┌──────────────────────┐
│   httpdispatcher     │        │    make doc         │
│  路由绑定 + 治理      │        │   文档生成           │
└──────────────────────┘        └──────────────────────┘
        ↓                                      ↓
┌──────────────────────┐        ┌──────────────────────┐
│  运行时路由表         │        │  OpenAPI 文档        │
│  参数解析             │        │  公开/内部版本       │
│  限流配置             │        │  多语言支持          │
│  缓存配置             │        │  SDK 生成            │
└──────────────────────┘        └──────────────────────┘

元数据注释系统

1. 类型可见性控制

通过 //apidoc: 标签控制类型和枚举值的可见性:

//apidoc:public,zh:OrderStatus_Submitted,OrderStatus_Confirmed
type OrderStatus int

const (
    OrderStatus_Submitted OrderStatus = iota // 已提交
    OrderStatus_Confirming                  // 确认中
    OrderStatus_Confirmed                   // 已确认
    OrderStatus_Cancelled                   // 已取消
    OrderStatus_Refunded                    // 已退款
    OrderStatus_Failed                      // 失败
)

效果:

  • 公开文档(Public API):只显示 SubmittedConfirmed
  • 内部文档(Internal):显示所有状态
  • 多语言支持:自动生成中英文文档,zh 标签指示支持中文

2. 方法元数据

httpdispatcher 从方法注释中提取元数据,自动配置路由和治理策略:

// @jwt
// @permission:order:read
// @tags:order,booking
// @cache:ttl=600,userLevel=true
// @param:orderID string "订单ID"
// @param:includeDetails bool "是否包含详情"
func (s *OrderService) GetOrder(ctx context.Context, req *GetOrderRequest) (*GetOrderResponse, error) {
    // 业务逻辑
}

httpdispatcher 自动使用:

  • ✅ JWT 认证(@jwt
  • ✅ 权限检查(@permission:order:read
  • ✅ API 标签(@tags:order,booking
  • ✅ 缓存配置(TTL=600s,用户级缓存)
  • ✅ 参数解析和验证(@param:
  • ✅ 自动生成 OpenAPI 文档

3. 字段可见性

通过 apidoc:"scope" 标签控制字段的可见性:

type OrderResponse struct {
    OrderID      string `json:"orderId" apidoc:"public"`
    Status       string `json:"status" apidoc:"public"`
    TotalPrice   int64  `json:"totalPrice" apidoc:"public"`
    InternalCode string `json:"internalCode" apidoc:"internal"`
    DebugInfo    string `json:"debugInfo" apidoc:"hidden"`
}

效果:

  • 公开文档:只显示 OrderIDStatusTotalPrice
  • 内部文档:额外显示 InternalCode
  • 完全隐藏DebugInfo 不在任何文档中

文档生成流程

命令使用

# 生成所有文档
make doc

# 只生成公开文档
make doc-public

# 只生成内部文档
make doc-internal

# 上传到文档服务器
make doc-upload

实际输出

公开 API 文档docs/public/openapi.yaml):

openapi: 3.0.0
info:
  title: HotelByte Public API
  version: 1.0.0
paths:
  /order/{orderId}:
    get:
      summary: 获取订单信息
      tags:
        - order
        - booking
      security:
        - jwt: []
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  orderId:
                    type: string
                    description: 订单ID
                  status:
                    type: string
                    description: 订单状态
                    enum:
                      - submitted
                      - confirmed

内部 API 文档docs/internal/openapi.yaml):

openapi: 3.0.0
info:
  title: HotelByte Internal API
  version: 1.0.0
paths:
  /order/{orderId}:
    get:
      summary: 获取订单信息(内部)
      tags:
        - order
        - booking
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - submitted
                      - confirming
                      - confirmed
                      - cancelled
                      - refunded
                      - failed
                  internalCode:
                    type: string
                    description: 内部错误码

与 OpenSpec 的协同

OpenSpec 定义功能规格,”代码即文档”确保实现与规格一致:

OpenSpec Spec (做什么)
         ↓
    实施代码 (怎么做)
         ↓
  元数据注释 (httpdispatcher 兼容)
         ↓
  make doc (自动生成文档)
         ↓
  公开/内部文档 (对外/对内)

减少样板代码

之前(手动编写文档):

// 业务代码
func (s *OrderService) GetOrder(...) {...}

// 文档代码(手动维护,容易过时)
var orderDocs = []api.Doc{
    {
        Path: "/order/{orderId}",
        Method: "GET",
        Summary: "获取订单信息",
        Parameters: []api.Param{
            {Name: "orderId", Type: "string", Required: true},
            {Name: "includeDetails", Type: "boolean"},
        },
        Responses: []api.Response{
            {Code: 200, Description: "成功"},
        },
    },
}

现在(自动生成):

// 只需添加元数据注释
// @jwt
// @permission:order:read
// @tags:order
// @param:orderID string "订单ID"
func (s *OrderService) GetOrder(ctx context.Context, req *GetOrderRequest) (*GetOrderResponse, error) {
    // 业务逻辑
}

// 文档自动生成,零维护成本,始终与代码同步

减少代码量: 80%

一致性保证

场景 手动维护 自动生成
添加参数 修改代码 + 修改文档(经常忘记) 只修改代码,文档自动更新
删除字段 修改代码 + 修改文档 只修改代码,文档自动更新
修改类型 修改代码 + 修改文档 只修改代码,文档自动更新
修改验证规则 修改代码 + 修改文档 + 修改路由配置 只修改代码,其他自动更新

一致性保证: 100%

实际效果数据

指标 手动维护 代码即文档 提升
文档维护时间 4-6 小时/周 0 小时/周 100%
文档准确性 60-70% 100% +40%
API 变更同步延迟 2-3 天 实时 即时
多语言文档维护 8-10 小时/周 0 小时/周 100%
SDK 生成时间 手动 2-3 天 自动 5 分钟 99%

关键文件

文档生成器(build/api/)

  • docgen.go - 文档生成主程序
  • yaml_generator.go - OpenAPI YAML 生成
  • doc_builder.go - 文档构建器
  • dependency_graph.go - 依赖图生成

AST 解析(build/api/asthelper/)

  • comment.go - 注释提取和标签解析
  • method_meta.go - 方法元数据提取
  • visibility_control.go - 可见性控制逻辑
  • parser.go - AST 解析器

路由框架(common/httpdispatcher/)

  • service_dispatcher.go - 服务路由分发
  • param_mapper.go - 参数映射
  • cache_config.go - 缓存配置
  • authz.go - 权限控制

实际案例

案例:订单状态回调功能

背景

供应商需要将订单状态变更回调到我们的系统,以便及时更新订单状态。

OpenSpec 提案

change-id: add-order-status-callback

proposal.md:

# 提案:添加订单状态回调功能

## Why
当前系统需要轮询供应商获取订单状态,效率低下。需要提供回调接口,让供应商主动推送状态变更。

## What Changes

- [ ] 添加订单状态回调端点
- [ ] 实现回调签名验证
- [ ] 添加回调重试机制
- [ ] 记录回调历史

## Impact

### 影响的规格
- `specs/order/spec.md` - 订单功能
- `specs/callback/spec.md` - 回调功能(新建)

### 影响的代码
- `api/handlers/callback.go` - 回调处理器(新建)
- `order/service/callback.go` - 回调服务(新建)
- `common/domain/callback_log.go` - 回调日志(新建)

### 影响的数据库
- `callback_logs` 表 - 新建

tasks.md:

# 实施任务清单

## 1. 领域层
- [ ] 1.1 创建 CallbackLog 实体
- [ ] 1.2 创建 CallbackService 接口
- [ ] 1.3 实现回调验证逻辑

## 2. 协议层
- [ ] 2.1 创建 CallbackRequest 协议
- [ ] 2.2 创建 CallbackResponse 协议

## 3. 数据访问层
- [ ] 3.1 创建 CallbackLogDAO

## 4. 服务层
- [ ] 4.1 实现 CallbackService
- [ ] 4.2 实现重试机制
- [ ] 4.3 集成到 OrderService

## 5. API 层
- [ ] 5.1 添加回调端点
- [ ] 5.2 实现签名验证中间件

## 6. 测试
- [ ] 6.1 编写单元测试
- [ ] 6.2 编写 E2E 测试
- [ ] 6.3 验证测试覆盖率

## 7. 部署
- [ ] 7.1 配置 Nginx
- [ ] 7.2 配置供应商回调 URL

specs/callback/spec.md:

## ADDED Requirements

### Requirement: Order Status Callback
供应商可以通过回调接口推送订单状态变更。

#### Scenario: Receive Callback
- **GIVEN** 供应商配置了回调 URL
- **WHEN** 供应商发送订单状态变更
- **THEN** 系统验证回调签名
- **AND** 更新订单状态
- **AND** 记录回调日志

#### Scenario: Retry Failed Callback
- **GIVEN** 回调失败
- **WHEN** 重试次数 < 最大重试次数
- **THEN** 系统自动重试
- **AND** 指数退避

实施结果

耗时: 3 天(规划 + 实施 + 测试)

测试覆盖率: 72%(超过 50% 的要求)

代码质量: 所有检查通过,无警告

部署: 成功上线,无回滚

效果:

  • 订单状态更新延迟从 10 分钟降低到 5 秒
  • 轮询请求减少 90%
  • 系统负载降低 40%

最佳实践

1. 提案编写

  • ✅ 清晰描述”为什么”
  • ✅ 明确列出”做什么”
  • ✅ 识别所有影响
  • ✅ 评估风险

2. 规格定义

  • ✅ 使用 ADDED/MODIFIED/REMOVED
  • ✅ 每个需求至少一个 Scenario
  • ✅ Scenario 使用 GWT 格式(Given-When-Then)
  • ✅ 明确成功和失败场景

3. 任务管理

  • ✅ 任务可量化
  • ✅ 顺序合理
  • ✅ 包含测试
  • ✅ 更新状态

4. 实施质量

  • ✅ 遵循 DDD 顺序
  • ✅ 编写完整测试
  • ✅ 确保覆盖率达标
  • ✅ 运行所有检查

5. 归档流程

  • ✅ 部署后立即归档
  • ✅ 更新规格文件
  • ✅ 运行验证
  • ✅ 清理临时文件

系列导航

  1. 从 DeepSeek 复制粘贴到 Claude Code
  2. Claude Code 深度集成
  3. 多模型与工具链集成
  4. OpenSpec 规格驱动开发 ✅ (本文)
  5. AI Coding 最佳实践

相关资源: