feat(jd_cookie):重构订单创建逻辑并优化相关模型

- 新增 CreateOrderReq 结构体用于统一订单创建参数- 修改 CreateOrder 方法签名,使用结构体传参替代多个参数
- 更新 jd_cookie 相关枚举值,增加 JdCookieStatusUnknown 状态
- 调整 OrderInfo 和 JdOrderInfo 模型字段,增强数据一致性
-优化订单与京东订单关联逻辑,移除冗余的 CurrentOrderId 字段
- 移除 ShouldExtractCard 方法,改为内部私有方法 shouldExtractCard- 精简 Callback 方法参数,移除不必要的 userOrderId 和 amount 参数
- 修复订单历史记录中订单号关联问题,直接使用 orderId 字段查询
- 更新控制器层参数传递方式,适配新的服务层接口定义
- 调整卡密提取逻辑,去除对用户订单实体的依赖
- 完善订单状态检查机制,提高卡密提取安全性
- 优化数据库查询逻辑,减少不必要的关联查询操作
This commit is contained in:
danial
2025-10-18 14:13:40 +08:00
parent d812e1700b
commit bc2d58753b
24 changed files with 914 additions and 235 deletions

View File

@@ -0,0 +1,29 @@
---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryrun `ls openspec` or `openspec update` if you
don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statusesmake sure every item in `tasks.md` is finished.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->

View File

@@ -0,0 +1,28 @@
---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryrun `ls openspec` or `openspec update` if you
don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Identify the requested change ID (via the prompt or `openspec list`).
2. Run `openspec archive <id> --yes` to let the CLI move the change and apply spec updates without prompts (use
`--skip-specs` only for tooling-only work).
3. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
4. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
<!-- OPENSPEC:END -->

View File

@@ -0,0 +1,42 @@
---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directoryrun `ls openspec` or `openspec update` if you
don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (
e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under
`openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas
with clear relationships and sequencing.
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or
demands trade-off discussion before committing to specs.
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using
`## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference
related capabilities when relevant.
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include
validation (tests, tooling), and highlight dependencies or parallelizable work.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation
fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation
realities.
<!-- OPENSPEC:END -->

21
AGENTS.md Normal file
View File

@@ -0,0 +1,21 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->

View File

@@ -1,3 +1,25 @@
<!-- OPENSPEC:START -->
# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open `@/openspec/AGENTS.md` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use `@/openspec/AGENTS.md` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@@ -12,6 +34,12 @@ This project uses GoFrame (GF) framework and includes a custom Makefile that del
- `go run main.go` - Run the application directly - `go run main.go` - Run the application directly
- `make up` - Update GoFrame and CLI to latest version (`gf up -a`) - `make up` - Update GoFrame and CLI to latest version (`gf up -a`)
### Code Quality & Linting
- `go fmt ./...` - Format all Go files
- `go vet ./...` - Run Go vet for potential issues
- `go mod tidy` - Clean up module dependencies
### Code Generation (GoFrame CLI) ### Code Generation (GoFrame CLI)
- `make ctrl` - Generate controllers from API definitions (`gf gen ctrl`) - `make ctrl` - Generate controllers from API definitions (`gf gen ctrl`)
@@ -27,12 +55,16 @@ This project uses GoFrame (GF) framework and includes a custom Makefile that del
- `go test -v ./internal/logic/...` - Run tests for specific packages - `go test -v ./internal/logic/...` - Run tests for specific packages
- `go test ./internal/logic/card_apple_account -v` - Run tests for specific module - `go test ./internal/logic/card_apple_account -v` - Run tests for specific module
- `go test -run TestName` - Run specific test - `go test -run TestName` - Run specific test
- `go test -race ./...` - Run tests with race detection
- `go test -cover ./...` - Run tests with coverage report
### Docker & Deployment ### Docker & Deployment
- `make image` - Build Docker image with auto-generated git-based tag - `make image` - Build Docker image with auto-generated git-based tag
- `make image.push` - Build and push Docker image - `make image.push` - Build and push Docker image
- `make deploy` - Deploy to kubernetes environment using kustomize - `make deploy` - Deploy to kubernetes environment using kustomize
- **Environment Variables**: Set `serverName` env var for service identification in OpenTelemetry
- **Deployment Config**: Uses `manifest/deploy/kustomize/overlays/${_ENV}` for environment-specific configs
## Architecture Overview ## Architecture Overview
@@ -79,6 +111,7 @@ prefixes.
### Technology Stack Details ### Technology Stack Details
- **Framework**: GoFrame v2 with heavy code generation usage - **Framework**: GoFrame v2 with heavy code generation usage
- **Language**: Go 1.24+ (see go.mod)
- **Database**: MySQL with GoFrame ORM (DAO/DO/Entity pattern), dual database support - **Database**: MySQL with GoFrame ORM (DAO/DO/Entity pattern), dual database support
- **Cache**: Redis for caching, sessions, rate limiting - **Cache**: Redis for caching, sessions, rate limiting
- **Tracing**: OpenTelemetry with custom headers (`x-service-token`) - **Tracing**: OpenTelemetry with custom headers (`x-service-token`)

View File

@@ -23,20 +23,20 @@ gf gen ctrl
**修复前后对比**: **修复前后对比**:
| 生成的接口方法 | 原控制器方法 | 修复后控制器方法 | 状态 | | 生成的接口方法 | 原控制器方法 | 修复后控制器方法 | 状态 |
|----------------|--------------|------------------|------| |------------------|----------------------|------------------|-------|
| `CreateAccount` | `CreateAccount` | `CreateAccount` | 匹配 | | `CreateAccount` | `CreateAccount` | `CreateAccount` | 匹配 |
| `BatchCreate` | `BatchCreateAccount` | `BatchCreate` | 已修复 | | `BatchCreate` | `BatchCreateAccount` | `BatchCreate` | 已修复 |
| `ListAccount` | `ListAccount` | `ListAccount` | 匹配 | | `ListAccount` | `ListAccount` | `ListAccount` | 匹配 |
| `UpdateAccount` | `UpdateAccount` | `UpdateAccount` | 匹配 | | `UpdateAccount` | `UpdateAccount` | `UpdateAccount` | 匹配 |
| `DeleteAccount` | `DeleteAccount` | `DeleteAccount` | 匹配 | | `DeleteAccount` | `DeleteAccount` | `DeleteAccount` | 匹配 |
| `BatchCheck` | `BatchCheckAccount` | `BatchCheck` | 已修复 | | `BatchCheck` | `BatchCheckAccount` | `BatchCheck` | 已修复 |
| `CreateOrder` | `CreateOrder` | `CreateOrder` | 匹配 | | `CreateOrder` | `CreateOrder` | `CreateOrder` | 匹配 |
| `GetPaymentUrl` | `GetPaymentUrl` | `GetPaymentUrl` | 匹配 | | `GetPaymentUrl` | `GetPaymentUrl` | `GetPaymentUrl` | 匹配 |
| `GetOrderStatus` | `GetOrderStatus` | `GetOrderStatus` | 匹配 | | `GetOrderStatus` | `GetOrderStatus` | `GetOrderStatus` | 匹配 |
| `ListOrder` | `ListOrder` | `ListOrder` | 匹配 | | `ListOrder` | `ListOrder` | `ListOrder` | 匹配 |
| `CookieHistory` | `GetCookieHistory` | `CookieHistory` | 已修复 | | `CookieHistory` | `GetCookieHistory` | `CookieHistory` | 已修复 |
| `OrderHistory` | `GetOrderHistory` | `OrderHistory` | 已修复 | | `OrderHistory` | `GetOrderHistory` | `OrderHistory` | 已修复 |
### 4. 验证结果 ### 4. 验证结果

View File

@@ -90,13 +90,17 @@ type GetJdOrderRes struct {
} }
type OrderInfo struct { type OrderInfo struct {
Id int64 `json:"id" dc:"主键ID"`
OrderId string `json:"orderId" dc:"订单号"` OrderId string `json:"orderId" dc:"订单号"`
UserOrderId string `json:"userOrderId" dc:"用户订单号"`
Amount float64 `json:"amount" dc:"订单金额"` Amount float64 `json:"amount" dc:"订单金额"`
Category string `json:"category" dc:"商品品类"` Category string `json:"category" dc:"商品品类"`
JdOrderId string `json:"jdOrderId" dc:"关联的京东订单号"` JdOrderId string `json:"jdOrderId" dc:"关联的京东订单号"`
Status consts.OrderStatus `json:"status" dc:"状态1待支付 2已支付 3已过期 4已取消"` Status consts.OrderStatus `json:"status" dc:"状态1待支付 2已支付 3已过期 4已取消"`
LastRequest *gtime.Time `json:"lastRequestAt" dc:"最后请求时间"` LastRequest *gtime.Time `json:"lastRequestAt" dc:"最后请求时间"`
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
} }
type JdOrderInfo struct { type JdOrderInfo struct {
@@ -111,7 +115,7 @@ type JdOrderInfo struct {
WxPayUrl string `json:"wxPayUrl" dc:"微信支付链接"` WxPayUrl string `json:"wxPayUrl" dc:"微信支付链接"`
WxPayExpireAt *gtime.Time `json:"wxPayExpireAt" dc:"微信支付链接过期时间"` WxPayExpireAt *gtime.Time `json:"wxPayExpireAt" dc:"微信支付链接过期时间"`
OrderExpireAt *gtime.Time `json:"orderExpireAt" dc:"订单过期时间(默认24小时)"` OrderExpireAt *gtime.Time `json:"orderExpireAt" dc:"订单过期时间(默认24小时)"`
CurrentOrderId int64 `json:"currentOrderId" dc:"当前关联的订单ID"` OrderId string `json:"orderId" dc:"关联的用户订单号"`
PaidAt *gtime.Time `json:"paidAt" dc:"支付完成时间"` PaidAt *gtime.Time `json:"paidAt" dc:"支付完成时间"`
CardNo string `json:"cardNo" dc:"卡号"` CardNo string `json:"cardNo" dc:"卡号"`
CardPassword string `json:"cardPassword" dc:"卡密"` CardPassword string `json:"cardPassword" dc:"卡密"`
@@ -119,7 +123,6 @@ type JdOrderInfo struct {
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"` DeletedAt *gtime.Time `json:"deletedAt" dc:"删除时间"`
OrderId string `json:"orderId" dc:"关联的用户订单号"`
} }
// ListOrderReq Order List Query Request // ListOrderReq Order List Query Request

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@ var JdCookieStatusText = map[JdCookieStatus]string{
JdCookieStatusNormal: "正常", JdCookieStatusNormal: "正常",
JdCookieStatusSuspend: "暂停", JdCookieStatusSuspend: "暂停",
JdCookieStatusExpired: "失效", JdCookieStatusExpired: "失效",
JdCookieStatusUnknown: "未知",
} }
// JdOrderStatus 京东订单状态枚举 // JdOrderStatus 京东订单状态枚举
@@ -93,6 +94,7 @@ const (
JdOrderChangeTypeExpire JdOrderChangeType = "expire" // 过期 JdOrderChangeTypeExpire JdOrderChangeType = "expire" // 过期
JdOrderChangeTypeInvalid JdOrderChangeType = "invalid" // 失效(新增) JdOrderChangeTypeInvalid JdOrderChangeType = "invalid" // 失效(新增)
JdOrderChangeTypeSend JdOrderChangeType = "send" // 发货 JdOrderChangeTypeSend JdOrderChangeType = "send" // 发货
JdOrderChangeTypeReplace JdOrderChangeType = "replace" // 换绑
) )
// JdOrderChangeTypeText 京东订单变更类型文本映射 // JdOrderChangeTypeText 京东订单变更类型文本映射
@@ -104,6 +106,7 @@ var JdOrderChangeTypeText = map[JdOrderChangeType]string{
JdOrderChangeTypeExpire: "过期", JdOrderChangeTypeExpire: "过期",
JdOrderChangeTypeInvalid: "失效", JdOrderChangeTypeInvalid: "失效",
JdOrderChangeTypeSend: "发货", JdOrderChangeTypeSend: "发货",
JdOrderChangeTypeReplace: "换绑",
} }
// OrderChangeType 订单变更类型 // OrderChangeType 订单变更类型

View File

@@ -3,6 +3,7 @@ package jd_cookie
import ( import (
"context" "context"
"kami/api/jd_cookie/v1" "kami/api/jd_cookie/v1"
"kami/internal/model"
"kami/internal/service" "kami/internal/service"
"github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gcode"
@@ -11,7 +12,12 @@ import (
// CreateOrder 创建订单 // CreateOrder 创建订单
func (c *ControllerV1) CreateOrder(ctx context.Context, req *v1.CreateOrderReq) (res *v1.CreateOrderRes, err error) { func (c *ControllerV1) CreateOrder(ctx context.Context, req *v1.CreateOrderReq) (res *v1.CreateOrderRes, err error) {
result, err := service.JdCookie().CreateOrder(ctx, req.UserOrderId, req.Amount, req.Category) createOrderReq := &model.CreateOrderReq{
UserOrderId: req.UserOrderId,
Amount: req.Amount,
Category: req.Category,
}
result, err := service.JdCookie().CreateOrder(ctx, createOrderReq)
if err != nil { if err != nil {
return nil, gerror.WrapCode(gcode.CodeInternalError, err, "创建订单失败") return nil, gerror.WrapCode(gcode.CodeInternalError, err, "创建订单失败")
} }

View File

@@ -32,7 +32,7 @@ type V1JdCookieJdOrderColumns struct {
WxPayUrl string // 微信支付链接 WxPayUrl string // 微信支付链接
WxPayExpireAt string // 微信支付链接过期时间 WxPayExpireAt string // 微信支付链接过期时间
OrderExpireAt string // 订单过期时间(默认24小时) OrderExpireAt string // 订单过期时间(默认24小时)
CurrentOrderId string // 当前关联的订单ID OrderId string // 关联的用户订单号
PaidAt string // 支付完成时间 PaidAt string // 支付完成时间
CardNo string // 卡号 CardNo string // 卡号
CardPassword string // 卡密 CardPassword string // 卡密
@@ -55,7 +55,7 @@ var v1JdCookieJdOrderColumns = V1JdCookieJdOrderColumns{
WxPayUrl: "wx_pay_url", WxPayUrl: "wx_pay_url",
WxPayExpireAt: "wx_pay_expire_at", WxPayExpireAt: "wx_pay_expire_at",
OrderExpireAt: "order_expire_at", OrderExpireAt: "order_expire_at",
CurrentOrderId: "current_order_id", OrderId: "order_id",
PaidAt: "paid_at", PaidAt: "paid_at",
CardNo: "card_no", CardNo: "card_no",
CardPassword: "card_password", CardPassword: "card_password",

View File

@@ -51,12 +51,19 @@ amount := 100.0
category := consts.ProductCategoryApple category := consts.ProductCategoryApple
// 调用创建订单 // 调用创建订单
wxPayUrl, expireTime, jdOrderId, err := service.CreateOrder(ctx, orderId, amount, category) createOrderReq := &model.CreateOrderReq{
UserOrderId: orderId,
Amount: amount,
Category: category,
}
result, err := service.CreateOrder(ctx, createOrderReq)
if err != nil { if err != nil {
// 所有 Cookie 都失败,返回错误 // 所有 Cookie 都失败,返回错误
// 错误信息会包含:已尝试的 Cookie 数量 // 错误信息会包含:已尝试的 Cookie 数量
return err return err
} }
wxPayUrl := result.WxPayUrl
jdOrderId := result.JdOrderId
// 成功返回支付链接 // 成功返回支付链接
``` ```

View File

@@ -15,18 +15,18 @@ import (
) )
// CreateOrder 创建订单 // CreateOrder 创建订单
func (s *sJdCookie) CreateOrder(ctx context.Context, userOrderId string, amount float64, category consts.RedeemOrderCardCategory) (result *model.CreateOrderResult, err error) { func (s *sJdCookie) CreateOrder(ctx context.Context, req *model.CreateOrderReq) (result *model.CreateOrderResult, err error) {
_ = s.ReleaseExpiredJdOrders(ctx) _ = s.ReleaseExpiredJdOrders(ctx)
if userOrderId == "" { if req.UserOrderId == "" {
return nil, gerror.New("用户订单号不能为空") return nil, gerror.New("用户订单号不能为空")
} }
if amount <= 0 { if req.Amount <= 0 {
return nil, gerror.New("订单金额必须大于0") return nil, gerror.New("订单金额必须大于0")
} }
// 获取用户订单分布式锁,防止并发创建重复订单 // 获取用户订单分布式锁,防止并发创建重复订单
lockKey := "jd_cookie:create_order:" + userOrderId lockKey := consts.OrderLockKeyPrefix + req.UserOrderId
lockValue, err := cache.NewCache().Lock(ctx, lockKey, time.Minute*3, time.Second*60) lockValue, err := cache.NewCache().Lock(ctx, lockKey, time.Minute*3, time.Second*60)
if err != nil { if err != nil {
return nil, gerror.Wrap(err, "系统繁忙,请稍后重试") return nil, gerror.Wrap(err, "系统繁忙,请稍后重试")
@@ -43,7 +43,7 @@ func (s *sJdCookie) CreateOrder(ctx context.Context, userOrderId string, amount
}() }()
// 在锁保护下再次检查用户订单是否已存在(双重检查) // 在锁保护下再次检查用户订单是否已存在(双重检查)
existingOrder, err := s.getOrderByUserOrderId(ctx, userOrderId) existingOrder, err := s.getOrderByUserOrderId(ctx, req.UserOrderId)
if err != nil { if err != nil {
return nil, gerror.Wrap(err, "检查用户订单是否存在失败") return nil, gerror.Wrap(err, "检查用户订单是否存在失败")
} }
@@ -63,7 +63,7 @@ func (s *sJdCookie) CreateOrder(ctx context.Context, userOrderId string, amount
} }
// 优先尝试复用现有的京东订单 // 优先尝试复用现有的京东订单
reusableJdOrder, err := s.findReusableJdOrder(ctx, amount, category) reusableJdOrder, err := s.findReusableJdOrder(ctx, req.Amount, req.Category)
if err != nil { if err != nil {
glog.Warning(ctx, "查找可复用京东订单失败", err) glog.Warning(ctx, "查找可复用京东订单失败", err)
} }
@@ -136,8 +136,8 @@ func (s *sJdCookie) CreateOrder(ctx context.Context, userOrderId string, amount
if jdOrderId == "" { if jdOrderId == "" {
retryRes, err := s.createNewJdOrderWithRetry(ctx, &model.CreateNewJdOrderWithRetryReq{ retryRes, err := s.createNewJdOrderWithRetry(ctx, &model.CreateNewJdOrderWithRetryReq{
OrderId: internalOrderId, OrderId: internalOrderId,
Amount: amount, Amount: req.Amount,
Category: category, Category: req.Category,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -148,19 +148,19 @@ func (s *sJdCookie) CreateOrder(ctx context.Context, userOrderId string, amount
} }
// 创建订单记录 // 创建订单记录
err = s.createOrderRecord(ctx, internalOrderId, userOrderId, amount, category, jdOrderId, wxPayUrl) err = s.createOrderRecord(ctx, internalOrderId, req.UserOrderId, req.Amount, req.Category, jdOrderId, wxPayUrl)
if err != nil { if err != nil {
return nil, gerror.Wrap(err, "创建订单记录失败") return nil, gerror.Wrap(err, "创建订单记录失败")
} }
// 更新京东订单的当前关联订单ID // 更新京东订单的当前关联订单ID
_ = s.updateJdOrderCurrentOrderId(ctx, jdOrderId, internalOrderId) _ = s.updateJdOrderId(ctx, jdOrderId, internalOrderId)
// 记录Cookie使用历史 // 记录Cookie使用历史
_ = s.RecordCookieHistory(ctx, &model.RecordCookieHistoryReq{ _ = s.RecordCookieHistory(ctx, &model.RecordCookieHistoryReq{
CookieId: cookieId, CookieId: cookieId,
ChangeType: consts.CookieChangeTypeUse, ChangeType: consts.CookieChangeTypeUse,
StatusBefore: consts.JdCookieStatusNormal, StatusBefore: consts.JdCookieStatusUnknown,
StatusAfter: consts.JdCookieStatusNormal, StatusAfter: consts.JdCookieStatusNormal,
UserOrderId: internalOrderId, UserOrderId: internalOrderId,
FailureCount: 0, FailureCount: 0,

View File

@@ -49,7 +49,7 @@ func (s *sJdCookie) GetJdOrder(ctx context.Context, jdOrderId string) (order *v1
WxPayUrl: jdOrderEntity.WxPayUrl, WxPayUrl: jdOrderEntity.WxPayUrl,
WxPayExpireAt: jdOrderEntity.WxPayExpireAt, WxPayExpireAt: jdOrderEntity.WxPayExpireAt,
OrderExpireAt: jdOrderEntity.OrderExpireAt, OrderExpireAt: jdOrderEntity.OrderExpireAt,
CurrentOrderId: jdOrderEntity.CurrentOrderId, OrderId: jdOrderEntity.OrderId,
CardNo: jdOrderEntity.CardNo, CardNo: jdOrderEntity.CardNo,
CardPassword: jdOrderEntity.CardPassword, CardPassword: jdOrderEntity.CardPassword,
CardExtractedAt: jdOrderEntity.CardExtractedAt, CardExtractedAt: jdOrderEntity.CardExtractedAt,
@@ -76,17 +76,6 @@ func (s *sJdCookie) GetJdOrder(ctx context.Context, jdOrderId string) (order *v1
} }
} }
// 获取关联的用户订单信息
if jdOrderEntity.CurrentOrderId > 0 {
var userOrder *entity.V1JdCookieOrder
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, jdOrderEntity.CurrentOrderId).
Scan(&userOrder)
if userOrder != nil {
order.OrderId = userOrder.OrderId
}
}
return return
} }
@@ -112,17 +101,8 @@ func (s *sJdCookie) ListJdOrder(ctx context.Context, page, size int, status cons
m = m.WhereLTE(dao.V1JdCookieJdOrder.Columns().CreatedAt, endTime) m = m.WhereLTE(dao.V1JdCookieJdOrder.Columns().CreatedAt, endTime)
} }
if orderId != "" { if orderId != "" {
// 先查询对应的内部订单ID然后筛选 // 直接根据order_id字段筛选
var userOrder *entity.V1JdCookieOrder m = m.Where(dao.V1JdCookieJdOrder.Columns().OrderId, orderId)
err := dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().OrderId, orderId).
Scan(&userOrder)
if err == nil && userOrder != nil {
m = m.Where(dao.V1JdCookieJdOrder.Columns().CurrentOrderId, userOrder.Id)
} else {
// 如果找不到对应的内部订单,返回空结果
return []*v1.JdOrderInfo{}, 0, nil
}
} }
// 查询总数 // 查询总数
@@ -176,7 +156,7 @@ func (s *sJdCookie) ListJdOrder(ctx context.Context, page, size int, status cons
WxPayUrl: jdOrderEntity.WxPayUrl, WxPayUrl: jdOrderEntity.WxPayUrl,
WxPayExpireAt: jdOrderEntity.WxPayExpireAt, WxPayExpireAt: jdOrderEntity.WxPayExpireAt,
OrderExpireAt: jdOrderEntity.OrderExpireAt, OrderExpireAt: jdOrderEntity.OrderExpireAt,
CurrentOrderId: jdOrderEntity.CurrentOrderId, OrderId: jdOrderEntity.OrderId,
PaidAt: jdOrderEntity.PaidAt, PaidAt: jdOrderEntity.PaidAt,
CardNo: jdOrderEntity.CardNo, CardNo: jdOrderEntity.CardNo,
CardPassword: jdOrderEntity.CardPassword, CardPassword: jdOrderEntity.CardPassword,
@@ -198,17 +178,6 @@ func (s *sJdCookie) ListJdOrder(ctx context.Context, page, size int, status cons
} }
} }
// 获取关联的用户订单信息
if jdOrderEntity.CurrentOrderId > 0 {
var userOrder *entity.V1JdCookieOrder
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, jdOrderEntity.CurrentOrderId).
Scan(&userOrder)
if userOrder != nil {
info.OrderId = userOrder.OrderId
}
}
list = append(list, info) list = append(list, info)
} }
@@ -451,7 +420,7 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
} }
// 检查订单状态是否允许提取卡密 // 检查订单状态是否允许提取卡密
if !s.ShouldExtractCard(ctx, jdOrder) { if !s.shouldExtractCard(ctx, jdOrder) {
glog.Debug(ctx, "订单状态不允许提取卡密", g.Map{ glog.Debug(ctx, "订单状态不允许提取卡密", g.Map{
"jdOrderId": jdOrderId, "jdOrderId": jdOrderId,
"status": jdOrder, "status": jdOrder,
@@ -459,14 +428,6 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
return nil return nil
} }
// 记录卡密提取历史
var order *entity.V1JdCookieOrder
if jdOrder.CurrentOrderId > 0 {
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, jdOrder.CurrentOrderId).
Scan(&order)
}
// 获取Cookie信息 // 获取Cookie信息
cookieInfo, err := s.getCookieById(ctx, jdOrder.CookieId) cookieInfo, err := s.getCookieById(ctx, jdOrder.CookieId)
if err != nil || cookieInfo == nil { if err != nil || cookieInfo == nil {
@@ -484,13 +445,12 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
if resp.IsCkFailed { if resp.IsCkFailed {
s.handleCookieFailure(ctx, cookieInfo.CookieId, jdOrderId, true, resp.Remark) s.handleCookieFailure(ctx, cookieInfo.CookieId, jdOrderId, true, resp.Remark)
_ = s.UpdateJdOrderStatus(ctx, jdOrderId, consts.JdOrderStatusCkFailed, "", resp.Remark) _ = s.UpdateJdOrderStatus(ctx, jdOrderId, consts.JdOrderStatusCkFailed, "", resp.Remark)
if order != nil { _, _ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1JdCookieOrder.Columns().OrderId, jdOrder.OrderId).
_, _ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1JdCookieOrder.Columns().Id, order.Id). Update(do.V1JdCookieOrder{
Update(do.V1JdCookieOrder{ Status: consts.OrderStatusCkFailed,
Status: consts.OrderStatusCkFailed, })
}) _ = s.RecordOrderHistory(ctx, jdOrder.OrderId, consts.OrderChangeTypeCkFailed, jdOrderId)
_ = s.RecordOrderHistory(ctx, order.OrderId, consts.OrderChangeTypeCkFailed, jdOrderId)
}
} }
} }
return gerror.Wrap(err, "查询京东订单支付状态失败") return gerror.Wrap(err, "查询京东订单支付状态失败")
@@ -507,13 +467,13 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
PaidAt: gtime.Now(), PaidAt: gtime.Now(),
Status: consts.JdOrderStatusPaid, Status: consts.JdOrderStatusPaid,
}) })
if order != nil { if jdOrder.OrderId != "" {
_ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypePay, order.OrderId, jdOrder.WxPayUrl, "") _ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypePay, jdOrder.OrderId, jdOrder.WxPayUrl, "")
_, _ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1JdCookieOrder.Columns().Id, order.Id). _, _ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1JdCookieOrder.Columns().OrderId, jdOrder.OrderId).
Update(do.V1JdCookieOrder{ Update(do.V1JdCookieOrder{
Status: consts.OrderStatusPaid, Status: consts.OrderStatusPaid,
}) })
_ = s.RecordOrderHistory(ctx, order.OrderId, consts.OrderChangeTypePay, jdOrderId) _ = s.RecordOrderHistory(ctx, jdOrder.OrderId, consts.OrderChangeTypePay, jdOrderId)
} }
return nil return nil
} }
@@ -521,13 +481,16 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
return gerror.New("获取卡密信息为空") return gerror.New("获取卡密信息为空")
} }
if order != nil && order.Status != int(consts.OrderStatusPaid) { affected, _ := dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
_, _ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1JdCookieOrder.Columns().Id, order.Id). Where(dao.V1JdCookieOrder.Columns().OrderId, jdOrder.OrderId).
Update(do.V1JdCookieOrder{ WhereNot(dao.V1JdCookieOrder.Columns().Status, consts.OrderStatusPaid).
Status: consts.OrderStatusPaid, UpdateAndGetAffected(do.V1JdCookieOrder{
}) Status: consts.OrderStatusPaid,
_ = s.RecordOrderHistory(ctx, order.OrderId, consts.OrderChangeTypePay, jdOrderId) })
if affected > 0 {
_ = s.RecordOrderHistory(ctx, jdOrder.OrderId, consts.OrderChangeTypePay, jdOrderId)
} }
// 保存卡密信息到数据库 // 保存卡密信息到数据库
_, err = dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1()). _, err = dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId). Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).
@@ -541,39 +504,46 @@ func (s *sJdCookie) ExtractCardInfo(ctx context.Context, jdOrderId string) error
return gerror.Wrap(err, "保存卡密信息失败") return gerror.Wrap(err, "保存卡密信息失败")
} }
_ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypeSend, order.OrderId, jdOrder.WxPayUrl, "") _ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypeSend, jdOrder.OrderId, jdOrder.WxPayUrl, "")
//提取成功要回调 //提取成功要回调
go s.Callback(ctx, order.OrderId, order.UserOrderId, order.Amount.InexactFloat64()) go s.Callback(ctx, jdOrder.OrderId)
glog.Info(ctx, "卡密提取成功", g.Map{ glog.Info(ctx, "卡密提取成功", g.Map{
"jdOrderId": jdOrderId, "jdOrderId": jdOrderId,
"cardNo": resp.CardNo, "cardNo": resp.CardNo,
"orderId": order.OrderId, "orderId": jdOrder.OrderId,
}) })
return nil return nil
} }
// Callback TODO:临时的回调 // Callback TODO:临时的回调
func (s *sJdCookie) Callback(ctx context.Context, orderId, userOrderId string, amount float64) { func (s *sJdCookie) Callback(ctx context.Context, orderId string) {
var order *entity.V1JdCookieOrder
if err := dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().OrderId, orderId).Scan(&order); err != nil || order == nil || order.Id == 0 {
glog.Error(ctx, "查询订单失败", g.Map{"orderId": orderId, "err": err})
return
}
var data *entity.V1OrderInfo var data *entity.V1OrderInfo
if err := dao.V1OrderInfo.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1OrderInfo.Columns().MerchantOrderId, userOrderId).Scan(&data); err != nil || data == nil || data.Id == 0 { if err := dao.V1OrderInfo.Ctx(ctx).DB(config.GetDatabaseV1()).Where(dao.V1OrderInfo.Columns().BankOrderId, order.UserOrderId).Scan(&data); err != nil || data == nil || data.Id == 0 {
glog.Error(ctx, "查询订单失败", g.Map{"userOrderId": userOrderId, "err": err}) glog.Error(ctx, "查询订单失败", g.Map{"userOrderId": order.UserOrderId, "err": err})
return return
} }
response, _ := gclient.New().Get(ctx, "http://kami_gateway:12309/appleCard/notify", g.Map{ response, _ := gclient.New().Get(ctx, "http://kami_gateway:12309/appleCard/notify", g.Map{
"attach": data.BankOrderId, "attach": data.BankOrderId,
"merchantId": orderId, "merchantId": orderId,
"amount": amount, "amount": order.Amount,
"status": "1", "status": "1",
"sign": "123456", "sign": "123456",
}) })
glog.Info(ctx, "回调成功", g.Map{"response": response.ReadAllString()}) glog.Info(ctx, "回调成功", g.Map{"response": response.ReadAllString()})
} }
// ShouldExtractCard 判断是否需要提取卡密 // shouldExtractCard 判断是否需要提取卡密
func (s *sJdCookie) ShouldExtractCard(ctx context.Context, jdOrder *entity.V1JdCookieJdOrder) bool { func (s *sJdCookie) shouldExtractCard(ctx context.Context, jdOrder *entity.V1JdCookieJdOrder) bool {
if jdOrder == nil { if jdOrder == nil {
return false return false
} }
@@ -621,17 +591,10 @@ func (s *sJdCookie) CleanupExpiredOrders(ctx context.Context) error {
// 为每个过期的京东订单记录历史 // 为每个过期的京东订单记录历史
for _, jdOrder := range expiredJdOrders { for _, jdOrder := range expiredJdOrders {
orderId := "" orderId := jdOrder.OrderId
if jdOrder.CurrentOrderId > 0 { if jdOrder.OrderId != "" {
var order *entity.V1JdCookieOrder // 同时记录用户订单的历史
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()). _ = s.RecordOrderHistory(ctx, orderId, consts.OrderChangeTypeExpire, jdOrder.JdOrderId)
Where(dao.V1JdCookieOrder.Columns().Id, jdOrder.CurrentOrderId).
Scan(&order)
if order != nil {
orderId = order.OrderId
// 同时记录用户订单的历史
_ = s.RecordOrderHistory(ctx, orderId, consts.OrderChangeTypeExpire, jdOrder.JdOrderId)
}
} }
_ = s.RecordJdOrderHistory(ctx, jdOrder.JdOrderId, consts.JdOrderChangeTypeExpire, orderId, jdOrder.WxPayUrl, "") _ = s.RecordJdOrderHistory(ctx, jdOrder.JdOrderId, consts.JdOrderChangeTypeExpire, orderId, jdOrder.WxPayUrl, "")
} }
@@ -683,10 +646,10 @@ func (s *sJdCookie) ReleaseExpiredJdOrders(ctx context.Context) error {
jdOrderModel := dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1()) jdOrderModel := dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1())
_, err := jdOrderModel. _, err := jdOrderModel.
Where(dao.V1JdCookieJdOrder.Columns().Status, int(consts.JdOrderStatusPending)). Where(dao.V1JdCookieJdOrder.Columns().Status, int(consts.JdOrderStatusPending)).
WhereNotNull(dao.V1JdCookieJdOrder.Columns().CurrentOrderId). WhereNotNull(dao.V1JdCookieJdOrder.Columns().OrderId).
WhereLT(dao.V1JdCookieJdOrder.Columns().CreatedAt, expireTime). WhereLT(dao.V1JdCookieJdOrder.Columns().CreatedAt, expireTime).
Update(g.Map{ Update(do.V1JdCookieJdOrder{
dao.V1JdCookieJdOrder.Columns().CurrentOrderId: nil, OrderId: nil,
}) })
if err != nil { if err != nil {
glog.Error(ctx, "释放过期京东订单关联失败", err) glog.Error(ctx, "释放过期京东订单关联失败", err)
@@ -710,17 +673,8 @@ func (s *sJdCookie) ExportJdOrder(ctx context.Context, status consts.JdOrderStat
m = m.WhereLTE(dao.V1JdCookieJdOrder.Columns().CreatedAt, endTime) m = m.WhereLTE(dao.V1JdCookieJdOrder.Columns().CreatedAt, endTime)
} }
if orderId != "" { if orderId != "" {
// 先查询对应的内部订单ID然后筛选 // 直接根据order_id字段筛选
var userOrder *entity.V1JdCookieOrder m = m.Where(dao.V1JdCookieJdOrder.Columns().OrderId, orderId)
err := dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().OrderId, orderId).
Scan(&userOrder)
if err == nil && userOrder != nil {
m = m.Where(dao.V1JdCookieJdOrder.Columns().CurrentOrderId, userOrder.Id)
} else {
// 如果找不到对应的内部订单返回空Excel
return s.createEmptyExcel(ctx)
}
} }
// 查询所有符合条件的订单 // 查询所有符合条件的订单
@@ -778,17 +732,7 @@ func (s *sJdCookie) ExportJdOrder(ctx context.Context, status consts.JdOrderStat
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), jdOrder.JdOrderId) f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), jdOrder.JdOrderId)
// 用户订单号 // 用户订单号
userOrderId := "" f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), jdOrder.OrderId)
if jdOrder.CurrentOrderId > 0 {
var order *entity.V1JdCookieOrder
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, jdOrder.CurrentOrderId).
Scan(&order)
if order != nil {
userOrderId = order.OrderId
}
}
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), userOrderId)
// 卡号 // 卡号
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), jdOrder.CardNo) f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), jdOrder.CardNo)

View File

@@ -50,9 +50,8 @@ func (s *sJdCookie) GetPaymentUrl(ctx context.Context, orderId string) (result *
CookieId: jdOrder.CookieId, CookieId: jdOrder.CookieId,
}) })
if isCkFailed { if isCkFailed {
s.handleCookieFailure(ctx, jdOrder.CookieId, jdOrder.JdOrderId, isCkFailed, "获取支付链接失败") s.handleCookieFailure(ctx, jdOrder.CookieId, jdOrder.JdOrderId, isCkFailed, refreshErr.Error())
} }
if refreshErr != nil { if refreshErr != nil {
// 刷新失败,标记旧订单为失效 // 刷新失败,标记旧订单为失效
_ = s.UpdateJdOrderStatus(ctx, jdOrder.JdOrderId, consts.JdOrderStatusExpired, jdOrder.WxPayUrl, refreshErr.Error()) _ = s.UpdateJdOrderStatus(ctx, jdOrder.JdOrderId, consts.JdOrderStatusExpired, jdOrder.WxPayUrl, refreshErr.Error())
@@ -67,7 +66,7 @@ func (s *sJdCookie) GetPaymentUrl(ctx context.Context, orderId string) (result *
}) })
// 解绑旧京东订单 // 解绑旧京东订单
_ = s.updateJdOrderCurrentOrderId(ctx, jdOrder.JdOrderId, "") _ = s.updateJdOrderId(ctx, jdOrder.JdOrderId, "")
// 创建新的京东订单(带重试机制) // 创建新的京东订单(带重试机制)
retryRes, createErr := s.createNewJdOrderWithRetry(ctx, &model.CreateNewJdOrderWithRetryReq{ retryRes, createErr := s.createNewJdOrderWithRetry(ctx, &model.CreateNewJdOrderWithRetryReq{
@@ -83,20 +82,22 @@ func (s *sJdCookie) GetPaymentUrl(ctx context.Context, orderId string) (result *
_ = s.updateOrderJdOrderId(ctx, orderId, retryRes.JdOrderId, retryRes.WxPayUrl) _ = s.updateOrderJdOrderId(ctx, orderId, retryRes.JdOrderId, retryRes.WxPayUrl)
// 更新京东订单的当前关联订单ID // 更新京东订单的当前关联订单ID
_ = s.updateJdOrderCurrentOrderId(ctx, retryRes.JdOrderId, orderId) _ = s.updateJdOrderId(ctx, retryRes.JdOrderId, orderId)
// 记录Cookie使用历史
_ = s.RecordCookieHistory(ctx, &model.RecordCookieHistoryReq{
CookieId: retryRes.CookieId,
ChangeType: consts.CookieChangeTypeUse,
StatusBefore: consts.JdCookieStatusNormal,
StatusAfter: consts.JdCookieStatusNormal,
UserOrderId: orderId,
FailureCount: 0,
})
// 记录订单重新绑定历史 // 记录订单重新绑定历史
_ = s.RecordOrderHistory(ctx, orderId, consts.OrderChangeTypeRebind, retryRes.JdOrderId) go func() {
_ = s.RecordOrderHistory(ctx, orderId, consts.OrderChangeTypeRebind, retryRes.JdOrderId)
// 记录Cookie使用历史
_ = s.RecordCookieHistory(ctx, &model.RecordCookieHistoryReq{
CookieId: retryRes.CookieId,
ChangeType: consts.CookieChangeTypeUse,
StatusBefore: consts.JdCookieStatusNormal,
StatusAfter: consts.JdCookieStatusNormal,
UserOrderId: orderId,
FailureCount: 0,
})
}()
// 返回新的支付信息 // 返回新的支付信息
jdOrderId = retryRes.JdOrderId jdOrderId = retryRes.JdOrderId
@@ -133,13 +134,17 @@ func (s *sJdCookie) GetOrder(ctx context.Context, orderId string) (order *v1.Ord
} }
order = &v1.OrderInfo{ order = &v1.OrderInfo{
Id: orderEntity.Id,
OrderId: orderEntity.OrderId, OrderId: orderEntity.OrderId,
UserOrderId: orderEntity.UserOrderId,
Amount: gconv.Float64(orderEntity.Amount), Amount: gconv.Float64(orderEntity.Amount),
Category: orderEntity.Category, Category: orderEntity.Category,
JdOrderId: orderEntity.JdOrderId, JdOrderId: orderEntity.JdOrderId,
Status: consts.OrderStatus(orderEntity.Status), Status: consts.OrderStatus(orderEntity.Status),
LastRequest: orderEntity.LastRequestAt, LastRequest: orderEntity.LastRequestAt,
CreatedAt: orderEntity.CreatedAt, CreatedAt: orderEntity.CreatedAt,
UpdatedAt: orderEntity.UpdatedAt,
DeletedAt: orderEntity.DeletedAt,
} }
return return
@@ -160,13 +165,17 @@ func (s *sJdCookie) GetOrderStatus(ctx context.Context, orderId string) (order *
} }
order = &v1.OrderInfo{ order = &v1.OrderInfo{
Id: orderEntity.Id,
OrderId: orderEntity.OrderId, OrderId: orderEntity.OrderId,
UserOrderId: orderEntity.UserOrderId,
Amount: gconv.Float64(orderEntity.Amount), Amount: gconv.Float64(orderEntity.Amount),
Category: orderEntity.Category, Category: orderEntity.Category,
JdOrderId: orderEntity.JdOrderId, JdOrderId: orderEntity.JdOrderId,
Status: consts.OrderStatus(orderEntity.Status), Status: consts.OrderStatus(orderEntity.Status),
LastRequest: orderEntity.LastRequestAt, LastRequest: orderEntity.LastRequestAt,
CreatedAt: orderEntity.CreatedAt, CreatedAt: orderEntity.CreatedAt,
UpdatedAt: orderEntity.UpdatedAt,
DeletedAt: orderEntity.DeletedAt,
} }
return return
@@ -211,13 +220,17 @@ func (s *sJdCookie) ListOrder(ctx context.Context, page, size int, status consts
list = make([]*v1.OrderInfo, 0, len(orders)) list = make([]*v1.OrderInfo, 0, len(orders))
for _, orderEntity := range orders { for _, orderEntity := range orders {
info := &v1.OrderInfo{ info := &v1.OrderInfo{
Id: orderEntity.Id,
OrderId: orderEntity.OrderId, OrderId: orderEntity.OrderId,
UserOrderId: orderEntity.UserOrderId,
Amount: gconv.Float64(orderEntity.Amount), Amount: gconv.Float64(orderEntity.Amount),
Category: orderEntity.Category, Category: orderEntity.Category,
JdOrderId: orderEntity.JdOrderId, JdOrderId: orderEntity.JdOrderId,
Status: consts.OrderStatus(orderEntity.Status), Status: consts.OrderStatus(orderEntity.Status),
LastRequest: orderEntity.LastRequestAt, LastRequest: orderEntity.LastRequestAt,
CreatedAt: orderEntity.CreatedAt, CreatedAt: orderEntity.CreatedAt,
UpdatedAt: orderEntity.UpdatedAt,
DeletedAt: orderEntity.DeletedAt,
} }
list = append(list, info) list = append(list, info)
} }

View File

@@ -18,47 +18,47 @@ func TestShouldExtractCard(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// 测试空订单 // 测试空订单
result := s.ShouldExtractCard(ctx, nil) result := s.shouldExtractCard(ctx, nil)
t.Assert(result, false) t.Assert(result, false)
// 测试未支付订单 // 测试未支付订单
jdOrder := &entity.V1JdCookieJdOrder{ jdOrder := &entity.V1JdCookieJdOrder{
Status: int(consts.JdOrderStatusPending), Status: int(consts.JdOrderStatusPending),
} }
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, true) // 待支付状态可以提取卡密 t.Assert(result, true) // 待支付状态可以提取卡密
// 测试已支付订单 // 测试已支付订单
jdOrder.Status = int(consts.JdOrderStatusPaid) jdOrder.Status = int(consts.JdOrderStatusPaid)
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, true) // 已支付状态可以提取卡密 t.Assert(result, true) // 已支付状态可以提取卡密
// 测试已支付且有支付时间的订单 // 测试已支付且有支付时间的订单
now := gtime.Now() now := gtime.Now()
jdOrder.PaidAt = now jdOrder.PaidAt = now
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, true) t.Assert(result, true)
// 测试已经提取过卡密的订单 // 测试已经提取过卡密的订单
jdOrder.CardNo = "1234567890" jdOrder.CardNo = "1234567890"
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, false) t.Assert(result, false)
// 测试卡密不完整的订单(只有卡密没有卡号) // 测试卡密不完整的订单(只有卡密没有卡号)
jdOrder.CardNo = "" jdOrder.CardNo = ""
jdOrder.CardPassword = "password123" jdOrder.CardPassword = "password123"
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, false) t.Assert(result, false)
// 测试其他状态(已发货) // 测试其他状态(已发货)
jdOrder.CardPassword = "" jdOrder.CardPassword = ""
jdOrder.Status = int(consts.JdOrderStatusSent) jdOrder.Status = int(consts.JdOrderStatusSent)
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, false) t.Assert(result, false)
// 测试其他状态(已过期) // 测试其他状态(已过期)
jdOrder.Status = int(consts.JdOrderStatusExpired) jdOrder.Status = int(consts.JdOrderStatusExpired)
result = s.ShouldExtractCard(ctx, jdOrder) result = s.shouldExtractCard(ctx, jdOrder)
t.Assert(result, false) t.Assert(result, false)
}) })
} }

View File

@@ -153,71 +153,27 @@ func (s *sJdCookie) updateJdOrderPaymentUrl(ctx context.Context, jdOrderId, wxPa
// 记录支付链接更新历史 // 记录支付链接更新历史
if oldOrder != nil && oldOrder.WxPayUrl != wxPayUrl { if oldOrder != nil && oldOrder.WxPayUrl != wxPayUrl {
orderId := "" _ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypeSend, oldOrder.OrderId, wxPayUrl, "")
if oldOrder.CurrentOrderId > 0 {
var order *entity.V1JdCookieOrder
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, oldOrder.CurrentOrderId).
Scan(&order)
if order != nil {
orderId = order.OrderId
}
}
_ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypeSend, orderId, wxPayUrl, "")
} }
return err return err
} }
// updateJdOrderCurrentOrderId 更新京东订单的当前关联订单ID // updateJdOrderId 更新京东订单关联的用户订单ID
func (s *sJdCookie) updateJdOrderCurrentOrderId(ctx context.Context, jdOrderId, orderId string) error { func (s *sJdCookie) updateJdOrderId(ctx context.Context, jdOrderId, orderId string) error {
m := dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1()) m := dao.V1JdCookieJdOrder.Ctx(ctx).DB(config.GetDatabaseV1())
// 如果orderId为空表示解绑
if orderId == "" {
// 获取更新前的京东订单信息
var oldJdOrder *entity.V1JdCookieJdOrder
err := m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Scan(&oldJdOrder)
if err != nil || oldJdOrder == nil {
glog.Warning(ctx, "查询京东订单失败,无法记录历史", err)
// 即使查询失败也继续更新
}
// 解绑设置为null
_, err = m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Update(&do.V1JdCookieJdOrder{
CurrentOrderId: nil,
})
if err != nil {
return err
}
// 记录解绑历史
if oldJdOrder != nil && oldJdOrder.CurrentOrderId > 0 {
_ = s.RecordJdOrderHistory(ctx, jdOrderId, consts.JdOrderChangeTypeUnbind, "", oldJdOrder.WxPayUrl, "")
}
return nil
}
// 查找订单ID对应的内部ID
var order *entity.V1JdCookieOrder
err := dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().OrderId, orderId).
Scan(&order)
if err != nil || order == nil {
return gerror.New("订单不存在")
}
// 获取更新前的京东订单信息 // 获取更新前的京东订单信息
var oldJdOrder *entity.V1JdCookieJdOrder var oldJdOrder *entity.V1JdCookieJdOrder
err = m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Scan(&oldJdOrder) err := m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Scan(&oldJdOrder)
if err != nil || oldJdOrder == nil { if err != nil || oldJdOrder == nil {
glog.Warning(ctx, "查询京东订单失败,无法记录历史", err) glog.Warning(ctx, "查询京东订单失败,无法记录历史", err)
// 即使查询失败也继续更新 // 即使查询失败也继续更新
} }
// 更新订单ID
_, err = m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Update(&do.V1JdCookieJdOrder{ _, err = m.Where(dao.V1JdCookieJdOrder.Columns().JdOrderId, jdOrderId).Update(&do.V1JdCookieJdOrder{
CurrentOrderId: order.Id, OrderId: orderId,
}) })
if err != nil { if err != nil {
return err return err
@@ -226,10 +182,10 @@ func (s *sJdCookie) updateJdOrderCurrentOrderId(ctx context.Context, jdOrderId,
// 记录订单绑定历史 // 记录订单绑定历史
var changeType consts.JdOrderChangeType var changeType consts.JdOrderChangeType
if oldJdOrder != nil { if oldJdOrder != nil {
if oldJdOrder.CurrentOrderId == 0 { if oldJdOrder.OrderId == "" {
changeType = consts.JdOrderChangeTypeBind // 首次绑定 changeType = consts.JdOrderChangeTypeBind // 首次绑定
} else if oldJdOrder.CurrentOrderId != order.Id { } else if oldJdOrder.OrderId != orderId {
changeType = consts.JdOrderChangeTypeSend // 更换绑定 changeType = consts.JdOrderChangeTypeReplace // 更换绑定
} }
} else { } else {
changeType = consts.JdOrderChangeTypeBind // 首次绑定 changeType = consts.JdOrderChangeTypeBind // 首次绑定
@@ -256,12 +212,12 @@ func (s *sJdCookie) findReusableJdOrder(ctx context.Context, amount float64, cat
// 查找符合条件的京东订单: // 查找符合条件的京东订单:
// 1. 金额和品类相同 // 1. 金额和品类相同
// 2. 状态为待支付 // 2. 状态为待支付
// 3. 没有当前关联订单或者关联订单已过期 // 3. 没有关联订单
// 4. 订单未过期 // 4. 订单未过期
err = m.Where(dao.V1JdCookieJdOrder.Columns().Amount, amount). err = m.Where(dao.V1JdCookieJdOrder.Columns().Amount, amount).
Where(dao.V1JdCookieJdOrder.Columns().Category, category). Where(dao.V1JdCookieJdOrder.Columns().Category, category).
Where(dao.V1JdCookieJdOrder.Columns().Status, int(consts.JdOrderStatusPending)). Where(dao.V1JdCookieJdOrder.Columns().Status, int(consts.JdOrderStatusPending)).
WhereNull(dao.V1JdCookieJdOrder.Columns().CurrentOrderId). WhereNull(dao.V1JdCookieJdOrder.Columns().OrderId).
WhereGT(dao.V1JdCookieJdOrder.Columns().OrderExpireAt, gtime.Now()). WhereGT(dao.V1JdCookieJdOrder.Columns().OrderExpireAt, gtime.Now()).
OrderAsc(dao.V1JdCookieJdOrder.Columns().CreatedAt). OrderAsc(dao.V1JdCookieJdOrder.Columns().CreatedAt).
Limit(1). Limit(1).

View File

@@ -251,18 +251,8 @@ func (s *sJdCookie) UpdateJdOrderStatus(ctx context.Context, jdOrderId string, s
changeType = consts.JdOrderChangeTypeSend changeType = consts.JdOrderChangeTypeSend
} }
// 获取当前关联的订单ID // 获取关联的订单ID
orderId := "" orderId := oldOrder.OrderId
if oldOrder.CurrentOrderId > 0 {
// 查询订单表获取订单号
var order *entity.V1JdCookieOrder
_ = dao.V1JdCookieOrder.Ctx(ctx).DB(config.GetDatabaseV1()).
Where(dao.V1JdCookieOrder.Columns().Id, oldOrder.CurrentOrderId).
Scan(&order)
if order != nil {
orderId = order.OrderId
}
}
payUrl := wxPayUrl payUrl := wxPayUrl
if payUrl == "" { if payUrl == "" {

View File

@@ -23,7 +23,7 @@ type V1JdCookieJdOrder struct {
WxPayUrl any // 微信支付链接 WxPayUrl any // 微信支付链接
WxPayExpireAt *gtime.Time // 微信支付链接过期时间 WxPayExpireAt *gtime.Time // 微信支付链接过期时间
OrderExpireAt *gtime.Time // 订单过期时间(默认24小时) OrderExpireAt *gtime.Time // 订单过期时间(默认24小时)
CurrentOrderId any // 当前关联的订单ID OrderId any // 关联的用户订单号
PaidAt *gtime.Time // 支付完成时间 PaidAt *gtime.Time // 支付完成时间
CardNo any // 卡号 CardNo any // 卡号
CardPassword any // 卡密 CardPassword any // 卡密

View File

@@ -22,7 +22,7 @@ type V1JdCookieJdOrder struct {
WxPayUrl string `json:"wxPayUrl" orm:"wx_pay_url" description:"微信支付链接"` WxPayUrl string `json:"wxPayUrl" orm:"wx_pay_url" description:"微信支付链接"`
WxPayExpireAt *gtime.Time `json:"wxPayExpireAt" orm:"wx_pay_expire_at" description:"微信支付链接过期时间"` WxPayExpireAt *gtime.Time `json:"wxPayExpireAt" orm:"wx_pay_expire_at" description:"微信支付链接过期时间"`
OrderExpireAt *gtime.Time `json:"orderExpireAt" orm:"order_expire_at" description:"订单过期时间(默认24小时)"` OrderExpireAt *gtime.Time `json:"orderExpireAt" orm:"order_expire_at" description:"订单过期时间(默认24小时)"`
CurrentOrderId int64 `json:"currentOrderId" orm:"current_order_id" description:"当前关联的订单ID"` OrderId string `json:"orderId" orm:"order_id" description:"关联的用户订单号"`
PaidAt *gtime.Time `json:"paidAt" orm:"paid_at" description:"支付完成时间"` PaidAt *gtime.Time `json:"paidAt" orm:"paid_at" description:"支付完成时间"`
CardNo string `json:"cardNo" orm:"card_no" description:"卡号"` CardNo string `json:"cardNo" orm:"card_no" description:"卡号"`
CardPassword string `json:"cardPassword" orm:"card_password" description:"卡密"` CardPassword string `json:"cardPassword" orm:"card_password" description:"卡密"`

View File

@@ -6,6 +6,13 @@ import "kami/internal/consts"
// JD Cookie 相关模型结构体 // JD Cookie 相关模型结构体
// ==================================================================================== // ====================================================================================
// CreateOrderReq 创建订单请求参数
type CreateOrderReq struct {
UserOrderId string `json:"userOrderId" dc:"用户订单号"`
Amount float64 `json:"amount" dc:"订单金额"`
Category consts.RedeemOrderCardCategory `json:"category" dc:"卡券类别"`
}
// CreateOrderResult 创建订单返回结果 // CreateOrderResult 创建订单返回结果
type CreateOrderResult struct { type CreateOrderResult struct {
WxPayUrl string `json:"wxPayUrl" dc:"微信支付链接"` WxPayUrl string `json:"wxPayUrl" dc:"微信支付链接"`

View File

@@ -10,7 +10,6 @@ import (
v1 "kami/api/jd_cookie/v1" v1 "kami/api/jd_cookie/v1"
"kami/internal/consts" "kami/internal/consts"
"kami/internal/model" "kami/internal/model"
"kami/internal/model/entity"
) )
type ( type (
@@ -38,7 +37,7 @@ type (
// GetJdOrderHistoryByJdOrderId 根据京东订单ID获取京东订单历史 // GetJdOrderHistoryByJdOrderId 根据京东订单ID获取京东订单历史
GetJdOrderHistoryByJdOrderId(ctx context.Context, jdOrderId string, page int, size int) (list []*v1.JdOrderHistoryInfo, total int, err error) GetJdOrderHistoryByJdOrderId(ctx context.Context, jdOrderId string, page int, size int) (list []*v1.JdOrderHistoryInfo, total int, err error)
// CreateOrder 创建订单 // CreateOrder 创建订单
CreateOrder(ctx context.Context, userOrderId string, amount float64, category consts.RedeemOrderCardCategory) (result *model.CreateOrderResult, err error) CreateOrder(ctx context.Context, req *model.CreateOrderReq) (result *model.CreateOrderResult, err error)
// GetJdOrder 获取单个京东订单 // GetJdOrder 获取单个京东订单
GetJdOrder(ctx context.Context, jdOrderId string) (order *v1.JdOrderInfo, err error) GetJdOrder(ctx context.Context, jdOrderId string) (order *v1.JdOrderInfo, err error)
// ListJdOrder 京东订单列表查询 // ListJdOrder 京东订单列表查询
@@ -50,9 +49,7 @@ type (
// ExtractCardInfo 提取卡密信息 // ExtractCardInfo 提取卡密信息
ExtractCardInfo(ctx context.Context, jdOrderId string) error ExtractCardInfo(ctx context.Context, jdOrderId string) error
// Callback TODO:临时的回调 // Callback TODO:临时的回调
Callback(ctx context.Context, orderId string, userOrderId string, amount float64) Callback(ctx context.Context, orderId string)
// ShouldExtractCard 判断是否需要提取卡密
ShouldExtractCard(ctx context.Context, jdOrder *entity.V1JdCookieJdOrder) bool
// CleanupExpiredOrders 清理过期订单(定时任务) // CleanupExpiredOrders 清理过期订单(定时任务)
CleanupExpiredOrders(ctx context.Context) error CleanupExpiredOrders(ctx context.Context) error
// ReleaseExpiredJdOrders 释放过期京东订单的关联(使其可以被复用) // ReleaseExpiredJdOrders 释放过期京东订单的关联(使其可以被复用)

507
openspec/AGENTS.md Normal file
View File

@@ -0,0 +1,507 @@
# OpenSpec Instructions
Instructions for AI coding assistants using OpenSpec for spec-driven development.
## TL;DR Quick Checklist
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
- Decide scope: new capability vs modify existing capability
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per
requirement
- Validate: `openspec validate [change-id] --strict` and fix issues
- Request approval: Do not start implementation until proposal is approved
## Three-Stage Workflow
### Stage 1: Creating Changes
Create proposal when you need to:
- Add features or functionality
- Make breaking changes (API, schema)
- Change architecture or patterns
- Optimize performance (changes behavior)
- Update security patterns
Triggers (examples):
- "Help me create a change proposal"
- "Help me plan a change"
- "Help me create a proposal"
- "I want to create a spec proposal"
- "I want to create a spec"
Loose matching guidance:
- Contains one of: `proposal`, `change`, `spec`
- With one of: `create`, `plan`, `make`, `start`, `help`
Skip proposal for:
- Bug fixes (restore intended behavior)
- Typos, formatting, comments
- Dependency updates (non-breaking)
- Configuration changes
- Tests for existing behavior
**Workflow**
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas
under `openspec/changes/<id>/`.
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/` `changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
**Context Checklist:**
- [ ] Read relevant specs in `specs/[capability]/spec.md`
- [ ] Check pending changes in `changes/` for conflicts
- [ ] Read `openspec/project.md` for conventions
- [ ] Run `openspec list` to see active changes
- [ ] Run `openspec list --specs` to see existing capabilities
**Before Creating Specs:**
- Always check if capability already exists
- Prefer modifying existing specs over creating duplicates
- Use `openspec show [spec]` to review current state
- If request is ambiguous, ask 12 clarifying questions before scaffolding
### Search Guidance
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
- Show details:
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
- Change: `openspec show <change-id> --json --deltas-only`
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
## Quick Start
### CLI Commands
```bash
# Essential commands
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec diff [change] # Show spec differences
openspec validate [item] # Validate changes or specs
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
openspec update [path] # Update instruction files
# Interactive mode
openspec show # Prompts for selection
openspec validate # Bulk validation mode
# Debugging
openspec show [change] --json --deltas-only
openspec validate [change] --strict
```
### Command Flags
- `--json` - Machine-readable output
- `--type change|spec` - Disambiguate items
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
```
openspec/
├── project.md # Project conventions
├── specs/ # Current truth - what IS built
│ └── [capability]/ # Single focused capability
│ ├── spec.md # Requirements and scenarios
│ └── design.md # Technical patterns
├── changes/ # Proposals - what SHOULD change
│ ├── [change-name]/
│ │ ├── proposal.md # Why, what, impact
│ │ ├── tasks.md # Implementation checklist
│ │ ├── design.md # Technical decisions (optional; see criteria)
│ │ └── specs/ # Delta changes
│ │ └── [capability]/
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
│ └── archive/ # Completed changes
```
## Creating Change Proposals
### Decision Tree
```
New request?
├─ Bug fix restoring spec behavior? → Fix directly
├─ Typo/format/comment? → Fix directly
├─ New feature/capability? → Create proposal
├─ Breaking change? → Create proposal
├─ Architecture change? → Create proposal
└─ Unclear? → Create proposal (safer)
```
### Proposal Structure
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
2. **Write proposal.md:**
```markdown
## Why
[1-2 sentences on problem/opportunity]
## What Changes
- [Bullet list of changes]
- [Mark breaking changes with **BREAKING**]
## Impact
- Affected specs: [list capabilities]
- Affected code: [key files/systems]
```
3. **Create spec deltas:** `specs/[capability]/spec.md`
```markdown
## ADDED Requirements
### Requirement: New Feature
The system SHALL provide...
#### Scenario: Success case
- **WHEN** user performs action
- **THEN** expected result
## MODIFIED Requirements
### Requirement: Existing Feature
[Complete modified requirement]
## REMOVED Requirements
### Requirement: Old Feature
**Reason**: [Why removing]
**Migration**: [How to handle]
```
If multiple capabilities are affected, create multiple delta files under
`changes/[change-id]/specs/<capability>/spec.md`one per capability.
4. **Create tasks.md:**
```markdown
## 1. Implementation
- [ ] 1.1 Create database schema
- [ ] 1.2 Implement API endpoint
- [ ] 1.3 Add frontend component
- [ ] 1.4 Write tests
```
5. **Create design.md when needed:**
Create `design.md` if any of the following apply; otherwise omit it:
- Cross-cutting change (multiple services/modules) or a new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Minimal `design.md` skeleton:
```markdown
## Context
[Background, constraints, stakeholders]
## Goals / Non-Goals
- Goals: [...]
- Non-Goals: [...]
## Decisions
- Decision: [What and why]
- Alternatives considered: [Options + rationale]
## Risks / Trade-offs
- [Risk] → Mitigation
## Migration Plan
[Steps, rollback]
## Open Questions
- [...]
```
## Spec File Format
### Critical: Scenario Formatting
**CORRECT** (use #### headers):
```markdown
#### Scenario: User login success
- **WHEN** valid credentials provided
- **THEN** return JWT token
```
**WRONG** (don't use bullets or bold):
```markdown
- **Scenario: User login** ❌
**Scenario**: User login ❌
### Scenario: User login ❌
```
Every requirement MUST have at least one scenario.
### Requirement Wording
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
### Delta Operations
- `## ADDED Requirements` - New capabilities
- `## MODIFIED Requirements` - Changed behavior
- `## REMOVED Requirements` - Deprecated features
- `## RENAMED Requirements` - Name changes
Headers matched with `trim(header)` - whitespace ignored.
#### When to use ADDED vs MODIFIED
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the
change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing
requirement.
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full,
updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you
provide here; partial deltas will drop previous details.
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content)
referencing the new name.
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at
archive time. If you arent explicitly changing the existing requirement, add a new requirement under ADDED instead.
Authoring a MODIFIED requirement correctly:
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
Example for RENAMED:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Login`
- TO: `### Requirement: User Authentication`
```
## Troubleshooting
### Common Errors
**"Change must have at least one delta"**
- Check `changes/[name]/specs/` exists with .md files
- Verify files have operation prefixes (## ADDED Requirements)
**"Requirement must have at least one scenario"**
- Check scenarios use `#### Scenario:` format (4 hashtags)
- Don't use bullet points or bold for scenario headers
**Silent scenario parsing failures**
- Exact format required: `#### Scenario: Name`
- Debug with: `openspec show [change] --json --deltas-only`
### Validation Tips
```bash
# Always use strict mode for comprehensive checks
openspec validate [change] --strict
# Debug delta parsing
openspec show [change] --json | jq '.deltas'
# Check specific requirement
openspec show [spec] --json -r 1
```
## Happy Path Script
```bash
# 1) Explore current state
openspec spec list --long
openspec list
# Optional full-text search:
# rg -n "Requirement:|Scenario:" openspec/specs
# rg -n "^#|Requirement:" openspec/changes
# 2) Choose change id and scaffold
CHANGE=add-two-factor-auth
mkdir -p openspec/changes/$CHANGE/{specs/auth}
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
# 3) Add deltas (example)
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
## ADDED Requirements
### Requirement: Two-Factor Authentication
Users MUST provide a second factor during login.
#### Scenario: OTP required
- **WHEN** valid credentials are provided
- **THEN** an OTP challenge is required
EOF
# 4) Validate
openspec validate $CHANGE --strict
```
## Multi-Capability Example
```
openspec/changes/add-2fa-notify/
├── proposal.md
├── tasks.md
└── specs/
├── auth/
│ └── spec.md # ADDED: Two-Factor Authentication
└── notifications/
└── spec.md # ADDED: OTP email notification
```
auth/spec.md
```markdown
## ADDED Requirements
### Requirement: Two-Factor Authentication
...
```
notifications/spec.md
```markdown
## ADDED Requirements
### Requirement: OTP Email Notification
...
```
## Best Practices
### Simplicity First
- Default to <100 lines of new code
- Single-file implementations until proven insufficient
- Avoid frameworks without clear justification
- Choose boring, proven patterns
### Complexity Triggers
Only add complexity with:
- Performance data showing current solution too slow
- Concrete scale requirements (>1000 users, >100MB data)
- Multiple proven use cases requiring abstraction
### Clear References
- Use `file.ts:42` format for code locations
- Reference specs as `specs/auth/spec.md`
- Link related changes and PRs
### Capability Naming
- Use verb-noun: `user-auth`, `payment-capture`
- Single purpose per capability
- 10-minute understandability rule
- Split if description needs "AND"
### Change ID Naming
- Use kebab-case, short and descriptive: `add-two-factor-auth`
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
## Tool Selection Guide
| Task | Tool | Why |
|-----------------------|------|--------------------------|
| Find files by pattern | Glob | Fast pattern matching |
| Search code content | Grep | Optimized regex search |
| Read specific files | Read | Direct file access |
| Explore unknown scope | Task | Multi-step investigation |
## Error Recovery
### Change Conflicts
1. Run `openspec list` to see active changes
2. Check for overlapping specs
3. Coordinate with change owners
4. Consider combining proposals
### Validation Failures
1. Run with `--strict` flag
2. Check JSON output for details
3. Verify spec file format
4. Ensure scenarios properly formatted
### Missing Context
1. Read project.md first
2. Check related specs
3. Review recent archives
4. Ask for clarification
## Quick Reference
### Stage Indicators
- `changes/` - Proposed, not yet built
- `specs/` - Built and deployed
- `archive/` - Completed changes
### File Purposes
- `proposal.md` - Why and what
- `tasks.md` - Implementation steps
- `design.md` - Technical decisions
- `spec.md` - Requirements and behavior
### CLI Essentials
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec diff [change] # What's changing?
openspec validate --strict # Is it correct?
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.

93
openspec/project.md Normal file
View File

@@ -0,0 +1,93 @@
# Project Context
## Purpose
A comprehensive card redemption platform (卡密兑换平台) that manages various types of gift cards and payment processing,
including Apple, T-Mall, JD, Walmart, and C-Trip cards. The platform handles order processing, merchant management, user
authentication, and integration with multiple external payment providers.
## Tech Stack
- **Framework**: GoFrame v2 with heavy code generation usage
- **Language**: Go 1.21+
- **Database**: MySQL with dual database setup (kami_v2 primary, kami legacy)
- **Cache**: Redis for caching, sessions, rate limiting
- **Authentication**: JWT with TOTP support, multi-login capability
- **Authorization**: Casbin RBAC
- **Tracing**: OpenTelemetry with custom headers (x-service-token)
- **Task Scheduling**: Cron jobs with graceful shutdown
- **Container**: Docker with Kubernetes deployment
- **API**: RESTful APIs with OpenTelemetry instrumentation
## Project Conventions
### Code Style
- Go standard formatting (`gofmt`)
- Verb-led naming for change IDs (add-, update-, remove-, refactor-)
- Kebab-case for file and directory names
- Domain separation with clear business boundaries
### Architecture Patterns
- **DAO/DO/Entity pattern**: GoFrame ORM with generated data access layers
- **API-First**: Controllers generated from api/ definitions
- **Domain-Driven Design**: Each business domain has its own logic folder
- **Dual Database**: Separate connections for primary and legacy data
- **Graceful Shutdown**: Implemented for cron jobs and connection pools
- **Code Generation Workflow**: Database changes make dao make service make ctrl
### Testing Strategy
- Focused test coverage in critical business logic areas
- Unit tests for domain logic in `internal/logic/*/`
- Integration tests for external platform integrations
- Test command: `go test ./...` with verbose mode for specific packages
- Critical areas: Apple account management, T-Mall order processing, rate limiting
### Git Workflow
- **Main branch**: `develop` (primary development branch)
- **Feature branches**: Created from develop for new features
- **Commit format**: Conventional commits with type(scope): description
- **Code generation**: Automated through Makefile targets
- **Deployment**: Docker-based with auto-generated git-based tags
## Domain Context
### Business Domains
- **Card Platform Management**: Apple, T-Mall, JD, Walmart, C-Trip, and generic redemption cards
- **Order Processing**: Complete lifecycle with callbacks, status tracking, summaries
- **Merchant Management**: Configurations, deployments, hidden settings, steal rules
- **Channel & Road Management**: Business routing with road pools and entrance management
- **User Management**: Authentication, roles, authorization
- **Payment Processing**: Payment methods, deductions, statistics
- **JDCookie Management**: Cookie rotation, order processing, account management
### Key Concepts
- **Steal Rules**: Automated card acquisition logic
- **Road Pools**: Business routing management
- **Cookie Rotation**: JD account management strategy
- **Order Callbacks**: Asynchronous payment status notifications
- **Rate Limiting**: Redis-based access control
## Important Constraints
- **Security**: Built-in rate limiting, IP/device restrictions, authentication middleware
- **Performance**: Optimized for high-volume card redemption operations
- **Scalability**: Designed for multi-tenant architecture with merchant isolation
- **Compliance**: Payment processing industry standards
- **Reliability**: Graceful shutdown, error handling, monitoring integration
## External Dependencies
- **T-Mall**: OAuth gateway integration with eco.taobao.com
- **JD**: JD API integration with cookie management
- **Walmart**: Walmart API integration
- **C-Trip**: Travel platform API integration
- **Agiso**: App authentication service
- **Redis**: Caching, sessions, rate limiting
- **MySQL**: Primary and legacy data storage
- **OpenTelemetry**: Distributed tracing and monitoring