標籤: API

  • Jackson JSON 序列化:時區與日期處理

    商業價值:正確的時區處理讓「跨平台訂單時間一致」,避免 導讀篇提到的「超賣損失」——如果訂單時間錯誤,先後順序就會亂,庫存扣減就會出問題。

    為什麼不用其他方案?

    方案 優點 缺點 適用場景
    統一 ObjectMapper(本文) 全系統一致、自動時區轉換 需要初期設定 多平台整合系統
    各處自行處理 彈性高 格式不一致、時區 bug 頻發 單一平台小專案
    字串直接儲存 簡單 無法比較、排序困難 純展示用途
    Gson 輕量 時區支援較弱、擴展性差 簡單 JSON 處理

    前言:跨時區系統的日期處理

    多通路系統需要處理各種日期格式:

    平台 日期格式 範例
    蝦皮 Unix timestamp 1710748800
    Yahoo ISO 8601 2024-03-18T15:30:00+08:00
    Momo 台灣時間字串 2024/03/18 15:30:00
    資料庫 UTC 2024-03-18T07:30:00Z
    問題:如果沒有統一處理,時區 bug 會在各種轉換中出現

    解決方案:統一 JSON 處理

    ObjectMapper 設定

    @Configuration
    public class JsonConfig {

    @Bean
    public ObjectMapper objectMapper() {
    ObjectMapper mapper = new ObjectMapper();

    // 1. 允許序列化私有欄位
    mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);

    // 2. 日期格式設定
    mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    mapper.setDateFormat(new SimpleDateFormat(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”));

    // 3. 設定 UTC 時區
    mapper.setTimeZone(TimeZone.getTimeZone(“UTC”));

    // 4. 支援 Java 8 時間 API
    mapper.registerModule(new JavaTimeModule());

    // 5. 自定義序列化器
    SimpleModule module = new SimpleModule();
    module.addSerializer(ZonedDateTime.class, new ZonedDateTimeSerializer());
    mapper.registerModule(module);

    // 6. 忽略未知欄位(平台加新欄位不會報錯)
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    return mapper;
    }
    }

    設定 效果
    FIELD visibility 不需要 getter 也能序列化
    UTC 時區 系統內部統一用 UTC
    JavaTimeModule 支援 LocalDate、ZonedDateTime 等
    忽略未知欄位 API 加新欄位不會報錯

    自定義日期序列化器

    public class ZonedDateTimeSerializer extends JsonSerializer<ZonedDateTime> {

    private static final DateTimeFormatter FORMATTER =
    DateTimeFormatter.ofPattern(“yyyy-MM-dd’T’HH:mm:ss.SSS’Z’”);

    @Override
    public void serialize(
    ZonedDateTime value,
    JsonGenerator gen,
    SerializerProvider provider) throws IOException {

    // 無論輸入什麼時區,都轉成 UTC 輸出
    ZonedDateTime utc = value.withZoneSameInstant(ZoneId.of(“UTC”));
    gen.writeString(FORMATTER.format(utc));
    }
    }

    效果展示

    // 輸入:台灣時間
    ZonedDateTime taiwanTime = ZonedDateTime.now(ZoneId.of(“Asia/Taipei”));
    // 2024-03-18T15:30:00+08:00[Asia/Taipei]

    // 輸出:自動轉成 UTC
    String json = objectMapper.writeValueAsString(Map.of(“time”, taiwanTime));
    // {“time”:”2024-03-18T07:30:00.000Z”}


    JSON 工具類

    @Component
    public class JsonUtil {

    private static ObjectMapper mapper;

    @Autowired
    public void setMapper(ObjectMapper mapper) {
    JsonUtil.mapper = mapper;
    }

    /**
    * 物件轉 JSON
    */

    public static String toJson(Object obj) {
    try {
    return mapper.writeValueAsString(obj);
    } catch (JsonProcessingException e) {
    throw new JsonException(“序列化失敗”, e);
    }
    }

    /**
    * JSON 轉物件
    */

    public static <T> T fromJson(String json, Class<T> clazz) {
    try {
    return mapper.readValue(json, clazz);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉泛型物件
    */

    public static <T> T fromJson(String json, TypeReference<T> type) {
    try {
    return mapper.readValue(json, type);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉 List
    */

    public static <T> List<T> fromJsonList(String json, Class<T> clazz) {
    try {
    JavaType type = mapper.getTypeFactory()
    .constructCollectionType(List.class, clazz);
    return mapper.readValue(json, type);
    } catch (JsonProcessingException e) {
    throw new JsonException(“反序列化失敗”, e);
    }
    }

    /**
    * JSON 轉 Map
    */

    public static Map<String, Object> toMap(String json) {
    return fromJson(json, new TypeReference<>() {});
    }
    }


    平台日期轉換

    public class DateConverter {

    /**
    * Unix timestamp → ZonedDateTime(蝦皮)
    */

    public static ZonedDateTime fromUnixTimestamp(long timestamp) {
    return Instant.ofEpochSecond(timestamp)
    .atZone(ZoneId.of(“UTC”));
    }

    /**
    * ISO 8601 → ZonedDateTime(Yahoo)
    */

    public static ZonedDateTime fromISO(String isoString) {
    return ZonedDateTime.parse(isoString);
    }

    /**
    * 台灣時間字串 → ZonedDateTime(Momo)
    */

    public static ZonedDateTime fromTaiwanTime(String timeStr) {
    DateTimeFormatter formatter =
    DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”);

    LocalDateTime ldt = LocalDateTime.parse(timeStr, formatter);
    return ldt.atZone(ZoneId.of(“Asia/Taipei”));
    }

    /**
    * 轉成台灣時間顯示
    */

    public static String toTaiwanDisplay(ZonedDateTime time) {
    ZonedDateTime taiwanTime = time.withZoneSameInstant(
    ZoneId.of(“Asia/Taipei”)
    );
    return taiwanTime.format(
    DateTimeFormatter.ofPattern(“yyyy/MM/dd HH:mm:ss”)
    );
    }
    }


    使用範例

    // 從蝦皮 API 取得資料
    long shopeeTimestamp = 1710748800;
    ZonedDateTime orderTime = DateConverter.fromUnixTimestamp(shopeeTimestamp);

    // 存入資料庫(UTC)
    order.setCreatedAt(orderTime);
    orderRepository.save(order);

    // 回傳 API(自動轉成 UTC JSON)
    return JsonUtil.toJson(order);
    // {“createdAt”:”2024-03-18T08:00:00.000Z”}

    // 前端顯示(轉成台灣時間)
    String display = DateConverter.toTaiwanDisplay(order.getCreatedAt());
    // 2024/03/18 16:00:00


    實戰踩坑

    踩坑 1:時區雙重轉換
    情境:前端顯示時間比實際晚 8 小時
    原因:資料庫已經是 UTC,但讀取時又被當成本地時間再轉一次 UTC
    解法:確保 JDBC 連線設定 serverTimezone=UTC,並且 Entity 用 ZonedDateTime 而非 Date
    踩坑 2:蝦皮 timestamp 單位搞錯
    情境:訂單時間顯示成 1970 年
    原因:蝦皮回傳秒級 timestamp,但程式用 Instant.ofEpochMilli() 處理
    解法:確認平台 API 文件的時間單位,秒用 ofEpochSecond,毫秒用 ofEpochMilli
    踩坑 3:SimpleDateFormat 執行緒不安全
    情境:高併發時偶發日期解析錯誤或 NumberFormatException
    原因:把 SimpleDateFormat 設成 static 共用
    解法:改用 DateTimeFormatter(執行緒安全),或每次 new 新的 SimpleDateFormat

    總結

    設計 效果
    統一 ObjectMapper 全系統一致的 JSON 處理
    UTC 儲存 避免時區混亂
    自定義序列化 控制輸出格式
    平台轉換器 各平台格式統一處理

    上一篇 系列目錄 下一篇
    PDF生成與Builder Pattern 系列導讀 OpenTracing分散式追蹤

    這是「多通路電商 OMS 系統實戰」系列的第八篇。下一篇會介紹分散式追蹤。

  • HTTP 客戶端設計:OkHttp 連接池與多場景應用

    商業價值:穩定的 HTTP 客戶端讓系統「能可靠地跟 17 個平台通訊」,這是 導讀篇提到「99% 庫存準確率」的基礎——API 不穩定,庫存同步就會失敗。

    前言:呼叫外部 API 的挑戰

    多通路系統需要呼叫大量外部 API:

    API 類型 特性 挑戰
    蝦皮 API 流量限制嚴格 需要控制請求頻率
    物流 API 回應慢 需要較長逾時
    支付 API 高可靠性要求 需要重試機制
    問題:每次都建立新連線 → 效能差、資源浪費

    解決方案:OkHttp 連線池

    連線池設定

    @Configuration
    public class HttpClientConfig {

    @Bean
    public OkHttpClient okHttpClient() {
    return new OkHttpClient.Builder()
    // 連線池設定
    .connectionPool(new ConnectionPool(
    100, // 最大閒置連線數
    5, TimeUnit.MINUTES // 閒置時間
    ))

    // 逾時設定
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)

    // 重試
    .retryOnConnectionFailure(true)

    .build();
    }
    }

    設定 說明
    maxIdleConnections 100 最多保持 100 條閒置連線
    keepAliveDuration 5 分鐘 閒置連線保持時間
    connectTimeout 10 秒 建立連線逾時
    readTimeout 30 秒 讀取回應逾時

    HTTP 客戶端封裝

    @Component
    public class HttpClientService {

    @Autowired
    private OkHttpClient okHttpClient;

    /**
    * GET 請求
    */

    public HttpResult get(String url, Map<String, String> headers) {
    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .get()
    .build();

    return execute(request);
    }

    /**
    * POST 請求(JSON)
    */

    public HttpResult postJson(String url, Object body, Map<String, String> headers) {
    String json = JsonUtil.toJson(body);

    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .post(RequestBody.create(json, MediaType.parse(“application/json”)))
    .build();

    return execute(request);
    }

    /**
    * POST 請求(Form)
    */

    public HttpResult postForm(String url, Map<String, String> params, Map<String, String> headers) {
    FormBody.Builder formBuilder = new FormBody.Builder();
    params.forEach(formBuilder::add);

    Request request = new Request.Builder()
    .url(url)
    .headers(Headers.of(headers))
    .post(formBuilder.build())
    .build();

    return execute(request);
    }

    private HttpResult execute(Request request) {
    try (Response response = okHttpClient.newCall(request).execute()) {
    return HttpResult.builder()
    .statusCode(response.code())
    .body(response.body() != null ? response.body().string() : null)
    .headers(response.headers().toMultimap())
    .success(response.isSuccessful())
    .build();

    } catch (IOException e) {
    return HttpResult.builder()
    .success(false)
    .errorMessage(e.getMessage())
    .build();
    }
    }
    }


    回應結果封裝

    @Data
    @Builder
    public class HttpResult {
    private boolean success;
    private int statusCode;
    private String body;
    private Map<String, List<String>> headers;
    private String errorMessage;

    /**
    * 解析 JSON 回應
    */

    public <T> T parseJson(Class<T> clazz) {
    if (!success || body == null) {
    return null;
    }
    return JsonUtil.fromJson(body, clazz);
    }

    /**
    * 解析 JSON 陣列回應
    */

    public <T> List<T> parseJsonList(Class<T> clazz) {
    if (!success || body == null) {
    return Collections.emptyList();
    }
    return JsonUtil.fromJsonList(body, clazz);
    }
    }


    追蹤 Header 傳遞

    支援分散式追蹤,自動傳遞追蹤 Header:

    @Component
    public class TracingHttpClient {

    private static final List<String> TRACING_HEADERS = List.of(
    “x-request-id”,
    “x-b3-traceid”,
    “x-b3-spanid”,
    “x-b3-parentspanid”,
    “x-b3-sampled”
    );

    @Autowired
    private HttpClientService httpClient;

    /**
    * 從當前請求提取追蹤 Header
    */

    public Map<String, String> extractTracingHeaders(HttpServletRequest request) {
    Map<String, String> headers = new HashMap<>();

    for (String name : TRACING_HEADERS) {
    String value = request.getHeader(name);
    if (value != null) {
    headers.put(name, value);
    }
    }

    return headers;
    }

    /**
    * 發送請求,自動帶入追蹤 Header
    */

    public HttpResult getWithTracing(String url, HttpServletRequest currentRequest) {
    Map<String, String> headers = extractTracingHeaders(currentRequest);
    return httpClient.get(url, headers);
    }
    }


    重試機制

    @Component
    public class RetryableHttpClient {

    @Autowired
    private HttpClientService httpClient;

    /**
    * 帶重試的請求
    */

    public HttpResult getWithRetry(String url, Map<String, String> headers, int maxRetries) {
    int attempt = 0;
    HttpResult result = null;

    while (attempt < maxRetries) {
    result = httpClient.get(url, headers);

    if (result.isSuccess()) {
    return result;
    }

    // 只對可重試的錯誤重試
    if (!isRetryable(result.getStatusCode())) {
    break;
    }

    attempt++;
    sleep(calculateBackoff(attempt));
    }

    return result;
    }

    private boolean isRetryable(int statusCode) {
    // 5xx 錯誤和 429 (Too Many Requests) 可重試
    return statusCode >= 500 || statusCode == 429;
    }

    private long calculateBackoff(int attempt) {
    // 指數退避:1秒, 2秒, 4秒…
    return (long) Math.pow(2, attempt – 1) * 1000;
    }
    }

    HTTP 狀態碼 是否重試 原因
    2xx 不需要 成功
    4xx (非 429) 不重試 客戶端錯誤,重試也沒用
    429 重試 流量限制,稍後重試
    5xx 重試 伺服器暫時錯誤

    使用範例

    @Service
    public class ShopeeApiClient {

    @Autowired
    private RetryableHttpClient httpClient;

    public List<ShopeeOrder> getOrders(String accessToken) {
    String url = “https://partner.shopeemobile.com/api/v2/order/get_order_list”;

    Map<String, String> headers = Map.of(
    “Authorization”, “Bearer “ + accessToken,
    “Content-Type”, “application/json”
    );

    HttpResult result = httpClient.getWithRetry(url, headers, 3);

    if (!result.isSuccess()) {
    throw new ApiException(“Shopee API 錯誤: “ + result.getErrorMessage());
    }

    return result.parseJsonList(ShopeeOrder.class);
    }
    }


    總結

    設計 效果
    連線池 復用連線,減少建立成本
    逾時設定 避免請求無限等待
    結果封裝 統一處理成功/失敗
    追蹤 Header 支援分散式追蹤
    重試機制 自動處理暫時性錯誤

    為什麼不用其他方案?

    方案 優點 缺點 結論
    HttpURLConnection JDK 內建 API 難用、功能少 不推薦
    Apache HttpClient 功能完整 API 複雜、依賴多 可用但重
    Spring RestTemplate Spring 整合好 已被標記為 maintenance 舊專案可用
    Spring WebClient 非同步、Reactive 學習曲線、除錯困難 Reactive 專案用
    OkHttp 輕量、效能好、API 簡潔 非 Spring 原生 同步 HTTP 首選

    實戰踩坑

    坑 1:連線池用完了

    早期沒設連線池,每次請求都建新連線。流量一大,系統噴 Connection refused。加上連線池後,效能提升 10 倍,問題消失。

    坑 2:逾時設太長

    最初 readTimeout 設 60 秒。某平台 API 掛了,執行緒都在等待,整個服務卡死。改成 30 秒 + 重試機制後,就算 API 慢也能處理。

    坑 3:沒處理 429 Too Many Requests

    蝦皮有流量限制,瘋狂打 API 會收到 429。最初沒處理,一直重試反而更慢。加上指數退避(1秒、2秒、4秒…)後,流量限制問題大幅改善。


    系列導航

    ◀ 上一篇
    DTO 設計
    📚 返回目錄 下一篇 ▶
    PDF 生成
  • 企業級多租戶認證:Token 驗證實戰

    商業價值:多租戶認證讓「一套系統服務多個商戶」,是 SaaS 模式的技術基礎。這直接支撐 導讀篇提到的 70% 人力成本降低(多商戶共用系統,不用每家都建一套)。

    前言:SaaS 系統的認證挑戰

    在多租戶 SaaS 系統中,我們面臨兩種使用者:

    角色 說明 存取範圍
    商戶 (Merchant) 使用系統的客戶 只能看到自己的資料
    平台 (Platform) 系統管理者 可以看到所有商戶資料
    安全需求:

    • 商戶 A 絕對不能看到商戶 B 的訂單
    • Token 被盜用時要能快速失效
    • 支援多裝置同時登入

    Token 設計

    Token 結構

    {
    “tokenId”: “uuid-xxxx-xxxx”,
    “userType”: “MERCHANT”,
    “userId”: “U001”,
    “merchantId”: “M001”,
    “permissions”: [“ORDER_READ”, “ORDER_WRITE”],
    “issuedAt”: “2024-03-18T10:00:00Z”,
    “expiresAt”: “2024-03-18T12:00:00Z”
    }

    Token 驗證流程

    請求進來


    ┌─────────────────────┐
    │ 1. 檢查 Token 存在 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 2. 檢查 Token 有效 │ ──── 過期/無效 ──→ 401 Unauthorized
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 3. 載入使用者資訊 │
    └──────────┬──────────┘


    ┌─────────────────────┐
    │ 4. 設定安全上下文 │
    └──────────┬──────────┘


    繼續處理請求

    Filter 實作

    @Component
    public class TokenAuthFilter extends OncePerRequestFilter {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private TokenCache tokenCache;

    @Override
    protected void doFilterInternal(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain) throws Exception {

    // 1. 從 Header 取得 Token
    String token = extractToken(request);

    if (token == null) {
    sendError(response, “Missing token”);
    return;
    }

    // 2. 驗證 Token(先查快取)
    TokenInfo tokenInfo = tokenCache.get(token);
    if (tokenInfo == null) {
    tokenInfo = tokenService.validate(token);
    if (tokenInfo != null) {
    tokenCache.put(token, tokenInfo);
    }
    }

    if (tokenInfo == null || tokenInfo.isExpired()) {
    sendError(response, “Invalid or expired token”);
    return;
    }

    // 3. 設定安全上下文
    SecurityContext.set(tokenInfo);

    try {
    chain.doFilter(request, response);
    } finally {
    SecurityContext.clear();
    }
    }

    private String extractToken(HttpServletRequest request) {
    String header = request.getHeader(“Authorization”);
    if (header != null && header.startsWith(“Bearer “)) {
    return header.substring(7);
    }
    return null;
    }
    }


    商戶隔離

    所有資料存取都要加上商戶過濾:

    @Repository
    public class OrderRepository {

    public List<Order> findOrders(OrderQuery query) {
    // 從安全上下文取得當前商戶
    TokenInfo token = SecurityContext.get();

    if (token.isMerchant()) {
    // 商戶只能查自己的資料
    query.setMerchantId(token.getMerchantId());
    }
    // 平台角色可以查所有商戶(不加過濾條件)

    return jdbcTemplate.query(
    “SELECT * FROM orders WHERE merchant_id = ? AND …”,
    query.getMerchantId(),

    );
    }
    }

    安全保證:即使前端傳入其他商戶 ID,後端也會強制覆蓋為當前登入商戶的 ID。

    Token 快取策略

    策略 設定 原因
    快取時間 5 分鐘 減少 DB 查詢,但不會太舊
    最大數量 10,000 避免記憶體爆炸
    淘汰策略 LRU 不常用的 Token 先移除
    @Bean
    public Cache<String, TokenInfo> tokenCache() {
    return Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .recordStats()
    .build();
    }

    Token 失效機制

    當需要強制登出時:

    @Service
    public class TokenService {

    /**
    * 強制失效單一 Token
    */

    public void revokeToken(String tokenId) {
    // 1. 從資料庫標記失效
    tokenRepository.markRevoked(tokenId);

    // 2. 從快取移除
    tokenCache.invalidate(tokenId);

    // 3. 發布失效事件(通知其他節點)
    eventPublisher.publish(new TokenRevokedEvent(tokenId));
    }

    /**
    * 強制登出商戶所有 Token
    */

    public void revokeAllTokens(String merchantId) {
    List<String> tokens = tokenRepository.findByMerchant(merchantId);
    tokens.forEach(this::revokeToken);
    }
    }


    權限檢查

    @RestController
    public class OrderController {

    // 需要 ORDER_READ 權限
    @RequirePermission(“ORDER_READ”)
    @GetMapping(“/api/orders”)
    public List<Order> listOrders() {
    return orderService.findOrders();
    }

    // 需要 ORDER_WRITE 權限
    @RequirePermission(“ORDER_WRITE”)
    @PostMapping(“/api/orders/{id}/ship”)
    public Order shipOrder(@PathVariable String id) {
    return orderService.ship(id);
    }
    }


    安全總結

    安全機制 實作方式 防護目標
    Token 驗證 Filter 攔截 未授權存取
    商戶隔離 強制覆蓋 merchantId 資料外洩
    Token 快取 Caffeine + 分散式同步 效能
    強制失效 事件發布 + 快取清除 帳號被盜
    權限檢查 註解 + AOP 越權操作

    為什麼不用其他方案?

    方案 優點 缺點 結論
    Session-based 簡單、狀態伺服器端管理 不適合分散式、需要 Session 複製 單機可用
    JWT(無狀態) 不需查 DB、自包含 無法主動失效、Token 可能很大 短期 Token 可用
    OAuth 2.0 標準協議、支援第三方 複雜、需要額外服務 需要 SSO 時用
    Token + 快取 可主動失效、效能好 需要 Redis SaaS 系統首選

    實戰踩坑

    坑 1:商戶資料外洩

    早期版本有個 API 忘記加商戶過濾,某商戶發現可以透過改 URL 的 orderId 看到別人的訂單。好險被內部測試發現,立刻修復並全面檢查所有 API。教訓:所有 Repository 方法預設都要加 merchantId 過濾

    坑 2:Token 快取同步問題

    三台 Server,使用者在 A 機登出,但 B、C 機的快取還有 Token。結果登出後還能繼續操作 30 秒。解法:登出時發布事件到所有節點,同步清除快取。

    坑 3:權限設計太細

    最初設計了 50+ 種權限,結果商戶搞不懂、客服也解釋不清。後來簡化成 5 個角色(管理員、主管、操作員、財務、唯讀),大幅降低維護成本。


    系列導航

    ◀ 上一篇
    Kafka 事件驅動
    📚 返回目錄 下一篇 ▶
    健康檢查
  • WordPress REST API 調試實戰:從 NNNN 字符到完整修復

     

    WordPress REST API 調試實戰:從 NNNN 字符到完整修復

     

    🎯 重點摘要

    • 問題根源:WordPress 資料庫中存在字面 ‘n’ 字符(0x6E),而非換行符(0x0A)
    • 表現症狀:REST API 返回 NNNN、n< 模式、表格損壞、內容亂碼
    • 根本原因:多層架構的信息轉換導致錯誤的故障假設和調試方向偏離
    • 解決方案:使用 od -c 檢查二進位數據、多層驗證、直接在資料庫層修復

    問題是如何出現的?

    WordPress REST API 調試中最常見的陷阱就是 症狀與原因的巨大落差。你在前端看到 NNNN 字符和 n< 模式,但實際問題可能在完全不同的地方。

    這篇文章根據真實的 WordPress 修復案例(超過 1,000 個 NNNN 字符、16 個表格損壞),詳細解析多層故障排除流程。

    第 1 層:表面症狀 vs 實際原因

    當 REST API 返回異常內容時,最危險的假設就是直接指責過濾器或編碼問題。實際上,以下三層都可能是問題來源:

    你看到的 期望的原因 實際原因 解決難度
    REST API 顯示 n< 過濾器損壞內容 資料庫中有字面 ‘n’ 字符 ⭐⭐⭐⭐
    NNNN 字符出現 轉義或編碼問題 ‘nn’ 模式(字面n + 換行) ⭐⭐⭐⭐⭐
    表格消失或亂碼 HTML 結構破壞 字面 ‘n’ 阻斷了 HTML 標籤解析 ⭐⭐⭐⭐

    表 1:WordPress 調試常見誤判清單 — 本表格列出 REST API 常見症狀、直觀的誤判原因,以及實際根本原因。這些誤判會導致調試花費 2-4 小時無果。

    第 2 層:多層架構的信息失真

    WordPress 資料從資料庫到瀏覽器經過多個轉換層,每一層都會改變你看到的表現形式:

    層級 你看到的 實際的字節 驗證方式
    MySQL 命令列 n(轉義序列) 0x0A(真實)或 0x6E(’n’ 字符) od -c
    PHP 讀取 實際換行或字面 ‘n’ 二進位正確表示 strpos($str, “n”)
    REST API JSON n 字符或 n JSON 正確轉義 jq + od -c
    瀏覽器顯示 NNNN、亂碼或正常 HTML 渲染結果 DevTools 檢查

    表 2:多層架構信息轉換對比 — 同一份資料在不同層級呈現出不同的表現。MySQL 命令列使用轉義表示,PHP 使用二進位,REST API 使用 JSON,瀏覽器進行 HTML 渲染。如果不理解這些轉換,很容易做出錯誤的根因判斷。

    第 3 層:正確的調試順序

    大多數 WordPress 調試問題都是因為調試順序錯誤。正確的調試順序應該是:

    1. 直接檢查二進位資料(od -c)— 這是源頭事實,必須第一步做
    2. 對比 DB ↔ Filter ↔ REST API 的三層輸出 — 縮小問題範圍
    3. 假設反轉 — 如果不是編碼問題,那是資料損壞嗎?
    4. 定位損壞位置 — 哪一層引入的?是資料庫本身還是更新時損壞?
    5. 追蹤操作歷史 — 之前做過什麼導致損壞?

    在真實案例中,調試花費了大量時間的原因是:第 1 次調查順序是 2 → 3 → 1 → 4 → 5,而正確順序應該是 1 → 2 → 3 → 4 → 5。

    第 4 層:實際的修復步驟

    步驟 1:使用 od -c 檢查資料庫的實際字節

    docker exec wordpress mysql -u wpuser -pwp_password wordpress -e 
      "SELECT SUBSTRING(post_content, POSITION('' IN post_content), 50) 
       FROM wp_posts WHERE ID = 984;" | tail -1 | od -c | head -20

    輸出應該顯示:

    !   -   -   >   n      n   <   !   -   -
                        ^   ^
                字面'n'  實際換行

    如果看到這個模式,你已經找到了根本原因:資料庫中有字面 ‘n’ 字符

    步驟 2:修復資料庫損壞

    docker exec wordpress mysql -u wpuser -pwp_password wordpress -e "
    UPDATE wp_posts
    SET post_content = REPLACE(post_content, CONCAT('n', CHAR(10)), CHAR(10))
    WHERE ID = 984;
    "

    這個 SQL 語句移除所有「字面 ‘n’ + 換行符」的組合,只保留實際的換行符。

    步驟 3:驗證修復

    curl -s http://localhost:8001/wp-json/wp/v2/posts/984 | jq -r '.content.rendered' | grep -o 'n<' | wc -l
    # 應該返回 0

    第 5 層:為什麼調試這麼困難?

    困難點 為什麼 解決方案
    信息不對稱 MySQL 顯示 n、PHP 顯示實際換行、REST API 顯示 n 字符 建立單一源頭(od -c),在那層定位問題
    問題來源不清 用戶說「做表格後出現 NNNN」,但不知道之前對資料做過什麼 追蹤操作歷史,理解損壞何時引入
    多層架構複雜 Database → Filter(6 個) → REST API → Browser 逐層檢查,縮小問題範圍到特定層級
    工具轉換多次 MySQL CLI → od -c → PHP → curl → jq → JSON 固定驗證工具,避免多次轉換導致的失真

    表 3:WordPress REST API 調試困難點分析 — 列出調試過程中的四個主要困難,以及每個困難對應的解決方案。這些都是基於真實的修復案例總結出來的。

    第 6 層:最佳實踐清單

    • 第一步永遠是 od -c — 不要猜測,直接看二進位數據
    • 建立多層驗證 — 不要只檢查一層,Database + Filter + REST API 都要查
    • 假設反轉 — 一個方向卡住了,立即反轉假設方向
    • 追蹤操作歷史 — 理解「之前發生了什麼」比「現在看起來怎樣」更重要
    • 表格要有邊框 — 使用 inline style: style="border: 1px solid #333; padding: 8px;"
    • 保存配置檔 — WordPress API 認證信息應該存在 ~/.claude/projects/project-name/wordpress-config.env

    常見問題(FAQ)

    總結

    WordPress REST API 調試的關鍵是理解 多層架構中的信息失真。症狀永遠不等於原因,你看到的 NNNN 字符只是冰山一角。

    記住這個優先順序:

    1. od -c 檢查二進位(源頭事實)
    2. 逐層驗證(Database → Filter → REST API)
    3. 假設反轉(卡住時反向思考)
    4. 追蹤歷史(理解根本原因)
    5. 修復並驗證(修完要驗證三層)

    下次遇到 WordPress REST API 問題時,不要急著改過濾器或重建資料庫。先用 od -c 看看真正的二進位數據,一切就清楚了。

     

  • 各家API的比較

    目標們:
    * 91APP
    * cyberbiz
    * EasyStore
    * friday
    * iopenmall
    * MOMO
    * MO+
    * PChome
    * shopify
    * Shopline
    * Yahoo購物中心
    * 樂天
    * 東森
    * 博客來
    * 蝦皮
    * Coupang
    * 露天

    (閱讀全文…)

  • 推薦的V1新算法

    定義參數

    可超接的量 =(設定可超接==null)? 0: (可超接的量有值)?可超接值: 近兩天的訂單中此料號的量總和

    (閱讀全文…)

  • 成本預估故事

    Cost

    所有服務

    服務 VM VCPU Memory size(GB) Hard disk size(GB)
    K8s(Run 69 Pods) 3 16 128 200
    Solr 2 16 16 200
    PostgreSQL 2 16 32 600
    Kafka 3 4 4 80
    ZooKeeper(zk01) 1 16 8 20
    ZooKeeper(zk02,zk03) 2 4 4 20
    Infinispan 2 2 4 20
    HAProxy 2 4 4 20
    Nginx 2 2 4 50
    GitLab 1 8 8 100
    Jenkins 1 2 4 50
    Harbor Registry (IMG Hub) 1 2 2 100
    Elasticsearch 1 8 8 750
    Logstash 1 4 4 20
    Kibana 1 4 8 100
    DNS 1 2 2 16
    MAIL Server 1 4 4 20
    Object Storage (Ceph) 3 4 4 150

    故事

    2021

    5月 我加入精誠,非Oneec身分,但是閒暇時會與Ethan進行相關的討論,並且不時會看SHOPEE跟東森的API文件思考架構
    8月 infra加入精誠,非Oneec身分,但是Ethan已經準備好了技術選型並且請這位Infra整理機器,清理空間
    9月 PM加入精誠 ,Oneec身分,Ethan請她進行思考
    10月 最強的全端RD入場,Oneec身分,Ethan請他跟Infra準備K8S環境底下的高可用環境程式
    12月 還在討論Topic,12月中 全端RD回報,準備好了,開工

    (閱讀全文…)

  • 爬蟲機(OnGCP)

    總結

    需要做的事情
    1. GCP帳號
    2. 開VM
    3. 多台VM 指定一台為母機
    4. 其餘為子機
    5. 安裝DOCKER 並在子母機上設定關聯

    (閱讀全文…)

  • kafka 綜合筆記

    名詞介紹

    1. Producer:訊息生產者
    2. Broker:傳遞訊息的中介者
    3. Consumer:訊息消費者
    4. Topic:訊息的主題
    5. Partition:主題內的分區
    6. ComsumerGroup:消費者群組

    Producer

    只要是發送訊息出去的都是這一個腳色,定位上是往kafka push queue的就是。

    (閱讀全文…)