# 用量明细 CSV 解析最佳实践

> 全部文档的索引见 [llms.txt](https://ark.volcengine.com/llms.txt)；在任一文档页面地址后追加 `.md` 可获取该页的 Markdown 版本。

本文介绍如何解析通过 [查询用量明细导出任务状态](/docs/get-record-export-task-api) 返回的 *DownloadUrl* 下载得到的 CSV 文件，重点说明切换时刻、生效规则、`charge_value_list` 字段结构、兼容策略和多语言示例代码。

## 切换时刻与生效规则

`GetRecordExportTask` 每次调用按导出任务查询窗口的结束时间 *EndTime* 决定该任务的 CSV 使用哪种格式。切换时刻按调用用户分为两类：

- 2026 年 9 月 2 日前已产生调用的用户：切换时刻为 2026 年 9 月 15 日。
- 2026 年 9 月 2 日及以后新增调用的用户：切换时刻为 2026 年 9 月 2 日。

生效规则如下：

- *EndTime* 早于或等于对应切换时刻：CSV 返回旧格式。
- *EndTime* 晚于对应切换时刻：CSV 返回新格式。

任务提交后，该任务对应的 CSV 字段格式保持不变，不会在轮询期间发生切换。

## CSV 格式变更说明

`GetRecordExportTask` 接口的请求参数、响应参数以及 `DownloadUrl` 的生成方式保持不变。本次变更仅影响通过 `DownloadUrl` 下载的 CSV 文件内容格式。

- 旧格式：18 列。
- 新格式：16 列。
- 删除字段：`input_token_charge_item`、`output_token_charge_item`、`cache_hit_token_charge_item`。
- 新增字段：`charge_value_list`。该字段位于 `volc_instance_id` 之后、`timestamp` 之前，用于统一返回各计费项信息。
- 其他字段：其余字段名称、顺序和含义保持不变。

## 新旧 CSV 对比

```text
旧格式（18 列）
... | volc_instance_id | input_token_charge_item | output_token_charge_item | cache_hit_token_charge_item | timestamp | pt_hour

新格式（16 列）
... | volc_instance_id | charge_value_list | timestamp | pt_hour
```

## charge_value_list 字段说明

当导出任务对应的 CSV 使用新格式时，`charge_value_list` 字段的单元格值为 JSON 数组字符串，数组中的每个元素表示一个计费项。

示例如下：

```json
[
  {"code":"Doubao-lite-32k-inference-prompt_cn-beijing_realtime","value":54,"scale":1000},
  {"code":"Doubao-lite-32k-inference-completion_cn-beijing_realtime","value":5,"scale":1000},
  {"code":"Doubao-lite-32k-inference-KVcache-hit_cn-beijing_realtime","value":0,"scale":1000}
]
```

字段说明：

- `code`：计费项名称，对应旧格式中的 `*_charge_item` 字段值。
- `value`：整数计量值，例如 token 数或其他计费计量值。
- `scale`：整数缩放因子。

真实计费值为 `value / scale`。

## 接入建议

- 如需同时兼容历史 CSV 与新 CSV：建议优先按列名判断；若读取链路依赖列位置，也可按列数 `18` 或 `16` 做分支处理。
- 当读取到 `charge_value_list` 字段时：需先执行 JSON 解析，再遍历数组元素获取各计费项的真实计费值。
- 若已有旧逻辑依赖 `input_token_charge_item`、`output_token_charge_item`、`cache_hit_token_charge_item` 三列：需迁移为读取 `charge_value_list` 后按 `code` 分发处理。

## 注意事项

- 该变更仅影响通过 *DownloadUrl* 下载的 CSV 文件内容，不影响 `GetRecordExportTask` 接口的请求参数、响应参数 schema 和 `DownloadUrl` 字段本身的返回逻辑。
- 建议在任务完成并拿到 `DownloadUrl` 后尽快下载文件。下载链接有效期为 12 小时，过期后需重新提交任务以获取新链接。
- 若业务需要长期兼容历史导出结果，建议同时保留旧格式和新格式的解析分支，不要只按固定列位置解析。

## 示例代码

以下示例用于读取通过 *DownloadUrl* 下载得到的 CSV 文件，解析 `charge_value_list` 字段中的 JSON 数组字符串，并输出每条记录的请求 ID、各计费项的真实计费值，以及汇总后的 `total_billing`。你可以将其作为本地排查、校验 CSV 解析逻辑或接入业务系统前的验证脚本。

**选项：Python**

```Python
import csv
import json
import sys


def main() -> None:
    if len(sys.argv) != 2:
        raise SystemExit("Usage: python parse_charge_value_list.py <csv_path>")

    with open(sys.argv[1], newline="", encoding="utf-8") as file:
        reader = csv.DictReader(file)
        for row in reader:
            items = json.loads(row["charge_value_list"])
            total_billing = 0.0

            print(f"req_id={row.get('req_id', '')}")
            for item in items:
                billing = item["value"] / item["scale"]
                total_billing += billing
                print(f"  - {item['code']}: {billing:.6f}")

            print(f"total_billing={total_billing:.6f}")


if __name__ == "__main__":
    main()
```

**选项：Go**

```Go
package main

import (
	"encoding/csv"
	"encoding/json"
	"fmt"
	"io"
	"os"
)

type ChargeValue struct {
	Code  string `json:"code"`
	Value int64  `json:"value"`
	Scale int64  `json:"scale"`
}

func main() {
	if len(os.Args) != 2 {
		panic("usage: go run main.go <csv_path>")
	}

	file, err := os.Open(os.Args[1])
	if err != nil {
		panic(err)
	}
	defer file.Close()

	reader := csv.NewReader(file)
	header, err := reader.Read()
	if err != nil {
		panic(err)
	}

	index := map[string]int{}
	for i, name := range header {
		index[name] = i
	}

	for {
		row, err := reader.Read()
		if err != nil {
			if err == io.EOF {
				break
			}
			panic(err)
		}

		var items []ChargeValue
		if err := json.Unmarshal([]byte(row[index["charge_value_list"]]), &items); err != nil {
			panic(err)
		}

		totalBilling := 0.0
		fmt.Printf("req_id=%s\n", row[index["req_id"]])
		for _, item := range items {
			billing := float64(item.Value) / float64(item.Scale)
			totalBilling += billing
			fmt.Printf("  - %s: %.6f\n", item.Code, billing)
		}
		fmt.Printf("total_billing=%.6f\n", totalBilling)
	}
}
```

**选项：Java**

```Java
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class ParseChargeValueList {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    private static class ChargeValue {
        public String code;
        public double value;
        public double scale;
    }

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new IllegalArgumentException("usage: java ParseChargeValueList <csv_path>");
        }

        try (BufferedReader reader = new BufferedReader(new FileReader(args[0]))) {
            List<String> header = parseCsvLine(reader.readLine());
            Map<String, Integer> index = new HashMap<>();

            for (int i = 0; i < header.size(); i++) {
                index.put(header.get(i), i);
            }

            String line;
            while ((line = reader.readLine()) != null) {
                List<String> row = parseCsvLine(line);
                List<ChargeValue> items = OBJECT_MAPPER.readValue(
                        row.get(index.get("charge_value_list")),
                        new TypeReference<List<ChargeValue>>() {}
                );
                double totalBilling = 0.0;

                System.out.println("req_id=" + row.get(index.get("req_id")));
                for (ChargeValue item : items) {
                    double billing = item.value / item.scale;
                    totalBilling += billing;
                    System.out.printf("  - %s: %.6f%n", item.code, billing);
                }
                System.out.printf("total_billing=%.6f%n", totalBilling);
            }
        }
    }

    private static List<String> parseCsvLine(String line) {
        List<String> result = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        boolean inQuotes = false;

        for (int i = 0; i < line.length(); i++) {
            char ch = line.charAt(i);
            if (inQuotes) {
                if (ch == '"') {
                    if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
                        current.append('"');
                        i++;
                    } else {
                        inQuotes = false;
                    }
                } else {
                    current.append(ch);
                }
            } else {
                if (ch == '"') {
                    inQuotes = true;
                } else if (ch == ',') {
                    result.add(current.toString());
                    current.setLength(0);
                } else {
                    current.append(ch);
                }
            }
        }

        result.add(current.toString());
        return result;
    }
}
```

**选项：C++**

```C++
#include <fstream>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <vector>

#include <nlohmann/json.hpp>

struct ChargeValue {
    std::string code;
    double value;
    double scale;
};

std::vector<std::string> ParseCsvLine(const std::string& line) {
    std::vector<std::string> result;
    std::string current;
    bool in_quotes = false;

    for (size_t i = 0; i < line.size(); ++i) {
        char ch = line[i];
        if (in_quotes) {
            if (ch == '"') {
                if (i + 1 < line.size() && line[i + 1] == '"') {
                    current.push_back('"');
                    ++i;
                } else {
                    in_quotes = false;
                }
            } else {
                current.push_back(ch);
            }
        } else {
            if (ch == '"') {
                in_quotes = true;
            } else if (ch == ',') {
                result.push_back(current);
                current.clear();
            } else {
                current.push_back(ch);
            }
        }
    }

    result.push_back(current);
    return result;
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        throw std::runtime_error("Usage: ./parse_charge_value_list <csv_path>");
    }

    std::ifstream file(argv[1]);
    if (!file.is_open()) {
        throw std::runtime_error("Failed to open CSV file.");
    }

    std::string line;
    if (!std::getline(file, line)) {
        throw std::runtime_error("CSV file is empty.");
    }

    const std::vector<std::string> header = ParseCsvLine(line);
    std::unordered_map<std::string, size_t> index;
    for (size_t i = 0; i < header.size(); ++i) {
        index[header[i]] = i;
    }

    while (std::getline(file, line)) {
        const std::vector<std::string> row = ParseCsvLine(line);
        const auto items = nlohmann::json::parse(row.at(index.at("charge_value_list")));
        double total_billing = 0.0;

        std::cout << "req_id=" << row.at(index.at("req_id")) << '\n';
        for (const auto& item : items) {
            const double billing = item.at("value").get<double>() / item.at("scale").get<double>();
            total_billing += billing;
            std::cout << "  - " << item.at("code").get<std::string>() << ": "
                      << std::fixed << std::setprecision(6) << billing << '\n';
        }
        std::cout << "total_billing=" << std::fixed << std::setprecision(6) << total_billing << '\n';
    }

    return 0;
}
```

返回示例如下：

```
req_id=021738b4-2db3-4c4d-9ec1-6e4d8f3b1a2c
  - Doubao-lite-32k-inference-prompt_cn-beijing_realtime: 0.054000
  - Doubao-lite-32k-inference-completion_cn-beijing_realtime: 0.005000
  - Doubao-lite-32k-inference-KVcache-hit_cn-beijing_realtime: 0.000000
total_billing=0.059000
```
