Initial Dever 0.0.1

This commit is contained in:
Codex committed 2026-11-03 03:32:07 +00:00
commit 81233d098c
1479 files changed
+163782

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
# Dever 示例
入门项目见 [随包示例](../skills/references/examples.md)。CMS 演示多租户内容管理,包括登录、权限、文章修订、上传和定时发布。
CMS 只维护一份 [Dever 源码](cms/dever/module/news/article/api/admin/manage.dever),领域说明存放在 README 和 doc 中:
```text
module/
platform/tenant/ 平台租户
user/account/ 凭据、成员、会话、bootstrap CMD
user/authorization/ 核心角色授权的业务入口
content/category/ 分类
content/revision/ 文章编辑与修订快照
media/asset/ 上传、Storage Port/Adapter
publishing/schedule/ 定时发布与 Job
audit/operation/ 操作审计
system/health/ 健康 CMD
news/article/
app/editing.dever
app/publication.dever
api/admin/manage.dever
api/front/browse.dever
domain.dever
model.dever
model/publication.dever
test/
content/category/lifecycle.dever
news/article/publish.dever
user/account/bootstrap.dever
user/account/validation.dever
user/account/lifecycle.dever
```
## 数据和权限
平台租户、账号、成员和会话使用 `global type`,保存在平台库。文章、分类、修订、媒体、排期和审计放在各租户自己的数据库。
账户、成员和会话同属账户领域,避免登录调用与 Model ID 引用形成循环依赖。租户文章的作者编号来自已验证身份,使用 Int 存储;物理外键只连接同一个数据库中的 Model,不能从租户库指向平台账户库。
verify 先核实身份数据,核心再按站点和角色检查接口权限,通过后才访问租户数据。
文章接口位于 `/news/article/admin/manage/`:create 创建,list/detail 查询,remove 删除草稿,publish 发布。
编辑使用 `/content/revision/admin/manage/create`,传入文章 ID、expected_version、标题正文和可空分类/媒体 ID。版本冲突返回 409,文章更新与修订快照一起提交。文章不用自动 REST 写接口,以免跳过这些规则;列表返回 `app.ArticleView`,不泄漏私有 Model。
定时发布使用 `/publishing/schedule/admin/manage/schedule`,传入文章 ID 和 publish_at。创建时检查作者和草稿状态;每个排期有独立任务,不会合并同时发布的文章。
执行任务前,核心再次核验身份和权限。发布、审计和排期状态一起提交或回滚,完成任务重复执行不重复写入。手动重复发布返回 409。
## 先检查,再运行
在仓库根目录执行:
```bash
dever check examples/cms/dever
dever test examples/cms/dever --filter news.article.publish
dever fmt examples/cms/dever --check
```
测试使用临时 SQLite,不连接部署数据库。去掉 `--filter` 可执行全部应用测试。
实际部署前先替换 JWT secret,并把两个站点的 origin 和 Host 改成真实 HTTPS 入口;origin 是用户访问的外部地址,不是内部监听地址。
用 bootstrap CMD 建立平台租户、Owner 成员和密码:
```sh
dever run examples/cms/dever -- user.account.bootstrap '{"tenant_key":"tenant-one","tenant_name":"Tenant One","email":"owner@example.com","display_name":"Owner","password":"change-me-now"}'
```
替换示例账号与密码,再用返回的真实 tenant_id 和 user_id 执行以下命令,不要猜测 ID 是 1:
```text
dever tenant migrate examples/cms/dever <tenant_id>
dever tenant owner examples/cms/dever <tenant_id> admin <user_id>
dever tenant owner examples/cms/dever <tenant_id> front <user_id>
```
这会迁移租户数据库,并建立后台、前台的核心 Owner。之后各站点通过 `user/authorization/<site>/manage` 管理普通角色。运行配置在项目的 `config/setting.json`,数据在 `data/`;`dever run examples/cms/dever` 不带 CMD 参数时启动 HTTP 服务。
应用测试覆盖分类、发布规则、账号和会话。真实登录、HTTP、租户隔离及发布流程使用 [CMS 验收入口](../test/performance/cms.py)。
+58
View File
@@ -0,0 +1,58 @@
{
"runtime": {
"mode": "all",
"shutdown_ms": 30000
},
"job": {
"workers": 1,
"poll_ms": 1000,
"lease_ms": 60000,
"retry_base_ms": 1000,
"retry_max_ms": 300000
},
"http": {
"host": "127.0.0.1",
"port": 8080
},
"database": {
"default": {
"type": "sqlite",
"path": "data/db/platform.db",
"tenant_directory": "data/tenants",
"max_connections": 4,
"max_page_size": 100
}
},
"tenant": {
"database": "default",
"max_pools": 32,
"idle_timeout_ms": 300000
},
"auth": {
"providers": {
"session": {
"verify": "user.account.verify",
"jwtSecret": "replace-this-example-secret-before-deployment-2026",
"ttlSeconds": 3600,
"cookie": "cms_session"
}
}
},
"sites": {
"admin": {
"path": "admin",
"hosts": ["cms.example.com"],
"auth": "session",
"origin": "https://cms.example.com"
},
"front": {
"path": "front",
"hosts": ["cms.example.com"],
"auth": "session",
"origin": "https://cms.example.com"
}
},
"log": {
"level": "info"
}
}
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,7 @@
# 操作审计
保存登录、退出、发布、上传和授权等操作记录。
- [操作审计应用](doc/app.md)
- [操作审计模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,10 @@
record(kind: model.OperationKind, resource: Text, resource_id: Text) () {
stored = model.create(
{
kind = kind
resource = resource
resource_id = resource_id
}
)
discarded = stored
}
@@ -0,0 +1,5 @@
# 操作审计应用
## audit.operation.record
保存一次操作审计,记录操作类型、资源名称和资源标识。
@@ -0,0 +1,9 @@
# 操作审计模型
## audit.operation.model.OperationKind
审计操作类型:登录、退出、发布、上传和授权。
## audit.operation.model.Operation
租户操作审计记录,包含操作者、操作类型和目标资源。
@@ -0,0 +1,60 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 5cc5a514d99bdf266632507f65376a89a8ba16e0a87baa6f0f2be4435fa35943 -->
# audit.operation
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
没有已收录的入口。
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [audit.operation.record](app.dever#L1) | [media.asset.record](../../media/asset/app.dever#L17) | 调用 | [../../media/asset/app.dever:26:3](../../media/asset/app.dever#L26,3) |
| [audit.operation.record](app.dever#L1) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:65:3](../../news/article/app/publication.dever#L65,3) |
## 能力与内部调用
### audit.operation.model.Operation
定义:[model.dever:9:1](model.dever#L9,1)
没有已收录的下游调用。
### audit.operation.model.OperationKind
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### audit.operation.record
定义:[app.dever:1:1](app.dever#L1,1)
<a id="flow-c1586d946bef8bfb-1"></a>
```mermaid
flowchart TD
n0(["record"])
click n0 href "app.dever#L1,1" "打开源码"
n1(["返回"])
n3["audit.operation.model.create"]
click n3 href "app.dever#L2,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L2,12" "打开源码"
n5["赋值 discarded"]
click n5 href "app.dever#L9,15" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [audit.operation.model.create](model.dever#L9) | 领域内 | 内建操作 | [app.dever:2:12](app.dever#L2,12) |
@@ -0,0 +1,14 @@
type OperationKind {
Login = "登录"
Logout = "登出"
Publish = "发布"
Upload = "上传"
Access = "授权"
}
type Operation {
actor_id: Int?
kind: OperationKind index
resource: Text(1, 120)
resource_id: Text(1, 120)
}
@@ -0,0 +1,8 @@
# 内容分类
创建分类并管理启用状态,供文章校验所选分类。
- [分类接口](doc/api-admin-manage.md)
- [内容分类应用](doc/app.md)
- [内容分类模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,3 @@
post create = app.create
delete disable = app.disable
@@ -0,0 +1,39 @@
type CategoryView {
id: model.id
name: Text
slug: Text
status: Text
}
create(name: Text) (category: CategoryView) {
stored = model.create(
{
name = name
slug = name
}
)
category = CategoryView {
id = stored.id
name = stored.name
slug = stored.slug
status = "active"
}
}
disable(id: model.id) (ok: Bool) {
changed = model.update(
id,
{
status = model.CategoryStatus.Disabled
}
)
discarded = changed
ok = true
}
requireActive(id: model.id) () {
stored = model.get(id)
dever.api.require(stored.status == model.CategoryStatus.Active, dever.api.Error.Conflict)
}
requireActive(id: null) () {}
@@ -0,0 +1,9 @@
# 分类接口
## content.category.api.admin.manage.create
创建分类。
## content.category.api.admin.manage.disable
禁用分类。
@@ -0,0 +1,17 @@
# 内容分类应用
## content.category.CategoryView
分类接口返回的编号、名称、slug 和状态。
## content.category.create
创建分类。名称去除首尾空白,slug 同时转为小写并保持唯一。
## content.category.disable
把已有分类标为禁用,记录仍保留。
## content.category.requireActive
分类编号为空时跳过;否则要求分类已启用,禁用时返回冲突。
@@ -0,0 +1,9 @@
# 内容分类模型
## content.category.model.CategoryStatus
分类的启用或禁用状态。
## content.category.model.Category
分类名称、唯一 slug 和状态。
@@ -0,0 +1,402 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 22ff06f2e26c68d4bf521c52d2ecba992a5af0f615c4f380876f2fe34a8b4aa0 -->
# content.category
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [content.category.api.admin.manage.create](api/admin/manage.dever#L1) → [content.category.create](app.dever#L8)
- API [content.category.api.admin.manage.disable](api/admin/manage.dever#L3) → [content.category.disable](app.dever#L23)
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.requireActive](app.dever#L34) | [news.article.create](../../news/article/app/editing.dever#L16) | 调用 | [../../news/article/app/editing.dever:24:3](../../news/article/app/editing.dever#L24,3) |
| [content.category.requireActive](app.dever#L34) | [news.article.edit](../../news/article/app/editing.dever#L77) | 调用 | [../../news/article/app/editing.dever:89:3](../../news/article/app/editing.dever#L89,3) |
| [content.category.requireActive](app.dever#L34) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:51:3](../../news/article/app/publication.dever#L51,3) |
## 能力与内部调用
### content.category.CategoryView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### content.category.api.admin.manage.create
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-d5f276e1d0182184-1"></a>
```mermaid
flowchart TD
n0(["content.category.api.admin.manage.create"])
click n0 href "api/admin/manage.dever#L1,15" "打开源码"
n1{"路由 POST /content/category/admin/manage/create,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,15" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.category.{site}.create"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.category.create"]]
click n42 href "api/admin/manage.dever#L1,15" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.category.create | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [content.category.create](link.md#flow-dffb2069d02e287c-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.create](app.dever#L8) | 领域内 | API 绑定 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
### content.category.api.admin.manage.disable
定义:[api/admin/manage.dever:3:1](api/admin/manage.dever#L3,1)
<a id="flow-5e559918fcf97f3a-1"></a>
```mermaid
flowchart TD
n0(["content.category.api.admin.manage.disable"])
click n0 href "api/admin/manage.dever#L3,18" "打开源码"
n1{"路由 DELETE /content/category/admin/manage/disable,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L3,18" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.category.{site}.disable"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.category.disable"]]
click n42 href "api/admin/manage.dever#L3,18" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.category.disable | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) | [content.category.disable](link.md#flow-85101fdf961a71c3-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.disable](app.dever#L23) | 领域内 | API 绑定 | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:3:18](api/admin/manage.dever#L3,18) |
### content.category.create
定义:[app.dever:8:1](app.dever#L8,1)
<a id="flow-dffb2069d02e287c-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L8,1" "打开源码"
n1(["返回"])
n3["content.category.model.create"]
click n3 href "app.dever#L9,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L9,12" "打开源码"
n5["赋值 category"]
click n5 href "app.dever#L15,14" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:9:12](app.dever#L9,12) |
### content.category.disable
定义:[app.dever:23:1](app.dever#L23,1)
<a id="flow-85101fdf961a71c3-1"></a>
```mermaid
flowchart TD
n0(["disable"])
click n0 href "app.dever#L23,1" "打开源码"
n1(["返回"])
n3["content.category.model.update"]
click n3 href "app.dever#L24,13" "打开源码"
n4["赋值 changed"]
click n4 href "app.dever#L24,13" "打开源码"
n5["赋值 discarded"]
click n5 href "app.dever#L30,15" "打开源码"
n6["赋值 ok"]
click n6 href "app.dever#L31,8" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.update](model.dever#L6) | 领域内 | 内建操作 | [app.dever:24:13](app.dever#L24,13) |
### content.category.model.Category
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### content.category.model.CategoryStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### content.category.requireActive
定义:[app.dever:34:1](app.dever#L34,1)、[app.dever:39:1](app.dever#L39,1)
<a id="flow-eee0931d93e09e79-1"></a>
```mermaid
flowchart TD
n0(["requireActive"])
click n0 href "app.dever#L34,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["id: model.id"]
click n4 href "app.dever#L34,1" "打开源码"
n5["content.category.model.get"]
click n5 href "app.dever#L35,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L35,12" "打开源码"
n7[["dever.api.require"]]
click n7 href "app.dever#L36,3" "打开源码"
n8["id: null"]
click n8 href "app.dever#L39,1" "打开源码"
n0 --> n3
n3 -->|"id: model.id"| n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"id: null"| n8
n8 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:36:3](app.dever#L36,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.category.model.get](model.dever#L6) | 领域内 | 内建操作 | [app.dever:35:12](app.dever#L35,12) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:36:3](app.dever#L36,3) |
@@ -0,0 +1,10 @@
type CategoryStatus {
Active = "启用"
Disabled = "禁用"
}
type Category {
name: Text(1, 80)
slug: Text(1, 100) lower unique
status: CategoryStatus default CategoryStatus.Active index
}
@@ -0,0 +1,8 @@
# 内容修订
在编辑文章的同一事务中保存版本快照。
- [修订接口](doc/api-admin-manage.md)
- [内容修订应用](doc/app.md)
- [内容修订模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
post create = app.create
@@ -0,0 +1,45 @@
type RevisionView {
id: model.id
article_id: news.article.model.id
version: Int
title: Text
body: Text
status: Text
}
transaction create(
selected_article_id: news.article.model.id,
expected_version: Int,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (revision: RevisionView) {
edited = news.article.edit(
selected_article_id,
expected_version,
title,
body,
category_id,
asset_id
)
stored = model.create(
{
article_id = selected_article_id
version = edited.version
title = edited.title
body = edited.body
category_id = edited.category_id
asset_id = edited.asset_id
status = model.RevisionStatus.Draft
}
)
revision = RevisionView {
id = stored.id
article_id = stored.article_id
version = stored.version
title = stored.title
body = stored.body
status = "draft"
}
}
@@ -0,0 +1,5 @@
# 修订接口
## content.revision.api.admin.manage.create
按版本编辑文章,并保存修订快照。
@@ -0,0 +1,9 @@
# 内容修订应用
## content.revision.RevisionView
编辑后返回的修订编号、文章编号、版本及内容。
## content.revision.create
按 expected_version 编辑文章并保存修订快照,两步在同一事务中完成。版本冲突、分类或媒体不可用时整次操作失败。
@@ -0,0 +1,9 @@
# 内容修订模型
## content.revision.model.RevisionStatus
修订的草稿或已发布状态。
## content.revision.model.Revision
文章某一版本的内容快照;同一文章和版本只能保存一条。
@@ -0,0 +1,212 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 3a206461ddeadfa4087f3a0db7ee697474811b4cc2f983c8d57592e0bf48eeed -->
# content.revision
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [content.revision.api.admin.manage.create](api/admin/manage.dever#L1) → [content.revision.create](app.dever#L10)
## 外部调用方
当前加载的源码中没有已收录的外部调用。
## 能力与内部调用
### content.revision.RevisionView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### content.revision.api.admin.manage.create
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-61096d20b01e1b77-1"></a>
```mermaid
flowchart TD
n0(["content.revision.api.admin.manage.create"])
click n0 href "api/admin/manage.dever#L1,15" "打开源码"
n1{"路由 POST /content/revision/admin/manage/create,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,15" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:content, media, news"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:content.revision.{site}.create"}
n39["权限通过"]
n40["读取 API 输入"]
n41["解码具体参数并检查字段合同"]
n42[["content.revision.create"]]
click n42 href "api/admin/manage.dever#L1,15" "打开源码"
n43["按输出类型检查可序列化值并编码响应"]
n44["提交入口拥有的事务"]
n45(["返回 HTTP 500"])
n46["发布暂存的认证 Header / Cookie"]
n47(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:输入错误状态"| n34
n40 --> n41
n41 -->|"失败:400"| n34
n41 --> n42
n42 -->|"业务错误映射状态;其他故障:500"| n34
n42 --> n43
n43 -->|"编码失败:500"| n34
n43 --> n44
n44 -->|"提交失败"| n45
n44 --> n46
n46 --> n47
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| content.revision.create | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) | [content.revision.create](link.md#flow-8c2beaa3a2e4fcf0-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [content.revision.create](app.dever#L10) | 领域内 | API 绑定 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:15](api/admin/manage.dever#L1,15) |
### content.revision.create
定义:[app.dever:10:1](app.dever#L10,1)
<a id="flow-8c2beaa3a2e4fcf0-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L10,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4[["news.article.edit"]]
click n4 href "app.dever#L18,12" "打开源码"
n5["赋值 edited"]
click n5 href "app.dever#L18,12" "打开源码"
n6["content.revision.model.create"]
click n6 href "app.dever#L26,12" "打开源码"
n7["赋值 stored"]
click n7 href "app.dever#L26,12" "打开源码"
n8["赋值 revision"]
click n8 href "app.dever#L37,14" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.edit | [app.dever:18:12](app.dever#L18,12) | [news.article.edit](../../news/article/link.md#flow-2c557350a87b85a1-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.edit](../../news/article/app/editing.dever#L77) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:18:12](app.dever#L18,12) |
| [content.revision.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:26:12](app.dever#L26,12) |
### content.revision.model.Revision
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### content.revision.model.RevisionStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
@@ -0,0 +1,16 @@
type RevisionStatus {
Draft = "草稿"
Published = "已发布"
}
type Revision {
article_id: news.article.model.id index
version: Int
title: Text(1, 160)
body: Text
category_id: content.category.model.id?
asset_id: media.asset.model.id?
status: RevisionStatus default RevisionStatus.Draft index
}
unique(article_id, version)
@@ -0,0 +1,10 @@
# 媒体资源
保存上传文件及元数据,供文章引用可用媒体。
- [媒体本地适配器](doc/adapter.md)
- [上传接口](doc/api-admin-manage.md)
- [媒体资源应用](doc/app.md)
- [媒体资源模型](doc/model.md)
- [媒体存储端口](doc/port.md)
- [调用流程](link.md)
@@ -0,0 +1,3 @@
port.store(file: Upload) (key: Uuid) {
key = dever.storage.put(file)
}
@@ -0,0 +1 @@
long post upload = app.store
@@ -0,0 +1,41 @@
type StoreResult {
id: model.id
storage_key: Uuid
filename: Text
content_type: Text
size: Int
}
store(file: Upload) (result: StoreResult) {
filename = dever.api.uploadFilename(file)
content_type = dever.api.uploadContentType(file)
size = dever.api.uploadSize(file)
key = port.store(file)
result = record(key, filename, content_type, size)
}
transaction record(key: Uuid, filename: Text, content_type: Text, size: Int) (result: StoreResult) {
asset = model.create(
{
storage_key = key
filename = filename
content_type = content_type
size = size
}
)
audit.operation.record(audit.operation.model.OperationKind.Upload, "asset", filename)
result = StoreResult {
id = asset.id
storage_key = asset.storage_key
filename = asset.filename
content_type = asset.content_type
size = asset.size
}
}
requireReady(id: model.id) () {
stored = model.get(id)
dever.api.require(stored.status == model.AssetStatus.Ready, dever.api.Error.Conflict)
}
requireReady(id: null) () {}
@@ -0,0 +1,5 @@
# 媒体本地适配器
## media.asset.adapter.port.store
使用本地 storage 保存上传内容,返回文件 Uuid。
@@ -0,0 +1,5 @@
# 上传接口
## media.asset.api.admin.manage.upload
接收 multipart 文件上传,保存文件及资源元数据。
@@ -0,0 +1,17 @@
# 媒体资源应用
## media.asset.StoreResult
上传完成后返回资源编号、存储键、文件名、内容类型和字节数。
## media.asset.store
读取上传元数据并保存文件,再写入资源记录。文件存储不占用数据库事务。
## media.asset.record
在同一事务中写入资源元数据和上传审计。
## media.asset.requireReady
媒体编号为空时跳过;否则要求资源处于 Ready 状态,不可用时返回冲突。
@@ -0,0 +1,9 @@
# 媒体资源模型
## media.asset.model.AssetStatus
媒体资源的就绪或已删除状态。
## media.asset.model.Asset
已存文件的唯一存储键、元数据及状态。
@@ -0,0 +1,5 @@
# 媒体存储端口
## media.asset.port.store
保存一次性上传内容,返回存储键;失败使用 dever.storage.PutResult。
@@ -0,0 +1,350 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 11ad386ddcac8cef62a4a2d0330b0c2b4678196a227a7676c7e6a55aba63b5f1 -->
# media.asset
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [media.asset.api.admin.manage.upload](api/admin/manage.dever#L1) → [media.asset.store](app.dever#L9)
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.requireReady](app.dever#L36) | [news.article.create](../../news/article/app/editing.dever#L16) | 调用 | [../../news/article/app/editing.dever:25:3](../../news/article/app/editing.dever#L25,3) |
| [media.asset.requireReady](app.dever#L36) | [news.article.edit](../../news/article/app/editing.dever#L77) | 调用 | [../../news/article/app/editing.dever:90:3](../../news/article/app/editing.dever#L90,3) |
| [media.asset.requireReady](app.dever#L36) | [news.article.publishDraft](../../news/article/app/publication.dever#L48) | 调用 | [../../news/article/app/publication.dever:52:3](../../news/article/app/publication.dever#L52,3) |
## 能力与内部调用
### media.asset.StoreResult
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### media.asset.adapter.port.store
定义:[adapter.dever:1:1](adapter.dever#L1,1)
<a id="flow-eb9faa6e4f022f76-1"></a>
```mermaid
flowchart TD
n0(["port.store"])
click n0 href "adapter.dever#L1,1" "打开源码"
n1(["返回"])
n3[["dever.storage.put"]]
click n3 href "adapter.dever#L2,9" "打开源码"
n4["赋值 key"]
click n4 href "adapter.dever#L2,9" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.storage.put | [adapter.dever:2:9](adapter.dever#L2,9) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.storage.put` | 依赖 | 调用 | [adapter.dever:2:9](adapter.dever#L2,9) |
### media.asset.api.admin.manage.upload
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-7e3fa2eda06052de-1"></a>
```mermaid
flowchart TD
n0(["media.asset.api.admin.manage.upload"])
click n0 href "api/admin/manage.dever#L1,20" "打开源码"
n1{"路由 POST /media/asset/admin/manage/upload,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,20" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:audit, media"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["失败:保留 HTTP 错误状态"]
n34(["返回错误响应"])
n35{"权限检查:media.asset.{site}.upload"}
n36["权限通过"]
n37["读取 multipart、取得 Upload 和字段"]
n38["解码具体参数并检查字段合同"]
n39[["media.asset.store"]]
click n39 href "api/admin/manage.dever#L1,20" "打开源码"
n40["按输出类型检查可序列化值并编码响应"]
n41["发布暂存的认证 Header / Cookie"]
n42(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n33 --> n34
n25 --> n35
n35 -->|"拒绝:Access 状态;查询故障:500"| n33
n35 -->|"允许"| n36
n36 --> n37
n37 -->|"失败:上传错误状态"| n33
n37 --> n38
n38 -->|"失败:400"| n33
n38 --> n39
n39 -->|"业务错误映射状态;其他故障:500"| n33
n39 --> n40
n40 -->|"编码失败:500"| n33
n40 --> n41
n41 --> n42
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| media.asset.store | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) | [media.asset.store](link.md#flow-9ce0fcefee26c58b-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.store](app.dever#L9) | 领域内 | API 绑定 | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:20](api/admin/manage.dever#L1,20) |
### media.asset.model.Asset
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### media.asset.model.AssetStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### media.asset.port.store
定义:[port.dever:1:1](port.dever#L1,1)
没有已收录的下游调用。
### media.asset.record
定义:[app.dever:17:1](app.dever#L17,1)
<a id="flow-b00da9b3a217d427-1"></a>
```mermaid
flowchart TD
n0(["record"])
click n0 href "app.dever#L17,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4["media.asset.model.create"]
click n4 href "app.dever#L18,11" "打开源码"
n5["赋值 asset"]
click n5 href "app.dever#L18,11" "打开源码"
n6[["audit.operation.record"]]
click n6 href "app.dever#L26,3" "打开源码"
n7["赋值 result"]
click n7 href "app.dever#L27,12" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| audit.operation.record | [app.dever:26:3](app.dever#L26,3) | [audit.operation.record](../../audit/operation/link.md#flow-c1586d946bef8bfb-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:18:11](app.dever#L18,11) |
| [audit.operation.record](../../audit/operation/app.dever#L1) | 跨领域 [audit.operation](../../audit/operation/link.md) | 调用 | [app.dever:26:3](app.dever#L26,3) |
### media.asset.requireReady
定义:[app.dever:36:1](app.dever#L36,1)、[app.dever:41:1](app.dever#L41,1)
<a id="flow-2c00b9e4b7136711-1"></a>
```mermaid
flowchart TD
n0(["requireReady"])
click n0 href "app.dever#L36,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["id: model.id"]
click n4 href "app.dever#L36,1" "打开源码"
n5["media.asset.model.get"]
click n5 href "app.dever#L37,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L37,12" "打开源码"
n7[["dever.api.require"]]
click n7 href "app.dever#L38,3" "打开源码"
n8["id: null"]
click n8 href "app.dever#L41,1" "打开源码"
n0 --> n3
n3 -->|"id: model.id"| n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"id: null"| n8
n8 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:38:3](app.dever#L38,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [media.asset.model.get](model.dever#L6) | 领域内 | 内建操作 | [app.dever:37:12](app.dever#L37,12) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:38:3](app.dever#L38,3) |
### media.asset.store
定义:[app.dever:9:1](app.dever#L9,1)
<a id="flow-9ce0fcefee26c58b-1"></a>
```mermaid
flowchart TD
n0(["store"])
click n0 href "app.dever#L9,1" "打开源码"
n1(["返回"])
n3["dever.api.uploadFilename"]
click n3 href "app.dever#L10,14" "打开源码"
n4["赋值 filename"]
click n4 href "app.dever#L10,14" "打开源码"
n5["dever.api.uploadContentType"]
click n5 href "app.dever#L11,18" "打开源码"
n6["赋值 content_type"]
click n6 href "app.dever#L11,18" "打开源码"
n7["dever.api.uploadSize"]
click n7 href "app.dever#L12,10" "打开源码"
n8["赋值 size"]
click n8 href "app.dever#L12,10" "打开源码"
n9[["media.asset.adapter.port.store"]]
click n9 href "app.dever#L13,9" "打开源码"
n10["赋值 key"]
click n10 href "app.dever#L13,9" "打开源码"
n11[["media.asset.record"]]
click n11 href "app.dever#L14,12" "打开源码"
n12["赋值 result"]
click n12 href "app.dever#L14,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 --> n9
n9 --> n10
n10 --> n11
n11 --> n12
n12 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| media.asset.adapter.port.store | [app.dever:13:9](app.dever#L13,9) | [media.asset.adapter.port.store](link.md#flow-eb9faa6e4f022f76-1) |
| media.asset.record | [app.dever:14:12](app.dever#L14,12) | [media.asset.record](link.md#flow-b00da9b3a217d427-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.api.uploadFilename` | 内建操作 | 内建操作 | [app.dever:10:14](app.dever#L10,14) |
| `dever.api.uploadContentType` | 内建操作 | 内建操作 | [app.dever:11:18](app.dever#L11,18) |
| `dever.api.uploadSize` | 内建操作 | 内建操作 | [app.dever:12:10](app.dever#L12,10) |
| [media.asset.adapter.port.store](adapter.dever#L1) | 领域内 | 调用 | [app.dever:13:9](app.dever#L13,9) |
| [media.asset.record](app.dever#L17) | 领域内 | 调用 | [app.dever:14:12](app.dever#L14,12) |
@@ -0,0 +1,12 @@
type AssetStatus {
Ready = "就绪"
Removed = "已删除"
}
type Asset {
storage_key: Uuid unique
filename: Text(1, 255)
content_type: Text(1, 160)
size: Int
status: AssetStatus default AssetStatus.Ready index
}
@@ -0,0 +1 @@
store(file: Upload) (key: Uuid) fails dever.storage.PutResult
@@ -0,0 +1,12 @@
# 文章
管理作者草稿和版本,手动或定时发布为前台快照。
- [文章管理接口](doc/api-admin-manage.md)
- [文章浏览接口](doc/api-front-browse.md)
- [文章草稿](doc/app-editing.md)
- [文章发布](doc/app-publication.md)
- [文章规则](doc/domain.md)
- [文章公开读模型](doc/model-publication.md)
- [文章模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,9 @@
get list = app.list
get detail = app.detail
post create = app.create
delete remove = app.remove
post publish = app.publish
@@ -0,0 +1,3 @@
get list = app.published
get detail = app.publishedDetail
@@ -0,0 +1,111 @@
type ArticleView {
id: model.id
slug: Text
title: Text
body: Text
version: Int
category_id: content.category.model.id?
asset_id: media.asset.model.id?
}
type ArticlePage {
rows: List<ArticleView>
total: Int
}
create(
slug: Text,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (article: ArticleView) {
dever.api.require(text.trim(body) != "", dever.api.Error.Invalid)
content.category.requireActive(category_id)
media.asset.requireReady(asset_id)
stored = model.create(
{
author_id = user.account.currentUser()
slug = slug
title = title
body = body
category_id = category_id
asset_id = asset_id
}
)
article = detail(stored.id)
}
list(size: Int) (articles: ArticlePage) {
author = user.account.currentUser()
articles = ArticlePage {
rows = model.ownedArticles(author, domain.pageSize(size))
total = model.count(
{
where = author_id == author
}
)
}
}
detail(id: model.id) (article: ArticleView) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
article = ArticleView {
id = stored.id
slug = stored.slug
title = stored.title
body = stored.body
version = stored.version
category_id = stored.category_id
asset_id = stored.asset_id
}
}
remove(id: model.id) (removed: Int) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
removed = model.delete(
{
where = id == stored.id and version == stored.version
}
)
domain.requireUpdated(removed)
}
edit(
id: model.id,
expected_version: Int,
title: Text,
body: Text,
category_id: content.category.model.id?,
asset_id: media.asset.model.id?
) (article: ArticleView) {
stored = model.get(id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
dever.api.require(text.trim(body) != "", dever.api.Error.Invalid)
content.category.requireActive(category_id)
media.asset.requireReady(asset_id)
changed = model.update(
{
where = id == stored.id and version == expected_version and status == model.ArticleStatus.Draft
},
{
title = title
body = body
category_id = category_id
asset_id = asset_id
version = expected_version + 1
}
)
domain.requireUpdated(changed)
article = detail(stored.id)
}
requireSchedulable(article_id: model.id) () {
stored = model.get(article_id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
}
@@ -0,0 +1,95 @@
transaction publish(article_id: model.id) (publication: model.publication.Publication) {
stored = model.get(article_id)
dever.api.require(stored.author_id == user.account.currentUser(), dever.api.Error.Forbidden)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
publication = publishDraft(article_id)
}
published(size: Int) (articles: model.publication.Page) {
articles = model.publication.list(
{
order = [published_at.desc, id.desc]
size = domain.pageSize(size)
}
)
}
publishedDetail(id: model.publication.id) (article: model.publication.Publication) {
article = model.publication.get(id)
dever.api.require(text.trim(article.body) != "", dever.api.Error.NotFound)
}
transaction publishScheduled(article_id: model.id) (
publication: model.publication.Publication
) {
stored = model.get(article_id)
publication = scheduledPublication(stored.status, article_id)
}
scheduledPublication(status: model.ArticleStatus.Published, article_id: model.id) (
publication: model.publication.Publication
) {
stored = model.get(article_id)
publication = existingPublication(
model.publication.first(
{
where = slug == stored.slug
}
)
)
}
scheduledPublication(status: model.ArticleStatus.Draft, article_id: model.id) (
publication: model.publication.Publication
) {
publication = publishDraft(article_id)
}
publishDraft(article_id: model.id) (publication: model.publication.Publication) {
stored = model.get(article_id)
dever.api.require(stored.status == model.ArticleStatus.Draft, dever.api.Error.Conflict)
content.category.requireActive(stored.category_id)
media.asset.requireReady(stored.asset_id)
published_at = dever.time.now()
changed = model.update(
{
where = id == stored.id and version == stored.version and status == model.ArticleStatus.Draft
},
{
status = model.ArticleStatus.Published
published_at = published_at
version = stored.version + 1
}
)
domain.requireUpdated(changed)
audit.operation.record(
audit.operation.model.OperationKind.Publish,
"article",
stored.slug
)
publication = model.publication.upsert(
{
slug = stored.slug
},
{
title = stored.title
body = stored.body
published_at = published_at
},
{
title = stored.title
body = stored.body
published_at = published_at
}
)
}
existingPublication(stored: model.publication.Publication) (
publication: model.publication.Publication
) {
publication = stored
}
existingPublication(stored: null) (publication: model.publication.Publication) {
fail(dever.api.Error.NotFound)
}
@@ -0,0 +1,21 @@
# 文章管理接口
## news.article.api.admin.manage.list
读取当前作者的文章列表与总数。
## news.article.api.admin.manage.detail
读取当前作者的文章详情。
## news.article.api.admin.manage.create
创建当前作者的草稿。
## news.article.api.admin.manage.remove
删除当前作者的草稿。
## news.article.api.admin.manage.publish
发布当前作者的草稿。
@@ -0,0 +1,9 @@
# 文章浏览接口
## news.article.api.front.browse.list
读取当前租户的已发布文章。
## news.article.api.front.browse.detail
读取已发布文章详情。
@@ -0,0 +1,33 @@
# 文章草稿
## news.article.ArticleView
编辑接口返回的文章内容、版本、分类和媒体编号。
## news.article.ArticlePage
当前作者的文章列表及总数。
## news.article.create
为当前作者创建草稿。正文不能为空白,所选分类和媒体须可用;正文保留原文。
## news.article.list
读取当前作者的最新文章和总数,size 必须为 1–100。
## news.article.detail
读取当前作者的一篇文章,访问其他作者的文章返回禁止访问。
## news.article.remove
删除当前作者的草稿,并检查版本未被并发修改。已发布文章不能删除。
## news.article.edit
按 expected_version 修改当前作者的草稿,成功后版本加一。正文为空白或分类、媒体不可用时失败。
## news.article.requireSchedulable
要求文章属于当前作者且仍为草稿,供创建发布计划使用。
@@ -0,0 +1,29 @@
# 文章发布
## news.article.publish
发布当前作者的草稿;状态更新、审计和公开快照在同一事务中完成。
## news.article.published
按发布时间和编号倒序读取发布快照,size 必须为 1–100。
## news.article.publishedDetail
读取一份发布快照;正文为空白时返回不存在。
## news.article.publishScheduled
执行定时发布。已发布文章复用现有快照,草稿进入发布流程。
## news.article.scheduledPublication
根据文章状态选择复用快照或发布草稿。
## news.article.publishDraft
检查草稿、分类和媒体,更新文章状态与版本,写入审计并保存发布快照;并发修改返回冲突。
## news.article.existingPublication
取出已有发布快照;找不到时返回不存在。
@@ -0,0 +1,9 @@
# 文章规则
## news.article.domain.pageSize
只接受 1–100 的列表大小,其他值返回请求无效。
## news.article.domain.requireUpdated
要求数据库恰好修改一条记录,否则返回冲突。
@@ -0,0 +1,5 @@
# 文章公开读模型
## news.article.model.publication.Publication
前台读取的发布快照,保存 slug、标题、正文和发布时间。
@@ -0,0 +1,9 @@
# 文章模型
## news.article.model.ArticleStatus
文章的草稿或已发布状态。
## news.article.model.Article
作者所属的文章草稿和发布状态。版本、作者与发布状态不直接作为公开字段返回。
@@ -0,0 +1,13 @@
pageSize(value: Int >= 1 and <= 100) (size: Int) pure {
size = value
}
pageSize(value: other) (size: Int) pure {
fail(dever.api.Error.Invalid)
}
requireUpdated(changed: 1) () pure {}
requireUpdated(changed: other) () pure {
fail(dever.api.Error.Conflict)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,25 @@
type ArticleStatus {
Draft = "草稿"
Published = "已发布"
}
type Article {
private owner author_id: Int = dever.auth.userId()
slug: Text(1, 180) lower unique
title: Text(1, 160)
body: Text raw
category_id: content.category.model.id?
asset_id: media.asset.model.id?
private version: Int default 1
private status: ArticleStatus default ArticleStatus.Draft index
private published_at: DateTime?
}
index(author_id, created_at)
sql ownedArticles(author: Int, size: Int) (
articles: List<app.ArticleView>
) {
sqlite = "SELECT id, slug, title, body, version, category_id, asset_id FROM news_article_model WHERE author_id = ?1 ORDER BY id DESC LIMIT ?2"
postgres = "SELECT id, slug, title, body, version, category_id, asset_id FROM news_article_model WHERE author_id = $1 ORDER BY id DESC LIMIT $2"
}
@@ -0,0 +1,6 @@
type Publication {
slug: Text(1, 180) unique
title: Text(1, 160)
body: Text
published_at: DateTime index
}
@@ -0,0 +1,8 @@
# 租户
管理平台租户记录和启用状态,数据库准备由部署命令完成。
- [租户应用](doc/app.md)
- [租户规则](doc/domain.md)
- [租户模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1,41 @@
type TenantView {
id: model.id
key: Text
name: Text
}
provision(key: Text, name: Text) (tenant: TenantView) {
stored = model.create(
{
key = key
name = name
}
)
tenant = TenantView {
id = stored.id
key = stored.key
name = stored.name
}
}
active(tenant_key: Text) (tenant: TenantView) {
stored = model.first(
{
where = key == domain.normalizeKey(tenant_key)
}
)
tenant = activeView(stored)
}
activeView(stored: model.Tenant) (tenant: TenantView) {
dever.api.require(stored.status == model.TenantStatus.Active, dever.api.Error.Unauthorized)
tenant = TenantView {
id = stored.id
key = stored.key
name = stored.name
}
}
activeView(stored: null) (tenant: TenantView) {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,17 @@
# 租户应用
## platform.tenant.TenantView
租户编号、唯一键和显示名称。
## platform.tenant.provision
创建租户记录,key 去除首尾空白后转为小写并保持唯一。
## platform.tenant.active
按规范化 key 查找启用租户,不存在或已禁用时返回未授权。
## platform.tenant.activeView
检查租户启用状态并转换为 TenantView,空记录返回未授权。
@@ -0,0 +1,5 @@
# 租户规则
## platform.tenant.domain.normalizeKey
去掉租户键两端空白并转为小写,用于与 Model 写入规则一致地查询。
@@ -0,0 +1,9 @@
# 租户模型
## platform.tenant.model.TenantStatus
租户的启用或禁用状态。
## platform.tenant.model.Tenant
平台库中的租户记录;key 唯一,UUID 自动生成。
@@ -0,0 +1,3 @@
normalizeKey(value: Text) (key: Text) pure {
key = text.lower(text.trim(value))
}
@@ -0,0 +1,197 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 3d8386ca09494f4f62fa83613bf8379bbfa858632449d9d19fe86f3a86ad1d9c -->
# platform.tenant
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
没有已收录的入口。
## 外部调用方
| 能力 | 调用方 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.active](app.dever#L21) | [user.account.login](../../user/account/app/account.dever#L70) | 调用 | [../../user/account/app/account.dever:78:14](../../user/account/app/account.dever#L78,14) |
| [platform.tenant.active](app.dever#L21) | [user.account.establishLogin](../../user/account/app/account.dever#L91) | 调用 | [../../user/account/app/account.dever:99:14](../../user/account/app/account.dever#L99,14) |
| [platform.tenant.active](app.dever#L21) | [user.account.verify](../../user/account/app/account.dever#L133) | 调用 | [../../user/account/app/account.dever:137:14](../../user/account/app/account.dever#L137,14) |
| [platform.tenant.provision](app.dever#L7) | [user.account.registerAccount](../../user/account/app/account.dever#L41) | 调用 | [../../user/account/app/account.dever:48:12](../../user/account/app/account.dever#L48,12) |
## 能力与内部调用
### platform.tenant.TenantView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### platform.tenant.active
定义:[app.dever:21:1](app.dever#L21,1)
<a id="flow-720cd3e6ee01541e-1"></a>
```mermaid
flowchart TD
n0(["active"])
click n0 href "app.dever#L21,1" "打开源码"
n1(["返回"])
n3[["platform.tenant.domain.normalizeKey"]]
click n3 href "app.dever#L24,22" "打开源码"
n4["platform.tenant.model.first"]
click n4 href "app.dever#L22,12" "打开源码"
n5["赋值 stored"]
click n5 href "app.dever#L22,12" "打开源码"
n6[["platform.tenant.activeView"]]
click n6 href "app.dever#L27,12" "打开源码"
n7["赋值 tenant"]
click n7 href "app.dever#L27,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| platform.tenant.domain.normalizeKey | [app.dever:24:22](app.dever#L24,22) | [platform.tenant.domain.normalizeKey](link.md#flow-675cb56e9567397f-1) |
| platform.tenant.activeView | [app.dever:27:12](app.dever#L27,12) | [platform.tenant.activeView](link.md#flow-b450477f16968f30-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.model.first](model.dever#L6) | 领域内 | 内建操作 | [app.dever:22:12](app.dever#L22,12) |
| [platform.tenant.domain.normalizeKey](domain.dever#L1) | 领域内 | 调用 | [app.dever:24:22](app.dever#L24,22) |
| [platform.tenant.activeView](app.dever#L30) | 领域内 | 调用 | [app.dever:27:12](app.dever#L27,12) |
### platform.tenant.activeView
定义:[app.dever:30:1](app.dever#L30,1)、[app.dever:39:1](app.dever#L39,1)
<a id="flow-b450477f16968f30-1"></a>
```mermaid
flowchart TD
n0(["activeView"])
click n0 href "app.dever#L30,1" "打开源码"
n1(["返回"])
n2(["传播错误"])
n3{"按实参匹配分句(互斥)"}
n4["stored: model.Tenant"]
click n4 href "app.dever#L30,1" "打开源码"
n5[["dever.api.require"]]
click n5 href "app.dever#L31,3" "打开源码"
n6["赋值 tenant"]
click n6 href "app.dever#L32,12" "打开源码"
n7["stored: null"]
click n7 href "app.dever#L39,1" "打开源码"
n8["fail dever.api.Error.Unauthorized"]
click n8 href "app.dever#L40,8" "打开源码"
n0 --> n3
n3 -->|"stored: model.Tenant"| n4
n4 --> n5
n5 --> n6
n6 --> n1
n3 -->|"stored: null"| n7
n7 --> n8
n8 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.api.require | [app.dever:31:3](app.dever#L31,3) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `dever.api.require` | 依赖 | 调用 | [app.dever:31:3](app.dever#L31,3) |
### platform.tenant.domain.normalizeKey
定义:[domain.dever:1:1](domain.dever#L1,1)
<a id="flow-675cb56e9567397f-1"></a>
```mermaid
flowchart TD
n0(["normalizeKey"])
click n0 href "domain.dever#L1,1" "打开源码"
n1(["返回"])
n3[["text.trim"]]
click n3 href "domain.dever#L2,20" "打开源码"
n4[["text.lower"]]
click n4 href "domain.dever#L2,9" "打开源码"
n5["赋值 key"]
click n5 href "domain.dever#L2,9" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| text.trim | [domain.dever:2:20](domain.dever#L2,20) | 依赖或生成的分派(本页不展开) |
| text.lower | [domain.dever:2:9](domain.dever#L2,9) | 依赖或生成的分派(本页不展开) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| `text.lower` | 依赖 | 调用 | [domain.dever:2:9](domain.dever#L2,9) |
| `text.trim` | 依赖 | 调用 | [domain.dever:2:20](domain.dever#L2,20) |
### platform.tenant.model.Tenant
定义:[model.dever:6:1](model.dever#L6,1)
没有已收录的下游调用。
### platform.tenant.model.TenantStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### platform.tenant.provision
定义:[app.dever:7:1](app.dever#L7,1)
<a id="flow-cc4bbca9579ab41a-1"></a>
```mermaid
flowchart TD
n0(["provision"])
click n0 href "app.dever#L7,1" "打开源码"
n1(["返回"])
n3["platform.tenant.model.create"]
click n3 href "app.dever#L8,12" "打开源码"
n4["赋值 stored"]
click n4 href "app.dever#L8,12" "打开源码"
n5["赋值 tenant"]
click n5 href "app.dever#L14,12" "打开源码"
n0 --> n3
n3 --> n4
n4 --> n5
n5 --> n1
```
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [platform.tenant.model.create](model.dever#L6) | 领域内 | 内建操作 | [app.dever:8:12](app.dever#L8,12) |
@@ -0,0 +1,11 @@
type TenantStatus {
Active = "启用"
Disabled = "禁用"
}
global type Tenant {
uuid: Uuid generated unique
key: Text(1, 64) lower unique
name: Text(1, 100)
status: TenantStatus default TenantStatus.Active index
}
@@ -0,0 +1,9 @@
# 发布计划
为文章安排一次定时发布,重复任务不会重复发布。
- [发布计划接口](doc/api-admin-manage.md)
- [发布计划应用](doc/app.md)
- [发布计划任务](doc/job.md)
- [发布计划模型](doc/model.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
post schedule = app.create
@@ -0,0 +1,74 @@
type PublishRequest {
schedule_id: model.id
}
type ScheduleView {
id: model.id
article_id: news.article.model.id
publish_at: DateTime
status: Text
}
transaction create(article_id: news.article.model.id, publish_at: DateTime) (
schedule: ScheduleView
) {
news.article.requireSchedulable(article_id)
stored = model.create(
{
article_id = article_id
publish_at = publish_at
}
)
queued = dever.job.enqueueAt(
job.publish,
PublishRequest {
schedule_id = stored.id
},
uuid.toText(stored.uuid),
publish_at
)
discarded = queued
schedule = ScheduleView {
id = stored.id
article_id = stored.article_id
publish_at = stored.publish_at
status = "pending"
}
}
transaction publish(input: PublishRequest) () {
scheduled = model.get(input.schedule_id)
complete(scheduled.status, scheduled.id)
}
complete(status: model.ScheduleStatus.Completed, schedule_id: model.id) () {}
complete(status: model.ScheduleStatus.Cancelled, schedule_id: model.id) () {}
complete(status: model.ScheduleStatus.Pending, schedule_id: model.id) () {
scheduled = model.get(schedule_id)
dever.api.require(model.exists(
{
where = id == schedule_id and publish_at <= dever.time.now()
}
), dever.api.Error.Invalid)
changed = model.update(
{
where = id == scheduled.id and status == model.ScheduleStatus.Pending
},
{
status = model.ScheduleStatus.Completed
}
)
publishClaimed(changed, scheduled.article_id)
}
publishClaimed(changed: 0, article_id: news.article.model.id) () {}
publishClaimed(changed: 1, article_id: news.article.model.id) () {
publication = news.article.publishScheduled(article_id)
}
publishClaimed(changed: other, article_id: news.article.model.id) () {
fail(dever.api.Error.Conflict)
}
@@ -0,0 +1,5 @@
# 发布计划接口
## publishing.schedule.api.admin.manage.schedule
为当前作者的草稿建立定时发布计划。
@@ -0,0 +1,25 @@
# 发布计划应用
## publishing.schedule.PublishRequest
发布任务的输入,只保存计划编号。
## publishing.schedule.ScheduleView
创建计划后返回文章编号、计划时间和状态。
## publishing.schedule.create
为当前作者的草稿建立发布计划并按时入队;计划记录和入队在同一事务内完成。每篇文章只能有一条计划。
## publishing.schedule.publish
加载发布计划并按其状态执行,供 Job 调用。
## publishing.schedule.complete
仅处理已经到时的 Pending 计划;已完成或取消的计划不再执行。状态更新与文章发布在同一事务中完成。
## publishing.schedule.publishClaimed
成功更新一条计划后发布文章;零条表示无需重复执行,其他数量返回冲突。
@@ -0,0 +1,5 @@
# 发布计划任务
## publishing.schedule.job.publish
执行发布计划,最多执行 3 次(含首次),单次期限 30 秒。
@@ -0,0 +1,9 @@
# 发布计划模型
## publishing.schedule.model.ScheduleStatus
计划的待发布、已完成或已取消状态。
## publishing.schedule.model.Schedule
每篇文章的唯一发布计划,保存执行时间和状态。
Whitespace-only changes.
@@ -0,0 +1,5 @@
database default
job publish(input: app.PublishRequest) () retry(3) timeout(30000) {
app.publish(input)
}
@@ -0,0 +1,430 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: be17003a018467df95914a96c87fcb8890e4ade3118c3511d7e00fde98087ea7 -->
# publishing.schedule
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- API [publishing.schedule.api.admin.manage.schedule](api/admin/manage.dever#L1) → [publishing.schedule.create](app.dever#L12)
- Job [publishing.schedule.job.publish](job.dever#L3)
## 外部调用方
当前加载的源码中没有已收录的外部调用。
## 能力与内部调用
### publishing.schedule.PublishRequest
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### publishing.schedule.ScheduleView
定义:[app.dever:5:1](app.dever#L5,1)
没有已收录的下游调用。
### publishing.schedule.api.admin.manage.schedule
定义:[api/admin/manage.dever:1:1](api/admin/manage.dever#L1,1)
<a id="flow-4a14d9ba44a23d84-1"></a>
```mermaid
flowchart TD
n0(["publishing.schedule.api.admin.manage.schedule"])
click n0 href "api/admin/manage.dever#L1,17" "打开源码"
n1{"路由 POST /publishing/schedule/admin/manage/schedule,topic #34;admin/manage#34; 接受请求?"}
n2(["返回 HTTP 404"])
n3["进入 API"]
n4["取得认证时间"]
n5(["返回 HTTP 500"])
n6["按 topic #34;admin/manage#34; 选择站点与 Provider"]
n7(["返回 HTTP 403"])
n8{"读取 Bearer 或站点 Cookie;两者互斥"}
n9(["返回 HTTP 401"])
n10["认证上下文就绪"]
n11(["返回 HTTP 401"])
n12["校验 Token 的签名、有效期及站点"]
n13{"Cookie 且非 GET / HEAD / OPTIONS?"}
n14["检查站点 Origin"]
n15(["返回 HTTP 403"])
n16(["返回 HTTP 401"])
n17{"按 Provider.verify 选择验证函数"}
n18[["user.account.verify"]]
click n18 href "api/admin/manage.dever#L1,17" "打开源码"
n19(["返回 HTTP 业务错误映射状态;其他故障 500"])
n20["用验证结果建立身份"]
n21(["返回 HTTP 认证错误状态"])
n22(["返回 HTTP 500"])
n23["建立请求数据库上下文"]
n24{"有已验证租户?"}
n25["数据库上下文就绪"]
n26{"部署启用了租户存储?"}
n27(["返回 HTTP 403"])
n28["按已验证租户选择数据库"]
n29(["返回 HTTP 503"])
n30{"检查租户组件启用状态:news, publishing"}
n31(["返回 HTTP 403"])
n32(["返回 HTTP 503"])
n33["进入写入口事务"]
n34["失败:保留 HTTP 错误状态"]
n35["回滚入口拥有的事务"]
n36(["返回 HTTP 500"])
n37(["返回错误响应"])
n38{"权限检查:publishing.schedule.{site}.schedule"}
n39["权限通过"]
n40["从可信身份建立 Job 执行上下文"]
n41["读取 API 输入"]
n42["解码具体参数并检查字段合同"]
n43[["publishing.schedule.create"]]
click n43 href "api/admin/manage.dever#L1,17" "打开源码"
n44["按输出类型检查可序列化值并编码响应"]
n45["提交入口拥有的事务"]
n46(["返回 HTTP 500"])
n47["发布暂存的认证 Header / Cookie"]
n48(["返回成功响应"])
n0 --> n1
n1 -->|"未匹配此入口:继续路由;均未匹配"| n2
n1 --> n3
n3 --> n4
n4 -->|"失败"| n5
n4 --> n6
n6 -->|"无站点绑定"| n7
n6 --> n8
n8 -->|"凭据格式错误或冲突"| n9
n8 -->|"无凭据"| n11
n8 -->|"有凭据"| n13
n13 -->|"否"| n12
n13 -->|"是"| n14
n14 -->|"不符合"| n15
n14 -->|"通过"| n12
n12 -->|"无 Provider 或 Token 无效"| n16
n12 --> n17
n17 -->|"user.account.verify"| n18
n18 -->|"验证函数失败"| n19
n18 --> n20
n20 -->|"身份不合法"| n21
n20 -->|"通过"| n10
n17 -->|"无匹配验证函数"| n22
n10 --> n23
n23 --> n24
n24 -->|"否"| n26
n26 -->|"是:缺少租户身份"| n27
n26 -->|"否"| n25
n24 -->|"是"| n28
n28 -->|"选择失败"| n29
n28 --> n30
n30 -->|"未启用"| n31
n30 -->|"查询失败"| n32
n30 -->|"允许"| n25
n25 --> n33
n34 --> n35
n35 -->|"事务结束失败"| n36
n35 --> n37
n33 --> n38
n38 -->|"拒绝:Access 状态;查询故障:500"| n34
n38 -->|"允许"| n39
n39 --> n40
n40 -->|"失败:认证错误状态"| n34
n40 --> n41
n41 -->|"失败:输入错误状态"| n34
n41 --> n42
n42 -->|"失败:400"| n34
n42 --> n43
n43 -->|"业务错误映射状态;其他故障:500"| n34
n43 --> n44
n44 -->|"编码失败:500"| n34
n44 --> n45
n45 -->|"提交失败"| n46
n45 --> n47
n47 --> n48
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| user.account.verify | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) | [user.account.verify](../../user/account/link.md#flow-aaaacb87d61f819c-1) |
| publishing.schedule.create | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) | [publishing.schedule.create](link.md#flow-a5c2828f7faa5041-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.create](app.dever#L12) | 领域内 | API 绑定 | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) |
| [user.account.verify](../../user/account/app/account.dever#L133) | 跨领域 [user.account](../../user/account/link.md) | 身份验证 | [api/admin/manage.dever:1:17](api/admin/manage.dever#L1,17) |
### publishing.schedule.complete
定义:[app.dever:44:1](app.dever#L44,1)、[app.dever:46:1](app.dever#L46,1)、[app.dever:48:1](app.dever#L48,1)
<a id="flow-87bb9cb4f5f0c2f6-1"></a>
```mermaid
flowchart TD
n0(["complete"])
click n0 href "app.dever#L44,1" "打开源码"
n1(["返回"])
n3{"按实参匹配分句(互斥)"}
n4["status: model.ScheduleStatus.Completed"]
click n4 href "app.dever#L44,1" "打开源码"
n5["status: model.ScheduleStatus.Cancelled"]
click n5 href "app.dever#L46,1" "打开源码"
n6["status: model.ScheduleStatus.Pending"]
click n6 href "app.dever#L48,1" "打开源码"
n7["publishing.schedule.model.get"]
click n7 href "app.dever#L49,15" "打开源码"
n8["赋值 scheduled"]
click n8 href "app.dever#L49,15" "打开源码"
n9[["dever.time.now"]]
click n9 href "app.dever#L52,53" "打开源码"
n10["publishing.schedule.model.exists"]
click n10 href "app.dever#L50,21" "打开源码"
n11[["dever.api.require"]]
click n11 href "app.dever#L50,3" "打开源码"
n12["publishing.schedule.model.update"]
click n12 href "app.dever#L55,13" "打开源码"
n13["赋值 changed"]
click n13 href "app.dever#L55,13" "打开源码"
n14[["publishing.schedule.publishClaimed"]]
click n14 href "app.dever#L63,3" "打开源码"
n0 --> n3
n3 -->|"status: model.ScheduleStatus.Completed"| n4
n4 --> n1
n3 -->|"status: model.ScheduleStatus.Cancelled"| n5
n5 --> n1
n3 -->|"status: model.ScheduleStatus.Pending"| n6
n6 --> n7
n7 --> n8
n8 --> n9
n9 --> n10
n10 --> n11
n11 --> n12
n12 --> n13
n13 --> n14
n14 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| dever.time.now | [app.dever:52:53](app.dever#L52,53) | 依赖或生成的分派(本页不展开) |
| dever.api.require | [app.dever:50:3](app.dever#L50,3) | 依赖或生成的分派(本页不展开) |
| publishing.schedule.publishClaimed | [app.dever:63:3](app.dever#L63,3) | [publishing.schedule.publishClaimed](link.md#flow-c4a292464addac3f-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.model.get](model.dever#L7) | 领域内 | 内建操作 | [app.dever:49:15](app.dever#L49,15) |
| `dever.api.require` | 依赖 | 调用 | [app.dever:50:3](app.dever#L50,3) |
| [publishing.schedule.model.exists](model.dever#L7) | 领域内 | 内建操作 | [app.dever:50:21](app.dever#L50,21) |
| `dever.time.now` | 依赖 | 调用 | [app.dever:52:53](app.dever#L52,53) |
| [publishing.schedule.model.update](model.dever#L7) | 领域内 | 内建操作 | [app.dever:55:13](app.dever#L55,13) |
| [publishing.schedule.publishClaimed](app.dever#L66) | 领域内 | 调用 | [app.dever:63:3](app.dever#L63,3) |
### publishing.schedule.create
定义:[app.dever:12:1](app.dever#L12,1)
<a id="flow-a5c2828f7faa5041-1"></a>
```mermaid
flowchart TD
n0(["create"])
click n0 href "app.dever#L12,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4[["news.article.requireSchedulable"]]
click n4 href "app.dever#L15,3" "打开源码"
n5["publishing.schedule.model.create"]
click n5 href "app.dever#L16,12" "打开源码"
n6["赋值 stored"]
click n6 href "app.dever#L16,12" "打开源码"
n7[["uuid.toText"]]
click n7 href "app.dever#L27,5" "打开源码"
n8["持久化任务入队"]
click n8 href "app.dever#L22,12" "打开源码"
n9[["Job:publishing.schedule.job.publish"]]
click n9 href "app.dever#L22,12" "打开源码"
n10["赋值 queued"]
click n10 href "app.dever#L22,12" "打开源码"
n11["赋值 discarded"]
click n11 href "app.dever#L30,15" "打开源码"
n12["赋值 schedule"]
click n12 href "app.dever#L31,14" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n7
n7 --> n8
n8 -.->|"由 Worker 后续调度,不等待执行"| n9
n8 --> n10
n10 --> n11
n11 --> n12
n12 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.requireSchedulable | [app.dever:15:3](app.dever#L15,3) | [news.article.requireSchedulable](../../news/article/link.md#flow-dc698efe57d88983-1) |
| uuid.toText | [app.dever:27:5](app.dever#L27,5) | 依赖或生成的分派(本页不展开) |
| Job:publishing.schedule.job.publish | [app.dever:22:12](app.dever#L22,12) | [publishing.schedule.job.publish](link.md#flow-c9df8cbe8f8cd013-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.requireSchedulable](../../news/article/app/editing.dever#L107) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:15:3](app.dever#L15,3) |
| [publishing.schedule.model.create](model.dever#L7) | 领域内 | 内建操作 | [app.dever:16:12](app.dever#L16,12) |
| [publishing.schedule.job.publish](job.dever#L3) | 领域内 | 入队 | [app.dever:23:5](app.dever#L23,5) |
| `uuid.toText` | 依赖 | 调用 | [app.dever:27:5](app.dever#L27,5) |
### publishing.schedule.job.publish
定义:[job.dever:3:1](job.dever#L3,1)
<a id="flow-c9df8cbe8f8cd013-1"></a>
```mermaid
flowchart TD
n0(["publish"])
click n0 href "job.dever#L3,1" "打开源码"
n1(["返回"])
n3[["publishing.schedule.publish"]]
click n3 href "job.dever#L4,3" "打开源码"
n0 --> n3
n3 --> n1
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| publishing.schedule.publish | [job.dever:4:3](job.dever#L4,3) | [publishing.schedule.publish](link.md#flow-1452f352cf5266e6-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.publish](app.dever#L39) | 领域内 | 调用 | [job.dever:4:3](job.dever#L4,3) |
### publishing.schedule.model.Schedule
定义:[model.dever:7:1](model.dever#L7,1)
没有已收录的下游调用。
### publishing.schedule.model.ScheduleStatus
定义:[model.dever:1:1](model.dever#L1,1)
没有已收录的下游调用。
### publishing.schedule.publish
定义:[app.dever:39:1](app.dever#L39,1)
<a id="flow-1452f352cf5266e6-1"></a>
```mermaid
flowchart TD
n0(["publish"])
click n0 href "app.dever#L39,1" "打开源码"
n1["开始事务"]
n2(["提交事务,返回"])
n3(["回滚事务,传播错误"])
n4["publishing.schedule.model.get"]
click n4 href "app.dever#L40,15" "打开源码"
n5["赋值 scheduled"]
click n5 href "app.dever#L40,15" "打开源码"
n6[["publishing.schedule.complete"]]
click n6 href "app.dever#L41,3" "打开源码"
n0 --> n1
n1 -.->|"事务体任一未捕获错误"| n3
n1 --> n4
n4 --> n5
n5 --> n6
n6 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| publishing.schedule.complete | [app.dever:41:3](app.dever#L41,3) | [publishing.schedule.complete](link.md#flow-87bb9cb4f5f0c2f6-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [publishing.schedule.model.get](model.dever#L7) | 领域内 | 内建操作 | [app.dever:40:15](app.dever#L40,15) |
| [publishing.schedule.complete](app.dever#L44) | 领域内 | 调用 | [app.dever:41:3](app.dever#L41,3) |
### publishing.schedule.publishClaimed
定义:[app.dever:66:1](app.dever#L66,1)、[app.dever:68:1](app.dever#L68,1)、[app.dever:72:1](app.dever#L72,1)
<a id="flow-c4a292464addac3f-1"></a>
```mermaid
flowchart TD
n0(["publishClaimed"])
click n0 href "app.dever#L66,1" "打开源码"
n1(["返回"])
n2(["传播错误"])
n3{"按实参匹配分句(互斥)"}
n4["changed: 0"]
click n4 href "app.dever#L66,1" "打开源码"
n5["changed: 1"]
click n5 href "app.dever#L68,1" "打开源码"
n6[["news.article.publishScheduled"]]
click n6 href "app.dever#L69,17" "打开源码"
n7["赋值 publication"]
click n7 href "app.dever#L69,17" "打开源码"
n8["changed: other"]
click n8 href "app.dever#L72,1" "打开源码"
n9["fail dever.api.Error.Conflict"]
click n9 href "app.dever#L73,8" "打开源码"
n0 --> n3
n3 -->|"changed: 0"| n4
n4 --> n1
n3 -->|"changed: 1"| n5
n5 --> n6
n6 --> n7
n7 --> n1
n3 -->|"changed: other"| n8
n8 --> n9
n9 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| news.article.publishScheduled | [app.dever:69:17](app.dever#L69,17) | [news.article.publishScheduled](../../news/article/link.md#flow-5ba4105cff8222e5-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [news.article.publishScheduled](../../news/article/app/publication.dever#L22) | 跨领域 [news.article](../../news/article/link.md) | 调用 | [app.dever:69:17](app.dever#L69,17) |
@@ -0,0 +1,12 @@
type ScheduleStatus {
Pending = "待发布"
Completed = "已完成"
Cancelled = "已取消"
}
type Schedule {
uuid: Uuid generated unique
article_id: news.article.model.id unique
publish_at: DateTime index
status: ScheduleStatus default ScheduleStatus.Pending index
}
@@ -0,0 +1,7 @@
# 健康检查
提供命令行健康检查。
- [健康检查命令](doc/api.md)
- [系统健康](doc/app.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
cmd ping = app.ping
@@ -0,0 +1,9 @@
type HealthView {
status: Text
}
ping() (result: HealthView) {
result = HealthView {
status = "ready"
}
}
@@ -0,0 +1,5 @@
# 健康检查命令
## system.health.api.ping
返回 ready 状态。
@@ -0,0 +1,9 @@
# 系统健康
## system.health.HealthView
健康检查返回的状态。
## system.health.ping
返回 ready,供命令行健康检查使用。
@@ -0,0 +1,73 @@
<!-- Generated by dever inspect --links. Do not edit. -->
<!-- source-sha256: 763ebcc3cdd50ee3c2f028ca31712656106fab555c878ad702f7f58bca5f0737 -->
# system.health
按求值顺序展示调用、分支和任务调度,包含 API 与自动 REST 入口。点击节点打开源码;双框为调用,虚线为调度或错误路径。外部库和驱动不展开。
图不是某次运行的日志,分句和 Adapter 按输入或配置选择。修改源码后运行 `dever inspect . --links`。
## 入口
- CMD [system.health.api.ping](api.dever#L1) → [system.health.ping](app.dever#L5)
## 外部调用方
当前加载的源码中没有已收录的外部调用。
## 能力与内部调用
### system.health.HealthView
定义:[app.dever:1:1](app.dever#L1,1)
没有已收录的下游调用。
### system.health.api.ping
定义:[api.dever:1:1](api.dever#L1,1)
<a id="flow-97bc3f948e0bd788-1"></a>
```mermaid
flowchart TD
n0(["system.health.api.ping"])
click n0 href "api.dever#L1,12" "打开源码"
n1[["system.health.ping"]]
click n1 href "api.dever#L1,12" "打开源码"
n2(["返回命令结果;未捕获错误终止命令"])
n0 --> n1
n1 --> n2
```
<details>
<summary>源码与子流程</summary>
| 节点 | 源码 | 子流程 |
| --- | --- | --- |
| system.health.ping | [api.dever:1:12](api.dever#L1,12) | [system.health.ping](link.md#flow-e4086f731ad69c62-1) |
</details>
| 目标 | 归属 | 方式 | 调用位置 |
| --- | --- | --- | --- |
| [system.health.ping](app.dever#L5) | 领域内 | CMD 绑定 | [api.dever:1:12](api.dever#L1,12) |
### system.health.ping
定义:[app.dever:5:1](app.dever#L5,1)
<a id="flow-e4086f731ad69c62-1"></a>
```mermaid
flowchart TD
n0(["ping"])
click n0 href "app.dever#L5,1" "打开源码"
n1(["返回"])
n3["赋值 result"]
click n3 href "app.dever#L6,12" "打开源码"
n0 --> n3
n3 --> n1
```
没有已收录的下游调用。
@@ -0,0 +1,17 @@
# 账户与会话
管理用户、凭据、租户成员和站点会话。
- [后台会话接口](doc/api-admin-session.md)
- [前台会话接口](doc/api-front-session.md)
- [账户引导命令](doc/api.md)
- [账户应用](doc/app.md)
- [账户规则](doc/domain.md)
- [用户主体模型](doc/model-principal.md)
- [账户凭据模型](doc/model.md)
- [成员应用](doc/membership-app.md)
- [租户成员模型](doc/membership-model.md)
- [会话应用](doc/session-app.md)
- [会话模型](doc/session-model.md)
- [会话清理任务](doc/session-job.md)
- [调用流程](link.md)
@@ -0,0 +1 @@
long cmd bootstrap = app.bootstrap
@@ -0,0 +1,3 @@
public long post login = app.login
post logout = app.logout
@@ -0,0 +1,3 @@
public long post login = app.login
post logout = app.logout
@@ -0,0 +1,163 @@
type AccountView {
id: model.principal.id
uuid: Uuid
email: Text
display_name: Text
}
type BootstrapView {
tenant_id: platform.tenant.model.id
user_id: model.principal.id
credential_id: model.id
}
type LoginView {
account: AccountView
tenant_id: platform.tenant.model.id
site: Text
}
type LogoutView {
revoked: Bool
}
type Identity {
id: Text
user_id: model.principal.id?
tenant_id: platform.tenant.model.id?
}
bootstrap(
tenant_key: Text,
tenant_name: Text,
email: Text,
display_name: Text,
password: Secret
) (result: BootstrapView) {
hash = blocking(dever.crypto.passwordHash(password))
result = registerAccount(tenant_key, tenant_name, email, display_name, hash)
}
transaction registerAccount(
tenant_key: Text,
tenant_name: Text,
email: Text,
display_name: Text,
hash: Text
) (result: BootstrapView) {
tenant = platform.tenant.provision(tenant_key, tenant_name)
principal = model.principal.create(
{
display_name = display_name
}
)
stored = model.create(
{
principal_id = principal.id
email = email
password_hash = hash
}
)
domain.validateEmail(stored.email)
grantOwner(principal.id, tenant.id)
result = BootstrapView {
tenant_id = tenant.id
user_id = principal.id
credential_id = stored.id
}
}
login(credential_id: model.id, password: Secret, tenant: Text) (result: LoginView) {
dever.api.require(model.exists(
{
where = id == credential_id
}
), dever.api.Error.Unauthorized)
stored = model.get(credential_id)
principal = model.principal.get(stored.principal_id)
selected = platform.tenant.active(tenant)
dever.api.require(stored.status == model.AccountStatus.Active, dever.api.Error.Unauthorized)
dever.api.require(principal.status == model.principal.PrincipalStatus.Active, dever.api.Error.Unauthorized)
dever.api.require(blocking(dever.crypto.passwordVerify(password, stored.password_hash)), dever.api.Error.Unauthorized)
authenticated = establishLogin(credential_id, selected.key, dever.site.key())
dever.auth.issueCookie(
authenticated.subject,
uuid.toText(authenticated.session_id),
authenticated.tenant_key
)
result = authenticated.result
}
transaction establishLogin(credential_id: model.id, tenant: Text, site: Text) (
result: LoginView,
subject: Text,
session_id: Uuid,
tenant_key: Text
) {
stored = model.get(credential_id)
principal = model.principal.get(stored.principal_id)
selected = platform.tenant.active(tenant)
dever.api.require(stored.status == model.AccountStatus.Active, dever.api.Error.Unauthorized)
dever.api.require(principal.status == model.principal.PrincipalStatus.Active, dever.api.Error.Unauthorized)
requireMembership(principal.id, selected.id)
session = open(
uuid.toText(principal.uuid),
principal.id,
selected.id,
selected.key,
site
)
result = LoginView {
account = AccountView {
id = principal.id
uuid = principal.uuid
email = stored.email
display_name = principal.display_name
}
tenant_id = selected.id
site = site
}
subject = uuid.toText(principal.uuid)
session_id = session.uuid
tenant_key = selected.key
}
logout() (result: LogoutView) {
revoke(uuid.parse(dever.auth.session()))
dever.auth.clearCookie()
result = LogoutView {
revoked = true
}
}
verify(claims: dever.auth.Claims) (identity: Identity) {
session = authenticate(uuid.parse(claims.session), claims.site)
dever.api.require(session.subject == claims.subject, dever.api.Error.Unauthorized)
domain.requireTenant(session.tenant_key, claims.tenant)
selected = platform.tenant.active(session.tenant_key)
dever.api.require(selected.id == session.tenant_id, dever.api.Error.Unauthorized)
active(session.user_id)
requireMembership(session.user_id, session.tenant_id)
identity = Identity {
id = claims.subject
user_id = session.user_id
tenant_id = session.tenant_id
}
}
active(user_id: model.principal.id) () {
principal = model.principal.get(user_id)
dever.api.require(principal.status == model.principal.PrincipalStatus.Active, dever.api.Error.Unauthorized)
}
currentUser() (id: Int) {
id = requireUser(dever.auth.userId())
}
requireUser(value: Int) (id: Int) {
id = value
}
requireUser(value: null) (id: Int) {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,31 @@
grantOwner(
account_id: model.principal.id,
selected_tenant_id: platform.tenant.model.id
) () {
stored = model.membership.create(
{
user_id = account_id
tenant_id = selected_tenant_id
role = model.membership.MembershipRole.Owner
}
)
discarded = stored
}
requireMembership(
account_id: model.principal.id,
selected_tenant_id: platform.tenant.model.id
) () {
stored = model.membership.first(
{
where = user_id == account_id and tenant_id == selected_tenant_id and status == model.membership.MembershipStatus.Active
}
)
activeMembership(stored)
}
activeMembership(stored: model.membership.Membership) () {}
activeMembership(stored: null) () {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,94 @@
type SessionView {
uuid: Uuid
}
type AuthSession {
subject: Text
user_id: model.principal.id
tenant_id: platform.tenant.model.id
tenant_key: Text
}
open(
subject: Text,
account_id: model.principal.id,
selected_tenant_id: platform.tenant.model.id,
selected_tenant_key: Text,
site_key: Text
) (session: SessionView) {
stored = model.session.create(
{
subject = subject
user_id = account_id
tenant_id = selected_tenant_id
tenant_key = selected_tenant_key
site = site_key
expires_at = dever.time.add(dever.time.now(), dever.time.duration(3600000))
}
)
session = SessionView {
uuid = stored.uuid
}
}
authenticate(session_id: Uuid, site_key: Text) (result: AuthSession) {
stored = model.session.first(
{
where = uuid == session_id and site == site_key and revoked == false and expires_at > dever.time.now(
)
}
)
result = authSession(stored)
}
authenticate(session_id: null, site_key: Text) (result: AuthSession) {
fail(dever.api.Error.Unauthorized)
}
revoke(session_id: Uuid) () {
stored = model.session.first(
{
where = uuid == session_id and revoked == false
}
)
revokeFound(stored)
}
revoke(session_id: null) () {
fail(dever.api.Error.Unauthorized)
}
authSession(stored: model.session.Session) (result: AuthSession) {
result = AuthSession {
subject = stored.subject
user_id = stored.user_id
tenant_id = stored.tenant_id
tenant_key = stored.tenant_key
}
}
authSession(stored: null) (result: AuthSession) {
fail(dever.api.Error.Unauthorized)
}
revokeFound(stored: model.session.Session) () {
changed = model.session.update(
stored.id,
{
revoked = true
}
)
discarded = changed
}
revokeFound(stored: null) () {
fail(dever.api.Error.Unauthorized)
}
cleanup() () {
removed = model.session.delete(
{
where = revoked == true or expires_at <= dever.time.now()
}
)
}
@@ -0,0 +1,9 @@
# 后台会话接口
## user.account.api.admin.session.login
校验凭据并建立后台站点会话,成功后设置认证 Cookie。
## user.account.api.admin.session.logout
撤销当前会话并清除认证 Cookie。
@@ -0,0 +1,9 @@
# 前台会话接口
## user.account.api.front.session.login
校验凭据并建立前台站点会话,成功后设置认证 Cookie。
## user.account.api.front.session.logout
撤销当前会话并清除认证 Cookie。
@@ -0,0 +1,5 @@
# 账户引导命令
## user.account.api.bootstrap
创建租户、用户、凭据和 Owner 成员关系,返回部署所需编号。
@@ -0,0 +1,57 @@
# 账户应用
## user.account.AccountView
登录后公开的用户编号、UUID、邮箱和显示名,不含密码哈希。
## user.account.BootstrapView
引导完成后返回租户、用户和凭据编号,供后续部署命令使用。
## user.account.LoginView
登录成功后的公开账户、租户编号和站点。
## user.account.LogoutView
退出操作是否已撤销会话。
## user.account.Identity
Provider 返回的可信主体、用户编号和租户编号。
## user.account.bootstrap
在线程池计算密码哈希,再创建租户、用户、凭据和 Owner 成员关系。核心站点 Owner 权限仍由部署命令建立。
## user.account.registerAccount
在同一事务中创建租户、用户、邮箱凭据和 Owner 成员记录;邮箱验证失败会回滚。
## user.account.login
校验凭据、用户和租户状态及密码,创建会话并设置当前站点 Cookie。
## user.account.establishLogin
事务中重新检查账号状态和成员关系,创建站点会话,返回签发 Cookie 所需信息。
## user.account.logout
撤销当前会话并清除认证 Cookie。
## user.account.verify
校验会话、主体、租户、用户和成员关系,返回可信身份供核心鉴权。
## user.account.active
要求用户主体已启用,否则返回未授权。
## user.account.currentUser
读取当前认证用户编号,没有用户身份时返回未授权。
## user.account.requireUser
取出非空用户编号,null 返回未授权。
@@ -0,0 +1,9 @@
# 账户规则
## user.account.domain.validateEmail
要求邮箱包含非首尾位置的 @,不验证邮件能否投递。
## user.account.domain.requireTenant
要求认证声明中的租户键与会话一致,缺失或不同返回未授权。
@@ -0,0 +1,13 @@
# 成员应用
## user.account.grantOwner
创建租户 Owner 成员记录,不修改核心站点角色。
## user.account.requireMembership
要求用户与租户之间存在启用的成员关系。
## user.account.activeMembership
找到有效成员记录时通过,否则返回未授权。
@@ -0,0 +1,13 @@
# 租户成员模型
## user.account.model.membership.MembershipRole
租户成员的 Owner、Editor 或 Reader 身份。
## user.account.model.membership.MembershipStatus
成员关系的启用或禁用状态。
## user.account.model.membership.Membership
平台库中的用户与租户关系,每对用户和租户只能有一条。
@@ -0,0 +1,9 @@
# 用户主体模型
## user.account.model.principal.PrincipalStatus
用户主体的启用或禁用状态。
## user.account.model.principal.Principal
平台用户主体,保存自动生成的 UUID、显示名和状态。
@@ -0,0 +1,9 @@
# 账户凭据模型
## user.account.model.AccountStatus
登录凭据的启用或禁用状态。
## user.account.model.Account
平台登录凭据,关联用户主体;邮箱规范化且唯一,密码哈希为私有原文。
@@ -0,0 +1,33 @@
# 会话应用
## user.account.SessionView
新会话的 UUID。
## user.account.AuthSession
已认证会话的主体、用户和租户信息。
## user.account.open
创建有效期一小时的站点会话。
## user.account.authenticate
按会话 UUID 和站点查找未撤销、未过期的会话;无效时返回未授权。
## user.account.revoke
按 UUID 查找并撤销尚未撤销的会话;无效编号或缺失记录返回未授权。
## user.account.authSession
把已找到的会话转换为认证信息;空记录返回未授权。
## user.account.revokeFound
标记已有会话为已撤销;空记录返回未授权。
## user.account.cleanup
删除已撤销或已过期的会话。
@@ -0,0 +1,5 @@
# 会话清理任务
## user.account.job.cleanup
每小时清理失效会话,最多执行 3 次(含首次),单次期限 30 秒。
@@ -0,0 +1,5 @@
# 会话模型
## user.account.model.session.Session
平台会话记录,包含主体、用户、租户、站点、到期时间和撤销标记。
@@ -0,0 +1,17 @@
validateEmail(address: Text) () pure {
dever.api.require(
text.contains(address, "@") and not text.startsWith(address, "@") and not text.endsWith(
address,
"@"
),
dever.api.Error.Invalid
)
}
requireTenant(expected: Text, actual: Text) () pure {
dever.api.require(expected == actual, dever.api.Error.Unauthorized)
}
requireTenant(expected: Text, actual: null) () pure {
fail(dever.api.Error.Unauthorized)
}
@@ -0,0 +1,7 @@
database default
job cleanup() () retry(3) timeout(30000) {
app.cleanup()
}
schedule cleanup = "0 * * * *"
File diff suppressed because it is too large. Load diff
Loaded 100 of 1479 files, more files were not shown because too many files have changed in this diff. Show more