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 }