Metadata-Version: 2.4
Name: kksanic
Version: 0.2.0
Summary: 基于 Sanic + Tortoise 的后端小工具箱：统一响应、入参校验、出参序列化、RESTful 一键接口、后台管理站、接口文档、分表、请求日志、IP 限流
Author: Python卡皮巴拉
License-Expression: MIT
Keywords: sanic,tortoise-orm,restful,validator,serializer,scaffold,async,rate-limit
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: sanic<26,>=24.12
Requires-Dist: tortoise-orm<2,>=1.1
Requires-Dist: python-dateutil>=2.8
Requires-Dist: python-jose>=3.3
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: redis
Requires-Dist: redis>=4.0; extra == "redis"
Provides-Extra: mysql
Requires-Dist: aiomysql>=0.2; extra == "mysql"
Provides-Extra: postgres
Requires-Dist: asyncpg>=0.29; extra == "postgres"
Provides-Extra: all
Requires-Dist: redis>=4.0; extra == "all"
Requires-Dist: aiomysql>=0.2; extra == "all"
Requires-Dist: asyncpg>=0.29; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5; extra == "dev"
Dynamic: license-file

# kksanic

基于 **Sanic + Tortoise ORM** 的异步 API 工具箱。把「每开一个项目都要重写一遍」的那层骨架固化下来：统一响应、入参校验、出参序列化、一键 CRUD、后台管理站、接口文档、分表、请求日志、IP 限流 —— 每一项都能单独用，也能串起来用。

```python
from kksanic import add_restful_apis

add_restful_apis(app, Article, base_path='/api/article')
# 一套 6 个接口（含 PATCH）就有了：分页、过滤、排序、软删、事务都在里面
```

**它不做什么**（避免误用）：

- 不封装 ORM —— 模型仍是 Tortoise 的 `Model`，字段、查询、迁移全部照旧；
- 不接管 app 生命周期 —— 你仍然自己 `Sanic(...)`、自己 `app.run()`；
- 不引入依赖注入或分层约定 —— 想只用校验器、或只用限流，都不需要动其它部分；
- 不提供完整用户体系 —— `AbstractUser` 只是带 `username` / `password` 的模型基类，登录接口要自己写（下文有示例）。

## 能力一览

| 能力 | 入口 | 说明 |
|---|---|---|
| 统一响应 | `ApiException` / `get_response` | 状态码走 HTTP，body 固定 `{data, msg}` |
| 入参校验 | `Validator` + 各类 `Rule` | 声明式字段，按类型选规则，写错在定义期报 `TypeError` |
| 出参序列化 | `ModelSerializer` | 模型 → dict，`Meta.include` / `exclude` 收窄，支持嵌套 |
| 一键 CRUD | `add_restful_apis` | POST / GET / GET 详情 / PUT / PATCH / DELETE 一次注册 |
| 鉴权 | `add_permission_middleware` / `@authorized` | JWT（access + refresh），可选服务端台账，支持单点登录 |
| 后台管理站 | `@admin` + `kksanic admin` | 给接口打个标记，生成单文件管理页，挂成 `/admin` |
| 接口文档 | `kksanic doc` / `add_swagger_ui` | 导出 Markdown + OpenAPI，或运行期挂 `/swagger`（自带登录框） |
| 分表 | `ShardRule` + `expand()` | 固定分片数，声明式展开成多个真实模型 |
| 请求日志 | `add_request_logging` | 一行一请求：方法、路径、状态码、耗时、用户、IP |
| IP 限流 | `add_rate_limit` | 默认 10 次/秒；超限可「封禁」或「降速」，计数在 Redis |

## 安装

```bash
pip install kksanic

pip install 'kksanic[redis]'      # 缓存与限流（Redis）
pip install 'kksanic[mysql]'      # 或 kksanic[postgres]
pip install 'kksanic[all]'        # 以上全装
```

| 依赖 | 版本 | 说明 |
|---|---|---|
| Python | `>=3.10` | Sanic 25 与 Tortoise 1.1 都要求 3.10+ |
| `sanic` | `>=24.12,<26` | 26 起内部 API 有变动，暂未适配 |
| `tortoise-orm` | `>=1.1,<2` | 1.1 起连接注册表改为 contextvar（见「集成注意点」） |
| `python-dateutil` / `python-jose` | 自动安装 | 日期解析、JWT 编解码 |
| `redis` | 可选 | 缓存与限流；不装时缓存退回进程内、限流需 `ENABLED=False` |

## 5 分钟跑通

```bash
python -m kksanic init                 # 生成骨架，默认 sqlite
python -m kksanic init --db postgres   # 或 mysql / postgres
```

生成 `main.py` / `settings.py` / `models.py` / `serializers.py` / `validators.py`，
以及 `migrations/`、`static/`、`media/` 目录和一个只含 `[tool.tortoise]` 的
`pyproject.toml`。后者让迁移命令不必再传 `-c`：

```bash
tortoise init
tortoise makemigrations
tortoise migrate
python main.py            # 默认 0.0.0.0:8000
```

定义一个模型（`models.py`）：

```python
from tortoise import fields

from kksanic import BaseModel


class Article(BaseModel):
    title = fields.CharField(max_length=64, description='标题')
    body = fields.TextField(default='')

    class Meta:
        table = 'article'
        name = '文章'
```

`BaseModel` 自带 `id` / `created_at` / `updated_at` / `visible`（软删复用 `visible`），以及
`soft_delete()` / `restore()` / `all_alive()`。

在 `main.py` 里挂接口：

```python
from kksanic import add_restful_apis

add_restful_apis(app, Article, base_path='/api/article')   # 放在 register_tortoise 之后
```

跑起来即可用：

```bash
curl -X POST localhost:8000/api/article \
     -H 'Content-Type: application/json' -d '{"title": "第一篇"}'
# 200 {"data": {"id": 1, "title": "第一篇", ...}, "msg": "文章创建成功"}

curl 'localhost:8000/api/article?page=1&page_size=10&search=第一&sort_by=-id'
# 200 {"data": {"current_page": 1, "prev_page": 0, "next_page": 0,
#              "page_size": 10, "total": 1, "list": [...]}, "msg": "请求成功"}
```

`DELETE` 默认走软删，之后详情返回 `404 Article不存在`（软删可以关掉，见下文「删除策略」）；
`title` 超过 64 字返回 `400 标题长度不能超过64`（`标题` 取自字段注解里的中文名）。

## 核心能力

### 统一响应与异常

```python
from kksanic import ApiException, get_response, register_exception_handler

register_exception_handler(app)      # 必须调一次，否则业务错会变成 500 纯文本


@app.get('/api/profile')
async def profile(request):
    if not request.ctx.user:
        raise ApiException(status_code=401, msg='请先登录')
    return get_response(data={'name': 'kk'})
```

约定：**成败看 HTTP 状态码**，body 只有 `data` 和 `msg`，没有额外的业务码字段。
`ApiException(status_code=..., msg=...)` 里的状态码就是客户端收到的状态码。

### 入参校验

```python
from typing import Annotated

from kksanic import IntRule, StringRule, Validator


class ArticleCreateValidator(Validator):
    title: Annotated[str, '标题', StringRule(min_length=1, max_length=64)]
    body: Annotated[str, '正文'] = ''
    views: Annotated[int, '浏览量', IntRule(min_value=0)] = 0


data = await ArticleCreateValidator().validate(request.data)       # 协程，必须 await
```

在 handler 上用装饰器时，结果放在 `request.ctx.validated`，handler 只接收 `request`：

```python
@app.post('/api/article')
@ArticleCreateValidator().data_validate          # 读 request.data（json / 表单 / 文件）
async def create(request):
    article = await Article.create(**request.ctx.validated)
    return get_response(data=await ArticleSerializer().to_dict(article))
    # ArticleSerializer 见下一节，来自 serializers.py
```

三条约定：

- `None` 与 `''` 都当作「没传」；
- 校验失败抛 `ValidateError`，`str(e)` 可直接返给前端，`e.field` 是出错字段；
  用装饰器时会自动转成 `400`，直接 `await validate()` 则保持 `ValidateError`；
- 查询场景用 `validate(request.args, enforce_required=False, apply_default=False)`，
  不强制必填、不补默认值。

`Rule` 按字段类型分：`StringRule` / `IntRule` / `FloatRule` / `BoolRule` / `DateRule` /
`DateTimeRule` / `ListRule` / `DictRule` / `FileRule` / `ModelRule`，`Optional` 标记可空。

### 出参序列化

```python
from kksanic import ModelSerializer


class ArticleSerializer(ModelSerializer):
    class Meta:
        model = Article
        include = ('id', 'title', 'body', 'created_at')


await ArticleSerializer().to_dict(article)      # 单条
await ArticleSerializer().to_list(articles)     # 列表（共用一次上下文）
await ArticleSerializer().to_json(articles)     # 直接拿 JSON 字符串
```

- 三个方法都是协程（嵌套外键要查库）；
- 外键默认只出 `{id: n}`，配嵌套序列化器才出完整对象；
- 外键真列 `xxx_id`、多对多默认不出，要出就 `include` 点名或配嵌套。

### 一键 CRUD

`add_restful_apis(app_or_blueprint, Model, base_path=..., ...)` 生成 6 条路由：

| 方法 | 路径 | 数据源 | 语义 |
|---|---|---|---|
| POST | `/api/article` | body | 新建（必填 + 默认值） |
| GET | `/api/article` | query | 列表（分页 / 过滤 / 搜索 / 排序） |
| GET | `/api/article/<id>` | 路径 | 详情 |
| PUT | `/api/article/<id>` | body | 整体替换 |
| PATCH | `/api/article/<id>` | body | 局部更新 |
| DELETE | `/api/article/<id>` | 路径 | 删除（默认软删） |

列表查询参数：

| 参数 | 示例 | 说明 |
|---|---|---|
| `page` / `page_size` | `?page=2&page_size=20` | 分页；默认与上限见 `settings.RESTFUL` |
| `search` | `?search=关键词` | 命中 `ListConfig(search_fields=[...])` 里的列 |
| `sort_by` | `?sort_by=-created_at` | 前缀 `-` 表示倒序；不传用接口默认排序 |
| 列过滤 | `?title__contains=abc&views__gte=10` | 支持 `contains` / `gte` / `lte` / `in` 等 |

按需裁剪：

```python
from kksanic import ListConfig, Method, add_restful_apis
from kksanic.permission import authorized

add_restful_apis(
    app, Article,
    base_path='/api/article',
    methods=[Method.LIST, Method.DETAIL, Method.PATCH],   # 只暴露这三个
    validator_class=ArticleCreateValidator,
    serializer_class=ArticleSerializer,
    list_config=ListConfig(search_fields=['title'], sort_by='-id'),
    decorators=[authorized()],                            # 给这组接口加鉴权
)
```

`PUT` 与 `PATCH` 都要列出来时请写 `[Method.UPDATE, Method.PATCH]` —— 只写 `UPDATE`
时 `PATCH` 会返回 405。

### 删除策略：软删 / 硬删

DELETE 默认**软删**：行留在表里，`visible` 置假，列表 / 详情 / 更新都看不到它，
再删一次是 404（与真删后的口径一致）。

改整个项目的默认口径：

```python
RESTFUL = {
    'SOFT_DELETE': False,      # 全局改成物理删除
}
```

单个接口想不一样，用 `delete_config` 指定 —— **接口级优先于全局**：

```python
from kksanic import DeleteConfig, add_restful_apis

add_restful_apis(
    app, Article,
    base_path='/api/article',
    delete_config=DeleteConfig(soft_delete=False),   # 这个接口真删
)
```

`soft_delete=None`（默认）跟随全局；`True` / `False` 分别强制软删 / 硬删。
物理删除时行从表里消失，文件字段按 `auto_delete` 一并清磁盘。

需要更复杂的策略（换返回文案、按条件选删法）时，用 `before` 钩子自己删并返回响应，
默认删除就不再执行：

```python
async def purge(instance, data, request):
    await instance.hard_delete()          # 或者：await instance.soft_delete()
    return get_response(data=data, msg='已彻底删除')


add_restful_apis(app, Article, delete_config=DeleteConfig(before=purge))
```

保留软删前，先确认能接受这三个副作用：

- **不删文件**：行还可能被 `restore()`，附件必须留着。要「删记录即删附件」就关掉软删；
- **不释放唯一约束**：软删掉的 `username` 仍占着那个名字，新建同名还是 400。
  要「删了能重名」，只能真删，或让唯一约束带上 `visible`（部分唯一索引）；
- **只有继承 `BaseModel` 的模型才支持软删**（它带 `visible` 列）；裸 `tortoise.Model`
  在软删开关为 `True` 时也是真删，不报错。

### 鉴权

`AbstractUser` 是带 `username` / `password` 的模型基类，登录接口自己写：

```python
from kksanic import AbstractUser, ApiException, get_response, permission
from kksanic.auth import verify_password


class User(AbstractUser):
    class Meta:
        table = 'user'
        name = '用户'


@app.post('/api/login')
async def login(request):
    user = await User.get_or_none(username=request.data.get('username'))
    if not user or not verify_password(request.data.get('password'), user.password):
        raise ApiException(status_code=401, msg='用户名或密码错误')
    return get_response(data=await permission.create_token_pair(user.id))
```

`create_token_pair()` 返回 access + refresh 两个 token **并登记台账**（`TOKEN['STORE']=True`
时）。不要用 `create_token()` 做登录 —— 它不写台账，签出的票鉴权时必然 401。

全站默认必须登录，用中间件（无需逐个接口加装饰器）：

```python
from kksanic.permission import add_permission_middleware, public

add_permission_middleware(app)          # 除了 exclude 与 @public 的接口都要带票


@app.post('/api/login')
@public                                  # 放行单个接口
async def login(request):
    ...
```

也可以反过来：默认放行，只给需要的接口加 `@authorized()`。两种方式二选一，不要同时用。

### 后台管理站与接口文档

给接口打标记（每个动作单独标，只标列表就只有列表）：

```python
from kksanic import (
    Admin, ListConfig, add_admin_site, add_restful_apis, add_swagger_ui, admin,
)

add_restful_apis(
    app, Article,
    base_path='/api/article',
    list_config=ListConfig(decorators=[
        admin('文章', (Admin.List, Admin.Detail, Admin.Create,
                       Admin.Update, Admin.Delete, Admin.Export, Admin.Import)),
    ]),
)

add_admin_site(app, 'admin')      # GET /admin
add_swagger_ui(app)               # GET /swagger，内置登录框
```

生成页面：

```bash
python -m kksanic admin        # 生成 admin/index.html
python -m kksanic doc          # 生成 docs/api.html + docs/openapi.json
```

页面是静态文件，接口改动后重跑一次命令即可，不用重启服务（页面请求期读盘）。
没生成时访问 `/admin/` 返回一段 200 的提示页，而不是 404。

### 请求日志

```python
from kksanic import add_request_logging, add_rate_limit
from kksanic.permission import add_permission_middleware

add_request_logging(app)          # ① 最外层
add_rate_limit(app)               # ② 次外层
add_permission_middleware(app)    # ③ 内层
```

顺序不能反：日志在最外层才记得到 401 / 429 的耗时，限流在鉴权之前才能省掉
「注定被拒的请求先解一次 token」的开销。

日志默认走 logger；要落盘在 `settings.REQUEST_LOG` 里配 `FILE`
（相对路径按项目根解析，默认 10MB 轮转留 5 份），多 worker 不要共用同一个文件。

### IP 限流

```python
from kksanic import add_rate_limit

add_rate_limit(app)               # 需要 settings.REDIS
```

```python
REDIS = {'HOST': '127.0.0.1', 'PORT': 6379, 'DB': 0}

RATE_LIMIT = {
    'RATE': 10,                    # 每个窗口允许的请求数
    'PER_SECONDS': 1,              # 窗口长度（秒）
    'MODE': 'BAN',                 # 超限模式：'BAN' 封禁 / 'THROTTLE' 降速
    # 仅 MODE='BAN' 生效
    'BLOCK_SECONDS': 5,            # 撞线后封多久，期间一个请求都不放行
    'BAN_AFTER': 3,                # BAN_WINDOW 内被短封几次升级成长封
    'BAN_WINDOW': 180,
    'BAN_SECONDS': 180,
    # 仅 MODE='THROTTLE' 生效
    'THROTTLE_RATE': 1,            # 降速后每窗口只放这么多次（会压到 ≤ RATE）
    'THROTTLE_SECONDS': 300,       # 降速持续多久，到点自动恢复；0 = 不降速
}
```

两种模式互斥：

| | `MODE='BAN'`（默认） | `MODE='THROTTLE'` |
|---|---|---|
| 撞线之后 | 封 `BLOCK_SECONDS`，期内全拒；累犯封 `BAN_SECONDS` | 口径收紧为 `THROTTLE_RATE` 次/窗口，`THROTTLE_SECONDS` 后自动恢复 |
| 适合 | 恶意脚本 | 正常客户端的突发流量 |

计数存在 Redis 里，`workers=N` 也只有一份计数。被拒返回真 `429` + `Retry-After`，
`msg` 区分「已暂停访问」与「已限制访问频率」。生效值随时可查：

```python
from kksanic import get_rate_limit_settings
print(get_rate_limit_settings())
```

想手工解封某个 IP：

```python
from kksanic import get_limiter
limiter = get_limiter()
await limiter.reset('1.2.3.4')
```

`RedisRateLimiter` 需要 `redis` 包，缺了会在 `add_rate_limit()` 当场 `ImportError`
（而不是静默 fail-open）。单进程或单测可以用 `MemoryRateLimiter`。

### 分表

```python
from tortoise import fields

from kksanic import BaseModel, ShardRule, expand


class Log(BaseModel):
    uid = fields.IntField()
    content = fields.TextField(default='')

    class Meta:
        table = 'log'
        shard = ShardRule(key='uid', count=4, table='log_{index}')


LOG_SHARDS = expand(Log)      # 必须在 Tortoise.init 之前（通常写在 models.py 末尾）
```

`expand()` 返回一个门面对象：`await LOG_SHARDS.create(uid=7, ...)` 会按 `uid % 4`
落到 `log_3`，`LOG_SHARDS.all()` / `.filter(...)` 走同一套路由；要拿具体的分片类用
`LOG_SHARDS.of(7)` 或 `LOG_SHARDS.classes`（`add_restful_apis` 要的就是后者里的真类）。

按月分表这类用自定义 `index` 函数映射（如 `index=lambda m: m - 1`）；表名由外部决定的
场景也可以直接 `Log(shard='log_2026_10', ...)`，完全不声明 `Meta.shard`。

限制：不支持多对多、跨分片 JOIN、跨分片事务；`count` 调小等于删表。

## 与原生 Sanic 集成的注意点

### 1. 手写 `Tortoise.init()` 必须加 `_enable_global_fallback=True`

```python
# 走 tortoise.contrib.sanic
register_tortoise(app, config=settings.TORTOISE_ORM)          # 已默认开启，正常

# 自己初始化
await Tortoise.init(config=settings.TORTOISE_ORM,
                    _enable_global_fallback=True)             # 漏了就报下面这条
```

漏了会在第一个请求里抛 `RuntimeError: No TortoiseContext is currently active.`：
Tortoise 1.1 把连接注册表换成了 contextvar，而 Sanic 的 startup 与请求处理不在同一个 task。

### 2. `register_exception_handler(app)` 要自己调

Sanic 对没有处理器的异常一律 500 + 纯文本。库里所有 `raise ApiException(4xx)` 都需要
这个处理器才能变成带状态码的 JSON。`add_restful_apis` 不会替你注册 —— 否则你后面写的
`@app.exception(ApiException)` 会在启动期撞成 `ServerError`。重复调用是幂等的。

### 3. 路由要在 `register_tortoise` 之后挂

外键真列 `xxx_id` 是 `Tortoise.init()` 期间才挂进 `_meta.fields_map` 的。提前挂路由会让
字段表永久缺列，`?cat_id=3` 这类过滤被静默忽略（仍返回 200 和全量数据）。

### 4. 中间件顺序：日志 → 限流 → 鉴权

见上文「请求日志」。

### 5. 项目根是自动探测的

`settings.py`、`media/`、`static/` 这些路径都相对**项目根**解析。库装进 `site-packages`
后无法从自身位置推断项目根，所以按下面顺序探测，先命中先返回：

| 顺序 | 依据 |
|---|---|
| 1 | `kksanic.set_project_root(...)` 显式设置 |
| 2 | 环境变量 `KKSANIC_PROJECT_ROOT` |
| 3 | 已经 `import` 过的 `settings` 模块所在目录 —— 脚手架 `main.py` 第一行就是 `import settings`，这条最常命中 |
| 4 | `sys.path` 中第一个含 `settings.py` 的绝对路径目录（排除 `site-packages` / `dist-packages`） |
| 5 | `__main__.__file__` 所在目录，并向上查找标志文件（`settings.py` / `pyproject.toml` / `setup.py` / `.git`） |
| 6 | 库目录的上一级（仅当它含 `settings.py`，即库源码就在项目里的开发场景） |

`python main.py`、`python -m app`、`sanic main:app` 这类常规启动都能命中，**不需要**
手动指定。只有探测不到时才会在用到文件字段的地方报：

```
无法确定项目根目录，因而算不出 MEDIA_ROOT 指向哪里。
```

此时显式指定一次即可（两种方式等价，任选一种）：

```python
import kksanic
kksanic.set_project_root('/path/to/your/project')
```

```bash
export KKSANIC_PROJECT_ROOT=/path/to/your/project
```

探测链**刻意不含 cwd**：`python app.py` 与 `sanic server:app` 的 cwd 常常不同，按 cwd
解析会让同一个上传请求落到不同目录。所以「先 cd 到项目根」不构成探测依据。

需要注意的两种结构：

- 项目里既没有 `settings.py`，也没有 `pyproject.toml` / `setup.py` / `.git` 这类标志文件
  → 六条都命不中，必须显式指定；
- `settings` 写成了**包**（`settings/__init__.py`）→ 第 3、4 级认不出（它们只认
  `settings.py` 这个文件名，拿包目录当项目根会让相对 `MEDIA_ROOT` 落到
  `<项目根>/settings/media`）。此时若入口脚本在项目内，第 5 级仍能向上找到标志文件；
  否则需要显式指定。

## 常见错误速查

| 现象 | 原因 | 处理 |
|---|---|---|
| `No TortoiseContext is currently active` | 手写 `Tortoise.init()` 没开全局兜底 | 加 `_enable_global_fallback=True` |
| 业务错误变成 500 + 纯文本 | 没注册异常处理器 | `register_exception_handler(app)` |
| `PATCH` 返回 405 | `methods` 里只写了 `Method.UPDATE` | 补上 `Method.PATCH` |
| `?cat_id=3` 不生效 | 路由挂在 `register_tortoise` 之前 | 调整注册顺序 |
| `?title__contains=x` 不生效 | 自定义校验器没写 `Meta.model` | 补 `Meta.model`，或让框架用默认分页校验器 |
| 登录拿到的票一用就 401 | 用了 `create_token()` | 改用 `create_token_pair()` |
| 详情接口查不到刚软删的行 | `get_or_404()` 默认不含软删行 | 传 `include_deleted=True` |
| 软删后新建同名用户仍 400 | 软删不释放唯一约束 | 该接口配 `DeleteConfig(soft_delete=False)`，或全局 `RESTFUL['SOFT_DELETE']=False` |
| 报「无法确定项目根目录，因而算不出 … 指向哪里」 | 探测链没命中（项目里没有 `settings.py` / 标志文件） | `set_project_root()` 或 `KKSANIC_PROJECT_ROOT` |
| 限流 429 迟迟不恢复 | `MODE='BAN'` 且触发了长封 | 查 `get_rate_limit_settings()`，或 `reset(ip)` |
| 改配置没生效 | 配置在运行时读取，只改文件不重启 | 重启进程 |

## License

MIT © Python卡皮巴拉
