package multitable import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strings" "sync" "time" ) type Client struct { baseURL string apiKey string http *http.Client mu sync.Mutex sourceColumns map[uint64]string } func NewClient(baseURL, apiKey string, timeout time.Duration) *Client { return &Client{baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, http: &http.Client{Timeout: timeout}, sourceColumns: make(map[uint64]string)} } func (c *Client) Upsert(ctx context.Context, tableID uint64, sourceID string, data map[string]interface{}) error { sourceColumn, err := c.sourceColumn(ctx, tableID) if err != nil { return err } filters, err := json.Marshal([]map[string]interface{}{{"col": sourceColumn, "op": "eq", "val": sourceID}}) if err != nil { return fmt.Errorf("encode filters: %w", err) } endpoint := c.endpoint("tables", fmt.Sprint(tableID), "records") u, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("parse records endpoint: %w", err) } query := u.Query() query.Set("page", "1") query.Set("pageSize", "2") query.Set("filters", string(filters)) u.RawQuery = query.Encode() var listed struct { Items []struct { ID uint64 `json:"id"` } `json:"items"` } if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &listed); err != nil { return err } body := map[string]interface{}{"data": data} if len(listed.Items) == 0 { return c.doJSON(ctx, http.MethodPost, endpoint, body, nil) } return c.doJSON(ctx, http.MethodPut, c.endpoint("tables", fmt.Sprint(tableID), "records", fmt.Sprint(listed.Items[0].ID)), body, nil) } func (c *Client) sourceColumn(ctx context.Context, tableID uint64) (string, error) { c.mu.Lock() if columnID, ok := c.sourceColumns[tableID]; ok { c.mu.Unlock() return columnID, nil } c.mu.Unlock() var columns []struct { ID uint64 `json:"id"` Name string `json:"name"` } if err := c.doJSON(ctx, http.MethodGet, c.endpoint("tables", fmt.Sprint(tableID), "columns"), nil, &columns); err != nil { return "", err } for _, column := range columns { if column.Name == "来源ID" { columnID := fmt.Sprint(column.ID) c.mu.Lock() c.sourceColumns[tableID] = columnID c.mu.Unlock() return columnID, nil } } return "", fmt.Errorf("multitable table %d has no 来源ID column", tableID) } func (c *Client) endpoint(parts ...string) string { return c.baseURL + "/" + path.Join(parts...) } func (c *Client) doJSON(ctx context.Context, method, endpoint string, input interface{}, output interface{}) error { var body io.Reader if input != nil { encoded, err := json.Marshal(input) if err != nil { return fmt.Errorf("encode request: %w", err) } body = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, endpoint, body) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("X-API-Key", c.apiKey) req.Header.Set("Accept", "application/json") if input != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("call multitable API: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) if err != nil { return fmt.Errorf("read multitable response: %w", err) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("multitable API returned %d: %s", resp.StatusCode, truncate(string(data), 512)) } if output != nil && len(data) > 0 { if err := json.Unmarshal(data, output); err != nil { return fmt.Errorf("decode multitable response: %w", err) } } return nil } func truncate(value string, max int) string { if len(value) <= max { return value } return value[:max] }