Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const credential = await qdmp.auth.getUserAccessToken(code)
// 调业务接口:accessToken 每次显式传进去
const me = await qdmp.user.me({ accessToken: credential.accessToken })
await qdmp.mark.add({ accessToken: credential.accessToken }, { spuId: '123', rating: { value: 5 } })
await qdmp.mark.batchAdd({ accessToken: credential.accessToken }, { spuIds: ['123', '456'] })
await qdmp.comment.create({ accessToken: credential.accessToken }, { postId: '123', content: '很喜欢' })

// accessToken 过期了,自己拿 refreshToken 换新的(SDK 不代管,也不自动重试)
const fresh = await qdmp.auth.refreshToken(credential.refreshToken)
Expand Down Expand Up @@ -123,7 +125,7 @@ appCredential, err := client.Auth.GetAppAccessToken(ctx)
| 分组 | 要求 |
|---|---|
| `auth.*` | 不需要凭证(用 appId/appSecret 或 refreshToken) |
| `user.me` / `mark.*` / `wishspu.*` | 必须是用户授权凭证,缺失时本地直接报错,不发请求 |
| `user.me` / `mark.*` / `wishspu.*` / `post.*` / `comment.*` | 必须是用户授权凭证,缺失时本地直接报错,不发请求 |
| `island.*` / `spu.*` / `tag.*` / `genai.*` | 应用凭证也实测调通过,但 SDK 不做静默 fallback,传哪种由调用方决定 |

`x-echo-qdmp-version` 头只在 `standard` 鉴权方案下发送,`genai` 分组用 `x-openapi-access-token` + `x-openapi-app-id` 另一对头,`auth` 分组不需要——三端都从 `shared/generated/route-meta.json` 读取每个 operation 的 `authScheme`/`tokenRequired`。
Expand Down
51 changes: 50 additions & 1 deletion go/auth_contract_matrix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/http"
"os"
"sort"
"strings"
"sync"
"testing"

Expand Down Expand Up @@ -158,6 +159,41 @@ func allBusinessOps() []businessOp {
_, err := c.Mark.Add(ctx, q, generated.MarkAddJSONBody{SpuId: "123"})
return err
}},
{"markBatchAdd", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Mark.BatchAdd(ctx, q, generated.MarkBatchAddJSONRequestBody{SpuIds: []string{"1"}})
return err
}},
{"postDetail", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Post.Detail(ctx, q, "1")
return err
}},
{"postList", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Post.List(ctx, q, generated.PostListParams{})
return err
}},
{"postMyList", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Post.MyList(ctx, q, generated.PostMyListParams{})
return err
}},
{"commentCreate", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Comment.Create(ctx, q, generated.CommentCreateJSONRequestBody{PostId: "1", Content: "ok"})
return err
}},
{"commentReply", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Comment.Reply(ctx, q, "1", generated.CommentReplyJSONRequestBody{Content: "ok"})
return err
}},
{"commentLike", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
return c.Comment.Like(ctx, q, "1", generated.CommentLikeJSONRequestBody{Liked: true})
}},
{"postComments", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Comment.PostComments(ctx, q, "1", generated.PostCommentsParams{})
return err
}},
{"commentReplies", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Comment.Replies(ctx, q, "1", generated.CommentRepliesParams{})
return err
}},
{"markList", func(c *qdmp.Client, ctx context.Context, q qdmp.Context) error {
_, err := c.Mark.List(ctx, q, generated.MarkListParams{Limit: "20", Offset: "0"})
return err
Expand Down Expand Up @@ -283,7 +319,12 @@ func TestBusinessOps_TokenComesFromPerCallContext(t *testing.T) {
// The table row must actually call the endpoint route-meta says it
// does, otherwise a mis-wired row could satisfy the header check
// while exercising a different operation entirely.
if method != route.Method || path != route.Path {
pathMatches := path == route.Path
if open := strings.Index(route.Path, "{"); open >= 0 {
close := strings.Index(route.Path[open:], "}")
pathMatches = close > 0 && strings.HasPrefix(path, route.Path[:open]) && strings.HasSuffix(path, route.Path[open+close+1:])
}
if method != route.Method || !pathMatches {
t.Fatalf("%s called %s %s, want %s %s per %s",
op.operationID, method, path, route.Method, route.Path, routeMetaPath)
}
Expand Down Expand Up @@ -328,6 +369,14 @@ func TestBusinessOps_EmptyContextFailsLocally(t *testing.T) {
if !errors.Is(err, qdmp.ErrAccessTokenRequired) {
t.Fatalf("%s error = %v, want errors.Is(..., ErrAccessTokenRequired)", op.operationID, err)
}
requireSpecificOperationName := map[string]bool{
"postDetail": true, "postList": true, "postMyList": true,
"commentCreate": true, "commentReply": true, "commentLike": true,
"postComments": true, "commentReplies": true,
}
if requireSpecificOperationName[op.operationID] && !strings.Contains(err.Error(), op.operationID) {
t.Fatalf("%s error = %q, want operationId in missing-token message", op.operationID, err)
}
if errors.Is(err, qdmp.ErrInvalidAccessToken) {
t.Fatalf("%s: a missing token must not be reported as ErrInvalidAccessToken (err = %v)",
op.operationID, err)
Expand Down
4 changes: 4 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ type Client struct {
Spu *SpuGroup
Tag *TagGroup
Mark *MarkGroup
Post *PostGroup
Comment *CommentGroup
WishSpu *WishSpuGroup
GenAI *GenAIGroup
}
Expand Down Expand Up @@ -146,6 +148,8 @@ func NewClient(opts ClientOptions) (*Client, error) {
c.Spu = &SpuGroup{client: c}
c.Tag = &TagGroup{client: c}
c.Mark = &MarkGroup{client: c}
c.Post = &PostGroup{client: c}
c.Comment = &CommentGroup{client: c}
c.WishSpu = &WishSpuGroup{client: c}
c.GenAI = &GenAIGroup{client: c}
return c, nil
Expand Down
130 changes: 130 additions & 0 deletions go/comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package qdmp

import (
"context"
"encoding/json"
"fmt"
"github.com/EchoTechFE/qdmp-server-sdk/go/generated"
"net/http"
"net/url"
"regexp"
)

type CommentGroup struct{ client *Client }

var positiveIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`)

// CommentCreateResult is the identifier returned after creating a comment or reply.
type CommentCreateResult struct {
ID string `json:"id"`
}

// PostCommentsResult is a page of top-level comments and their first replies.
type PostCommentsResult struct {
Items []generated.CommentThread `json:"items"`
Count int32 `json:"count"`
}

// CommentRepliesResult is a cursor page of replies to a top-level comment.
type CommentRepliesResult struct {
Items []generated.CommentItem `json:"items"`
HasMore bool `json:"hasMore"`
Cursor string `json:"cursor"`
Count int32 `json:"count"`
}

func (g *CommentGroup) Create(ctx context.Context, q Context, b generated.CommentCreateJSONRequestBody) (*CommentCreateResult, error) {
return g.postID(ctx, q, "commentCreate", "/comment", b)
}
func (g *CommentGroup) Reply(ctx context.Context, q Context, id string, b generated.CommentReplyJSONRequestBody) (*CommentCreateResult, error) {
if err := requireAccessToken(q, "commentReply"); err != nil {
return nil, err
}
if err := validatePositiveID("commentId", id); err != nil {
return nil, err
}
return g.postID(ctx, q, "commentReply", "/comment/"+id+"/reply", b)
}
func (g *CommentGroup) Like(ctx context.Context, q Context, id string, b generated.CommentLikeJSONRequestBody) error {
if err := requireAccessToken(q, "commentLike"); err != nil {
return err
}
if err := validatePositiveID("commentId", id); err != nil {
return err
}
_, err := g.client.doRequest(ctx, requestParams{method: http.MethodPost, path: "/comment/" + url.PathEscape(id) + "/like", jsonBody: b, authScheme: authSchemeStandard, accessToken: q.AccessToken})
return err
}

// PostComments returns top-level comments and their first replies for a post.
func (g *CommentGroup) PostComments(ctx context.Context, q Context, postID string, params generated.PostCommentsParams) (*PostCommentsResult, error) {
if err := requireAccessToken(q, "postComments"); err != nil {
return nil, err
}
if err := validatePositiveID("postId", postID); err != nil {
return nil, err
}
query := url.Values{}
if params.Limit != nil {
query.Set("limit", *params.Limit)
}
if params.Offset != nil {
query.Set("offset", *params.Offset)
}
data, err := g.client.doRequest(ctx, requestParams{method: http.MethodGet, path: "/post/" + postID + "/comments", query: query, authScheme: authSchemeStandard, accessToken: q.AccessToken})
if err != nil {
return nil, err
}
var result PostCommentsResult
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("qdmp: decode post comments response: %w", err)
}
return &result, nil
}

// Replies returns a page of replies for a top-level comment.
func (g *CommentGroup) Replies(ctx context.Context, q Context, commentID string, params generated.CommentRepliesParams) (*CommentRepliesResult, error) {
if err := requireAccessToken(q, "commentReplies"); err != nil {
return nil, err
}
if err := validatePositiveID("commentId", commentID); err != nil {
return nil, err
}
query := url.Values{}
if params.Limit != nil {
query.Set("limit", *params.Limit)
}
if params.Cursor != nil {
query.Set("cursor", *params.Cursor)
}
data, err := g.client.doRequest(ctx, requestParams{method: http.MethodGet, path: "/comment/" + commentID + "/replies", query: query, authScheme: authSchemeStandard, accessToken: q.AccessToken})
if err != nil {
return nil, err
}
var result CommentRepliesResult
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("qdmp: decode comment replies response: %w", err)
}
return &result, nil
}
func (g *CommentGroup) postID(ctx context.Context, q Context, operationID, path string, b any) (*CommentCreateResult, error) {
if err := requireAccessToken(q, operationID); err != nil {
return nil, err
}
data, err := g.client.doRequest(ctx, requestParams{method: http.MethodPost, path: path, jsonBody: b, authScheme: authSchemeStandard, accessToken: q.AccessToken})
if err != nil {
return nil, err
}
var v CommentCreateResult
if err = json.Unmarshal(data, &v); err != nil {
return nil, fmt.Errorf("qdmp: decode comment response: %w", err)
}
return &v, nil
}

func validatePositiveID(name, value string) error {
if !positiveIDPattern.MatchString(value) {
return fmt.Errorf("qdmp: %s must be a positive integer ID", name)
}
return nil
}
Loading
Loading