Flexprice 的 Azure Marketplace 用量上报集成:FLE-1071 v3 设计深度解析
Flexprice 的 Azure Marketplace 用量上报集成:FLE-1071 v3 设计深度解析
本技术指南以 docs/design/2026-07-23-FLE-1071-marketplace-integration-v3.md 设计文档为核心,结合 Flexprice 开源仓库中已落地的客户端、Temporal 活动与 DTO 实现,系统讲解 Azure Marketplace 如何作为第三个对称的用量上报渠道接入 Flexprice:包括 usageEvent 上报契约、Entra 应用注册与 client_credentials 认证、/v1/marketplace/agreements 注册、实体映射与 syncs 跳过语义,以及完整的排障日志体系。读完本文,你可以理解 Azure 与 AWS/GCP 三条集成的异同,掌握在 Flexprice 上接通 Azure 计量的端到端配置步骤,并学会如何用日志回答"这条用量记录到底有没有被计费"。
说明:本文对应设计文档为 v3(2026-07-23),其取消时序契约(§10)等部分已被 v4(FLE-1106,2026-07-30) 部分取代,文中已逐处标注。仓库内实现以"实际发货的 v2 起点 + v3 设计"为准(如 azuremarketplace/client.go 已实现 Azure 路径)。
1. 我们要构建什么:第三个对称的 Marketplace 集成
Flexprice 的任务对三家云市场(AWS、GCP、Azure)完全一致:对租户(ISV)的每个 marketplace 买家,周期性地把该买家在一个时间窗内应付的金额上报给 marketplace——计算方式与发票行(invoice line)相同。Azure 是三个对称集成中的第三个,它复用了既有的参与者、快照 cron、实体映射表和生命周期契约(生命周期由租户拥有,Flexprice 从不监听),区别仅在于:
- 具体 API 调用(Azure 用
usageEvent); - 认证机制(Azure 用 Entra 应用 +
client_secret,与 AWS 的AssumeRole、GCP 的 Workload Identity Federation 都不同); - Azure 独有的前置条件:租户必须先建好 offer 和 dimension(计量维度),之后才能上报任何用量。
参与者(Actors)
| 角色 | 职责 |
|---|---|
| Marketplace(AWS / GCP / Azure) | 托管 listing、签约买家、向买家开票收款、向租户付款 |
| Tenant | Flexprice 的客户——在 marketplace 上售卖的 ISV。拥有自己的 marketplace 账号/offer 以及自己的集成代码 |
| Buyer | 租户的终端客户,通过 marketplace 订阅;与 Flexprice customer 一一对应 |
| Flexprice | 计算每个买家的用量费用并按计划上报给 marketplace |
该集成建立在 v2 实际发货形态之上:usage_records 是 provider 无关的(带 syncs map),且没有 connection_id 列。
2. 用量上报契约:三家提供商的定价约定与 API 差异
2.1 定价约定:单一 usage_fee 维度 + 美分
租户定义一个名为 usage_fee 的可计费维度,单价设为 $0.01/单位。Flexprice 计算该窗口的真实美元费用,转换为美分(cents)后把整数作为 quantity 上报;marketplace 用 quantity × $0.01 计算应收费。这样所有定价逻辑都留在 Flexprice 内部——租户无需在 marketplace 里镜像自己的价目表。
Azure 与 AWS/GCP 一样支持任意 Price per unit(无文档下限),因此 $0.01 是合法值;基础 plan 自身的循环价格设为 $0,全部收入走计量维度。
2.2 AWS:BatchMeterUsage(每次调用一条记录)
// Request
{
"ProductCode": "4qwerty789", // Concurrent Agreements 产品可省略
"UsageRecords": [
{ "CustomerAWSAccountId": "222222222222", "LicenseArn": "arn:aws:license-manager:...:l-abc",
"Dimension": "usage_fee", "Quantity": 1250, "Timestamp": 1752300000 }
]
}
// Response
{
"Results": [
{ "MeteringRecordId": "abc-123", "Status": "Success", "UsageRecord": {"...":"..."} }
],
"UnprocessedRecords": []
}
关键点:记录自带 Status(Success | CustomerNotSubscribed | DuplicateRecord),Results 里有条目 ≠ 被接受,必须检查 Status。完全无法处理的记录出现在 UnprocessedRecords 中,下一轮重试。仓库实现中,reporter.go 的 reportAWSRecord 逐分支处理这三种状态(StatusCustomerNotSubscribed 自愈重试、StatusDuplicateRecord 需要人工介入)。
2.3 GCP:services.report(每次调用一条记录)
// Request
POST https://servicecontrol.googleapis.com/v1/services/{service_name}:report
{
"operations": [
{ "operationId": "usage_rec_001", "operationName": "flexprice/usage_report",
"consumerId": "USAGE_REPORTING_ID_A", "startTime": "...", "endTime": "...",
"metricValueSets": [{ "metricName": "{service_name}/usage_fee", "metricValues": [{ "int64Value": "1250" }] }] }
]
}
// Response — HTTP 200, operation rejected
{
"reportErrors": [
{ "operationId": "usage_rec_002", "status": { "code": 5, "message": "Consumer '...' not found or not active." } }
],
"serviceConfigId": "2026-07-16r0", "serviceRolloutId": "..."
}
GCP 是唯一不返回逐条成功回执的提供商,成功 = 在 reportErrors 中缺席。按 Service Control API discovery 文档,响应有三种情况:
- RPC 成功且
report_errors为空 = 完全成功,请求中所有Operations都被处理成功; - RPC 成功且
report_errors非空 = 部分成功,每个失败的操作在列表中都有对应条目; - RPC 状态失败(网络错误、5xx、超时——而非 200 带
reportErrors)= 结果未知,行保持未同步、下轮重试。
情况 3 是这里的实操规则:RPC 本身失败时调用结果未知,该行留待下轮。这之所以安全,是因为 operationId = usage_record.id(见 gcpmarketplace/client.go),重试会重发字节完全相同的 operation。MetricValue 允许 int64Value | doubleValue | moneyValue | distributionValue | boolValue | stringValue,本集成只使用 int64Value。
2.4 Azure:usageEvent(新增)
// Request — POST https://marketplaceapi.microsoft.com/api/usageEvent?api-version=2018-08-31
{ "resourceId": "<saas-subscription-guid>", "quantity": 1250.0, "dimension": "usage_fee",
"effectiveStartTime": "2026-07-23T14:00:00", "planId": "silver" }
// Response — HTTP 200
{
"usageEventId": "<guid>", "status": "Accepted", "messageTime": "2026-07-23T14:00:05Z",
"resourceId": "<saas-subscription-guid>", "quantity": 1250.0, "dimension": "usage_fee",
"effectiveStartTime": "2026-07-23T14:00:00", "planId": "silver"
}
与 AWS 的 Results[]、GCP 的 reportErrors 不同,Azure 的 200 响应没有任何需要检查的独立状态——官方文档明确 "status: Accepted —— 这是单条用量事件时唯一的值"。每一种拒绝(Duplicate、Expired、无效资源、格式错误请求)都以独立的非 2xx 状态码 + 各自的响应体返回,而不是 200 携带不同的 status 值。其中 Duplicate 特指 HTTP 409,冲突事件的详情嵌套在 additionalInfo.acceptedMessage 下。usageEventId 就是 Azure 的回执,等价于 AWS 的 MeteringRecordId。
Azure 的去重键是 (resourceId, dimension, calendar hour)——每个资源每个维度每小时只能发一条事件,且事件只能上报过去 24 小时:
"Only one usage event can be emitted for each hour of a calendar day per resource and dimension. … Usage events can only be emitted for the past 24 hours." —— Azure Metering service APIs
409 Conflict / Duplicate 的判定以资源为键:指定的 resource ID、生效日期和小时若已成功上报过一条事件,即返回 409。
Quantity 必须严格大于 0——而且是双重校验。 quantity 字段内联注释写着 "must be greater than 0 or a double integer",措辞含糊("double integer" 不是被定义的值类别,页面所有示例都是双精度格式的整数 5.0、39.0)。但本设计不依赖这句含糊注释:批量端点的错误表给出了规范性拒绝规则——InvalidQuantity:"The quantity passed is lower or equal to 0." 把零格式化成 double 不改变任何事:0.0 仍是 0,0 <= 0 无论表示形式如何都会触发 InvalidQuantity。两个来源一致:Azure 严格要求 quantity > 0,对零的编码形式没有任何例外。
2.5 三家并排对比
| AWS | GCP | Azure | |
|---|---|---|---|
| 上报调用 | BatchMeterUsage |
services.report |
usageEvent |
| 去重键 | product + customer + dimension + Timestamp(字节相同、重试安全) |
operationId(调用方指定) |
(resourceId, dimension, calendar hour) |
| Quantity 类型 | int32 美分 | int64 美分 | double(美分以整数承载) |
| 提交窗口 | 24h + 6h 月末宽限 | 窗口未文档化;取消后 1 小时(见 v4 §4) | 24h,无注明宽限 |
| 逐条回执 | MeteringRecordId |
无——不在 reportErrors 中 = 成功 |
usageEventId |
| 拒绝形态 | Results[] 中的 Status,或缺失(UnprocessedRecords,重试) |
列于 reportErrors;成功是它的缺席 |
独立的非 2xx 状态码,而非 200 |
| 调用级歧义 | 无——每次都有明确结果 | RPC 级失败 = 结果未知、重试 | 无——每次都有明确结果 |
3. 买家(Buyer)上线流程
3.1 AWS / GCP:与 v2 相同
AWS:买家重定向 + ResolveCustomer,由租户完成,Flexprice 从不调用。GCP:Pub/Sub + Procurement API,同样由租户完成。两者在租户向 Flexprice 注册协议(agreement)时,就已握有全部所需标识符。
3.2 Azure:两条路径
推荐自动激活,因为它不依赖浏览器重定向。
路径 A——手动 Resolve/Activate(SaaS fulfillment Subscription APIs v2):
sequenceDiagram
participant Buyer
participant Azure as Azure Marketplace
participant Tenant as Tenant backend
participant FP as Flexprice API
Buyer->>Azure: subscribes to the offer
Azure->>Tenant: redirect to landing page URL, carrying token (valid 24h)
Tenant->>Azure: POST /api/saas/subscriptions/resolve (x-ms-marketplace-token)
Azure-->>Tenant: subscription id, offerId, planId, quantity, beneficiary/purchaser
Tenant->>Azure: POST /api/saas/subscriptions/{id}/activate
Azure-->>Tenant: 200 (customer now billable)
Tenant->>FP: POST /v1/subscriptions (create Flexprice subscription)
FP-->>Tenant: subscription_id
Tenant->>FP: POST /v1/marketplace/agreements (provider: azure_marketplace)
FP-->>Tenant: mapping ids, status active
路径 B——自动激活(推荐)(SaaS fulfillment webhook):
"For plans with auto activation enabled, the Resolve API isn't needed. Microsoft sends the subscription details directly to the publisher via the Subscribe webhook notification."
sequenceDiagram
participant Buyer
participant Azure as Azure Marketplace
participant Tenant as Tenant backend
participant FP as Flexprice API
Buyer->>Azure: subscribes to the offer (auto-activation plan)
Azure->>Tenant: POST webhook — action "Subscribe", status "Succeeded"
Note over Tenant: payload carries subscriptionId, planId, quantity,<br/>subscription.beneficiary.{objectId, tenantId}
Tenant->>Tenant: return HTTP 200 (Subscribe is notify-only)
Tenant->>FP: POST /v1/subscriptions (create Flexprice subscription)
FP-->>Tenant: subscription_id
Tenant->>FP: POST /v1/marketplace/agreements (provider: azure_marketplace)
FP-->>Tenant: mapping ids, status active
两条路径提供给租户的字段相同,这些字段流入协议注册调用:
// Resolve response / Subscribe webhook — the fields we use
{
"id": "<guid>", // -> resource_id (this is resourceId in the usage-report payload)
"offerId": "offer1", // informational; not part of the usage-report payload, not stored
"planId": "silver", // -> azure.plan_id
"subscription": {
"beneficiary": { "objectId": "<guid>", "tenantId": "<guid>", "emailId": "..." }, // tenantId -> beneficiary_account_id
"saasSubscriptionStatus": "Subscribed"
}
}
4. 向 Flexprice 注册:POST /v1/marketplace/agreements
一个端点服务所有提供商(见 dto/marketplace.go):一个 provider 判别字段 + 恰好一个提供商专属块。Azure 是新增的第三个块。DTO 中的枚举与结构体与设计文档完全一致:
const (
ProviderAWS MarketplaceProvider = "aws_marketplace"
ProviderGCP MarketplaceProvider = "gcp_marketplace"
ProviderAzure MarketplaceProvider = "azure_marketplace" // new
)
// AzureMarketplaceAgreement — new
type AzureMarketplaceAgreement struct {
PlanID string `json:"plan_id" validate:"required"` // Azure's planId (distinct from the request's top-level Flexprice plan_id)
Dimension string `json:"dimension" validate:"required"` // the usage_fee dimension on the offer
ResourceID string `json:"resource_id" validate:"required"` // Azure SaaS subscription id -> resourceId in the usage-report payload
BeneficiaryAccountID string `json:"beneficiary_account_id" validate:"required"` // buyer's Entra tenant id, from subscription.beneficiary.tenantId
}
仓库实现中,三个 provider 枚举(types/secret.go 的 SecretProviderAWSMarketplace/SecretProviderGCPMarketplace/SecretProviderAzureMarketplace)与 DTO 校验逻辑(每个块都校验非空、且只允许与 provider 匹配的块存在)均已落地。
// Example body
{
"provider": "azure_marketplace",
"subscription_id": "subs_01KY75NK7SF1WAH4N5XF4TXMZQ",
"customer_id": "cust_01KY75M8JJJYAXC2PTP7DC0SFE",
"plan_id": "plan_01KY75N4JCY64266FES411GEK7",
"azure": {
"plan_id": "silver",
"dimension": "usage_fee",
"resource_id": "11111111-2222-3333-4444-555555555555",
"beneficiary_account_id": "test-account-001"
}
}
没有 offer_id 字段:offerId 不属于用量上报 payload(§2.4),因此两侧都不存储。
5. 实体映射:同一张 entity_integration_mapping 表
沿用通用 entity_integration_mapping 表,无 schema 变更。Azure 只是第三个 provider_type 值,如同 v2 中 GCP 作为第二个值加入。
| entity_type | AWS provider_entity_id |
GCP provider_entity_id |
Azure provider_entity_id |
metadata |
|---|---|---|---|---|
| plan | product_code |
service_name |
planId |
AWS:dimension、concurrent_agreements。GCP:metric_name。Azure:{"dimension": "usage_fee"} |
| subscription | license_arn |
usageReportingId |
Azure SaaS subscription id(resourceId) |
— |
| customer | customer_aws_account_id |
account_id |
beneficiary_account_id |
—(任何提供商的上报调用都不读取;保留以对齐并供审计) |
plan 映射到 Azure 的 planId 而非 offerId,因为 planId 是用量上报 payload 中唯一的产品/plan 标识符(§2.4、§3.2)。一个 Azure offer 可以容纳多个 plan("Billing dimensions are shared across all plans for an offer"),所以拥有三层 offer 的租户会有三个 Flexprice plan,每个 plan 各自持有自己的 planId 映射行——与表已有的"每个 plan 一行"形态一致。源码佐证:reporter.go 的 loadAzureMappings 只加载 subscription 与 plan 两类映射,customer 映射由快照活动在"判断哪些订阅需要快照"时使用。
6. 连接租户的 Azure marketplace(认证)
6.1 AWS / GCP:与 v2 相同
AWS:租户创建 IAM 角色 + ExternalId,Flexprice 扮演该角色。GCP:Workload Identity Federation,不存密钥。两者都不适用于 Azure——第三方 AWS 托管的调用方没有联合路径可以兑换成 Entra token。
6.2 Azure:Entra 应用注册 + client credentials
对 SaaS offer,这是唯一受支持的认证方式:
"Applicable offer types are transactable SaaS and Azure Applications with managed application plan type. … For SaaS offers, this is the only available option."
Entra 应用必须绑定到租户自己的 offer:
"You must generate tokens using the same Entra tenant ID and Entra application ID that you specified in the Partner Center Technical Configuration page of the offer."
Azure 要求存储静态 client_secret——这是 Azure 与 AWS/GCP(两者都避免存储密钥)唯一的区别点;Microsoft 对 SaaS 计量没有提供联合等价物(§11.4)。以下是租户侧的全部设置步骤,Flexprice 不创建任何 Azure 应用、不向 Microsoft 注册任何东西、从不接触 Partner Center——与 AWS(租户建 IAM 角色,Flexprice 收 ARN)和 GCP(租户跑 WIF 脚本,Flexprice 收 JSON)分工相同:Flexprice 消费凭据,从不创建凭据。
租户侧设置步骤:
- 在 Microsoft Entra ID 注册一个应用(Entra ID → App registrations → New registration),位于租户自己的 Azure AD tenant 内,推荐 single-tenant。这就是 Flexprice 以租户名义认证的身份。
- 生成 client secret(该应用注册的 Certificates & secrets)。这是
client_credentials流程中应用的密码。 - 在租户目录中注册 Marketplace metering API 的服务主体——一次性操作,由租户执行。
20e940b3-4c77-4b0b-9a53-9e16a1b010a7是 Microsoft 为 Marketplace SaaS/metering API 固定的应用 ID——对每个 publisher 都相同,不是租户生成的。步骤 1 的应用要请求限定到该 API 的 token 之前,租户目录必须先有一条该应用的本地记录。这条记录就是服务主体(service principal):一个应用在目录中的实例。租户在 Azure Cloud Shell 或任何以租户身份登录(az login)的终端里执行:az ad sp create --id 20e940b3-4c77-4b0b-9a53-9e16a1b010a7该命令不创建 secret、不授予任何访问权限——它只是让那个 Microsoft API 能从租户目录寻址,以便步骤 1 的应用能为它请求 token。这也是 Microsoft 官方给出的方法。仓库实现中,该固定 ID 被硬编码为 azuremarketplace/client.go 的marketplaceSaaSResourceID,用作 OAuth2 scope({id}/.default)。 - 把应用的 tenant ID 和 application ID 填入 Partner Center 中 offer 的 Technical Configuration(针对该应用要上报用量的每个 offer)。这对实际用量上报至关重要——Microsoft 在每次 Marketplace API 调用时都会交叉核对 token 的应用 ID 与 offer 注册的应用 ID,而不只是在设置时——但完全由租户自己负责(§11.7)。Flexprice 无从得知某个应用代表哪些 offer,因此连接时不做此项检查;配错会在上报时以 401 浮现(§8.4),而不是拒绝连接。
- 把
tenant_id/client_id/client_secret粘贴进 Flexprice 连接抽屉。 这是 Flexprice 唯一接触的步骤:它接收租户已生成的三串字符串。
Token 请求:
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id={client_id}&client_secret={client_secret}&scope=20e940b3-4c77-4b0b-9a53-9e16a1b010a7/.default
返回一小时有效的 bearer token(expires_in: 3600)。Flexprice 每个 cron 运行周期、每条连接请求一次 token,与 AWS AssumeRole、GCP WifSession 的模式相同。实现上 azuremarketplace/client.go 的 GetToken 对 expires_in 的 JSON 类型做了兼容处理(文档中带引号字符串、真实端点返回裸数字),并在响应无 access_token 时返回明确错误。
连接验证——同步执行,发生在连接保存之前。与 AWS AssumeRole、GCP WifSession 不同,这里只做 token 请求,不再对特定 Marketplace 资源做一次调用(§11.7 解释原因):
sequenceDiagram
participant Tenant
participant FE as Flexprice UI
participant API as Flexprice API
participant Entra as login.microsoftonline.com
Tenant->>FE: paste tenant_id, client_id, client_secret
FE->>API: POST /v1/connections (azure_marketplace, encrypted_secret_data)
API->>Entra: POST /{tenant_id}/oauth2/v2.0/token (client_credentials)
alt token request fails
Entra-->>API: error
API-->>Tenant: 422, naming the failed step
else token issued
Entra-->>API: bearer token (1h)
API-->>Tenant: 201, connection saved (published)
end
token 请求成功只证明 tenant_id/client_id/client_secret 三件套真实存在、且 Marketplace API 的服务主体已注册在该目录(步骤 3)。它不证明这个 Entra 应用被接入到某个 offer 的 Technical Configuration——该绑定只有在 token 用于真实 Marketplace API 调用时才会被 Microsoft 检查,而连接时 Flexprice 也没有可检查的 offer。不匹配会在上报时以真实 401 浮现(§8.4):
"401 Unauthorized. … The request is attempting to access an SaaS subscription for an offer that was published with a different Microsoft Entra app ID from the one used to create the authentication token." —— SaaS fulfillment Subscription APIs v2
存储——与 AWS/GCP 相同的加密 blob 形态:
{
"provider_type": "azure_marketplace",
"encrypted_secret_data": { "azure_marketplace": { "tenant_id": "...", "client_id": "...", "client_secret": "..." } },
"metadata": {},
"status": "published"
}
仓库中 reporter.go 的 authAzureConnection 分三个阶段解密三个字段(decrypt_tenant_id/decrypt_client_id/decrypt_client_secret),任一步失败都会跳过整条连接本轮上报。
7. Flexprice 存储什么
无新表。entity_integration_mapping 增加第三个 provider_type 值。usage_records 已经是 provider 无关的(v2 §12.1:通过 syncs 扇出,无 connection_id 列),Azure 直接插入使用。
erDiagram
CONNECTION ||--o{ ENTITY_INTEGRATION_MAPPING : "tenant+env+provider_type"
PLAN ||--o| ENTITY_INTEGRATION_MAPPING : "entity_type=plan"
SUBSCRIPTION ||--o| ENTITY_INTEGRATION_MAPPING : "entity_type=subscription"
CUSTOMER ||--o| ENTITY_INTEGRATION_MAPPING : "entity_type=customer"
SUBSCRIPTION ||--o| USAGE_RECORD : "one row per window (shared across relevant connections)"
USAGE_RECORD {
string id PK
string tenant_id
string environment_id
string customer_id
string subscription_id
string plan_id
decimal amount "computed charge, native currency"
string currency
timestamp period_start
timestamp period_end
bool synced "true once every currently-relevant connection has a syncs entry"
json syncs "map[connection_id]SyncEntry"
string status "published soft-delete flag"
}
ENTITY_INTEGRATION_MAPPING {
string id PK
string tenant_id
string environment_id
string entity_type "plan | subscription | customer"
string entity_id
string provider_type "aws_marketplace | gcp_marketplace | azure_marketplace"
string provider_entity_id
json metadata
string status "published soft-delete flag"
}
CONNECTION {
string id PK
string provider_type "aws_marketplace | gcp_marketplace | azure_marketplace"
json encrypted_secret_data
json metadata
json sync_config "e.g. aws_marketplace.region"
string status
}
Marketplace 枚举加入 Azure。SyncEntry 新增两个字段 Skipped 与 SkipReason(理由见 §8.3/§11.6):本行相关的连接可以被解析为两种情形之一——要么 marketplace 真的接受了上报(有真实 ReportingID),要么发送确定性地无意义(当前只有 "Azure + amount == 0" 一种情况)。两种情况都写入条目,synced 才能达到 true;只有第一种表示真的发布过任何东西。
type Marketplace string
const (
MarketplaceAWS Marketplace = "aws_marketplace"
MarketplaceGCP Marketplace = "gcp_marketplace"
MarketplaceAzure Marketplace = "azure_marketplace" // new
)
type SyncEntry struct {
Marketplace Marketplace `json:"marketplace"`
ReportingID string `json:"reporting_id"` // AWS MeteringRecordId | GCP operationId | Azure usageEventId; empty when Skipped
SyncedAt time.Time `json:"synced_at"`
Skipped bool `json:"skipped,omitempty"` // new: true = never sent to this marketplace, not a real acceptance
SkipReason string `json:"skip_reason,omitempty"` // new: e.g. "zero_amount_not_supported" — only set when Skipped
}
仓库中 internal/types/usagerecords.go 的实现进一步明确了语义:syncs 的键是 marketplace 而非 connection——租户删除一条连接、为同一 marketplace 另建一条时,连接 id 变了而订阅在该 marketplace 上的协议没变;若以 connection 为键,已上报的记录会立刻显得未上报而被重复发送。以 marketplace 为键,问的是真正重要的问题:这条记录到达 AWS/Azure/GCP 了没有。真实接受 = Skipped == false(或零值)且 ReportingID 非空;跳过 = Skipped == true、ReportingID == ""、SkipReason 已设置。两者绝不混淆——任何想回答"marketplace 是否真的确认了"的读者检查的是 Skipped == false,而不只是条目是否存在。SkipReasonZeroAmountNotSupported = "zero_amount_not_supported" 也已在源码中落地。
8. 按计划上报
8.1 快照 cron——不变
6 小时节奏不变:period_start = scheduledTime − 10h,period_end = scheduledTime − 4h(v2 §7.1),对三家统一应用。
这与 Azure 小时粒度去重键不冲突:period_end 总是 4 小时前,在 Azure 24 小时窗口内;连续快照运行相隔 6 小时,period_end 每次落在不同的小时——同一订阅的任何两个窗口都不可能共享 (resourceId, dimension, hour) 键。Azure 上报时 effectiveStartTime = period_end,与 AWS 的 Timestamp = period_end 一致。
8.2 上报 cron——加入 Azure
已发货的上报 cron(report_activities.go)对三家做同样的事。MarketplaceUsageReportActivity 按 tenant/environment 分组所有已发布 marketplace 连接;reportForTenant 然后对一个 tenant/environment:
- 每条连接认证一次并加载其映射(
prepareConnection); - 一次读取该租户的未同步记录(
usageRecordRepo.ListUnsynced——v4 §5.2 为其增加period_end >= now-24h边界); - 保留符合条件者(
isEligibleForReport:货币为 USD、金额不为负); - 把每条符合条件的记录上报给每个相关且尚未在
syncsmap 中的连接(isRelevantForSubscription),每条记录一次 API 调用,并持久化记录的syncs与synced(MarkSynced)。
Azure 与 AWS/GCP 一样进入第 4 步——reportAzureRecord 与 reportAWSRecord/reportGCPRecord 并列——只有一处新增:零金额行永不发送给 Azure 连接(§2.4、§11.6),而是立即以 skipped 的 syncs 条目解析该连接,使该行在其其他连接成功后仍能达到 synced = true,且从不发布 Azure 会拒绝的 quantity。仓库中的 reportRecordToMarketplaces 正是这样实现的:skip 也留下条目,因此跳过的 marketplace 被视为已解析、不再尝试——因为重试时金额不会变。
reportForTenant(conns):
preparedConns = [prepareConnection(c) for c in conns] # auth once each; drop any that fail
eligible = ListUnsynced(tenant, env) # every unsynced row this tenant/environment owns
for r in eligible:
if not isEligibleForReport(r): continue # usd, amount not negative
relevantConns = [c for c in preparedConns if c.isRelevantForSubscription(r.SubscriptionID)]
if not relevantConns: continue # no connection maps to this subscription yet
added = false
for c in relevantConns:
if c.ID in r.Syncs: continue # already reported here
if c.provider == azure_marketplace and r.Amount == 0:
r.Syncs[c.ID] = { skipped: true, skip_reason: "zero_amount_not_supported" } # Azure rejects 0 (§11.6)
added = true
continue
res = reportRecord(c, r) # ONE call: BatchMeterUsage | services.report | usageEvent
if res.accepted:
r.Syncs[c.ID] = { marketplace, reporting_id: res.reportingID, synced_at: now }
added = true
# not accepted -> no entry; retried next run (per-provider rules in §8.3)
synced = every connection in relevantConns now has an entry in r.Syncs
if not added and not synced: continue # nothing changed this run; leave the row as-is
MarkSynced(r.ID, r.Syncs, synced)
sequenceDiagram
participant Sch as Temporal (3h)
participant Cron as Reporting cron
participant DB as usage_records
participant MP as Marketplace API
Sch->>Cron: run (per tenant/environment)
Cron->>Cron: prepareConnection each (auth once)
Cron->>DB: eligible = ListUnsynced (usd, amount not negative)
loop each eligible row
Cron->>Cron: relevant = connections mapped to this row's subscription
loop each relevant connection not yet in syncs
alt Azure and amount == 0
Cron->>Cron: syncs entry = skipped (§11.6), no call made
else
Cron->>MP: reportRecord — one BatchMeterUsage | services.report | usageEvent
MP-->>Cron: outcome
Cron->>Cron: accepted -> add syncs entry; else leave unsynced (§8.3)
end
end
Cron->>DB: MarkSynced (synced = all relevant connections present)
end
8.3 各提供商响应处理
每个提供商的 report 方法读取各自的响应形态,逐条返回 accepted(带 reporting_id)或 not。amount == 0 的行像其他行一样发送给 AWS/GCP 并按同样方式读取(AWS 文档规定 Quantity 合法范围最小值为 0;GCP 未规定最小值);只有 Azure 在发送前将其过滤掉(§8.2、§11.6)。
# AWS: Status in Results[], or absent from UnprocessedRecords (retried next run)
Success -> syncs[conn] = { reporting_id: MeteringRecordId }
CustomerNotSubscribed -> no entry, log.error (self-heals when the buyer resubscribes)
DuplicateRecord -> no entry, log.error (a conflicting different record — needs a human)
in UnprocessedRecords -> no entry, retried next run
# GCP: absence from reportErrors (given a 200 RPC) = accepted
not in reportErrors -> syncs[conn] = { reporting_id: operationId } # our own id
in reportErrors -> no entry, log.error(code + message)
RPC itself failed -> no entry, log.error (§2.3 case 3)
# Azure: 200 is unconditionally Accepted; every rejection is a distinct non-2xx status (§2.4)
200 (Accepted) -> syncs[conn] = { reporting_id: usageEventId }
409 Duplicate -> no entry, log.error (ambiguous — §11.5)
400 / 401 / 403 / 409 (other) / 500 -> no entry, log.error
amount == 0 (never sent, §8.2) -> syncs[conn] = { skipped: true, skip_reason: "zero_amount_not_supported" }
syncs 条目只在确定的接受(真实 reporting_id)或 Azure 零金额跳过(skipped: true)时写入。所有其他结果——包括 Azure Duplicate(含歧义,§11.5)——都不写条目、下轮重试。usage_records 保持真相来源:条目要么是真实回执、要么是显式 skipped,绝不是猜测。
8.4 日志与排查要点
日志级别只有 error、info、debug——绝无 warn。
任何提供商、任何级别都绝不记录凭据。 不记录 client_secret 和 bearer token(Azure);不记录 role_arn、external_id 或 AssumeRole 临时凭据(AWS);不记录 Workload Identity Federation JSON、联合 token 或模拟 token(GCP)。每条提供商错误消息在记录或返回前都经 utils.RedactSecrets 处理:租户自己的标识符被剥离,提供商说的其余一切原样保留——因为状态码、错误码和 reason 才是让故障可诊断的东西。实现佐证:azuremarketplace/client.go 的 sendFailureReason 从类型化 HTTP 错误中提取 status %d: body 后再脱敏,从而保留 Entra 的 AADSTS 码和描述、Azure 的错误码与 409 携带的既有事件详情。
两条日志消息承载整个上报路径,值得记住:
marketplace usage report failed——通用失败行。总是带stage标签,精确指明哪里坏了。这是首先要 grep 的行。marketplace usage record synced——唯一表示 marketplace 接受了一条记录的行。
每条与连接相关的行都带 marketplace(aws_marketplace | gcp_marketplace | azure_marketplace)与 connection_id,任何搜索都可以限定到单一提供商或单一连接。isEligibleForReport 预过滤是例外:它们在选择连接之前运行,不带 marketplace 标签,因为一行可能同时与多条连接相关。
以下阶段按顺序运行,从创建连接一直到记录被标记 synced。
Stage 1 —— 连接建立(POST /v1/connections,同步)
验证在连接持久化之前运行,因此这里的失败意味着什么都没保存(§6.2)。这是租户唯一能实时看到(作为 API 响应)的阶段。
| Provider | Level | Message | Tags | 含义 |
|---|---|---|---|---|
| AWS | error |
aws marketplace assume role failed |
region、role_session_name、duration、error |
AssumeRole 被拒。信任策略、external ID 或角色 ARN 有误。由客户端发出。 |
| AWS | error |
aws marketplace connection verification failed |
tenant_id、environment_id、region、error |
同一失败的服务层包装,携带租户上下文。 |
| GCP | error |
gcp marketplace failed to assume the flexprice gcp metering role |
region、error |
Flexprice 自身的 AWS 角色无法被扮演。这是 Flexprice 侧配置错误(marketplace.aws.*),不是租户的。标记 ErrSystem。 |
| GCP | error |
gcp marketplace wif exchange failed |
error |
AWS → GCP STS → 服务账号模拟链路失败。Pool、provider、属性条件或 workloadIdentityUser 绑定有误。 |
| Azure | error |
azure marketplace token request failed |
error |
client_credentials token 请求被拒。error 携带 Entra 自己的 AADSTS 码与描述(bad secret、应用不存在、租户不存在、或 Marketplace 服务主体未注册——§6.2)。 |
注意不对称性:GCP 和 Azure 没有与 AWS 的 aws marketplace connection verification failed 等价的服务层包装。它们的验证块直接返回客户端错误,因此上述客户端行是它们唯一发出的行。
Stage 2 —— 快照 cron(marketplace-usage-snapshot,每 6h)
Provider 无关。 该 cron 计算用量并写入 usage_records;它从不调用 marketplace。所有失败都是 error + marketplace usage snapshot failed,用 stage 区分。它打 provider_type(而非 marketplace)标签,因为它在遍历连接以决定快照哪些订阅,而不是向任何人上报。
stage |
什么坏了 |
|---|---|
list_connections |
无法列出某 provider 的已发布连接。 |
list_customer_mappings |
无法为连接加载 customer 映射。 |
list_subscription_mappings |
无法为连接加载 subscription 映射。 |
get_subscription |
无法获取映射的订阅。 |
check_existing |
"本窗口已快照"查找失败。 |
get_meter_usage |
窗口用量获取失败。 |
calculate_charges |
CalculateMeterUsageCharges 失败。 |
create_usage_record |
写入 usage_records 行失败。 |
一个没有产生任何行的订阅永远不会进入 Stage 5 上报——如果某个买家整体从 marketplace 消失,先检查这个 cron,再怀疑上报路径。源码佐证:snapshot_activities.go 的 MarketplaceUsageSnapshotActivity 遍历 marketplaceProviderTypes 三家,snapshotSubscription 用 ExistsForPeriod 保证活动重试幂等。
Stage 3 —— 上报 cron:连接认证与映射加载
每条连接每轮运行一次,在任何记录被触碰之前。这里的失败会跳过整条连接本轮——该连接的每条记录都被静默推迟,因此这里的一行可以解释成千上万条未上报记录。所有行都是 error + marketplace usage report failed。
| Provider | stage 值 |
什么坏了 |
|---|---|---|
| All | list_connections |
列出某 provider 的已发布连接失败。 |
| All | list_unsynced |
读取租户未同步记录失败。 |
| All | read_connection |
连接没有自己 provider 类型的 secret 数据。 |
| All | load_mappings |
无法加载该连接的实体映射。 |
| AWS | decrypt_role_arn、decrypt_external_id |
secret 解密失败。 |
| AWS | assume_role |
上报时跨账号 AssumeRole 失败(连接时成功,说明信任策略或角色此后变了)。 |
| GCP | decrypt_credentials_json |
secret 解密失败。 |
| GCP | wif_session |
上报时 WIF 交换失败。 |
| Azure | decrypt_tenant_id、decrypt_client_id、decrypt_client_secret |
secret 解密失败。每个字段是独立 stage,行会点名是哪个。 |
| Azure | get_token |
上报时 token 请求失败。最常见的是过期的 client secret——唯一在正常运行数月后才出现的失败模式(§6.2)。 |
Stage 4 —— 记录预过滤(选择任何连接之前)
Provider 无关,因此这些行不带 marketplace 或 connection_id 标签。
| Level | Message | Tags | 含义 |
|---|---|---|---|
debug |
skipping marketplace usage record, currency not usd |
subscription_id、usage_record_id、currency |
不是错误。行保持未同步,货币换算落地后重试(§8.2)。 |
error |
marketplace usage record has negative amount |
subscription_id、usage_record_id、amount、error=negative_amount |
上游计费 bug。绝不发送给任何 provider。调查计费计算,而非 marketplace。 |
Stage 5 —— 上报调用(每条记录 × 每条连接)
真正与 marketplace 对话的阶段。注意各提供商发出拒绝信号的不对称性——这是三家之间最大的差异(§2.5、§8.3)。
三家通用
| Level | Message | Tags | 含义 |
|---|---|---|---|
info |
marketplace usage record synced |
marketplace、connection_id、subscription_id、usage_record_id、reporting_id |
已接受。 唯一表示此意的行。reporting_id 是 AWS 的 MeteringRecordId、GCP 的 operationId 或 Azure 的 usageEventId。跳过行绝不发出。 |
error |
marketplace usage report failed + stage=resolve_record |
marketplace、connection_id、subscription_id、customer_id、plan_id |
该记录在此连接上没有实体映射。协议从未注册,或注册到了不同 plan。什么都没发。 |
AWS——200 仍可能携带 Results[].Status 的逐条拒绝,必须读取 status;每个值有自己的消息。
| Level | Message | 附加 tags | 含义 |
|---|---|---|---|
error |
marketplace usage report failed + stage=convert_quantity |
error=quantity exceeds the maximum aws accepts |
仅 AWS:美分超过 int32。 |
error |
marketplace usage report failed + stage=batch_meter_usage |
license_arn、dimension、amount、error |
调用本身失败(传输、认证、格式)。 |
info |
marketplace usage record not processed by aws, will retry next run |
license_arn、dimension、amount |
返回在 UnprocessedRecords 中。瞬时;重试。 |
error |
marketplace usage report rejected by aws: customer not subscribed, will retry next run |
customer_id、license_arn、dimension、amount、error=customer_not_subscribed |
买家没有有效协议。自愈——重新订阅后恢复。 |
error |
marketplace usage report rejected by aws: conflicts with a different record already on file, needs manual investigation |
customer_id、license_arn、dimension、amount、period_end、error=duplicate_record |
需要人工。 AWS 对同一 customer+dimension+timestamp 持有不同记录。重试无法修复(§11.5)。 |
error |
marketplace usage report rejected by aws: unrecognized status, will retry next run |
license_arn、dimension、amount、aws_status、error=unrecognized_aws_status |
AWS 返回了未建模的 Status。aws_status 携带原始值。 |
GCP——Service Control 返回 200 OK 带填充的 reportErrors,因此接受绝不意味着 HTTP 状态。
| Level | Message | 附加 tags | 含义 |
|---|---|---|---|
error |
marketplace usage report failed + stage=services_report |
error |
调用本身失败。 |
error |
marketplace usage report rejected by gcp, will retry next run |
error=rejected_by_gcp、error_code、error_message |
200 内的行级拒绝。常见 error_code:5 NOT_FOUND(consumer 不活跃)、7 PERMISSION_DENIED、3 INVALID_ARGUMENT。 |
Azure——与前两者相反:200 无条件为 Accepted,所有拒绝都是客户端转成错误的非 2xx 状态(§2.4)。因此 Azure 根本没有 rejected by azure 消息——拒绝只以 stage=usage_event 浮现。
| Level | Message | 附加 tags | 含义 |
|---|---|---|---|
info |
marketplace usage record skipped: zero quantity not supported by azure |
marketplace、connection_id、subscription_id、usage_record_id、amount |
仅 Azure。从未发送;以 skipped: true 解析以免行被永久阻塞(§11.6)。不是计费事件。 |
error |
marketplace usage report failed + stage=usage_event |
resource_id、dimension、amount、error |
每个 Azure 拒绝都落在这里。读 error 中的状态:400 坏数据或 effectiveStartTime 超过 24h;401 offer 的 Technical Configuration 指向与 token 不同的 Entra 应用(§6.2);403 权限不足;409 该 (resourceId, dimension, hour) 重复——含歧义,绝不视为成功(§11.5);500 重试。 |
Stage 6 —— 持久化结果
| Level | Message | Tags | 含义 |
|---|---|---|---|
error |
marketplace usage report failed + stage=mark_synced |
subscription_id、usage_record_id、error |
marketplace 可能已接受上报,但 syncs 更新失败。行下轮重试,marketplace 自己的去重(AWS timestamp、GCP operationId、Azure (resourceId, dimension, hour))防止双重计费。 |
实战日志示例
level=error msg="azure marketplace token request failed" error="status 401: {\"error\":\"invalid_client\",\"error_description\":\"AADSTS7000215: Invalid client secret provided...\"}"
level=info msg="marketplace usage record synced" usage_record_id=ur_01H.. connection_id=conn_az_01 marketplace=azure_marketplace reporting_id=<usageEventId>
level=error msg="marketplace usage report failed" usage_record_id=ur_01H.. connection_id=conn_az_01 marketplace=azure_marketplace stage=usage_event error="status 409: {\"message\":\"This usage event already exist.\",\"code\":\"Conflict\"}"
level=error msg="marketplace usage report rejected by gcp, will retry next run" usage_record_id=ur_01H.. connection_id=conn_gcp_01 marketplace=gcp_marketplace error_code=5 error_message="Consumer not found or not active."
回答"这条记录被计费了吗?"
Grep usage_record_id=<id>,按此顺序读取:
marketplace usage record synced→ 是,已接受,reporting_id就是回执。marketplace usage record skipped: zero quantity not supported by azure→ 否,且从未发布过任何东西。通过该连接在该行syncsmap 中的skipped: true确认。绝不假设它被计费。- 任何其他行 → 已尝试但未接受;未同步,下轮重试。
- 完全无行 → 它从未到达上报路径。检查 Stage 3(整条连接被跳过)和 Stage 2(行从未创建)。
各 provider 首选 grep:marketplace=aws_marketplace、marketplace=gcp_marketplace、marketplace=azure_marketplace。要找出坏了什么而非哪条记录,grep stage=。
9. 客户端接口
// awsmarketplace.Client
AssumeRole(ctx, roleArn, externalID string, duration time.Duration) (aws.Credentials, error)
BatchMeterUsage(ctx, creds, region string, record UsageRecordInput) (*BatchMeterUsageResult, error)
// gcpmarketplace.Client
WifSession(ctx, wifCredentialsJSON string) (*servicecontrol.Service, error)
Report(ctx, svc *servicecontrol.Service, record UsageReportInput) (*ReportResult, error)
// azuremarketplace.Client — new
GetToken(ctx, tenantID, clientID, clientSecret string) (Token, error) // client_credentials, ~1h
ReportUsageEvent(ctx, token Token, record UsageEventInput) (*UsageEventResult, error)
type UsageEventInput struct {
ResourceID, Dimension, PlanID string
Quantity float64 // cents, a whole number carried as a double (Azure's schema)
EffectiveStartTime time.Time // = period_end
}
type UsageEventResult struct {
UsageEventID string // a nil error already means Accepted (§2.4) — nothing else to check
}
仓库实现与设计一致:azuremarketplace/client.go 定义 Client 接口(GetToken + ReportUsageEvent),ReportUsageEvent 用 azureTimeLayout = "2006-01-02T15:04:05"(UTC、无时区后缀)格式化 effectiveStartTime,status 字段在 200 响应中不做读取(因为按文档它总是 Accepted)。
10. 订阅生命周期与租户契约
Flexprice 不处理任何 Azure 生命周期事件:没有 Flexprice 侧监听 Azure 的 webhook,也不轮询 Azure 的订阅状态。租户自己的 webhook——注册在与 Entra 应用相同的 Technical Configuration 页面(§6.2),是 Flexprice 不设置的独立字段——是唯一接收 Subscribe、ChangePlan、ChangeQuantity、Renew、Suspend、Unsubscribe、Reinstate 的通道。Flexprice 只有在租户看到自己 webhook 的事件后、调用 Flexprice 的 subscription/agreement API 时才得知变更。买家订阅结束时,租户取消 Flexprice 订阅,这会归档映射,使两者 crons 的 published-only 过滤器将其剔除。取消就是全部信号。
Azure 生命周期事件(全部投递到租户 webhook):
Webhook action |
租户做什么 | 需要 ACK? |
|---|---|---|
Subscribe |
创建 Flexprice 订阅、注册协议 | 仅 200(notify-only) |
ChangePlan |
批准、更新 Flexprice 订阅的 plan 映射 | 是——10s 内 PATCH Operations API,否则 Azure 自动接受 |
ChangeQuantity |
若相关则更新席位数量 | 是——同样 10s ACK 窗口 |
Renew |
无 | notify-only |
Suspend |
无——映射保持 published(Reinstate 是可能的)。更正,见 v4 §6: 上报不继续——Azure 只接受 Subscribed 状态的用量,并以 ResourceNotActive 拒绝挂起订阅 |
notify-only |
Unsubscribe |
取消 Flexprice 订阅 → 映射归档 → 两个 cron 停止 | notify-only |
Reinstate |
无——买家的付款已恢复 | notify-only |
时序与 AWS/GCP 相同:租户应只在快照 cron 的 4–10h 滞后有机会捕获最终活跃期用量之后才归档。
被 v4 §3 取代。 等待快照 cron 的 4–10h 滞后会错过各提供商取消后的窗口:AWS 在
License Deprovisioned后约允许 1 小时,GCP 取消后 1 小时。现在取消 Flexprice 订阅会触发一次性 flush,立即上报积压与最终窗口,然后自行归档订阅映射——租户无需等待,也无需手工归档任何东西。
11. 设计决策
11.1 —— 快照节奏三家统一保持 6h,不做小时级。 曾考虑为 Azure 做小时级节奏以更贴近其小时粒度去重键。§8.1 显示 6 小时间隔已保证无同小时冲突,因此没有正确性理由为 Azure 特设节奏。三家一种节奏。
11.2 —— Quantity 是经单一 usage_fee 维度的美元美分,而非按功能维度映射。 AWS 已发货实现已使用单一 usage_fee 维度(dto/marketplace.go、ee/service/marketplace.go),与 GCP 的 metric_name 约定一致。Azure 相同,保持管线统一。早前仅 AWS 的前身设计用过按功能 dimension_map;那不是发货形态,也不延续。
11.3 —— 客户标识符字段名保持 provider 专属。 customer_aws_account_id 是 AWS BatchMeterUsage 自己的字段名。GCP 的 account_id 匹配 GCP 自己的词汇(Entitlement 资源的 account 字段和 Account 资源的 name accounts/{account_id})。Azure 使用 beneficiary_account_id——"beneficiary" 是 Microsoft 在 Resolve/webhook payload 中对买家的术语。每个名字匹配各自的平台。
11.4 —— Azure 存储静态 client_secret。 AWS(AssumeRole)和 GCP(Workload Identity Federation)都避免存储密钥;Azure SaaS 计量认证没有联合选项("For SaaS offers, this is the only available option")。加密存储在 encrypted_secret_data.azure_marketplace,与其他所有 provider 的 secret 相同。
11.5 —— Azure Duplicate 只记录日志,绝不标记 synced。 Duplicate 说明 Azure 已持有 (resourceId, dimension, hour) 的某条事件,但响应不与我们所发内容匹配,因此它不能证明那条事件是我们的。据此把行标记为 synced 会让 Azure 的响应、而非我们自己的记录,来决定表的状态。所以它不写 syncs 条目,以 error 记录,下轮重试(§12 覆盖随后永不解析的行)。区别于 AWS DuplicateRecord——那是冲突的不同记录,需要人工。
11.6 —— 零金额是 provider 专属的。 amount < 0 在任何 provider 上都绝不发送——负的已计算费用是上游 bug,以 error 记录。amount == 0 按 provider 各自文档而异:
- AWS 允许:
Quantity"Valid Range: Minimum value of 0"。已发货的资格检查只拒绝负金额,因此零今天就已经在发送。 - GCP 未规定
MetricValue最小值;已发货客户端已无守卫地发送Int64Value: 0。像任何其他值一样发送。 - Azure 拒绝:
InvalidQuantity—— "The quantity passed is lower or equal to 0"(§2.4)。绝不发送。
Azure 的跳过在上报的美分数上检查,而非原始 decimal 金额。金额在发送前转为整美分(§2.1),正的但不足 1 分(如 $0.004)会向下舍入为零美分。以 amount == 0 做门槛会让这样的行漏过、被 Azure 以 InvalidQuantity 拒绝、然后永远重试——每次尝试都发生同样的舍入,它永远不会成功、也永远不会得到 Skipped 条目。以 cents <= 0 做门槛(负数已在上游过滤)在发送前同时捕获真正为零和舍入为零的情况。仓库中 reportAzureRecord 正是用 cents := types.ToSmallestUnit(...) 后判断 cents == 0 来实现这一语义。
对 Azure,该连接仍需要解析,否则带有 Azure 连接的风扇出行即使在 AWS/GCP 成功后也无法达到 synced = true。它得到一个 Skipped: true、ReportingID: "" 的 SyncEntry(§7)——记录"什么都没发布"的同时仍让行完成。这是安全的,因为 Azure 的零拒绝是确定性的,不同于 Duplicate(§11.5,含歧义、无条目)。
11.7 —— 连接验证只做 token 请求,不做 offer 绑定检查。 成功的 client_credentials token 证明 tenant_id/client_id/client_secret 真实、Marketplace API 的服务主体已注册在该目录。它不证明该应用被接入了 Partner Center 中任何特定 offer 的 Technical Configuration——Microsoft 只在使用 token 对绑定到特定 offer 订阅的真实 Marketplace API 调用时才检查该绑定。设计早期版本曾添加第二次调用(GET /api/saas/subscriptions),纯粹为在保存连接前同步强制执行该检查,模拟 AWS AssumeRole 和 GCP WifSession 如何在保存前充分检验凭据。但这不能延续到 Azure:与 AWS/GCP 不同——那里连接本身限定于 Flexprice 可直接探测的单一资源——Azure 的连接限定于租户的 Entra 应用,而非任何单一 offer。Flexprice 在创建连接时没有可检查绑定的 offer,且一个 Entra 应用预期会被租户通过它上报的每个 offer 复用(§1、§5),因此没有单一的"那个 offer"可供调用定位。把 Technical Configuration 配错完全是租户自己的责任(上面设置步骤 4);它以真实 401 在第一次尝试上报时浮现(§8.4),而不是以拒绝连接的形式。
12. 已知缺口
-
不可接受或过期行没有终止态 / TTL。 marketplace 不会接受的行——订阅关闭、行超过 24h 提交窗口、或 Azure 一直回答
Duplicate(§11.5)——每轮都被重试,因为没有东西把行标记为永久完成。synced=false(或缺失syncs条目)同时意味着"重试我"和"我永远不会成功";两者今天不可区分。上报 cron 每 3h 运行,Azure 接受 24h 内的period_end,因此首次失败的行仍有约 21 小时——约 7 轮——在过期前成功。那个窗口是我们在没有终止态时依赖的安全裕度:每个失败都被记录(§8.4),因此可在那约 21 小时内告警并手工解决。未来工作是一个终止态(如expired),在行的period_end越过接受窗口(AWS/Azure 24h,GCP 假定类似)后设置,加上这些行的 TTL/回收,使它们停止被重试、停止累积。对error级日志行告警是自然的后续。这里什么都没构建。 该问题在 v4 §5.2 中部分解决:ListUnsynced增加period_end >= now-24h边界,越过提交窗口的行停止被重新扫描;真正的终止状态仍未构建。 -
无死信表。 失败行只以日志行加其自身在
usage_records中的状态可见。没有地方浮出"这些行已失败 N 次"。贯穿 v1 和 v2;不阻塞 Azure。 -
Azure 迟到提交规则未确认。 Azure 以提交时间还是以
effectiveStartTime判断迟到?没有已查文档回答。4–10h 快照滞后(§8.1)使普通上报不受影响,但订阅恰在 24h 边界关闭的边角案例未经测试。与 v2 中 AWS/GCP 相同问题的开放状态一致。该问题在 v4 §4 中部分解决:Azure 独立于 24heffectiveStartTime规则、以订阅状态拒绝(ResourceNotActive,或 400 "SaaS subscription isn't in Subscribed status"),并文档化取消前期间的用量仍可上报;迟到本身以提交时间还是effectiveStartTime判断仍无定论。 -
用量记录一条条上报,未批处理。 三家都支持多记录调用——AWS 与 Azure 最多 25 条,GCP 最多 1MB 请求。这里一概不用;每条记录一次 API 调用。延迟而非现在做的两个原因:
- AWS
BatchMeterUsage与 GCPservices.report都把单次调用限定于一个产品(AWS:整个UsageRecords[]数组一个ProductCode;GCP:service_name在 URL 路径中,而非按 operation)。一条连接可能支撑多个不同产品的 plan——租户在一个 seller 账号下运行 Basic 和 Pro 两个 listing 是现实配置——因此把不相关记录天真地塞进一次调用会静默错报所有不匹配该调用单一声明产品的记录。要安全就得在分块前按产品分组待处理记录,这是为无正确性收益的真实复杂度。Azure 的usageEvent/batchUsageEvent无此约束——批中每条记录都自带描述(resourceId、dimension、planId逐条)——但为了三家统一上报路径,与 AWS 和 GCP 一起推迟,而不是只批处理已安全的那一家。 - AWS 正把新 SaaS 产品从
ProductCode迁移到 Concurrent Agreements 的LicenseArn,2026 年 6 月 1 日起强制。与ProductCode不同,LicenseArn已经是UsageRecord上的逐条字段,不限定整次调用——因此一旦产品上了该模型,AWS 单产品每调用的约束不再适用,跨不同产品批处理 AWS 记录无需产品分组步骤也变得安全。等它成为常态而非例外后,重新审视三家批处理更有意义。
参考:BatchMeterUsage API reference(
ProductCode是顶层请求字段,非逐条);Azure 逐条批量请求形态见 Metering service APIs(§2.4)。注意:这些是文档中引用的外部参考,本文不展开外部站点细节,仓库内证据以 report_activities.go 的单条上报循环为准。 - AWS
13. 在仓库中继续深入
- docs/design/2026-07-23-FLE-1071-marketplace-integration-v3.md —— 本文所依据的完整设计文档(含 §13 全部外部参考链接)。
- docs/design/2026-07-30-FLE-1106-marketplace-integration-v4.md —— 取代 v3 的取消时序契约(§10)、
Suspend行更正、ListUnsynced24h 边界与取消后上报窗口的后续设计。 - internal/integration/azuremarketplace/client.go —— Azure 客户端:
GetToken(client_credentials,1h TTL 兼容解析)与ReportUsageEvent(usageEventwire 格式、status不读取、错误经RedactSecrets脱敏)。 - internal/api/dto/marketplace.go ——
/v1/marketplace/agreements请求/响应 DTO:三个 provider 块、校验逻辑与错误提示。 - internal/temporal/activities/marketplace/report_activities.go —— 上报 cron 活动:按 tenant/environment 分组、每连接一次认证、
ListUnsynced、MarkSynced。 - internal/temporal/activities/marketplace/reporter.go —— 共享
MarketplaceReporter:reportAzureRecord的零美分跳过、三家各自响应处理、loadAzureMappings。 - internal/temporal/activities/marketplace/snapshot_activities.go —— 快照 cron 活动:遍历
marketplaceProviderTypes、CalculateMeterUsageCharges、ExistsForPeriod幂等。 - internal/types/usagerecords.go ——
UsageRecordSyncEntry(含Skipped/SkipReason)与SkipReasonZeroAmountNotSupported常量;以 marketplace 为键的syncsmap 设计。 - internal/types/secret.go ——
SecretProviderAWSMarketplace/GCP/Azure枚举与连接类型映射。