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
|
||
}
|