237 lines
6.7 KiB
Go
Raw Permalink Normal View History

2026-09-17 18:49:22 +08:00
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
}