348 lines
9.7 KiB
Go
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")
|
|
}
|