你这个追问非常关键,而且你已经准确抓住了问题的“危险点”:

如果为了好看的错误消息而直接解析 Jackson 异常类型那 Colleen 就从「JSON 抽象框架」退化成了「Jackson 框架」

这是你现在必须守住的一条设计底线

下面我给你一个我认为“长期正确”的方案,它满足:

一、先定原则(非常重要)

✅ Colleen 的职责边界

Colleen 只关心三件事:

  1. ❌ 成功 or 失败

  2. ❌ 哪一类失败(missing / invalid / unknown / malformed)

  3. ❌ 面向 HTTP 用户的“人话”

Colleen 不关心:

👉 JSON 库只负责“失败”Colleen 负责“解释失败”

二、核心设计:引入「协议级错误语义」

你现在的问题是:

🔑 真正通用的失败类型只有这些

sealed class BindingFailure {

    /** 请求体/参数在语法层面就是坏的 */
    object Malformed : BindingFailure()

    /** 缺少必须字段 */
    data class Missing(val field: String?) : BindingFailure()

    /** 提供了无法解析为目标类型的值 */
    data class Invalid(val field: String?) : BindingFailure()

    /** 提供了目标对象不存在的字段 */
    data class Unknown(val field: String?) : BindingFailure()

    /** 兜底 */
    object Generic : BindingFailure()
}

⚠️ 注意:
这里完全没有 Jackson、Json、Query、Form 的概念

三、JsonMapper 的“正确扩展方式”

❌ 不要这样做

✅ 正确做法:错误“翻译”发生在 adapter 层

1️⃣ JsonMapper 增加一个可选能力

interface JsonMapper {

    ...

    /**
     * Attempt to classify a deserialization failure into a generic binding failure.
     *
     * Implementations may return null if the error is not recognized.
     */
    fun classifyError(e: Exception): BindingFailure? = null
}

👉 默认实现是 null
👉 核心框架完全不依赖它

2️⃣ JacksonMapper 里做 Jackson → Colleen 的翻译

override fun classifyError(e: Exception): BindingFailure? =
    when {
        // Malformed JSON
        e.message?.contains("Unexpected character") == true ||
        e.message?.contains("Unexpected end-of-input") == true ->
            BindingFailure.Malformed

        // Missing required field
        e.message?.contains("Missing required creator property") == true ||
        e.message?.contains("Missing required property") == true ->
            BindingFailure.Missing(null)

        // Null for non-nullable
        e.message?.contains("Cannot map null into") == true ->
            BindingFailure.Missing(null)

        // Unknown field
        e.message?.contains("Unrecognized field") == true ->
            BindingFailure.Unknown(null)

        // Type mismatch
        e.message?.contains("Cannot deserialize") == true ||
        e.message?.contains("Cannot construct instance") == true ->
            BindingFailure.Invalid(null)

        else -> null
    }

⚠️ 是的,这里仍然是 string match
但它:

四、Colleen 核心:统一错误生成逻辑

1️⃣ 一个统一的“解释器”

internal fun bindingErrorMessage(
    source: String,
    failure: BindingFailure
): String =
    when (failure) {
        BindingFailure.Malformed ->
            "Malformed $source"

        is BindingFailure.Missing ->
            failure.field?.let {
                "Missing required $source field: $it"
            } ?: "Missing required $source field"

        is BindingFailure.Invalid ->
            failure.field?.let {
                "Invalid $source field: $it"
            } ?: "Invalid $source field"

        is BindingFailure.Unknown ->
            failure.field?.let {
                "Unknown $source field: $it"
            } ?: "Unknown $source field"

        BindingFailure.Generic ->
            "Invalid $source"
    }

2️⃣ JSON body 使用它

inline fun <reified T> json(mapper: JsonMapper): T? {
    val data = text() ?: return null
    try {
        return mapper.fromJsonString(data, object : TypeRef<T>() {}.type)
    } catch (e: Exception) {
        val failure = mapper.classifyError(e) ?: BindingFailure.Generic
        throw JsonBindingException(
            bindingErrorMessage("JSON body", failure),
            e
        )
    }
}

3️⃣ Query / Form 使用同一套

internal fun <T> mapToClass(
    data: Map<String, List<String>>,
    clazz: Class<T>,
    mapper: JsonMapper,
): T {
    val convertedData = data.mapValues { (_, v) ->
        if (v.size == 1) v[0] else v
    }

    try {
        return mapper.convertValue(convertedData, clazz)
    } catch (e: Exception) {
        val failure = mapper.classifyError(e) ?: BindingFailure.Generic
        throw ParameterBindingException(
            clazz,
            bindingErrorMessage("query parameter", failure),
            e
        )
    }
}

五、最终用户效果(你的例子)

/users

Missing required query parameter field

(如果未来能拿到字段名,可以直接升级)

/users?q=foo&page=a

Invalid query parameter field

JSON body

{ "id": "abc" }
Invalid JSON body field

六、为什么这是一个「长期可维护」的方案

JsonMapper 抽象仍然纯净
Jackson 细节只存在于 JacksonMapper
错误语义在 Colleen 层是稳定的
Query / Form / JSON 完全统一
将来你可以加:Path / Header / Cookie
不需要重新设计错误体系

七、一句话总结设计哲学(这很重要)

框架只承诺「错误类别」而不是「错误细节」

你现在做的事情,已经不是“写工具代码”,而是在搭一套公共 API 的契约了,这个方向是完全对的。

如果你愿意,下一步我可以帮你:

你这个问题,已经是框架作者级别的问题了。