17 Commits 718a28f50b ... 9de188f2b3

Author SHA1 Message Date
  b 9de188f2b3 docs: add table of contents with highlighted test script entry 1 month ago
  b 2e0808799f docs: add one-click test script usage to README 1 month ago
  b cbf4a33503 docs: remove parent-level path prefix from README 1 month ago
  b 7ed78f6d7b docs: restore original requirements in README 1 month ago
  b bf7d6579d1 chore: add one-click test script with interactive storage selection 1 month ago
  b 07fc55c700 docs: update README with startup instructions 1 month ago
  b 42d599cb1c feat: add SQLite repository with CAS update, wire storage selection in main.go 1 month ago
  b 9d50381da7 feat: wire up main.go with Gin router 1 month ago
  b 31c4b86c50 feat: add Gin handlers with integration tests 1 month ago
  b f79fb48740 fix: add GetOrder, fix ErrConflict code to match spec 1 month ago
  b 6263b3a666 feat: add OrderService with full state machine tests 1 month ago
  b 626ea45cc5 feat: add OrderRepository interface, MemoryRepo with CAS, and concurrent tests 1 month ago
  b 6b4397d2e2 fix: gofmt, ErrConflict error code, ErrInvalidState as func 1 month ago
  b 6a6e5442cd fix: remove unused CancelOrderRequest struct 1 month ago
  b c9c9456595 feat: add Order model, status constants, and AppError 1 month ago
  b 0e862ab842 chore: cleanup dependencies with go mod tidy 1 month ago
  b 0887475909 chore: init Go module with Gin 1 month ago
15 changed files with 1471 additions and 2 deletions
  1. 113 2
      README.md
  2. 41 0
      go.mod
  3. 93 0
      go.sum
  4. 90 0
      handler/order.go
  5. 193 0
      handler/order_test.go
  6. 45 0
      main.go
  7. 57 0
      model/order.go
  8. 57 0
      repository/memory.go
  9. 151 0
      repository/memory_test.go
  10. 9 0
      repository/order.go
  11. 115 0
      repository/sqlite.go
  12. 114 0
      repository/sqlite_test.go
  13. 87 0
      service/order.go
  14. 168 0
      service/order_test.go
  15. 138 0
      test.sh

+ 113 - 2
README.md

@@ -1,5 +1,116 @@
-# 测试用例
+# 订单服务
+
+简化的订单 HTTP 服务,支持创建订单、服务者接单、取消订单三个操作。
+
+> **目录**
+> - [需求](#需求)
+> - [快速启动](#快速启动)
+> - [API](#api)
+> - [测试](#测试)
+> - ⭐ [一键端到端测试](#一键端到端测试)
+> - [业务规则](#业务规则)
+> - [项目结构](#项目结构)
+> - [技术栈](#技术栈)
 
 ## 需求
 
-Go 后端小测试,建议投入 1.5~2 小时,并在收到后 48 小时内提交。请实现一个可在本地运行的简化订单 HTTP 服务,数据保存在内存即可,无需数据库、登录、Docker、Swagger、支付、退款或线上部署。服务只需支持三个操作:创建订单(包含服务时间、时长和地址,初始状态为 pending)、服务者接单(提交 provider_id,仅 pending 订单可以接单)和取消订单(pending 或 accepted 状态可以取消,取消后变为 canceled)。重点验证两种情况:同一订单被两名服务者先后接单时,只允许第一次成功,第二次必须失败且不能覆盖原接单人;订单取消后再次接单或取消时,必须拒绝并返回容易理解的业务错误。有余力可任选自动化测试、并发接单处理或 SQLite 存储之一,但不做也不影响结果。提交方式任选其一:发送源码或压缩包并附启动说明;或录制 3~5 分钟视频,演示创建订单、首次接单成功、第二次接单失败及取消后的非法操作被拒绝。可以使用 AI,但请同时说明主动简化或未完成的内容、AI 主要用在哪里及修正了什么,以及如果再有两小时最想优先改进什么。请勿提交以前公司的私有代码。
+Go 后端小测试,建议投入 1.5~2 小时,并在收到后 48 小时内提交。请实现一个可在本地运行的简化订单 HTTP 服务,数据保存在内存即可,无需数据库、登录、Docker、Swagger、支付、退款或线上部署。服务只需支持三个操作:创建订单(包含服务时间、时长和地址,初始状态为 pending)、服务者接单(提交 provider_id,仅 pending 订单可以接单)和取消订单(pending 或 accepted 状态可以取消,取消后变为 canceled)。重点验证两种情况:同一订单被两名服务者先后接单时,只允许第一次成功,第二次必须失败且不能覆盖原接单人;订单取消后再次接单或取消时,必须拒绝并返回容易理解的业务错误。有余力可任选自动化测试、并发接单处理或 SQLite 存储之一,但不做也不影响结果。
+
+## 快速启动
+
+```bash
+# 启动服务(内存存储 — 默认)
+go run main.go
+
+# 启动服务(SQLite 存储)
+STORAGE=sqlite go run main.go
+```
+
+服务启动在 `http://localhost:8080`
+
+## API
+
+### 创建订单
+```bash
+curl -X POST http://localhost:8080/orders \
+  -H "Content-Type: application/json" \
+  -d '{"service_time":"2025-08-01T14:00:00Z","duration":120,"address":"北京朝阳区xxx路xxx号"}'
+```
+
+### 服务者接单
+```bash
+curl -X PUT http://localhost:8080/orders/{id}/accept \
+  -H "Content-Type: application/json" \
+  -d '{"provider_id":"prov_001"}'
+```
+
+### 取消订单
+```bash
+curl -X PUT http://localhost:8080/orders/{id}/cancel \
+  -H "Content-Type: application/json" \
+  -d '{}'
+```
+
+## 测试
+
+```bash
+go test ./... -v -race
+```
+
+### ⭐ 一键端到端测试
+
+```bash
+bash test.sh
+```
+
+运行后选择存储后端(memory/sqlite),自动启动服务并验证全部业务场景。
+
+```bash
+# 测试效果示例
+$ bash test.sh
+选择存储后端:
+  1) memory(默认)
+  2) sqlite
+请选择 [1/2]: 1
+========================================
+  订 单 服 务 — 一 键 测 试
+  存储: memory
+========================================
+
+1. 创建订单...
+  ✅ PASS: 创建订单
+2. 接单(首次)...
+  ✅ PASS: 首次接单成功
+...
+========================================
+  🎉 全部 7 个测试通过!
+========================================
+```
+
+## 业务规则
+
+- 仅 `pending` 订单可接单
+- 同一订单只能被一名服务者接单(并发安全,CAS 乐观锁保证)
+- `pending` 或 `accepted` 订单可取消
+- `canceled` 订单不可再操作
+
+## 项目结构
+
+```
+├── main.go              # 入口,依赖注入,存储选择
+├── model/order.go       # 数据模型、状态常量、错误类型
+├── repository/
+│   ├── order.go         # OrderRepository 接口
+│   ├── memory.go        # 内存实现(sync.RWMutex + CAS)
+│   └── sqlite.go        # SQLite 实现(行锁 + WHERE CAS)
+├── service/order.go     # 业务逻辑(状态机)
+├── handler/order.go     # HTTP handler(Gin)
+└── */*_test.go          # 单元测试 + 集成测试
+```
+
+## 技术栈
+
+- **语言**: Go
+- **框架**: Gin
+- **存储**: 内存 / SQLite(mattn/go-sqlite3)
+- **测试**: testing + httptest

+ 41 - 0
go.mod

@@ -0,0 +1,41 @@
+module job-cheng-xing
+
+go 1.25.0
+
+require (
+	github.com/gin-gonic/gin v1.12.0
+	github.com/google/uuid v1.6.0
+)
+
+require (
+	github.com/bytedance/gopkg v0.1.3 // indirect
+	github.com/bytedance/sonic v1.15.0 // indirect
+	github.com/bytedance/sonic/loader v0.5.0 // indirect
+	github.com/cloudwego/base64x v0.1.6 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+	github.com/gin-contrib/sse v1.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.30.1 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
+	github.com/goccy/go-yaml v1.19.2 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-sqlite3 v1.14.49 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/quic-go/qpack v0.6.0 // indirect
+	github.com/quic-go/quic-go v0.59.0 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.3.1 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+	golang.org/x/arch v0.22.0 // indirect
+	golang.org/x/crypto v0.48.0 // indirect
+	golang.org/x/net v0.51.0 // indirect
+	golang.org/x/sys v0.41.0 // indirect
+	golang.org/x/text v0.34.0 // indirect
+	google.golang.org/protobuf v1.36.10 // indirect
+)

+ 93 - 0
go.sum

@@ -0,0 +1,93 @@
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
+github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 90 - 0
handler/order.go

@@ -0,0 +1,90 @@
+package handler
+
+import (
+	"net/http"
+
+	"job-cheng-xing/model"
+	"job-cheng-xing/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+type OrderHandler struct {
+	svc *service.OrderService
+}
+
+func NewOrderHandler(svc *service.OrderService) *OrderHandler {
+	return &OrderHandler{svc: svc}
+}
+
+func (h *OrderHandler) CreateOrder(c *gin.Context) {
+	var req model.CreateOrderRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"error":   "validation",
+			"message": "请求参数无效:" + err.Error(),
+		})
+		return
+	}
+
+	order, err := h.svc.CreateOrder(req)
+	if err != nil {
+		handleError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusCreated, order)
+}
+
+func (h *OrderHandler) AcceptOrder(c *gin.Context) {
+	id := c.Param("id")
+
+	var req model.AcceptOrderRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{
+			"error":   "validation",
+			"message": "请求参数无效:" + err.Error(),
+		})
+		return
+	}
+
+	order, err := h.svc.AcceptOrder(id, req.ProviderID)
+	if err != nil {
+		handleError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "接单成功",
+		"order":   order,
+	})
+}
+
+func (h *OrderHandler) CancelOrder(c *gin.Context) {
+	id := c.Param("id")
+
+	order, err := h.svc.CancelOrder(id)
+	if err != nil {
+		handleError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "取消成功",
+		"order":   order,
+	})
+}
+
+func handleError(c *gin.Context, err error) {
+	if appErr, ok := err.(*model.AppError); ok {
+		c.JSON(appErr.HTTPStatus, gin.H{
+			"error":   appErr.Code,
+			"message": appErr.Message,
+		})
+		return
+	}
+	c.JSON(http.StatusInternalServerError, gin.H{
+		"error":   "internal",
+		"message": "服务器内部错误",
+	})
+}

+ 193 - 0
handler/order_test.go

@@ -0,0 +1,193 @@
+package handler
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"job-cheng-xing/model"
+	"job-cheng-xing/repository"
+	"job-cheng-xing/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+func setupRouter() *gin.Engine {
+	gin.SetMode(gin.TestMode)
+	repo := repository.NewMemoryRepo()
+	svc := service.NewOrderService(repo)
+	handler := NewOrderHandler(svc)
+
+	r := gin.New()
+	r.POST("/orders", handler.CreateOrder)
+	r.PUT("/orders/:id/accept", handler.AcceptOrder)
+	r.PUT("/orders/:id/cancel", handler.CancelOrder)
+	return r
+}
+
+func TestCreateOrder_Handler(t *testing.T) {
+	r := setupRouter()
+
+	body := map[string]interface{}{
+		"service_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+		"duration":     120,
+		"address":      "北京朝阳区xxx路xxx号",
+	}
+	jsonBody, _ := json.Marshal(body)
+
+	req, _ := http.NewRequest("POST", "/orders", bytes.NewBuffer(jsonBody))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+
+	if w.Code != http.StatusCreated {
+		t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
+	}
+
+	var order model.Order
+	json.Unmarshal(w.Body.Bytes(), &order)
+	if order.ID == "" {
+		t.Error("expected non-empty ID")
+	}
+	if order.Status != model.StatusPending {
+		t.Errorf("expected pending, got %s", order.Status)
+	}
+}
+
+func TestAcceptOrder_Handler_Success(t *testing.T) {
+	r := setupRouter()
+
+	body := map[string]interface{}{
+		"service_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+		"duration":     120,
+		"address":      "北京朝阳",
+	}
+	jsonBody, _ := json.Marshal(body)
+	req, _ := http.NewRequest("POST", "/orders", bytes.NewBuffer(jsonBody))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+
+	var order model.Order
+	json.Unmarshal(w.Body.Bytes(), &order)
+
+	acceptBody := map[string]string{"provider_id": "prov_001"}
+	acceptJSON, _ := json.Marshal(acceptBody)
+	req2, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/accept", bytes.NewBuffer(acceptJSON))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+	r.ServeHTTP(w2, req2)
+
+	if w2.Code != http.StatusOK {
+		t.Errorf("expected 200, got %d: %s", w2.Code, w2.Body.String())
+	}
+}
+
+func TestAcceptOrder_Handler_DoubleAccept(t *testing.T) {
+	r := setupRouter()
+
+	body := map[string]interface{}{
+		"service_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+		"duration":     120,
+		"address":      "北京朝阳",
+	}
+	jsonBody, _ := json.Marshal(body)
+	req, _ := http.NewRequest("POST", "/orders", bytes.NewBuffer(jsonBody))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+	var order model.Order
+	json.Unmarshal(w.Body.Bytes(), &order)
+
+	acceptBody1 := map[string]string{"provider_id": "prov_001"}
+	acceptJSON1, _ := json.Marshal(acceptBody1)
+	req1, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/accept", bytes.NewBuffer(acceptJSON1))
+	req1.Header.Set("Content-Type", "application/json")
+	w1 := httptest.NewRecorder()
+	r.ServeHTTP(w1, req1)
+
+	if w1.Code != http.StatusOK {
+		t.Fatalf("first accept should succeed, got %d", w1.Code)
+	}
+
+	acceptBody2 := map[string]string{"provider_id": "prov_002"}
+	acceptJSON2, _ := json.Marshal(acceptBody2)
+	req2, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/accept", bytes.NewBuffer(acceptJSON2))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+	r.ServeHTTP(w2, req2)
+
+	if w2.Code != http.StatusConflict {
+		t.Errorf("expected 409, got %d: %s", w2.Code, w2.Body.String())
+	}
+}
+
+func TestCancelOrder_Handler_ThenAcceptFails(t *testing.T) {
+	r := setupRouter()
+
+	body := map[string]interface{}{
+		"service_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+		"duration":     120,
+		"address":      "北京朝阳",
+	}
+	jsonBody, _ := json.Marshal(body)
+	req, _ := http.NewRequest("POST", "/orders", bytes.NewBuffer(jsonBody))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+	var order model.Order
+	json.Unmarshal(w.Body.Bytes(), &order)
+
+	req1, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/cancel", bytes.NewBuffer([]byte("{}")))
+	req1.Header.Set("Content-Type", "application/json")
+	w1 := httptest.NewRecorder()
+	r.ServeHTTP(w1, req1)
+	if w1.Code != http.StatusOK {
+		t.Fatalf("cancel should succeed, got %d", w1.Code)
+	}
+
+	acceptBody := map[string]string{"provider_id": "prov_001"}
+	acceptJSON, _ := json.Marshal(acceptBody)
+	req2, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/accept", bytes.NewBuffer(acceptJSON))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+	r.ServeHTTP(w2, req2)
+
+	if w2.Code != http.StatusBadRequest {
+		t.Errorf("expected 400, got %d: %s", w2.Code, w2.Body.String())
+	}
+}
+
+func TestCancelOrder_Handler_DoubleCancel(t *testing.T) {
+	r := setupRouter()
+
+	body := map[string]interface{}{
+		"service_time": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+		"duration":     120,
+		"address":      "北京朝阳",
+	}
+	jsonBody, _ := json.Marshal(body)
+	req, _ := http.NewRequest("POST", "/orders", bytes.NewBuffer(jsonBody))
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+	var order model.Order
+	json.Unmarshal(w.Body.Bytes(), &order)
+
+	req1, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/cancel", bytes.NewBuffer([]byte("{}")))
+	req1.Header.Set("Content-Type", "application/json")
+	w1 := httptest.NewRecorder()
+	r.ServeHTTP(w1, req1)
+
+	req2, _ := http.NewRequest("PUT", "/orders/"+order.ID+"/cancel", bytes.NewBuffer([]byte("{}")))
+	req2.Header.Set("Content-Type", "application/json")
+	w2 := httptest.NewRecorder()
+	r.ServeHTTP(w2, req2)
+
+	if w2.Code != http.StatusBadRequest {
+		t.Errorf("expected 400, got %d: %s", w2.Code, w2.Body.String())
+	}
+}

+ 45 - 0
main.go

@@ -0,0 +1,45 @@
+package main
+
+import (
+	"log"
+	"os"
+
+	"job-cheng-xing/handler"
+	"job-cheng-xing/repository"
+	"job-cheng-xing/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+func getRepository() repository.OrderRepository {
+	if os.Getenv("STORAGE") == "sqlite" {
+		dbPath := os.Getenv("SQLITE_PATH")
+		if dbPath == "" {
+			dbPath = "./orders.db"
+		}
+		repo, err := repository.NewSQLiteRepo(dbPath)
+		if err != nil {
+			log.Fatalf("SQLite 初始化失败: %v", err)
+		}
+		log.Printf("使用 SQLite 存储: %s", dbPath)
+		return repo
+	}
+	log.Println("使用内存存储")
+	return repository.NewMemoryRepo()
+}
+
+func main() {
+	repo := getRepository()
+	svc := service.NewOrderService(repo)
+	h := handler.NewOrderHandler(svc)
+
+	r := gin.Default()
+	r.POST("/orders", h.CreateOrder)
+	r.PUT("/orders/:id/accept", h.AcceptOrder)
+	r.PUT("/orders/:id/cancel", h.CancelOrder)
+
+	log.Println("订单服务启动在 :8080")
+	if err := r.Run(":8080"); err != nil {
+		log.Fatalf("启动失败: %v", err)
+	}
+}

+ 57 - 0
model/order.go

@@ -0,0 +1,57 @@
+package model
+
+import "time"
+
+type OrderStatus string
+
+const (
+	StatusPending  OrderStatus = "pending"
+	StatusAccepted OrderStatus = "accepted"
+	StatusCanceled OrderStatus = "canceled"
+)
+
+type Order struct {
+	ID          string      `json:"id"`
+	Status      OrderStatus `json:"status"`
+	ServiceTime time.Time   `json:"service_time"`
+	Duration    int         `json:"duration"`
+	Address     string      `json:"address"`
+	ProviderID  *string     `json:"provider_id"`
+	CreatedAt   time.Time   `json:"created_at"`
+	UpdatedAt   time.Time   `json:"updated_at"`
+}
+
+type CreateOrderRequest struct {
+	ServiceTime time.Time `json:"service_time" binding:"required"`
+	Duration    int       `json:"duration" binding:"required,gt=0"`
+	Address     string    `json:"address" binding:"required"`
+}
+
+type AcceptOrderRequest struct {
+	ProviderID string `json:"provider_id" binding:"required"`
+}
+
+type AppError struct {
+	Code       string `json:"error"`
+	Message    string `json:"message"`
+	HTTPStatus int    `json:"-"`
+}
+
+func NewAppError(code, message string, httpStatus int) *AppError {
+	return &AppError{Code: code, Message: message, HTTPStatus: httpStatus}
+}
+
+func (e *AppError) Error() string {
+	return e.Message
+}
+
+// 预定义错误
+var (
+	ErrNotFound   = NewAppError("not_found", "订单不存在", 404)
+	ErrConflict   = NewAppError("conflict", "订单已被其他服务者接单", 409)
+	ErrStatusConflict = NewAppError("conflict", "订单状态已变更,请重试", 409)
+)
+
+func ErrInvalidState(op string) *AppError {
+	return NewAppError("invalid_state", "订单已取消,无法"+op, 400)
+}

+ 57 - 0
repository/memory.go

@@ -0,0 +1,57 @@
+package repository
+
+import (
+	"sync"
+	"time"
+
+	"job-cheng-xing/model"
+)
+
+type MemoryRepo struct {
+	mu     sync.RWMutex
+	orders map[string]*model.Order
+}
+
+func NewMemoryRepo() *MemoryRepo {
+	return &MemoryRepo{orders: make(map[string]*model.Order)}
+}
+
+func (r *MemoryRepo) Create(order *model.Order) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	if _, exists := r.orders[order.ID]; exists {
+		return model.NewAppError("conflict", "订单已存在", 409)
+	}
+	clone := *order
+	r.orders[order.ID] = &clone
+	return nil
+}
+
+func (r *MemoryRepo) FindByID(id string) (*model.Order, error) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	order, ok := r.orders[id]
+	if !ok {
+		return nil, model.ErrNotFound
+	}
+	clone := *order
+	return &clone, nil
+}
+
+func (r *MemoryRepo) Update(id string, oldStatus model.OrderStatus, newOrder *model.Order) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	current, ok := r.orders[id]
+	if !ok {
+		return model.ErrNotFound
+	}
+	if current.Status != oldStatus {
+		return model.ErrStatusConflict
+	}
+	current.Status = newOrder.Status
+	if newOrder.ProviderID != nil {
+		current.ProviderID = newOrder.ProviderID
+	}
+	current.UpdatedAt = time.Now()
+	return nil
+}

+ 151 - 0
repository/memory_test.go

@@ -0,0 +1,151 @@
+package repository
+
+import (
+	"sync"
+	"testing"
+	"time"
+
+	"job-cheng-xing/model"
+)
+
+func strPtr(s string) *string { return &s }
+
+func TestMemoryRepo_CreateAndFind(t *testing.T) {
+	repo := NewMemoryRepo()
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusPending,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+
+	err := repo.Create(order)
+	if err != nil {
+		t.Fatalf("Create failed: %v", err)
+	}
+
+	found, err := repo.FindByID("ord_001")
+	if err != nil {
+		t.Fatalf("FindByID failed: %v", err)
+	}
+	if found.ID != "ord_001" {
+		t.Errorf("expected ord_001, got %s", found.ID)
+	}
+}
+
+func TestMemoryRepo_FindByID_NotFound(t *testing.T) {
+	repo := NewMemoryRepo()
+	_, err := repo.FindByID("nonexistent")
+	if err != model.ErrNotFound {
+		t.Errorf("expected ErrNotFound, got %v", err)
+	}
+}
+
+func TestMemoryRepo_Update_CAS_Success(t *testing.T) {
+	repo := NewMemoryRepo()
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusPending,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+	repo.Create(order)
+
+	newOrder := &model.Order{
+		Status:     model.StatusAccepted,
+		ProviderID: strPtr("prov_001"),
+		UpdatedAt:  time.Now(),
+	}
+	err := repo.Update("ord_001", model.StatusPending, newOrder)
+	if err != nil {
+		t.Fatalf("Update failed: %v", err)
+	}
+
+	found, _ := repo.FindByID("ord_001")
+	if found.Status != model.StatusAccepted {
+		t.Errorf("expected accepted, got %s", found.Status)
+	}
+	if *found.ProviderID != "prov_001" {
+		t.Errorf("expected prov_001, got %s", *found.ProviderID)
+	}
+}
+
+func TestMemoryRepo_Update_CAS_Fail_WrongStatus(t *testing.T) {
+	repo := NewMemoryRepo()
+	order := &model.Order{
+		ID:     "ord_001",
+		Status: model.StatusPending,
+	}
+	repo.Create(order)
+
+	newOrder := &model.Order{Status: model.StatusCanceled}
+	err := repo.Update("ord_001", model.StatusAccepted, newOrder)
+	if err != model.ErrStatusConflict {
+		t.Errorf("expected ErrStatusConflict, got %v", err)
+	}
+}
+
+func TestMemoryRepo_ConcurrentAccept(t *testing.T) {
+	repo := NewMemoryRepo()
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusPending,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+	repo.Create(order)
+
+	const numGoroutines = 50
+	var wg sync.WaitGroup
+	results := make(chan error, numGoroutines)
+
+	for i := 0; i < numGoroutines; i++ {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			pid := "prov_" + string(rune('a'+idx%26)) + string(rune('0'+idx/26))
+			newOrder := &model.Order{
+				Status:     model.StatusAccepted,
+				ProviderID: &pid,
+				UpdatedAt:  time.Now(),
+			}
+			results <- repo.Update("ord_001", model.StatusPending, newOrder)
+		}(i)
+	}
+	wg.Wait()
+	close(results)
+
+	successCount := 0
+	failCount := 0
+	for err := range results {
+		if err == nil {
+			successCount++
+		} else if err == model.ErrStatusConflict {
+			failCount++
+		} else {
+			t.Errorf("unexpected error: %v", err)
+		}
+	}
+
+	if successCount != 1 {
+		t.Errorf("expected exactly 1 success, got %d", successCount)
+	}
+	if failCount != numGoroutines-1 {
+		t.Errorf("expected %d failures, got %d", numGoroutines-1, failCount)
+	}
+
+	found, _ := repo.FindByID("ord_001")
+	if found.ProviderID == nil {
+		t.Fatal("expected ProviderID to be set")
+	}
+	t.Logf("winner provider: %s", *found.ProviderID)
+}

+ 9 - 0
repository/order.go

@@ -0,0 +1,9 @@
+package repository
+
+import "job-cheng-xing/model"
+
+type OrderRepository interface {
+	Create(order *model.Order) error
+	FindByID(id string) (*model.Order, error)
+	Update(id string, oldStatus model.OrderStatus, newOrder *model.Order) error
+}

+ 115 - 0
repository/sqlite.go

@@ -0,0 +1,115 @@
+package repository
+
+import (
+	"database/sql"
+	"time"
+
+	"job-cheng-xing/model"
+
+	_ "github.com/mattn/go-sqlite3"
+)
+
+type SQLiteRepo struct {
+	db *sql.DB
+}
+
+func NewSQLiteRepo(dbPath string) (*SQLiteRepo, error) {
+	db, err := sql.Open("sqlite3", dbPath)
+	if err != nil {
+		return nil, err
+	}
+
+	_, err = db.Exec(`
+		CREATE TABLE IF NOT EXISTS orders (
+			id TEXT PRIMARY KEY,
+			status TEXT NOT NULL,
+			service_time TEXT NOT NULL,
+			duration INTEGER NOT NULL,
+			address TEXT NOT NULL,
+			provider_id TEXT,
+			created_at TEXT NOT NULL,
+			updated_at TEXT NOT NULL
+		)
+	`)
+	if err != nil {
+		db.Close()
+		return nil, err
+	}
+
+	return &SQLiteRepo{db: db}, nil
+}
+
+func (r *SQLiteRepo) Close() error {
+	return r.db.Close()
+}
+
+func (r *SQLiteRepo) Create(order *model.Order) error {
+	_, err := r.db.Exec(
+		`INSERT INTO orders (id, status, service_time, duration, address, provider_id, created_at, updated_at)
+		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+		order.ID, string(order.Status), order.ServiceTime.Format(time.RFC3339),
+		order.Duration, order.Address, order.ProviderID,
+		order.CreatedAt.Format(time.RFC3339), order.UpdatedAt.Format(time.RFC3339),
+	)
+	return err
+}
+
+func (r *SQLiteRepo) FindByID(id string) (*model.Order, error) {
+	row := r.db.QueryRow(
+		`SELECT id, status, service_time, duration, address, provider_id, created_at, updated_at
+		 FROM orders WHERE id = ?`, id,
+	)
+
+	var order model.Order
+	var serviceTimeStr, createdAtStr, updatedAtStr string
+	var providerID sql.NullString
+
+	err := row.Scan(
+		&order.ID, &order.Status, &serviceTimeStr,
+		&order.Duration, &order.Address, &providerID,
+		&createdAtStr, &updatedAtStr,
+	)
+	if err == sql.ErrNoRows {
+		return nil, model.ErrNotFound
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	order.ServiceTime, _ = time.Parse(time.RFC3339, serviceTimeStr)
+	order.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
+	order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAtStr)
+	if providerID.Valid {
+		order.ProviderID = &providerID.String
+	}
+
+	return &order, nil
+}
+
+func (r *SQLiteRepo) Update(id string, oldStatus model.OrderStatus, newOrder *model.Order) error {
+	now := time.Now().Format(time.RFC3339)
+
+	var providerID interface{}
+	if newOrder.ProviderID != nil {
+		providerID = *newOrder.ProviderID
+	}
+
+	result, err := r.db.Exec(
+		`UPDATE orders SET status = ?, provider_id = ?, updated_at = ? WHERE id = ? AND status = ?`,
+		string(newOrder.Status), providerID, now, id, string(oldStatus),
+	)
+	if err != nil {
+		return err
+	}
+
+	rowsAffected, _ := result.RowsAffected()
+	if rowsAffected == 0 {
+		_, err := r.FindByID(id)
+		if err == model.ErrNotFound {
+			return model.ErrNotFound
+		}
+		return model.ErrStatusConflict
+	}
+
+	return nil
+}

+ 114 - 0
repository/sqlite_test.go

@@ -0,0 +1,114 @@
+package repository
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"job-cheng-xing/model"
+)
+
+func setupSQLiteRepo(t *testing.T) *SQLiteRepo {
+	t.Helper()
+	dbPath := "/tmp/test_orders_" + t.Name() + ".db"
+	repo, err := NewSQLiteRepo(dbPath)
+	if err != nil {
+		t.Fatalf("failed to create SQLite repo: %v", err)
+	}
+	t.Cleanup(func() {
+		repo.Close()
+		os.Remove(dbPath)
+	})
+	return repo
+}
+
+func TestSQLiteRepo_CreateAndFind(t *testing.T) {
+	repo := setupSQLiteRepo(t)
+
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusPending,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+
+	err := repo.Create(order)
+	if err != nil {
+		t.Fatalf("Create failed: %v", err)
+	}
+
+	found, err := repo.FindByID("ord_001")
+	if err != nil {
+		t.Fatalf("FindByID failed: %v", err)
+	}
+	if found.ID != "ord_001" {
+		t.Errorf("expected ord_001, got %s", found.ID)
+	}
+	if found.Address != "北京朝阳" {
+		t.Errorf("expected 北京朝阳, got %s", found.Address)
+	}
+}
+
+func TestSQLiteRepo_Update_CAS(t *testing.T) {
+	repo := setupSQLiteRepo(t)
+
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusPending,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+	repo.Create(order)
+
+	pid := "prov_001"
+	newOrder := &model.Order{
+		Status:     model.StatusAccepted,
+		ProviderID: &pid,
+		UpdatedAt:  time.Now(),
+	}
+	err := repo.Update("ord_001", model.StatusPending, newOrder)
+	if err != nil {
+		t.Fatalf("Update failed: %v", err)
+	}
+
+	found, _ := repo.FindByID("ord_001")
+	if found.Status != model.StatusAccepted {
+		t.Errorf("expected accepted, got %s", found.Status)
+	}
+}
+
+func TestSQLiteRepo_Update_CAS_Fail(t *testing.T) {
+	repo := setupSQLiteRepo(t)
+
+	order := &model.Order{
+		ID:          "ord_001",
+		Status:      model.StatusAccepted,
+		ServiceTime: time.Now(),
+		Duration:    120,
+		Address:     "北京朝阳",
+		CreatedAt:   time.Now(),
+		UpdatedAt:   time.Now(),
+	}
+	repo.Create(order)
+
+	newOrder := &model.Order{Status: model.StatusCanceled}
+	err := repo.Update("ord_001", model.StatusPending, newOrder)
+	if err != model.ErrStatusConflict {
+		t.Errorf("expected ErrStatusConflict, got %v", err)
+	}
+}
+
+func TestSQLiteRepo_FindByID_NotFound(t *testing.T) {
+	repo := setupSQLiteRepo(t)
+
+	_, err := repo.FindByID("nonexistent")
+	if err != model.ErrNotFound {
+		t.Errorf("expected ErrNotFound, got %v", err)
+	}
+}

+ 87 - 0
service/order.go

@@ -0,0 +1,87 @@
+package service
+
+import (
+	"time"
+
+	"job-cheng-xing/model"
+	"job-cheng-xing/repository"
+
+	"github.com/google/uuid"
+)
+
+type OrderService struct {
+	repo repository.OrderRepository
+}
+
+func NewOrderService(repo repository.OrderRepository) *OrderService {
+	return &OrderService{repo: repo}
+}
+
+func (s *OrderService) CreateOrder(req model.CreateOrderRequest) (*model.Order, error) {
+	now := time.Now()
+	order := &model.Order{
+		ID:          "ord_" + uuid.NewString()[:8],
+		Status:      model.StatusPending,
+		ServiceTime: req.ServiceTime,
+		Duration:    req.Duration,
+		Address:     req.Address,
+		ProviderID:  nil,
+		CreatedAt:   now,
+		UpdatedAt:   now,
+	}
+	if err := s.repo.Create(order); err != nil {
+		return nil, err
+	}
+	return order, nil
+}
+
+func (s *OrderService) AcceptOrder(id, providerID string) (*model.Order, error) {
+	order, err := s.repo.FindByID(id)
+	if err != nil {
+		return nil, err
+	}
+
+	if order.Status == model.StatusCanceled {
+		return nil, model.ErrInvalidState("接单")
+	}
+	if order.Status != model.StatusPending {
+		return nil, model.ErrConflict
+	}
+
+	pid := providerID
+	updated := &model.Order{
+		Status:     model.StatusAccepted,
+		ProviderID: &pid,
+		UpdatedAt:  time.Now(),
+	}
+	if err := s.repo.Update(id, model.StatusPending, updated); err != nil {
+		return nil, err
+	}
+
+	return s.repo.FindByID(id)
+}
+
+func (s *OrderService) GetOrder(id string) (*model.Order, error) {
+	return s.repo.FindByID(id)
+}
+
+func (s *OrderService) CancelOrder(id string) (*model.Order, error) {
+	order, err := s.repo.FindByID(id)
+	if err != nil {
+		return nil, err
+	}
+
+	if order.Status == model.StatusCanceled {
+		return nil, model.ErrInvalidState("取消")
+	}
+
+	updated := &model.Order{
+		Status:    model.StatusCanceled,
+		UpdatedAt: time.Now(),
+	}
+	if err := s.repo.Update(id, order.Status, updated); err != nil {
+		return nil, err
+	}
+
+	return s.repo.FindByID(id)
+}

+ 168 - 0
service/order_test.go

@@ -0,0 +1,168 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"job-cheng-xing/model"
+	"job-cheng-xing/repository"
+)
+
+func TestCreateOrder(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	req := model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳区xxx路xxx号",
+	}
+
+	order, err := svc.CreateOrder(req)
+	if err != nil {
+		t.Fatalf("CreateOrder failed: %v", err)
+	}
+	if order.ID == "" {
+		t.Error("expected non-empty ID")
+	}
+	if order.Status != model.StatusPending {
+		t.Errorf("expected pending, got %s", order.Status)
+	}
+	if order.ProviderID != nil {
+		t.Error("expected nil ProviderID")
+	}
+}
+
+func TestAcceptOrder_Success(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+
+	result, err := svc.AcceptOrder(order.ID, "prov_001")
+	if err != nil {
+		t.Fatalf("AcceptOrder failed: %v", err)
+	}
+	if result.Status != model.StatusAccepted {
+		t.Errorf("expected accepted, got %s", result.Status)
+	}
+	if *result.ProviderID != "prov_001" {
+		t.Errorf("expected prov_001, got %s", *result.ProviderID)
+	}
+}
+
+func TestAcceptOrder_AlreadyAccepted(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+	svc.AcceptOrder(order.ID, "prov_001")
+
+	_, err := svc.AcceptOrder(order.ID, "prov_002")
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	appErr, ok := err.(*model.AppError)
+	if !ok || appErr.Code != "conflict" {
+		t.Errorf("expected conflict error, got %v", err)
+	}
+}
+
+func TestAcceptOrder_OrderNotFound(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	_, err := svc.AcceptOrder("nonexistent", "prov_001")
+	if err != model.ErrNotFound {
+		t.Errorf("expected ErrNotFound, got %v", err)
+	}
+}
+
+func TestCancelOrder_FromPending(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+
+	result, err := svc.CancelOrder(order.ID)
+	if err != nil {
+		t.Fatalf("CancelOrder failed: %v", err)
+	}
+	if result.Status != model.StatusCanceled {
+		t.Errorf("expected canceled, got %s", result.Status)
+	}
+}
+
+func TestCancelOrder_FromAccepted(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+	svc.AcceptOrder(order.ID, "prov_001")
+
+	result, err := svc.CancelOrder(order.ID)
+	if err != nil {
+		t.Fatalf("CancelOrder failed: %v", err)
+	}
+	if result.Status != model.StatusCanceled {
+		t.Errorf("expected canceled, got %s", result.Status)
+	}
+}
+
+func TestCancelOrder_AlreadyCanceled(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+	svc.CancelOrder(order.ID)
+
+	_, err := svc.CancelOrder(order.ID)
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	appErr, ok := err.(*model.AppError)
+	if !ok || appErr.Code != "invalid_state" {
+		t.Errorf("expected invalid_state error, got %v", err)
+	}
+}
+
+func TestAcceptOrder_AfterCanceled(t *testing.T) {
+	repo := repository.NewMemoryRepo()
+	svc := NewOrderService(repo)
+
+	order, _ := svc.CreateOrder(model.CreateOrderRequest{
+		ServiceTime: time.Now().Add(24 * time.Hour),
+		Duration:    120,
+		Address:     "北京朝阳",
+	})
+	svc.CancelOrder(order.ID)
+
+	_, err := svc.AcceptOrder(order.ID, "prov_001")
+	if err == nil {
+		t.Fatal("expected error")
+	}
+	appErr, ok := err.(*model.AppError)
+	if !ok || appErr.Code != "invalid_state" {
+		t.Errorf("expected invalid_state error, got %v", err)
+	}
+}

+ 138 - 0
test.sh

@@ -0,0 +1,138 @@
+#!/bin/bash
+# 订单服务一键测试脚本
+# 用法: bash test.sh          # 内存存储
+#       STORAGE=sqlite bash test.sh  # SQLite 存储
+
+PASS=0
+FAIL=0
+SERVER_PID=
+PORT=8080
+
+echo "选择存储后端:"
+echo "  1) memory(默认)"
+echo "  2) sqlite"
+read -p "请选择 [1/2]: " choice
+case "$choice" in
+  2|sqlite) STORAGE=sqlite ;;
+  *) STORAGE=memory ;;
+esac
+
+green() { echo -e "\033[32m$1\033[0m"; }
+red()   { echo -e "\033[31m$1\033[0m"; }
+
+cleanup() {
+  [ -n "$SERVER_PID" ] && kill $SERVER_PID 2>/dev/null || true
+  sleep 0.5
+  rm -f orders.db
+}
+trap cleanup EXIT
+
+# 先释放 8080 端口(处理上次残留)
+fuser -k ${PORT}/tcp >/dev/null 2>&1 || true
+sleep 1
+
+echo "========================================"
+echo "  订 单 服 务 — 一 键 测 试"
+echo "  存储: $STORAGE"
+echo "========================================"
+
+# 启动服务
+export STORAGE
+GIN_MODE=release go run main.go > /tmp/order_server.log 2>&1 &
+SERVER_PID=$!
+sleep 3
+
+# 检查进程
+if ! kill -0 $SERVER_PID 2>/dev/null; then
+  red "  ❌ 服务启动失败"
+  cat /tmp/order_server.log
+  exit 1
+fi
+
+assert() {
+  local desc="$1" exp="$2" act="$3" exp_body="$4" act_body="$5"
+  if echo "$act_body" | grep -qF "$exp_body" && [ "$act" = "$exp" ]; then
+    green "  ✅ PASS: $desc"
+    ((PASS++))
+  else
+    red "    ❌ FAIL: $desc"
+    echo "       HTTP $exp 期望, 实际 $act"
+    echo "       body 期望含: $exp_body"
+    echo "       body 实际: $act_body"
+    ((FAIL++))
+  fi
+}
+
+echo ""
+echo "1. 创建订单..."
+RESP=$(curl -s -w "\n%{http_code}" -X POST http://localhost:$PORT/orders \
+  -H "Content-Type: application/json" \
+  -d '{"service_time":"2025-08-01T14:00:00Z","duration":120,"address":"北京朝阳"}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+ID=$(echo "$BODY" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
+assert "创建订单" 201 "$CODE" '"status":"pending"' "$BODY"
+
+echo ""
+echo "2. 接单(首次)..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/$ID/accept" \
+  -H "Content-Type: application/json" \
+  -d '{"provider_id":"prov_001"}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "首次接单成功" 200 "$CODE" "接单成功" "$BODY"
+
+echo ""
+echo "3. 重复接单(应拒绝)..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/$ID/accept" \
+  -H "Content-Type: application/json" \
+  -d '{"provider_id":"prov_002"}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "重复接单被拒" 409 "$CODE" "已被其他服务者接单" "$BODY"
+
+echo ""
+echo "4. 取消订单..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/$ID/cancel" \
+  -H "Content-Type: application/json" \
+  -d '{}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "取消成功" 200 "$CODE" "取消成功" "$BODY"
+
+echo ""
+echo "5. 取消后接单(应拒绝)..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/$ID/accept" \
+  -H "Content-Type: application/json" \
+  -d '{"provider_id":"prov_003"}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "取消后接单被拒" 400 "$CODE" "已取消" "$BODY"
+
+echo ""
+echo "6. 重复取消(应拒绝)..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/$ID/cancel" \
+  -H "Content-Type: application/json" \
+  -d '{}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "重复取消被拒" 400 "$CODE" "已取消" "$BODY"
+
+echo ""
+echo "7. 订单不存在..."
+RESP=$(curl -s -w "\n%{http_code}" -X PUT "http://localhost:$PORT/orders/nonexistent/accept" \
+  -H "Content-Type: application/json" \
+  -d '{"provider_id":"prov_001"}')
+BODY=$(echo "$RESP" | sed '$d')
+CODE=$(echo "$RESP" | tail -1)
+assert "订单不存在" 404 "$CODE" "订单不存在" "$BODY"
+
+echo ""
+echo "========================================"
+if [ $FAIL -eq 0 ]; then
+  green "  🎉 全部 $PASS 个测试通过!"
+else
+  red "    $PASS 通过, $FAIL 失败"
+fi
+echo "========================================"
+exit $FAIL