237 lines
6.7 KiB
Go
237 lines
6.7 KiB
Go
|
|
package feishu_util
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|||
|
|
larksheets "github.com/larksuite/oapi-sdk-go/v3/service/sheets/v3"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// SheetMeta 工作表元信息。
|
|||
|
|
type SheetMeta struct {
|
|||
|
|
SheetID string
|
|||
|
|
Title string
|
|||
|
|
Index int
|
|||
|
|
RowCount int
|
|||
|
|
ColumnCount int
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SheetValues 读取到的单元格数据。
|
|||
|
|
type SheetValues struct {
|
|||
|
|
SpreadsheetToken string
|
|||
|
|
SheetID string
|
|||
|
|
Title string
|
|||
|
|
Range string
|
|||
|
|
Values [][]interface{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListSheets 列出电子表格下全部工作表。
|
|||
|
|
func (c *Client) ListSheets(ctx context.Context, spreadsheetToken string) ([]*SheetMeta, error) {
|
|||
|
|
req := larksheets.NewQuerySpreadsheetSheetReqBuilder().
|
|||
|
|
SpreadsheetToken(spreadsheetToken).
|
|||
|
|
Build()
|
|||
|
|
resp, err := c.Lark.Sheets.V3.SpreadsheetSheet.Query(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("查询工作表列表失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if !resp.Success() {
|
|||
|
|
return nil, fmt.Errorf("查询工作表列表失败: code=%d msg=%s log_id=%s detail=%s",
|
|||
|
|
resp.Code, resp.Msg, resp.RequestId(), larkcore.Prettify(resp.CodeError))
|
|||
|
|
}
|
|||
|
|
if resp.Data == nil {
|
|||
|
|
return nil, nil
|
|||
|
|
}
|
|||
|
|
out := make([]*SheetMeta, 0, len(resp.Data.Sheets))
|
|||
|
|
for i, s := range resp.Data.Sheets {
|
|||
|
|
meta := &SheetMeta{Index: i}
|
|||
|
|
if s.SheetId != nil {
|
|||
|
|
meta.SheetID = *s.SheetId
|
|||
|
|
}
|
|||
|
|
if s.Title != nil {
|
|||
|
|
meta.Title = *s.Title
|
|||
|
|
}
|
|||
|
|
if s.GridProperties != nil {
|
|||
|
|
if s.GridProperties.RowCount != nil {
|
|||
|
|
meta.RowCount = *s.GridProperties.RowCount
|
|||
|
|
}
|
|||
|
|
if s.GridProperties.ColumnCount != nil {
|
|||
|
|
meta.ColumnCount = *s.GridProperties.ColumnCount
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out = append(out, meta)
|
|||
|
|
}
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// FindSheet 按 sheetID 或标题查找工作表;优先 sheetID,其次精确匹配标题。
|
|||
|
|
func (c *Client) FindSheet(ctx context.Context, spreadsheetToken, sheetID, title string) (*SheetMeta, error) {
|
|||
|
|
sheets, err := c.ListSheets(ctx, spreadsheetToken)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if sheetID != "" {
|
|||
|
|
for _, s := range sheets {
|
|||
|
|
if s.SheetID == sheetID {
|
|||
|
|
return s, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil, fmt.Errorf("未找到 sheet_id=%s 的工作表", sheetID)
|
|||
|
|
}
|
|||
|
|
title = strings.TrimSpace(title)
|
|||
|
|
if title != "" {
|
|||
|
|
for _, s := range sheets {
|
|||
|
|
if s.Title == title {
|
|||
|
|
return s, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil, fmt.Errorf("未找到标题为 %q 的工作表", title)
|
|||
|
|
}
|
|||
|
|
return nil, fmt.Errorf("请提供 sheetID 或 sheet 标题")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ReadSheetValues 读取指定工作表范围。
|
|||
|
|
// cellRange 为空时按工作表实际行列读取整表(飞书 open range 列数上限约 100)。
|
|||
|
|
func (c *Client) ReadSheetValues(ctx context.Context, spreadsheetToken, sheetID, cellRange string) (*SheetValues, error) {
|
|||
|
|
if spreadsheetToken == "" || sheetID == "" {
|
|||
|
|
return nil, fmt.Errorf("spreadsheetToken / sheetID 不能为空")
|
|||
|
|
}
|
|||
|
|
if cellRange == "" {
|
|||
|
|
var err error
|
|||
|
|
cellRange, err = c.defaultSheetRange(ctx, spreadsheetToken, sheetID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
rangeExpr := cellRange
|
|||
|
|
if !strings.Contains(cellRange, "!") {
|
|||
|
|
rangeExpr = sheetID + "!" + cellRange
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
apiPath := fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values/%s", spreadsheetToken, rangeExpr)
|
|||
|
|
apiResp, err := c.Lark.Do(ctx, &larkcore.ApiReq{
|
|||
|
|
HttpMethod: http.MethodGet,
|
|||
|
|
ApiPath: apiPath,
|
|||
|
|
QueryParams: larkcore.QueryParams{"valueRenderOption": []string{"ToString"}},
|
|||
|
|
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("读取表格失败: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var raw struct {
|
|||
|
|
Code int `json:"code"`
|
|||
|
|
Msg string `json:"msg"`
|
|||
|
|
Data struct {
|
|||
|
|
Revision int `json:"revision"`
|
|||
|
|
SpreadsheetToken string `json:"spreadsheetToken"`
|
|||
|
|
ValueRange struct {
|
|||
|
|
Range string `json:"range"`
|
|||
|
|
Values [][]interface{} `json:"values"`
|
|||
|
|
} `json:"valueRange"`
|
|||
|
|
} `json:"data"`
|
|||
|
|
}
|
|||
|
|
if err := json.Unmarshal(apiResp.RawBody, &raw); err != nil {
|
|||
|
|
return nil, fmt.Errorf("解析表格响应失败: %w", err)
|
|||
|
|
}
|
|||
|
|
if raw.Code != 0 {
|
|||
|
|
return nil, fmt.Errorf("读取表格失败: code=%d msg=%s body=%s", raw.Code, raw.Msg, string(apiResp.RawBody))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &SheetValues{
|
|||
|
|
SpreadsheetToken: spreadsheetToken,
|
|||
|
|
SheetID: sheetID,
|
|||
|
|
Range: raw.Data.ValueRange.Range,
|
|||
|
|
Values: raw.Data.ValueRange.Values,
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ReadSpreadsheetURL 解析飞书链接并读取指定 sheet(按 URL 中 sheet id 或标题)。
|
|||
|
|
func (c *Client) ReadSpreadsheetURL(ctx context.Context, rawURL, sheetTitle string) (*SheetValues, error) {
|
|||
|
|
ref, err := ParseSpreadsheetURL(rawURL)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
token, err := c.ResolveSpreadsheetToken(ctx, ref)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
meta, err := c.FindSheet(ctx, token, ref.SheetID, sheetTitle)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
values, err := c.ReadSheetValues(ctx, token, meta.SheetID, "")
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
values.Title = meta.Title
|
|||
|
|
return values, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (c *Client) defaultSheetRange(ctx context.Context, spreadsheetToken, sheetID string) (string, error) {
|
|||
|
|
sheets, err := c.ListSheets(ctx, spreadsheetToken)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
rows, cols := 200, 26
|
|||
|
|
for _, s := range sheets {
|
|||
|
|
if s.SheetID != sheetID {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if s.RowCount > 0 {
|
|||
|
|
rows = s.RowCount
|
|||
|
|
}
|
|||
|
|
if s.ColumnCount > 0 {
|
|||
|
|
cols = s.ColumnCount
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
// 飞书 values 接口对形如 A:Z 的开放范围最多约 100 列
|
|||
|
|
if cols > 100 {
|
|||
|
|
cols = 100
|
|||
|
|
}
|
|||
|
|
if rows < 1 {
|
|||
|
|
rows = 1
|
|||
|
|
}
|
|||
|
|
return fmt.Sprintf("A1:%s%d", colIndexToName(cols), rows), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// colIndexToName 将 1-based 列序号转为 Excel 列名(1->A, 26->Z, 27->AA)。
|
|||
|
|
func colIndexToName(index int) string {
|
|||
|
|
if index < 1 {
|
|||
|
|
index = 1
|
|||
|
|
}
|
|||
|
|
name := make([]byte, 0, 4)
|
|||
|
|
for index > 0 {
|
|||
|
|
index--
|
|||
|
|
name = append([]byte{byte('A' + index%26)}, name...)
|
|||
|
|
index /= 26
|
|||
|
|
}
|
|||
|
|
return string(name)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CheckReadPermission 验证应用是否具备读取目标飞书表格的权限。
|
|||
|
|
// 成功时返回可访问的工作表元信息;失败时返回飞书错误详情,便于排查权限/授权。
|
|||
|
|
func (c *Client) CheckReadPermission(ctx context.Context, rawURL, sheetTitle string) (*SheetMeta, string, error) {
|
|||
|
|
ref, err := ParseSpreadsheetURL(rawURL)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, "", err
|
|||
|
|
}
|
|||
|
|
token, err := c.ResolveSpreadsheetToken(ctx, ref)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, "", err
|
|||
|
|
}
|
|||
|
|
meta, err := c.FindSheet(ctx, token, ref.SheetID, sheetTitle)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, token, err
|
|||
|
|
}
|
|||
|
|
// 再读一小段确认内容读权限(不仅是元信息)
|
|||
|
|
if _, err := c.ReadSheetValues(ctx, token, meta.SheetID, "A1:A1"); err != nil {
|
|||
|
|
return meta, token, fmt.Errorf("可列举工作表,但读取内容失败(请检查 sheets 读权限及文档授权): %w", err)
|
|||
|
|
}
|
|||
|
|
return meta, token, nil
|
|||
|
|
}
|