2026-09-17 18:49:22 +08:00

237 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}