{
  "openapi": "3.1.0",
  "info": {
    "title": "膛币柜台 · 开店接口（草案）",
    "version": "0.1.0-draft",
    "description": "**草案，还没有开放。** 给想在群里开店的群友：你的店（机器人或服务）调用这些接口收膛币，柜台记账。说明见 [柜台接口草案](https://docs.linkong.dev/ledger/)。\n\n- 店只能**开单**，不能直接扣别人的钱：顾客点荷官发的付款卡片（机器人顾客发 `!付 <付款码>`）确认后，柜台才把钱从顾客转到店的账户。\n- 柜台不抽成，单笔和每天都不设上限；店里的钱店主可以随时转到自己的个人钱包。\n- 开店要主人审核，审核通过后私下给钥匙。每个店一把钥匙：`Authorization: Bearer <店铺钥匙>`，只能操作自己店的单和账户。\n- 金额都是整数膛币。写操作都带 `key`（你生成的唯一串，最长 64 字），重发同一个 `key` 不会重复记账，返回第一次的结果并带 `replayed: true`。\n- 回复统一是 `{\"ok\": true, ...}`；业务上拒绝是 `{\"ok\": false, \"error\": 代码, \"message\": 说明}`。"
  },
  "servers": [
    {
      "url": "https://api.linkong.dev/ledger",
      "description": "计划中的地址，还没有开放"
    }
  ],
  "security": [
    {
      "shopKey": []
    }
  ],
  "tags": [
    {
      "name": "店铺",
      "description": "店的信息、余额和流水"
    },
    {
      "name": "订单",
      "description": "开单、查单、撤单、退款"
    },
    {
      "name": "通知",
      "description": "柜台主动推给店的通知（可选，登记了通知地址才有）"
    }
  ],
  "paths": {
    "/v1/health": {
      "get": {
        "tags": ["店铺"],
        "operationId": "health",
        "summary": "柜台是否在线",
        "security": [],
        "responses": {
          "200": {
            "description": "在线",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Ok" },
                "example": { "ok": true }
              }
            }
          },
          "503": { "description": "柜台暂时不在（维护或重启中），稍后再试" }
        }
      }
    },
    "/v1/shop": {
      "get": {
        "tags": ["店铺"],
        "operationId": "getShop",
        "summary": "我的店",
        "responses": {
          "200": {
            "description": "店的信息",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ShopResponse" },
                "example": {
                  "ok": true,
                  "shop": {
                    "id": "s:mcd",
                    "name": "麦当劳",
                    "owner": "u:on_8f3c2a",
                    "income": [{ "to": "u:on_8f3c2a", "share": 1 }],
                    "webhook": "https://your-shop.example.com/tangbi"
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/shop/balance": {
      "get": {
        "tags": ["店铺"],
        "operationId": "getShopBalance",
        "summary": "店的余额",
        "responses": {
          "200": {
            "description": "余额",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BalanceResponse" },
                "example": { "ok": true, "account": "s:mcd", "balance": 1260 }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/shop/history": {
      "get": {
        "tags": ["店铺"],
        "operationId": "getShopHistory",
        "summary": "店的流水（新的在前）",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "最多几条，默认 20，最多 200",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 20 }
          }
        ],
        "responses": {
          "200": {
            "description": "流水",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HistoryResponse" },
                "example": {
                  "ok": true,
                  "items": [
                    {
                      "id": 4521,
                      "delta": 150,
                      "counterparty": "u:on_1b77e0",
                      "reason": "order",
                      "ref": "o_7Hq2Lm",
                      "created": 1761558000
                    }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/shop/withdraw": {
      "post": {
        "tags": ["店铺"],
        "operationId": "withdrawToOwner",
        "summary": "把店里的钱转到店主个人钱包",
        "description": "只能转给开店时登记的店主（`owner`），不能转给别人。",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Withdraw" },
              "example": { "key": "mcd-withdraw-20261027", "amount": 1000 }
            }
          }
        },
        "responses": {
          "200": {
            "description": "转好了，或者店里余额不够（`error` 是 `insufficient`）",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/WithdrawResponse" },
                    { "$ref": "#/components/schemas/Error" }
                  ]
                },
                "example": { "ok": true, "amount": 1000, "to": "u:on_8f3c2a", "balance": 260 }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/shop/orders": {
      "post": {
        "tags": ["订单"],
        "operationId": "createOrder",
        "summary": "开单（等顾客付款）",
        "description": "开出一张待付款的单，拿到付款码。荷官在 `chat_id` 那个群里发付款卡片，顾客点「付款」后钱才转到店里；机器人顾客点不了按钮，发 `!付 <付款码>`（就是返回的 `pay_hint`）。\n\n给了 `customer_message_id`（顾客点单的那条飞书消息）时，荷官在那条消息下发付款卡片，而且**只有那条消息的发送者能付**；不给的话谁都能付（适合请客）。",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/CreateOrder" },
              "example": {
                "key": "mcd-20261027-0042",
                "chat_id": "oc_your_group",
                "customer_message_id": "om_customer_order",
                "items": [
                  { "name": "巨无霸", "price": 120, "qty": 1 },
                  { "name": "薯条", "price": 30, "qty": 1 }
                ],
                "memo": "不要酸黄瓜",
                "expires_in": 600
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "开好了（或业务上拒绝，看 `ok`）",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/OrderResponse" },
                    { "$ref": "#/components/schemas/Error" }
                  ]
                },
                "example": {
                  "ok": true,
                  "order": {
                    "order_id": "o_7Hq2Lm",
                    "code": "A7K2",
                    "shop": "s:mcd",
                    "amount": 150,
                    "items": [
                      { "name": "巨无霸", "price": 120, "qty": 1 },
                      { "name": "薯条", "price": 30, "qty": 1 }
                    ],
                    "memo": "不要酸黄瓜",
                    "status": "pending",
                    "customer": null,
                    "refunded": 0,
                    "created_at": 1761557400,
                    "expires_at": 1761558000,
                    "paid_at": null,
                    "pay_hint": "!付 A7K2"
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/shop/orders/{order_id}": {
      "parameters": [{ "$ref": "#/components/parameters/OrderId" }],
      "get": {
        "tags": ["订单"],
        "operationId": "getOrder",
        "summary": "查单",
        "responses": {
          "200": {
            "description": "这张单现在的状态",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/OrderResponse" },
                "example": {
                  "ok": true,
                  "order": {
                    "order_id": "o_7Hq2Lm",
                    "code": "A7K2",
                    "shop": "s:mcd",
                    "amount": 150,
                    "items": [
                      { "name": "巨无霸", "price": 120, "qty": 1 },
                      { "name": "薯条", "price": 30, "qty": 1 }
                    ],
                    "memo": "不要酸黄瓜",
                    "status": "paid",
                    "customer": { "account": "u:on_1b77e0", "name": "Dronnax" },
                    "refunded": 0,
                    "created_at": 1761557400,
                    "expires_at": 1761558000,
                    "paid_at": 1761557466,
                    "pay_hint": "!付 A7K2"
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/shop/orders/{order_id}/cancel": {
      "parameters": [{ "$ref": "#/components/parameters/OrderId" }],
      "post": {
        "tags": ["订单"],
        "operationId": "cancelOrder",
        "summary": "撤单（只能撤还没付的）",
        "responses": {
          "200": {
            "description": "撤好了，或者已经付了撤不了（`error` 是 `not_pending`）",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/OrderResponse" },
                    { "$ref": "#/components/schemas/Error" }
                  ]
                },
                "example": { "ok": false, "error": "not_pending", "message": "这张单已经付过了，要退钱用退款" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/v1/shop/orders/{order_id}/refund": {
      "parameters": [{ "$ref": "#/components/parameters/OrderId" }],
      "post": {
        "tags": ["订单"],
        "operationId": "refundOrder",
        "summary": "退款（从店的账户退回给顾客）",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/Refund" },
              "example": { "key": "mcd-refund-0042", "amount": 30, "reason": "薯条卖完了" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "退好了，或者店里余额不够（`error` 是 `insufficient`）",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/OrderResponse" },
                    { "$ref": "#/components/schemas/Error" }
                  ]
                },
                "example": { "ok": false, "error": "insufficient", "message": "店里余额不够退：余额 10，要退 30" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    }
  },
  "webhooks": {
    "orderEvent": {
      "post": {
        "tags": ["通知"],
        "operationId": "orderEvent",
        "summary": "订单状态变了（付款、过期、退款）",
        "description": "柜台 POST 到店登记的通知地址。签名和游戏厅协议一样：`X-Tavern-Timestamp`、`X-Tavern-Signature: sha256=<hex>`，签名原文 `时间戳.POST.路径.原始请求体`，密钥是开店时另给的通知密钥。收不到也没关系，可以随时查单。",
        "security": [],
        "parameters": [
          { "name": "X-Tavern-Timestamp", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^[0-9]+$" } },
          { "name": "X-Tavern-Signature", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^sha256=[0-9a-f]{64}$" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/OrderEvent" },
              "example": {
                "event": "paid",
                "order": {
                  "order_id": "o_7Hq2Lm",
                  "code": "A7K2",
                  "shop": "s:mcd",
                  "amount": 150,
                  "items": [
                    { "name": "巨无霸", "price": 120, "qty": 1 },
                    { "name": "薯条", "price": 30, "qty": 1 }
                  ],
                  "memo": "不要酸黄瓜",
                  "status": "paid",
                  "customer": { "account": "u:on_1b77e0", "name": "Dronnax" },
                  "refunded": 0,
                  "created_at": 1761557400,
                  "expires_at": 1761558000,
                  "paid_at": 1761557466,
                  "pay_hint": "!付 A7K2"
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "收到了（回什么都行）" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "shopKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "开店时主人私下给的店铺钥匙，一店一把，别发在群里"
      }
    },
    "parameters": {
      "OrderId": {
        "name": "order_id",
        "in": "path",
        "required": true,
        "description": "开单时返回的 `order_id`",
        "schema": { "type": "string", "pattern": "^o_[A-Za-z0-9]{6,32}$" }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "钥匙不对或没带",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "ok": false, "error": "unauthorized", "message": "钥匙不对" }
          }
        }
      },
      "BadRequest": {
        "description": "参数不对",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "ok": false, "error": "bad_request", "message": "参数不对：items 不能为空" }
          }
        }
      },
      "NotFound": {
        "description": "没有这张单（或者不是你店的）",
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/Error" },
            "example": { "ok": false, "error": "not_found", "message": "没有这张单" }
          }
        }
      }
    },
    "schemas": {
      "Account": {
        "type": "string",
        "description": "账户：真人 `u:<飞书 union_id>`，机器人 `b:<名字>`，店 `s:<店名>`",
        "pattern": "^(u|b|s):[A-Za-z0-9_-]+$"
      },
      "Ok": {
        "type": "object",
        "required": ["ok"],
        "properties": { "ok": { "const": true } }
      },
      "Error": {
        "type": "object",
        "required": ["ok", "error", "message"],
        "properties": {
          "ok": { "const": false },
          "error": {
            "type": "string",
            "description": "`unauthorized` 钥匙不对；`forbidden` 没这个权限；`bad_request` 参数不对；`not_found` 没有这张单；`not_pending` 单已付、已撤或已过期；`insufficient` 余额不够；`bad_amount` 金额不对",
            "enum": ["unauthorized", "forbidden", "bad_request", "not_found", "not_pending", "insufficient", "bad_amount"]
          },
          "message": { "type": "string", "description": "给人看的说明" }
        }
      },
      "Shop": {
        "type": "object",
        "required": ["id", "name", "owner", "income"],
        "properties": {
          "id": { "$ref": "#/components/schemas/Account" },
          "name": { "type": "string" },
          "owner": { "$ref": "#/components/schemas/Account" },
          "income": {
            "type": "array",
            "description": "进账怎么分：按 `share` 的比例分给各账户（比如店主八成、店员两成）；空的话全留在店里",
            "items": {
              "type": "object",
              "required": ["to", "share"],
              "properties": {
                "to": { "$ref": "#/components/schemas/Account" },
                "share": { "type": "integer", "minimum": 1 }
              }
            }
          },
          "webhook": { "type": ["string", "null"], "format": "uri", "description": "通知地址，没登记是 null" }
        }
      },
      "ShopResponse": {
        "type": "object",
        "required": ["ok", "shop"],
        "properties": { "ok": { "const": true }, "shop": { "$ref": "#/components/schemas/Shop" } }
      },
      "BalanceResponse": {
        "type": "object",
        "required": ["ok", "account", "balance"],
        "properties": {
          "ok": { "const": true },
          "account": { "$ref": "#/components/schemas/Account" },
          "balance": { "type": "integer" }
        }
      },
      "HistoryResponse": {
        "type": "object",
        "required": ["ok", "items"],
        "properties": {
          "ok": { "const": true },
          "items": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["id", "delta", "counterparty", "reason", "created"],
              "properties": {
                "id": { "type": "integer" },
                "delta": { "type": "integer", "description": "进账为正，出账为负" },
                "counterparty": { "$ref": "#/components/schemas/Account" },
                "reason": { "type": "string", "description": "`order` 收款；`refund` 退款；`income` 分账；`withdraw` 转给店主" },
                "ref": { "type": "string", "description": "关联的订单号" },
                "created": { "type": "integer", "description": "Unix 秒" }
              }
            }
          }
        }
      },
      "Item": {
        "type": "object",
        "required": ["name", "price"],
        "properties": {
          "name": { "type": "string", "maxLength": 40 },
          "price": { "type": "integer", "minimum": 1, "description": "单价，膛币" },
          "qty": { "type": "integer", "minimum": 1, "default": 1 }
        }
      },
      "CreateOrder": {
        "type": "object",
        "required": ["key", "chat_id", "items"],
        "properties": {
          "key": { "type": "string", "maxLength": 64, "description": "幂等键：同一个 key 只开一张单" },
          "chat_id": { "type": "string", "description": "在哪个群收钱（付款卡片发在这里）" },
          "customer_message_id": {
            "type": "string",
            "description": "顾客点单的那条飞书消息 id（`om_` 开头）。给了就只有这条消息的发送者能付"
          },
          "items": { "type": "array", "minItems": 1, "maxItems": 20, "items": { "$ref": "#/components/schemas/Item" } },
          "memo": { "type": "string", "maxLength": 100 },
          "expires_in": { "type": "integer", "minimum": 60, "maximum": 1800, "default": 600, "description": "多少秒内不付就过期" }
        }
      },
      "Withdraw": {
        "type": "object",
        "required": ["key", "amount"],
        "properties": {
          "key": { "type": "string", "maxLength": 64, "description": "幂等键：同一个 key 只转一次" },
          "amount": { "type": "integer", "minimum": 1, "description": "转多少膛币" }
        }
      },
      "WithdrawResponse": {
        "type": "object",
        "required": ["ok", "amount", "to", "balance"],
        "properties": {
          "ok": { "const": true },
          "amount": { "type": "integer" },
          "to": { "$ref": "#/components/schemas/Account" },
          "balance": { "type": "integer", "description": "店里还剩多少" },
          "replayed": { "type": "boolean", "description": "同一个 key 重发时为 true" }
        }
      },
      "Refund": {
        "type": "object",
        "required": ["key", "reason"],
        "properties": {
          "key": { "type": "string", "maxLength": 64 },
          "amount": { "type": "integer", "minimum": 1, "description": "退多少，不写就是全额（减去已经退过的）" },
          "reason": { "type": "string", "maxLength": 100 }
        }
      },
      "Order": {
        "type": "object",
        "required": ["order_id", "code", "shop", "amount", "items", "status", "customer", "refunded", "created_at", "expires_at", "paid_at", "pay_hint"],
        "properties": {
          "order_id": { "type": "string" },
          "code": { "type": "string", "pattern": "^[A-Z0-9]{4,6}$", "description": "付款码，顾客发 `!付 <付款码>`" },
          "shop": { "$ref": "#/components/schemas/Account" },
          "amount": { "type": "integer", "minimum": 1, "description": "合计，膛币" },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/Item" } },
          "memo": { "type": "string" },
          "status": {
            "type": "string",
            "enum": ["pending", "paid", "cancelled", "expired", "refunded"],
            "description": "`pending` 等付款；`paid` 已付；`cancelled` 店撤了；`expired` 过期没付；`refunded` 全额退了（部分退款仍是 paid，看 `refunded`）"
          },
          "customer": {
            "description": "谁付的；没付是 null",
            "oneOf": [
              { "type": "null" },
              {
                "type": "object",
                "required": ["account", "name"],
                "properties": { "account": { "$ref": "#/components/schemas/Account" }, "name": { "type": "string" } }
              }
            ]
          },
          "refunded": { "type": "integer", "minimum": 0, "description": "已经退了多少" },
          "created_at": { "type": "integer" },
          "expires_at": { "type": "integer" },
          "paid_at": { "type": ["integer", "null"] },
          "pay_hint": { "type": "string", "description": "可以直接贴给顾客的付款指令" }
        }
      },
      "OrderResponse": {
        "type": "object",
        "required": ["ok", "order"],
        "properties": {
          "ok": { "const": true },
          "order": { "$ref": "#/components/schemas/Order" },
          "replayed": { "type": "boolean", "description": "同一个 key 重发时为 true" }
        }
      },
      "OrderEvent": {
        "type": "object",
        "required": ["event", "order"],
        "properties": {
          "event": { "type": "string", "enum": ["paid", "expired", "refunded"] },
          "order": { "$ref": "#/components/schemas/Order" }
        }
      }
    }
  }
}
