本日核心價值 (Core Focus): 在 ASP.NET Core Minimal API 用
HttpClient呼叫 OpenAI Chat Completions,API key 只從環境變數讀、timeout 必設、禁止把 secrets 寫進 log。提供/ai/summarize-order端點;PHP 給對等 cURL 片段。重試只針對 429/5xx,並做 backoff。
概念說明與實戰情境 (Overview)
把 ChatGPT 塞進後端,失敗通常不是 Prompt,而是 HTTP 衛生:key 寫死在 appsettings.json 並被 log、沒 timeout 拖垮 thread pool、把整個訂單 JSON 當 user 訊息導致 Token 爆炸。正確位置是一層薄 API:業務系統組好「已授權的訂單摘要 Context」,再由 /ai/summarize-order 轉呼 OpenAI。本日以 C# 為主、PHP 對等;模型預設 gpt-4o(可換)。Key 永不入 Prompt、永不入 response body、永不入 structured log。
關鍵操作與範例 (Implementation & Example)
契約先定:前端或內部服務 POST JSON { "orderId": "ORD-1001" }。伺服器自己查訂單(此處用 in-memory 示意),只把白名單欄位送給模型。不要讓呼叫端上傳任意 prompt 字串——那是 Prompt Injection 入口(Day 21 會專講)。
把 OpenAI 當「內部下游」而不是「公開聊天室」。對外暴露的是你的業務端點,超時、鑑權、rate limit 都先走 ASP.NET / PHP 既有機制;模型只看到已授權的訂單摘要。這樣才能把 Function Calling(Day 06–07)的 tool 結果與後端查詢放在同一信任邊界:key 在伺服器、資料在伺服器、模型只做語言層。
C# 使用 IHttpClientFactory、System.Text.Json、CancellationToken。Timeout 設在 HttpClient(本日 30 秒,含連線與讀取);另外對 429/5xx 做有限次 backoff。不引入官方 SDK 是為了讓 HTTP 細節可見:Authorization header、status code、retry 條件。團隊若已用 SDK,仍要核對同等的 timeout、log 紅線與重試策略。
端點要掛在既有鑑權後面:內部服務用 API key / mTLS,對員工後台則沿用 cookie / JWT。/ai/summarize-order 不是聊天室,呼叫者必須已經能讀該 orderId。授權失敗回 403,不要打到 OpenAI 再靠模型「拒絕」——那既貴也不安全。摘要結果可短 TTL cache(以 orderId + 訂單 updatedAt 當 key),避免同一張單被連續整理三次。
// Program.cs — ASP.NET Core 8 Minimal API
// 環境變數: OPENAI_API_KEY(必填), OPENAI_MODEL(選填,預設 gpt-4o)
// 執行: dotnet run
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("OPENAI_API_KEY is missing");
}
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o";
builder.Services.AddHttpClient("openai", client =>
{
client.BaseAddress = new Uri("https://api.openai.com/v1/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
});
builder.Services.AddSingleton(new OpenAiOptions(model));
builder.Services.AddSingleton<OrderStore>();
builder.Services.AddTransient<OpenAiChatClient>();
var app = builder.Build();
app.MapPost("/ai/summarize-order", SummarizeOrderAsync);
app.Run();
static async Task<IResult> SummarizeOrderAsync(
SummarizeRequest req,
OrderStore store,
OpenAiChatClient ai,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(req.OrderId))
{
return Results.BadRequest(new { error = "orderId required" });
}
var order = store.Find(req.OrderId);
if (order is null)
{
return Results.NotFound(new { error = "order_not_found" });
}
var summary = await ai.SummarizeOrderAsync(order, ct);
return Results.Json(new { orderId = order.OrderId, summary });
}
sealed record SummarizeRequest([property: JsonPropertyName("orderId")] string OrderId);
sealed record OpenAiOptions(string Model);
sealed class OrderStore
{
private readonly Dictionary<string, OrderRecord> _rows = new()
{
["ORD-1001"] = new(
"ORD-1001", "paid", "Ada",
new[] { new OrderLine("SKU-WIDGET-M", 2, 640m) })
};
public OrderRecord? Find(string orderId) =>
_rows.TryGetValue(orderId, out var row) ? row : null;
}
sealed record OrderLine(string Sku, int Qty, decimal UnitPrice);
sealed record OrderRecord(string OrderId, string Status, string CustomerName, IReadOnlyList<OrderLine> Lines);
sealed class OpenAiChatClient
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IHttpClientFactory _factory;
private readonly OpenAiOptions _options;
private readonly ILogger<OpenAiChatClient> _logger;
public OpenAiChatClient(
IHttpClientFactory factory,
OpenAiOptions options,
ILogger<OpenAiChatClient> logger)
{
_factory = factory;
_options = options;
_logger = logger;
}
public async Task<string> SummarizeOrderAsync(OrderRecord order, CancellationToken ct)
{
var payload = new
{
model = _options.Model,
temperature = 0.2,
messages = new object[]
{
new
{
role = "system",
content = "你是客服摘要器。只用使用者提供的訂單 JSON 寫 3 句中文摘要:狀態、品項、風險。禁止發明欄位。"
},
new
{
role = "user",
content = JsonSerializer.Serialize(order, JsonOpts)
}
}
};
using var content = new StringContent(
JsonSerializer.Serialize(payload, JsonOpts),
Encoding.UTF8,
"application/json");
var json = await SendWithRetryAsync(content, ct);
using var doc = JsonDocument.Parse(json);
return doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "";
}
private async Task<string> SendWithRetryAsync(HttpContent body, CancellationToken ct)
{
var client = _factory.CreateClient("openai");
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
// HttpRequestMessage 必須每次新建;失敗重送不可重複使用同一則 request
using var req = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = await CloneJsonContentAsync(body)
};
HttpResponseMessage resp;
try
{
resp = await client.SendAsync(req, ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogWarning("openai timeout attempt {Attempt}", attempt);
if (attempt == maxAttempts) throw;
await DelayBackoffAsync(attempt, ct);
continue;
}
var respBody = await resp.Content.ReadAsStringAsync(ct);
if (resp.IsSuccessStatusCode)
{
return respBody;
}
var retryable = resp.StatusCode is
HttpStatusCode.TooManyRequests or
HttpStatusCode.InternalServerError or
HttpStatusCode.BadGateway or
HttpStatusCode.ServiceUnavailable or
HttpStatusCode.GatewayTimeout;
// 禁止記錄 Authorization 或完整 request;只記 status 與 attempt
_logger.LogWarning("openai HTTP {Status} attempt {Attempt}", (int)resp.StatusCode, attempt);
if (!retryable || attempt == maxAttempts)
{
throw new HttpRequestException($"OpenAI HTTP {(int)resp.StatusCode}");
}
await DelayBackoffAsync(attempt, ct);
}
throw new HttpRequestException("OpenAI retry exhausted");
}
private static async Task<StringContent> CloneJsonContentAsync(HttpContent source)
{
var raw = await source.ReadAsStringAsync();
return new StringContent(raw, Encoding.UTF8, "application/json");
}
private static Task DelayBackoffAsync(int attempt, CancellationToken ct)
{
// 250ms, 500ms + jitter;不要對 400/401 重試
var jitter = Random.Shared.Next(50, 150);
var delay = TimeSpan.FromMilliseconds(250 * Math.Pow(2, attempt - 1) + jitter);
return Task.Delay(delay, ct);
}
}
appsettings.json 可放 OpenAI:Model,但 不要 放 API key。本機用 user-secrets 或環境變數;正式環境用 Key Vault / 容器 secret。Log 只允許 status、attempt、orderId;禁止 dump HttpRequestMessage.Headers.Authorization,也禁止把 respBody 在 401 時原樣寫進 log(有時錯誤訊息會回映部分 header)。
對等 PHP(同一端點語意,cURL + 環境變數)。可用 Guzzle 替換,但 timeout 與 header 規則相同:
<?php
// summarize_order.php — PHP 8.2
// 環境變數: OPENAI_API_KEY, OPENAI_MODEL(預設 gpt-4o)
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$apiKey = getenv('OPENAI_API_KEY');
if ($apiKey === false || $apiKey === '') {
http_response_code(500);
echo json_encode(['error' => 'OPENAI_API_KEY is missing']);
exit;
}
$model = getenv('OPENAI_MODEL') ?: 'gpt-4o';
$input = json_decode(file_get_contents('php://input') ?: '{}', true);
$orderId = $input['orderId'] ?? '';
if ($orderId === '') {
http_response_code(400);
echo json_encode(['error' => 'orderId required']);
exit;
}
$orders = [
'ORD-1001' => [
'orderId' => 'ORD-1001',
'status' => 'paid',
'customerName' => 'Ada',
'lines' => [['sku' => 'SKU-WIDGET-M', 'qty' => 2, 'unitPrice' => 640]],
],
];
if (!isset($orders[$orderId])) {
http_response_code(404);
echo json_encode(['error' => 'order_not_found']);
exit;
}
$payload = [
'model' => $model,
'temperature' => 0.2,
'messages' => [
[
'role' => 'system',
'content' => '你是客服摘要器。只用使用者提供的訂單 JSON 寫 3 句中文摘要:狀態、品項、風險。禁止發明欄位。',
],
['role' => 'user', 'content' => json_encode($orders[$orderId], JSON_UNESCAPED_UNICODE)],
],
];
function openai_chat(string $apiKey, array $payload, int $maxAttempts = 3): array
{
$attempt = 0;
while ($attempt < $maxAttempts) {
$attempt++;
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno === CURLE_OPERATION_TIMEDOUT && $attempt < $maxAttempts) {
usleep((int) ((250 * (2 ** ($attempt - 1)) + random_int(50, 150)) * 1000));
continue;
}
if ($raw === false) {
throw new RuntimeException('curl failed');
}
$retryable = in_array($status, [429, 500, 502, 503, 504], true);
if ($retryable && $attempt < $maxAttempts) {
error_log("openai HTTP {$status} attempt {$attempt}"); // 不要 log $apiKey 或 Authorization
usleep((int) ((250 * (2 ** ($attempt - 1)) + random_int(50, 150)) * 1000));
continue;
}
if ($status < 200 || $status >= 300) {
throw new RuntimeException("OpenAI HTTP {$status}");
}
$decoded = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
return $decoded;
}
throw new RuntimeException('OpenAI retry exhausted');
}
try {
$data = openai_chat($apiKey, $payload);
echo json_encode([
'orderId' => $orderId,
'summary' => $data['choices'][0]['message']['content'] ?? '',
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(502);
echo json_encode(['error' => 'upstream_failed']);
}
Backoff 規則寫死:最多 3 次;僅 timeout / 429 / 5xx;delay = 250ms * 2^(attempt-1) + jitter。不要對 400(Schema 錯)、401/403(key 錯或權限)、404 重試。401 重試只會放大洩漏面並鎖帳號。呼叫端若有 Retry-After header,優先採用其秒數。
C# 與 PHP 必須共用同一張契約:請求只含 orderId、成功回 {orderId, summary}、上游失敗回 502 {error: "upstream_failed"}、缺 key 在啟動或請求時失敗且不回 key 內容。PHP-FPM 的 CURLOPT_TIMEOUT 要小於 PHP max_execution_time,否則 worker 被殺時你看不到 OpenAI 的 status。Guzzle 等價設定是 timeout + connect_timeout,retry middleware 同樣排除 401。
日誌紅線寫進 code review:禁止序列化 HttpRequestMessage、禁止 var_dump($payload) 含 messages 全文(可能有 PII)、禁止在 exception message 夾帶 response body。需要除錯時用自訂 traceId 串起 orderId 與 OpenAI 的 x-request-id(若有),不要把 Bearer 當 trace。上線前用一筆假訂單打 /ai/summarize-order,同時確認 log 裡沒有 key、沒有完整訂單地址。
注意事項與常見失敗 (Pitfalls)
appsettings.json 並 commit: 改環境變數或 secret store。CI log、exception middleware 也要過濾 Bearer。本機可用 dotnet user-secrets,不要用「先寫死再上線前刪掉」的流程。Console.WriteLine(request) / error_log($payload) 含 Authorization: 改記 orderId + HTTP status。401 的 response body 也不要原樣落地。問題排查用 OpenAI 回傳的 x-request-id(若有),不要把整包 messages 貼到群組。HttpClient 卻每次 new HttpClient(): socket 耗盡。用 IHttpClientFactory;PHP 則每次 curl handle 要 curl_close。工廠裡設定的 timeout 對所有呼叫生效,避免有人另 new HttpClient 又不設 timeout。client.Timeout = 30s,PHP CURLOPT_TIMEOUT + CONNECTTIMEOUT。閘道(IIS / nginx)的 timeout 必須比這更長,否則使用者看到 504,你的重試還在跑。/ai/summarize-order 只收 orderId。摘要用伺服器端 system 指令 + 白名單訂單欄位。若產品需要「語氣:正式/口語」,用 enum 欄位,不要開放自由文字 Prompt。HttpRequestMessage: .NET 會丟例外。每次 attempt 新 request;body 要 clone。摘要端點視為可重試讀模型,但訂單查詢本身必須是冪等讀取。orderId。地址、電話、身分證字號不進摘要 Context,除非合規審查明確允許。本日總結 (Takeaways)
System.Text.Json 明確序列化。/ai/summarize-order 只收 orderId,由伺服器組 Context;不要暴露通用「代轉 OpenAI」端點。HttpClient 規則相同:30s timeout、Bearer 不落地、錯誤回 upstream_failed 給呼叫端。gpt-4o,可換成團隊現用模型而不改程式邏輯。明日預告 (Next)
明日進入 數據庫工作流:ChatGPT 自動生成高效 SQL Query、Migration 與 Index 優化,用 PostgreSQL 的 orders / order_items 強制產出可參數化查詢、EXPLAIN 意圖、index 建議與 up/down migration。