iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 9

Day 09: 後端系統整合實戰:將 ChatGPT API 嵌入 C# .NET / PHP Web 架構

  • 分享至 

  • xImage
  •  

Day 09: 後端系統整合實戰:將 ChatGPT API 嵌入 C# .NET / PHP Web 架構 (Backend Integration C# and PHP)

本日核心價值 (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# 使用 IHttpClientFactorySystem.Text.JsonCancellationTokenTimeout 設在 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 只允許 statusattemptorderId;禁止 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)

  • API key 進 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。
  • 沒 timeout: OpenAI 慢或網路卡住會占滿 thread / PHP-FPM worker。C# client.Timeout = 30s,PHP CURLOPT_TIMEOUT + CONNECTTIMEOUT。閘道(IIS / nginx)的 timeout 必須比這更長,否則使用者看到 504,你的重試還在跑。
  • 讓客戶端傳任意 Prompt: /ai/summarize-order 只收 orderId。摘要用伺服器端 system 指令 + 白名單訂單欄位。若產品需要「語氣:正式/口語」,用 enum 欄位,不要開放自由文字 Prompt。
  • 重試 POST 卻重用同一個 HttpRequestMessage .NET 會丟例外。每次 attempt 新 request;body 要 clone。摘要端點視為可重試讀模型,但訂單查詢本身必須是冪等讀取。
  • 把完整訂單 PII 丟進模型又寫進應用 log: 送給 OpenAI 的欄位白名單化;應用 log 只留 orderId。地址、電話、身分證字號不進摘要 Context,除非合規審查明確允許。

本日總結 (Takeaways)

  • 後端整合的核心是 HTTP 衛生:環境變數 key、timeout、不記錄 secrets、用 System.Text.Json 明確序列化。
  • /ai/summarize-order 只收 orderId,由伺服器組 Context;不要暴露通用「代轉 OpenAI」端點。
  • 重試限 429/5xx/timeout,指數 backoff + jitter;400/401/403 立刻失敗。
  • PHP cURL 與 C# HttpClient 規則相同:30s timeout、Bearer 不落地、錯誤回 upstream_failed 給呼叫端。
  • 模型名用環境變數覆寫,預設 gpt-4o,可換成團隊現用模型而不改程式邏輯。

明日預告 (Next)

明日進入 數據庫工作流:ChatGPT 自動生成高效 SQL Query、Migration 與 Index 優化,用 PostgreSQL 的 orders / order_items 強制產出可參數化查詢、EXPLAIN 意圖、index 建議與 up/down migration。


上一篇
Day 08: 多模態工作流 (Multimodal Workflow):從 UI/UX 設計圖自動生成前端程式碼
下一篇
Day 10: 數據庫工作流:ChatGPT 自動生成高效 SQL Query、Migration 與 Index 優化
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言