Files
dobotai-platform/internal/questiontable/client.go
2026-08-13 13:14:06 +08:00

348 lines
9.7 KiB
Go

package questiontable
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Default DingTalk API base.
const defaultBase = "https://api.dingtalk.com"
// Config holds the required parameters for the DingTalk question-table client.
type Config struct {
ClientID string
Secret string
BaseID string
SheetID string
OperatorID string
FieldMapping map[string]string
// TokenURL overrides the token endpoint. Empty → default.
TokenURL string
// BaseURL overrides the API base. Empty → default.
BaseURL string
}
// defaultTransport is a clone of http.DefaultTransport with Proxy=nil so the
// default client never uses a proxy (even from env). Shared across clients.
var defaultTransport = func() *http.Transport {
dt, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{
Proxy: nil,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
}
// Clone, then clear Proxy.
clone := dt.Clone()
clone.Proxy = nil
return clone
}()
// Client is a stdlib-only DingTalk HTTP client that inserts records into a
// Notable v1 base. It owns a mutex-protected in-memory access-token cache.
type Client struct {
clientID string
secret string
baseID string
sheetID string
operatorID string
fieldMapping map[string]string
tokenURL string
baseURL string
http *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// NewClient creates a validated Client. It returns an error when any of
// clientID, secret, baseID, sheetID, or operatorID is empty.
// httpClient may be nil; a default is used in that case.
func NewClient(cfg Config, httpClient *http.Client) (*Client, error) {
// Trim and validate.
clientID := strings.TrimSpace(cfg.ClientID)
if clientID == "" {
return nil, errors.New("questiontable: client id is required")
}
secret := strings.TrimSpace(cfg.Secret)
if secret == "" {
return nil, errors.New("questiontable: secret is required")
}
baseID := strings.TrimSpace(cfg.BaseID)
if baseID == "" {
return nil, errors.New("questiontable: base id is required")
}
sheetID := strings.TrimSpace(cfg.SheetID)
if sheetID == "" {
return nil, errors.New("questiontable: sheet id is required")
}
operatorID := strings.TrimSpace(cfg.OperatorID)
if operatorID == "" {
return nil, errors.New("questiontable: operator id is required")
}
if httpClient == nil {
httpClient = &http.Client{
Transport: defaultTransport,
Timeout: 30 * time.Second,
}
}
tokenURL := strings.TrimSpace(cfg.TokenURL)
if tokenURL == "" {
tokenURL = defaultBase + "/v1.0/oauth2/accessToken"
}
baseURL := strings.TrimSpace(cfg.BaseURL)
if baseURL == "" {
baseURL = defaultBase
}
return &Client{
clientID: clientID,
secret: secret,
baseID: baseID,
sheetID: sheetID,
operatorID: operatorID,
fieldMapping: cloneFieldMapping(cfg.FieldMapping),
tokenURL: tokenURL,
baseURL: baseURL,
http: httpClient,
}, nil
}
// TokenURL returns the configured token URL (test seam).
func (c *Client) TokenURL() string { return c.tokenURL }
// BaseURL returns the configured API base (test seam).
func (c *Client) BaseURL() string { return c.baseURL }
// insertRecordReq is the JSON body for the Notable v1 insertRecord endpoint.
type insertRecordReq struct {
Records []insertRecord `json:"records"`
}
type insertRecord struct {
Fields map[string]interface{} `json:"fields"`
}
// insertRecordResp is the success response body.
type insertRecordResp struct {
Value []struct {
ID string `json:"id"`
} `json:"value"`
}
// tokenResp is the access-token response body.
type tokenResp struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
// InsertRecord sends a single question-table row to DingTalk Notable v1.
// It remains the compatibility entry point for the existing question-table dispatcher.
func (c *Client) InsertRecord(ctx context.Context, row Row) (string, error) {
return c.InsertFields(ctx, row.ToFields())
}
// InsertFields sends one already-typed field map to DingTalk Notable v1 and
// returns the record ID. It is shared by other reviewed Notable table schemas.
func (c *Client) InsertFields(ctx context.Context, fields map[string]interface{}) (string, error) {
fields = c.remapFields(fields)
token, err := c.getAccessToken(ctx)
if err != nil {
return "", fmt.Errorf("questiontable token: %w", err)
}
path := fmt.Sprintf("/v1.0/notable/bases/%s/sheets/%s/records",
url.PathEscape(c.baseID), url.PathEscape(c.sheetID))
reqURL := c.baseURL + path + "?operatorId=" + url.QueryEscape(c.operatorID)
body := insertRecordReq{Records: []insertRecord{{Fields: fields}}}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("questiontable marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(jsonBody))
if err != nil {
return "", fmt.Errorf("questiontable request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.http.Do(req)
if err != nil {
return "", safeDoErr(ctx, err)
}
defer resp.Body.Close()
// Read bounded response body.
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return "", fmt.Errorf("questiontable read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("questiontable insert returned HTTP %d", resp.StatusCode)
}
var ir insertRecordResp
if err := json.Unmarshal(raw, &ir); err != nil {
return "", fmt.Errorf("questiontable decode response: %w", err)
}
if len(ir.Value) == 0 {
return "", errors.New("questiontable empty value list")
}
if strings.TrimSpace(ir.Value[0].ID) == "" {
return "", errors.New("questiontable empty record id")
}
return ir.Value[0].ID, nil
}
func cloneFieldMapping(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
var providerFieldLogical = map[string]string{
"ZaMZF6j": "chinese_question", "SCug9My": "asked_time", "S2XA1uZ": "asker",
"bETcL3r": "original_question", "Y4cpmxU": "answer", "JgY2f1z": "trigger_person",
"DNIV3mQ": "language", "eBc6a6b": "department", "fPNLeVW": "region",
"01ZM8y7": "standard_question", "fj3JQ1C": "original_question", "07YYagS": "language", "Eu7gkKu": "asker",
}
func (c *Client) remapFields(in map[string]interface{}) map[string]interface{} {
if len(c.fieldMapping) == 0 {
return in
}
out := make(map[string]interface{}, len(in))
for providerID, value := range in {
logical := providerFieldLogical[providerID]
if mapped := c.fieldMapping[logical]; logical != "" && mapped != "" {
out[mapped] = value
} else {
out[providerID] = value
}
}
return out
}
// safeDoErr wraps an http.Do error without exposing the raw URL or credentials.
// If the context is already done, returns a wrapped ctx.Err() so errors.Is works.
func safeDoErr(ctx context.Context, err error) error {
if ctx.Err() != nil {
return fmt.Errorf("questiontable insert: %w", ctx.Err())
}
return errors.New("questiontable insert: network error")
}
// getAccessToken returns a cached token or fetches a new one.
func (c *Client) getAccessToken(ctx context.Context) (string, error) {
c.mu.Lock()
if c.token != "" && time.Now().Before(c.tokenExp) {
tok := c.token
c.mu.Unlock()
return tok, nil
}
c.mu.Unlock()
return c.fetchToken(ctx)
}
// fetchToken calls the DingTalk OAuth2 endpoint and caches the result.
func (c *Client) fetchToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Double-check inside lock.
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body := map[string]string{
"appKey": c.clientID,
"appSecret": c.secret,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("questiontable token marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, bytes.NewReader(jsonBody))
if err != nil {
return "", fmt.Errorf("questiontable token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", safeTokenDoErr(ctx, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return "", fmt.Errorf("questiontable token read: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("questiontable token returned HTTP %d", resp.StatusCode)
}
var tr tokenResp
if err := json.Unmarshal(raw, &tr); err != nil {
return "", fmt.Errorf("questiontable token decode: %w", err)
}
if strings.TrimSpace(tr.AccessToken) == "" {
return "", errors.New("questiontable empty access token")
}
// Token expiry must never be extended beyond DingTalk's expireIn.
if tr.ExpireIn <= 0 {
return "", errors.New("questiontable token: non-positive expireIn")
}
exp := time.Duration(tr.ExpireIn) * time.Second
// For very short TTL (<=30s), cache for a positive fraction (TTL/2).
if exp <= 30*time.Second {
exp = exp / 2
} else {
// Safety margin: subtract 30 seconds from expiry.
exp -= 30 * time.Second
}
c.token = tr.AccessToken
c.tokenExp = time.Now().Add(exp)
return c.token, nil
}
// safeTokenDoErr wraps an http.Do error from token fetch without exposing raw URL or credentials.
func safeTokenDoErr(ctx context.Context, err error) error {
if ctx.Err() != nil {
return fmt.Errorf("questiontable token: %w", ctx.Err())
}
return errors.New("questiontable token: network error")
}