Files

478 lines
20 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package report
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification."
type reportPageEvidence struct {
HasMore bool
Next string
}
func reportReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func reportCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
description, collection, description, collection,
)),
SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"},
}
}
func reportTemplateSearchResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`),
SensitivePaths: []string{"templates.name"},
}
}
func reportLatestResult() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`),
SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"},
}
}
func reportPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "report " + command
return corecmd.ContractDecl{
Description: description,
Result: result,
Pagination: pagination,
Parameters: params,
Identity: contract.ToolIdentitySpec{
ProductID: "report", Name: name, CanonicalPath: "report." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"},
Examples: examples,
},
}
}
func reportResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func reportRequireSuccess(data map[string]any, operation string) error {
if len(data) == 0 {
return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空")
}
raw, present := data["success"]
if !present {
return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态")
}
success, ok := raw.(bool)
if !ok {
return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw))
}
if success {
return nil
}
message := "Report 服务明确返回失败"
for _, key := range []string{"errorMsg", "errorMessage", "message"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
message = strings.TrimSpace(value)
break
}
}
return reportResponseError(operation, "remote_failure", message)
}
func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value))
}
items := make([]map[string]any, 0, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index))
}
items = append(items, object)
}
return items, nil
}
func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, nil, err
}
result, present := data["result"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合")
}
if _, ok := result.([]any); ok {
items, err := reportObjectCollection(result, operation, "result")
return items, data, err
}
container, ok := result.(map[string]any)
if !ok || container == nil {
return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result))
}
raw, present := container["report_list"]
if !present {
return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组")
}
items, err := reportObjectCollection(raw, operation, "result.report_list")
return items, container, err
}
func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) {
var selected string
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key))
}
if selected != "" && selected != value {
return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index))
}
selected = value
}
if selected == "" {
return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index))
}
return selected, nil
}
func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
if !ok {
return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw))
}
return strings.TrimSpace(value), nil
}
return "", nil
}
func reportInteger(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
return 0, false
}
return int64(typed), true
case int:
return int64(typed), true
case int64:
return typed, true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) {
for _, key := range keys {
raw, present := item[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key))
}
return value, true, nil
}
return 0, false, nil
}
func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) {
items, pageContainer, err := reportListCollection(data, operation)
if err != nil {
return nil, reportPageEvidence{}, err
}
entries := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id")
if identityErr != nil {
return nil, reportPageEvidence{}, identityErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId")
}
seen[id] = struct{}{}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"},
"creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"},
"creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"},
} {
value, valueErr := reportOptionalString(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time", "gmtCreate", "sendTime"},
"modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"},
} {
value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...)
if valueErr != nil {
return nil, reportPageEvidence{}, valueErr
}
if present {
projected[target] = value
}
}
entries = append(entries, projected)
}
page, err := reportPage(data, pageContainer, operation, len(entries))
if err != nil {
return nil, reportPageEvidence{}, err
}
return entries, page, nil
}
func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) {
raw, present := data["hasMore"]
if nested, exists := result["hasMore"]; exists {
if present && !sameReportScalar(raw, nested) {
return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突")
}
raw, present = nested, true
}
if !present {
return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果")
}
hasMore, ok := raw.(bool)
if !ok {
return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值")
}
page := reportPageEvidence{HasMore: hasMore}
next, nextPresent, err := reportNextCursor(data, result, operation)
if err != nil {
return reportPageEvidence{}, err
}
if !hasMore {
// The Report service echoes an integer cursor on terminal pages. It is a
// page receipt, not a continuation: hasMore=false is authoritative and
// the unified projection intentionally omits next_token. Parsing above
// still rejects wrong types and conflicting cursor fields.
return page, nil
}
if count == 0 {
return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true")
}
if !nextPresent || next <= 0 {
return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor")
}
page.Next = strconv.FormatInt(next, 10)
return page, nil
}
func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) {
var selected int64
found := false
for _, object := range []map[string]any{data, result} {
for _, key := range []string{"nextCursor", "cursor"} {
raw, present := object[key]
if !present || raw == nil {
continue
}
value, ok := reportInteger(raw)
if !ok {
return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key))
}
if found && selected != value {
return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突")
}
selected, found = value, true
}
}
return selected, found, nil
}
func sameReportScalar(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON)
}
func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
raw, present := data["items"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组")
}
items, err := reportObjectCollection(raw, operation, "items")
if err != nil {
return nil, err
}
templates := make([]map[string]any, 0, len(items))
seen := make(map[string]struct{}, len(items))
for index, item := range items {
id, identityErr := reportRequiredString(item, operation, index, "report_template_id")
if identityErr != nil {
return nil, identityErr
}
name, nameErr := reportRequiredString(item, operation, index, "report_template_name")
if nameErr != nil {
return nil, nameErr
}
if _, duplicate := seen[id]; duplicate {
return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId")
}
seen[id] = struct{}{}
projected := map[string]any{"templateId": id, "name": name}
modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time")
if modifiedErr != nil {
return nil, modifiedErr
}
if present {
projected["lastModifiedTime"] = modified
}
templates = append(templates, projected)
}
return templates, nil
}
func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) {
if err := reportRequireSuccess(data, operation); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok || len(result) == 0 {
return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象")
}
id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id")
if err != nil {
return nil, err
}
if id != expectedID {
return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致")
}
projected := map[string]any{"reportId": id}
for target, aliases := range map[string][]string{
"title": {"report_name", "reportName", "title"},
"templateName": {"report_template_name", "templateName"},
"creatorName": {"creatorName", "senderName"},
} {
value, valueErr := reportOptionalString(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if value != "" {
projected[target] = value
}
}
for target, aliases := range map[string][]string{
"createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"},
} {
value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...)
if valueErr != nil {
return nil, valueErr
}
if present {
projected[target] = value
}
}
rawFields, present := result["report_content"]
if !present {
return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组")
}
fields, err := reportObjectCollection(rawFields, operation, "result.report_content")
if err != nil {
return nil, err
}
projectedFields := make([]map[string]any, 0, len(fields))
for index, field := range fields {
key, keyErr := reportRequiredString(field, operation, index, "key")
if keyErr != nil {
return nil, keyErr
}
rawValue, valuePresent := field["value"]
value, valueOK := rawValue.(string)
if !valuePresent || !valueOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index))
}
sortValue, sortOK := reportInteger(field["sort"])
typeValue, typeOK := reportInteger(field["type"])
if !sortOK || !typeOK {
return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index))
}
projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue})
}
projected["fields"] = projectedFields
return projected, nil
}
func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error {
payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore}
if !output.UsesUnifiedResult(rt.Command()) {
if page.HasMore {
payload["nextCursor"] = page.Next
}
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.Next)
if err != nil {
return reportResponseError("report/pagination", "invalid_pagination", err.Error())
}
meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination}
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
}