forked from xiongyuxing/tiku-backend.net
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 603bc24c26 | |||
| 1aa1ed4829 | |||
| 3385649a8d | |||
| 4793ad1832 | |||
| f7d364b381 | |||
| a39a8c1fe5 | |||
| a517ffc6a7 | |||
| 33375a38d7 | |||
| b38f12e60b | |||
| 3383a65867 | |||
| 8493f8f0ed | |||
| ed3321a34b | |||
| 2063cb59fd | |||
| e290719d4a | |||
| c06cf4a4f7 | |||
| 087ca78af5 | |||
| 0bd075e20d | |||
| 4d85463a87 | |||
| 1b867cfa3a | |||
| 4f8e216282 | |||
| 21f4a97f7e | |||
| 558b2a4ea8 | |||
| 2c4a0bad6c | |||
| 9f19bea7ee | |||
| 4751e738b1 | |||
| 7adcb6a3d5 | |||
| c497a3ca8d | |||
| caea0062b0 | |||
| 290a0c7bd7 | |||
| fe594c9ef5 | |||
| 33a08bfaab | |||
| 9d576d2d79 | |||
| 73dd96a178 | |||
| 684f4c2f81 | |||
| c54e223ca1 | |||
| 6bdfee6807 | |||
| a9a8f0ab9f | |||
| 134e96dc69 | |||
| 1d071f02fe | |||
| de629bc5c6 | |||
| ecf506df8a | |||
| d58bcd97e9 | |||
| 46abf4d62f | |||
| 84c2b0b21d | |||
| f776056834 | |||
| 589ecd06f0 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
.codegraph
|
||||
.idea
|
||||
.vscode
|
||||
**/.DS_Store
|
||||
**/bin
|
||||
**/obj
|
||||
**/TestResults
|
||||
**/node_modules
|
||||
Tiku.PlatformAdmin.Web
|
||||
tools/performance/results
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copy this file to .env before starting local infrastructure or .NET runtimes.
|
||||
# These credentials match compose.yaml defaults and are for local development only.
|
||||
TIKU_POSTGRES_DB=tiku
|
||||
TIKU_POSTGRES_USER=tiku
|
||||
TIKU_POSTGRES_PASSWORD=tiku_dev
|
||||
TIKU_POSTGRES_PORT=5432
|
||||
TIKU_REDIS_PORT=6379
|
||||
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
DOTNET_ENVIRONMENT=Development
|
||||
ConnectionStrings__Database="Host=127.0.0.1;Port=5432;Database=tiku;Username=tiku;Password=tiku_dev"
|
||||
ConnectionStrings__Redis="127.0.0.1:6379,abortConnect=false"
|
||||
|
||||
Tenancy__Resolution__PlatformHosts__0=localhost
|
||||
Tenancy__Resolution__PlatformHosts__1=127.0.0.1
|
||||
|
||||
# To access the platform API from another device on the LAN, uncomment this
|
||||
# setting and replace the address with this development machine's current LAN IP.
|
||||
# Tenancy__Resolution__PlatformHosts__2=192.168.1.100
|
||||
82
.gitea/workflows/ci.yaml
Normal file
82
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: Host=127.0.0.1;Port=5432;Database=tiku;Username=tiku;Password=tiku_dev
|
||||
TIKU_TEST_POSTGRES_ADMIN: Host=127.0.0.1;Port=5432;Database=postgres;Username=tiku;Password=tiku_dev;Pooling=false;Timeout=5;Command Timeout=60
|
||||
REDIS_URL: 127.0.0.1:6379,abortConnect=false
|
||||
DOTNET_ENVIRONMENT: Development
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Start development dependencies
|
||||
run: docker compose up -d --wait
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Tiku.PlatformAdmin.Web/package-lock.json
|
||||
- name: Restore
|
||||
run: dotnet restore TIKU-BACKEND.slnx
|
||||
- name: Build
|
||||
run: dotnet build TIKU-BACKEND.slnx --no-restore
|
||||
- name: Test
|
||||
run: dotnet test TIKU-BACKEND.slnx --no-build
|
||||
- name: Format
|
||||
run: dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
|
||||
- name: Verify EF model and migration SQL
|
||||
run: |
|
||||
dotnet ef migrations has-pending-model-changes --project Tiku.Infrastructure --startup-project Tiku.DbMigrator --no-build
|
||||
dotnet ef migrations script --idempotent --project Tiku.Infrastructure --startup-project Tiku.DbMigrator --no-build --output /tmp/tiku-migrations.sql
|
||||
test -s /tmp/tiku-migrations.sql
|
||||
- name: Migrate development database
|
||||
run: dotnet run --project Tiku.DbMigrator --no-build
|
||||
- name: Install and check platform frontend
|
||||
working-directory: Tiku.PlatformAdmin.Web
|
||||
run: |
|
||||
npm ci
|
||||
npm run check
|
||||
- name: Verify generated OpenAPI contract
|
||||
run: |
|
||||
dotnet run --project Tiku.Api --no-build --no-launch-profile --urls http://localhost:5090 > /tmp/tiku-api.log 2>&1 &
|
||||
api_pid=$!
|
||||
trap 'kill "$api_pid" 2>/dev/null || true' EXIT
|
||||
api_ready=false
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent --show-error http://localhost:5090/api/system/health/ready >/dev/null; then
|
||||
api_ready=true
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$api_pid" 2>/dev/null; then
|
||||
cat /tmp/tiku-api.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$api_ready" != true ]; then
|
||||
cat /tmp/tiku-api.log
|
||||
exit 1
|
||||
fi
|
||||
curl --fail --silent http://localhost:5090/openapi/v1.json >/dev/null
|
||||
cd Tiku.PlatformAdmin.Web
|
||||
npm run generate:api
|
||||
cd ..
|
||||
git diff --exit-code -- Tiku.PlatformAdmin.Web/src/api/platform-operations.generated.ts Tiku.PlatformAdmin.Web/src/api/schema.generated.d.ts
|
||||
- name: Build containers
|
||||
run: |
|
||||
docker build -f Tiku.Api/Dockerfile -t tiku-api:ci .
|
||||
docker build -f Tiku.Worker/Dockerfile -t tiku-worker:ci .
|
||||
- name: Verify clean diff formatting
|
||||
run: git diff --check
|
||||
- name: Stop development dependencies
|
||||
if: always()
|
||||
run: docker compose down -v
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -32,6 +32,7 @@ bld/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
@@ -372,4 +373,8 @@ FodyWeavers.xsd
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
.idea/
|
||||
.idea/
|
||||
|
||||
# Local runtime configuration. Keep the documented template tracked.
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
33
AGENTS.md
33
AGENTS.md
@@ -2,37 +2,32 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`TIKU-BACKEND.slnx` groups production projects under `src` and tests under `tests`. `Tiku.Api` contains controllers, middleware, authentication, OpenAPI setup, and hosted background processing. `Tiku.Application` defines use cases and provider interfaces; `Tiku.Domain` owns entities and enums; `Tiku.Infrastructure` implements EF Core persistence and external providers. Use `Tiku.DbMigrator` for schema changes; PostgreSQL-backed background jobs run as hosted services in `Tiku.Api`. Tests live in `Tiku.UnitTests` and `Tiku.IntegrationTests`; architecture decisions and migration notes belong in `docs/`.
|
||||
`TIKU-BACKEND.slnx` is a .NET 10 modular monolith. `Tiku.Domain` holds entities; `Tiku.Application` defines use-case contracts; `Tiku.Infrastructure` contains EF Core, PostgreSQL, Redis, jobs, and external integrations. `Tiku.Api` and `Tiku.Worker` are independent runtimes; `Tiku.DbMigrator` owns migration and bootstrap work. Tests live in `Tiku.UnitTests` and `Tiku.IntegrationTests`. The React/TypeScript UI is in `Tiku.PlatformAdmin.Web`; documentation and deployment assets live under `docs/` and `deploy/`.
|
||||
|
||||
Keep dependencies pointed inward: Domain must remain infrastructure-free, Application expresses abstractions, and provider SDKs or secret access stay in Infrastructure.
|
||||
Keep dependencies flowing `Domain <- Application <- Infrastructure`. Database access, SDK integrations, and secret handling belong in Infrastructure, not controllers or domain types.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Run commands from the repository root:
|
||||
|
||||
```bash
|
||||
dotnet restore TIKU-BACKEND.slnx
|
||||
dotnet build TIKU-BACKEND.slnx --no-restore
|
||||
dotnet test TIKU-BACKEND.slnx --no-build
|
||||
dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
|
||||
dotnet run --project Tiku.Api
|
||||
dotnet run --project Tiku.DbMigrator
|
||||
```
|
||||
|
||||
The API and migrator require PostgreSQL through `ConnectionStrings:Database` or `DATABASE_URL`. Generate reviewable migration SQL with `dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator`. Also run `git diff --check` before committing.
|
||||
- `dotnet restore TIKU-BACKEND.slnx` — restore centrally managed NuGet packages.
|
||||
- `dotnet build TIKU-BACKEND.slnx --no-restore` — compile the complete solution.
|
||||
- `ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator` — migrate and seed the local PostgreSQL database.
|
||||
- `dotnet run --project Tiku.Api` / `dotnet run --project Tiku.Worker` — start the API or background processor.
|
||||
- `dotnet test TIKU-BACKEND.slnx --no-build` — run all xUnit tests.
|
||||
- `dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore` — enforce C# formatting.
|
||||
- `npm --prefix Tiku.PlatformAdmin.Web run check` — type-check, build, and run Vitest tests.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use standard C# formatting: four-space indentation, file-scoped namespaces, nullable reference types, and implicit usings. Use PascalCase for types and public members, camelCase for locals and parameters, and prefix interfaces with `I`. Keep async methods suffixed `Async`. Database identifiers are mapped to `snake_case`; do not bypass centralized EF configurations or tenant safeguards.
|
||||
Use four-space indentation in C# and two spaces in TypeScript. Nullable reference types and implicit usings are enabled. Use PascalCase for public C# symbols, camelCase for locals, and `Async` for asynchronous methods. Prefer feature-oriented folders and focused services. Run `dotnet format` and `git diff --check` before review. Refresh generated OpenAPI clients with `npm --prefix Tiku.PlatformAdmin.Web run generate:api` when contracts change.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use xUnit and follow `*Tests.cs`; test methods use descriptive behavior names such as `Password_login_can_access_current_user_and_tenant`. Add focused unit tests for isolated logic and integration tests for API, EF model, migration, authorization, and tenant-isolation behavior. PostgreSQL-specific invariants must be proven against real PostgreSQL, not only the in-memory provider. No numeric coverage threshold is configured, but every behavior change needs regression coverage.
|
||||
Name xUnit files `*Tests.cs`; descriptive underscore-style method names are established. Name frontend tests `*.test.ts`. Add unit tests for isolated rules and real PostgreSQL integration tests for migrations, transactions, constraints, authorization, and tenant isolation; EF InMemory is insufficient for PostgreSQL behavior. No fixed coverage threshold is configured, but every behavior change should include regression coverage.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
History follows Conventional Commit subjects such as `feat:`, `fix:`, `refactor(api):`, `docs:`, and `chore:`. Keep commits narrow and imperative. Pull requests should explain the behavior and architectural impact, link the issue or migration phase, identify schema/configuration changes, and list verification commands. Include screenshots only for generated API documentation or other visible output; never commit credentials or temporary bootstrap passwords.
|
||||
History follows Conventional Commits, for example `feat(learning): ...`, `fix(auth): ...`, and `docs: ...`. Keep commits scoped and exclude secrets, build output, and unrelated generated files. Pull requests should explain intent and risk, link the issue, list executed validation, call out migrations or configuration changes, and include screenshots for UI changes.
|
||||
|
||||
## Agent-Specific Instructions
|
||||
## Security & Configuration
|
||||
|
||||
When `.codegraph/` exists, use `codegraph explore "<question or symbol>"` before text search for code discovery. Treat tenant context, authorization, migrations, and provider boundaries as security-sensitive changes requiring targeted integration tests.
|
||||
Never commit connection strings, passwords, tokens, or production keys. Use environment variables such as `ConnectionStrings__Database`. API startup does not apply migrations; run `Tiku.DbMigrator` explicitly. Preserve Host-based tenant resolution and fail-closed authorization behavior when changing middleware or caching.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
|
||||
<PropertyGroup>
|
||||
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
|
||||
<PropertyGroup>
|
||||
|
||||
|
||||
</PropertyGroup>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -33,29 +33,34 @@
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="3.0.17" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="3.1.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.OpenTelemetry" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson" Version="2.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
|
||||
<PackageVersion Include="Minio" Version="7.0.0" />
|
||||
<PackageVersion Include="AlibabaCloud.OSS.V2" Version="0.2.0" />
|
||||
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.4.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||
<PackageVersion Include="Senparc.Weixin" Version="6.25.0" />
|
||||
<PackageVersion Include="Senparc.Weixin.MP" Version="16.25.1" />
|
||||
<PackageVersion Include="Senparc.Weixin.TenPayV3" Version="2.0.0" />
|
||||
<PackageVersion Include="Senparc.Weixin.WxOpen" Version="3.28.1" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.17" />
|
||||
<PackageVersion Include="Senparc.Weixin" Version="6.25.1" />
|
||||
<PackageVersion Include="Senparc.Weixin.MP" Version="16.25.2" />
|
||||
<PackageVersion Include="Senparc.Weixin.TenPayV3" Version="2.6.1" />
|
||||
<PackageVersion Include="Senparc.Weixin.WxOpen" Version="3.28.2" />
|
||||
<PackageVersion Include="AlipaySDKNet.Standard" Version="4.9.1234" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.19.2" />
|
||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.22.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageVersion>
|
||||
|
||||
47
README.md
47
README.md
@@ -1,6 +1,6 @@
|
||||
# TIKU Backend
|
||||
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据,由同一个 API 进程提供平台端、租户端和学生端接口并处理后台任务。
|
||||
TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core 管理 PostgreSQL 数据。`Tiku.Api` 提供平台端、租户端和学生端接口,`Tiku.Worker` 独立处理周期任务与 PostgreSQL 后台任务。
|
||||
|
||||

|
||||
|
||||
@@ -10,7 +10,7 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
|
||||
- Entity Framework Core 10 + Npgsql 10 + PostgreSQL
|
||||
- ASP.NET Core Identity + RSA JWT + 数据库存储的 Session
|
||||
- Scalar + OpenAPI(仅 Development 暴露)
|
||||
- Redis(安全频控、Feature 缓存和生产输出缓存)
|
||||
- FusionCache(业务 L1/L2 与跨节点失效)+ Redis(安全频控、授权缓存和生产输出缓存)
|
||||
- PostgreSQL 后台任务队列、租约和重试
|
||||
- Serilog + OpenTelemetry
|
||||
- xUnit 单元测试和真实 PostgreSQL 集成测试
|
||||
@@ -18,7 +18,8 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
|
||||
## 解决方案结构
|
||||
|
||||
```text
|
||||
Tiku.Api HTTP API、中间件、认证授权、OpenAPI/Scalar 和 Hosted Service
|
||||
Tiku.Api HTTP API、中间件、认证授权和 OpenAPI/Scalar
|
||||
Tiku.Worker 域名、订阅、用量校准、导出和安全扫描等后台处理
|
||||
Tiku.Application 用例契约、应用服务接口和安全上下文
|
||||
Tiku.Domain 领域实体、枚举和值对象
|
||||
Tiku.Infrastructure EF Core、PostgreSQL、认证、后台任务和外部服务实现
|
||||
@@ -27,34 +28,54 @@ Tiku.UnitTests 单元测试
|
||||
Tiku.IntegrationTests API、授权、EF 模型、迁移和真实 PostgreSQL 测试
|
||||
```
|
||||
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 是唯一运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
依赖方向固定为:`Domain <- Application <- Infrastructure`。`Api` 与 `Worker` 是彼此独立的运行时组合根,`DbMigrator` 是部署时迁移入口;第三方 SDK、数据库访问和密钥处理只放在 Infrastructure。
|
||||
|
||||
## 快速启动
|
||||
|
||||
需要 .NET 10 SDK 和 PostgreSQL。Development 默认连接本机 `tiku` 数据库,也可以通过 `DATABASE_URL` 覆盖。
|
||||
需要 .NET 10 SDK、Node.js 24+ 和 Docker Desktop。本地 PostgreSQL、Redis 与 S3 兼容对象存储统一由根目录的 `compose.yaml` 提供:
|
||||
|
||||
```bash
|
||||
createdb -h 127.0.0.1 -U "$(whoami)" tiku
|
||||
cp .env.example .env
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
docker compose up -d --wait
|
||||
|
||||
dotnet restore TIKU-BACKEND.slnx
|
||||
dotnet build TIKU-BACKEND.slnx --no-restore
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run --project Tiku.DbMigrator
|
||||
dotnet run --project Tiku.Api
|
||||
dotnet run --project Tiku.DbMigrator
|
||||
dotnet run --project Tiku.Api --launch-profile http
|
||||
```
|
||||
|
||||
Development 首次迁移会创建平台管理员 `admin@tiku.local`,随机临时密码只在 DbMigrator 首次运行的终端输出。完整步骤见[本地开发与运行](docs/quickstart.md)。
|
||||
另开终端启动 Worker 时必须再次导入 `.env`:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
dotnet run --project Tiku.Worker
|
||||
```
|
||||
|
||||
必须先从 `.env.example` 创建本地 `.env`,并在每个运行 .NET 的新终端中导入;否则 DbMigrator、API 和 Worker 可能连接到错误的 PostgreSQL/Redis。`.env` 不进入 Git。`docker compose ps` 应显示 PostgreSQL、Redis 与 MinIO 均为 `healthy`。Development 未配置阿里云 OSS 时,`local_dev` Provider 会把对象实际保存到 MinIO,而不是返回占位结果。默认账号和密码只用于本机开发,不能用于共享或生产环境。完整配置、内网访问、停止和排障步骤见[本地开发快速上手](docs/quickstart.md)。
|
||||
|
||||
默认 Development seed 会在尚无平台角色绑定时创建平台管理员 `admin@tiku.local` 和演示数据;随机临时密码只在首次创建时输出。日常步骤见[本地开发快速上手](docs/quickstart.md),不含演示数据的完整 SaaS 验收见[空数据库到租户建站验收](docs/tenant-provisioning.md)。
|
||||
|
||||
默认开发入口:
|
||||
|
||||
- 平台管理端:首次在 `Tiku.PlatformAdmin.Web` 执行 `npm install`;之后启动 `Tiku.Api` 时会在 Development 自动启动前端,访问 <http://localhost:5173>
|
||||
- Scalar:<http://localhost:5090/scalar/v1>
|
||||
- OpenAPI:<http://localhost:5090/openapi/v1.json>
|
||||
- Liveness:<http://localhost:5090/api/health>
|
||||
- Readiness:<http://localhost:5090/api/health/ready>
|
||||
- Liveness:<http://localhost:5090/api/system/health>
|
||||
- Readiness:<http://localhost:5090/api/system/health/ready>
|
||||
|
||||
## 运行时边界
|
||||
|
||||
- API 不自动执行数据库迁移;部署和本地初始化都使用 `Tiku.DbMigrator`。
|
||||
- API 不运行后台循环;生产环境必须独立部署至少一个 `Tiku.Worker` 实例。
|
||||
- 多 Worker 实例通过 PostgreSQL advisory lock、任务租约和 `FOR UPDATE SKIP LOCKED` 协调。
|
||||
- Development 可不配置 Redis;Production 缺少 Redis 时 API 会拒绝启动。
|
||||
- 租户目录、Feature 快照和运行时配置使用独立的 `TikuBusiness` FusionCache;无 Redis 时退化为进程内 L1,Redis 不是业务事实源。
|
||||
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code` 或 `tenantCode` 指定租户。
|
||||
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
|
||||
- 普通请求默认要求认证;匿名接口必须显式声明 `[AllowAnonymous]`。
|
||||
@@ -80,8 +101,10 @@ PostgreSQL 特有的迁移、事务、约束和跨租户不变量必须由 `Tiku
|
||||
当前文档统一从[文档总览](docs/README.md)进入:
|
||||
|
||||
- [系统架构与业务边界](docs/architecture/overview.md)
|
||||
- [模块边界与所有权](docs/architecture/module-boundaries.md)
|
||||
- [认证、授权与租户隔离](docs/architecture/security-and-tenancy.md)
|
||||
- [配置与后台任务](docs/operations.md)
|
||||
- [本地开发与运行](docs/quickstart.md)
|
||||
- [本地开发快速上手](docs/quickstart.md)
|
||||
- [空数据库到租户建站验收](docs/tenant-provisioning.md)
|
||||
|
||||
接口、DTO、请求参数和响应模型以运行时 OpenAPI/Scalar 为准;文档不再维护手写接口清单或迁移过程记录。
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="Tiku.Api/Tiku.Api.csproj" />
|
||||
<Project Path="Tiku.Application/Tiku.Application.csproj" />
|
||||
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj" />
|
||||
<Project Path="Tiku.Domain/Tiku.Domain.csproj" />
|
||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="Tiku.IntegrationTests/Tiku.IntegrationTests.csproj" />
|
||||
<Project Path="Tiku.UnitTests/Tiku.UnitTests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="Tiku.Api/Tiku.Api.csproj"/>
|
||||
<Project Path="Tiku.Application/Tiku.Application.csproj"/>
|
||||
<Project Path="Tiku.DbMigrator/Tiku.DbMigrator.csproj"/>
|
||||
<Project Path="Tiku.Domain/Tiku.Domain.csproj"/>
|
||||
<Project Path="Tiku.Infrastructure/Tiku.Infrastructure.csproj"/>
|
||||
<Project Path="Tiku.Worker/Tiku.Worker.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="Tiku.IntegrationTests/Tiku.IntegrationTests.csproj"/>
|
||||
<Project Path="Tiku.UnitTests/Tiku.UnitTests.csproj"/>
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace Tiku.Api;
|
||||
|
||||
public sealed class ApiProgramMarker;
|
||||
public sealed class ApiProgramMarker;
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Background;
|
||||
|
||||
internal sealed class DevelopmentTenantDomainLifecycleHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
ILogger<DevelopmentTenantDomainLifecycleHostedService> logger) : BackgroundService
|
||||
{
|
||||
private readonly DomainLifecycleOptions options = domainOptions.Value;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!options.EnableDevelopmentLocalhostBypass) return;
|
||||
|
||||
var activeInterval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600));
|
||||
var maxIdleInterval = TimeSpan.FromSeconds(Math.Clamp(
|
||||
options.MaxIdlePollSeconds,
|
||||
(int)activeInterval.TotalSeconds,
|
||||
3600));
|
||||
var consecutiveIdleIterations = 0;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Development .localhost domain lifecycle");
|
||||
var processed = await scope.ServiceProvider
|
||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(stoppingToken);
|
||||
consecutiveIdleIterations = processed == 0 ? consecutiveIdleIterations + 1 : 0;
|
||||
if (processed > 0)
|
||||
logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
consecutiveIdleIterations++;
|
||||
logger.LogError(exception, "Development tenant domain lifecycle iteration failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(
|
||||
CalculateDelay(activeInterval, maxIdleInterval, consecutiveIdleIterations),
|
||||
stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal static TimeSpan CalculateDelay(
|
||||
TimeSpan activeInterval,
|
||||
TimeSpan maxIdleInterval,
|
||||
int consecutiveIdleIterations)
|
||||
{
|
||||
if (consecutiveIdleIterations <= 1) return activeInterval;
|
||||
|
||||
var shift = Math.Min(consecutiveIdleIterations - 1, 20);
|
||||
var delayTicks = activeInterval.Ticks * (1L << shift);
|
||||
return TimeSpan.FromTicks(Math.Min(delayTicks, maxIdleInterval.Ticks));
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.BackgroundProcessing;
|
||||
|
||||
public sealed class BackgroundProcessingOptions
|
||||
{
|
||||
public const string SectionName = "BackgroundProcessing";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int JobPollSeconds { get; set; } = 2;
|
||||
public int JobParallelism { get; set; } = 4;
|
||||
public int JobBatchSize { get; set; } = 5;
|
||||
|
||||
public static bool BeValid(BackgroundProcessingOptions options) =>
|
||||
options.JobPollSeconds is >= 1 and <= 3600 &&
|
||||
options.JobParallelism is >= 1 and <= 32 &&
|
||||
options.JobBatchSize is >= 1 and <= 100;
|
||||
}
|
||||
|
||||
internal abstract class PeriodicBackgroundService(
|
||||
ILogger logger,
|
||||
TimeSpan interval,
|
||||
bool enabled) : BackgroundService
|
||||
{
|
||||
protected abstract Task<int> ProcessAsync(CancellationToken cancellationToken);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
if (processed > 0)
|
||||
{
|
||||
logger.LogInformation("{Worker} processed {Count} items.", GetType().Name, processed);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "{Worker} iteration failed.", GetType().Name);
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static void InitializeSystem(IServiceProvider services, string reason) =>
|
||||
services.GetRequiredService<ITenantContextInitializer>().InitializeSystem(null, reason);
|
||||
}
|
||||
|
||||
internal sealed class TenantDomainBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DomainLifecycleOptions> domainOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<TenantDomainBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromSeconds(Math.Clamp(domainOptions.Value.PollSeconds, 10, 3600)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant domain DNS and TLS lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasSubscriptionBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<SaasSubscriptionBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(60), options.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "SaaS subscription lifecycle background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<ISaasSubscriptionLifecycleService>()
|
||||
.ProcessDueAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FeatureUsageBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<FeatureUsageReconciliationOptions> featureOptions,
|
||||
IOptions<BackgroundProcessingOptions> backgroundOptions,
|
||||
ILogger<FeatureUsageBackgroundService> logger)
|
||||
: PeriodicBackgroundService(
|
||||
logger,
|
||||
TimeSpan.FromMinutes(Math.Clamp(featureOptions.Value.IntervalMinutes, 1, 1440)),
|
||||
backgroundOptions.Value.Enabled)
|
||||
{
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Tenant feature usage reconciliation background service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ProcessDueAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BackgroundJobsBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<BackgroundProcessingOptions> options,
|
||||
ILogger<BackgroundJobsBackgroundService> logger)
|
||||
: PeriodicBackgroundService(logger, TimeSpan.FromSeconds(options.Value.JobPollSeconds), options.Value.Enabled)
|
||||
{
|
||||
private readonly string workerId = $"{Environment.MachineName}:{Guid.NewGuid():N}";
|
||||
private readonly int parallelism = options.Value.JobParallelism;
|
||||
private readonly int batchSize = options.Value.JobBatchSize;
|
||||
|
||||
protected override async Task<int> ProcessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var workers = Enumerable.Range(0, parallelism)
|
||||
.Select(index => ProcessPartitionAsync(index, cancellationToken));
|
||||
return (await Task.WhenAll(workers)).Sum();
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPartitionAsync(int index, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
InitializeSystem(scope.ServiceProvider, "Background job lease service");
|
||||
return await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
|
||||
.ProcessPendingAsync(
|
||||
$"{workerId}:{index}",
|
||||
batchSize,
|
||||
includeImmediateJobs: true,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,13 @@ namespace Tiku.Api.Caching;
|
||||
internal sealed class TenantPublicCacheInvalidator(IOutputCacheStore outputCacheStore)
|
||||
: ITenantPublicCacheInvalidator
|
||||
{
|
||||
public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) =>
|
||||
outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken);
|
||||
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await InvalidateCoreAsync(tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
return outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,10 @@ internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) =>
|
||||
ValueTask.CompletedTask;
|
||||
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -39,10 +41,8 @@ internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy
|
||||
if (response.StatusCode != StatusCodes.Status200OK ||
|
||||
!StringValues.IsNullOrEmpty(response.Headers.SetCookie) ||
|
||||
context.HttpContext.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
context.AllowCacheStorage = false;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Api.Security;
|
||||
|
||||
@@ -9,7 +10,7 @@ internal static class ApiPresentationExtensions
|
||||
internal static IServiceCollection AddApiPresentation(this IServiceCollection services)
|
||||
{
|
||||
services.AddControllers(options =>
|
||||
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
|
||||
options.Conventions.Add(new EndpointAuthorizationMetadataConvention()))
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
@@ -17,8 +18,19 @@ internal static class ApiPresentationExtensions
|
||||
services.AddOpenApi(options =>
|
||||
{
|
||||
options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
|
||||
options.AddOperationTransformer<PlatformOperationMetadataTransformer>();
|
||||
options.AddOperationTransformer<AuthenticationOperationTagsTransformer>();
|
||||
});
|
||||
services.AddProblemDetails();
|
||||
services.AddScoped<TenantAdminActorResolver>();
|
||||
services.AddScoped<DirectContentActorResolver>();
|
||||
services.AddScoped<TenantContentCapabilitySet>();
|
||||
services.AddScoped<CommerceAdminActorResolver>();
|
||||
services.AddScoped<CommerceRequestContextResolver>();
|
||||
services.AddScoped<LearningActorResolver>();
|
||||
services.AddScoped<PlatformAdminActorResolver>();
|
||||
services.AddScoped<AuthRequestContextResolver>();
|
||||
services.AddScoped<AuthCapabilitySet>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -42,4 +42,4 @@ public static class ApplicationBuilderExtensions
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Api.Security;
|
||||
@@ -98,9 +99,7 @@ internal static class AuthenticationExtensions
|
||||
if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) &&
|
||||
IsSameOriginBrowserRequest(context.Request) &&
|
||||
context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnTokenValidated = ValidateTokenAsync,
|
||||
@@ -111,16 +110,11 @@ internal static class AuthenticationExtensions
|
||||
private static bool IsSameOriginBrowserRequest(HttpRequest request)
|
||||
{
|
||||
var source = request.Headers.Origin.ToString();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
source = request.Headers.Referer.ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(source)) source = request.Headers.Referer.ToString();
|
||||
|
||||
if (Uri.TryCreate(source, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
request.Headers["Sec-Fetch-Site"].ToString(),
|
||||
@@ -154,14 +148,14 @@ internal static class AuthenticationExtensions
|
||||
var tenantId = Guid.TryParse(tenantIdValue, out var parsedTenantId)
|
||||
? parsedTenantId
|
||||
: (Guid?)null;
|
||||
if (realm is null || (realm == AuthRealm.Tenant) != tenantId.HasValue)
|
||||
if (realm is null || realm == AuthRealm.Tenant != tenantId.HasValue)
|
||||
{
|
||||
context.Fail("Token scope and tenant claims are inconsistent.");
|
||||
return;
|
||||
}
|
||||
|
||||
var resolutionOptions = context.HttpContext.RequestServices
|
||||
.GetRequiredService<Microsoft.Extensions.Options.IOptions<TenantResolutionOptions>>().Value;
|
||||
.GetRequiredService<IOptions<TenantResolutionOptions>>().Value;
|
||||
var requestHost = context.HttpContext.Request.Host.Host.Trim().TrimEnd('.');
|
||||
var isPlatformHost = resolutionOptions.PlatformHosts.Any(host =>
|
||||
string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase));
|
||||
@@ -199,8 +193,8 @@ internal static class AuthenticationExtensions
|
||||
}
|
||||
}
|
||||
|
||||
var sessionStore = context.HttpContext.RequestServices.GetRequiredService<IAuthSessionStore>();
|
||||
var session = await sessionStore.ValidateAccessSessionAsync(
|
||||
var accessValidator = context.HttpContext.RequestServices.GetRequiredService<IRequestAccessValidator>();
|
||||
var session = await accessValidator.ValidateAsync(
|
||||
sessionId,
|
||||
userId,
|
||||
realm.Value,
|
||||
@@ -218,10 +212,7 @@ internal static class AuthenticationExtensions
|
||||
|
||||
private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context)
|
||||
{
|
||||
if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!context.HttpContext.Items.ContainsKey("tenant_context_conflict")) return;
|
||||
|
||||
context.HandleResponse();
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
@@ -232,4 +223,4 @@ internal static class AuthenticationExtensions
|
||||
Extensions = { ["code"] = "tenant_context_conflict" }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,20 +25,15 @@ internal static class DataProtectionExtensions
|
||||
.Get<DataProtectionKeyRingOptions>() ?? new DataProtectionKeyRingOptions();
|
||||
ApplyEnvironmentOverrides(keyRingOptions, configuration);
|
||||
if (!DataProtectionKeyRingOptions.BeValid(keyRingOptions, requireProtectedKeys))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Data Protection requires an application name and, outside Development, an X509 certificate path.");
|
||||
}
|
||||
|
||||
var dataProtection = services
|
||||
.AddDataProtection()
|
||||
.SetApplicationName(keyRingOptions.ApplicationName.Trim())
|
||||
.PersistKeysToDbContext<TikuDbContext>();
|
||||
var certificate = keyRingOptions.LoadCertificate(requireProtectedKeys);
|
||||
if (certificate is not null)
|
||||
{
|
||||
dataProtection.ProtectKeysWithCertificate(certificate);
|
||||
}
|
||||
if (certificate is not null) dataProtection.ProtectKeysWithCertificate(certificate);
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -54,4 +49,4 @@ internal static class DataProtectionExtensions
|
||||
options.CertificatePassword =
|
||||
configuration["TIKU_DATA_PROTECTION_CERTIFICATE_PASSWORD"] ?? options.CertificatePassword;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
using Serilog;
|
||||
using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Api.Caching;
|
||||
using Tiku.Api.BackgroundProcessing;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Serilog;
|
||||
using Tiku.Api.Caching;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Caching;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -18,38 +18,45 @@ public static class DependencyInjection
|
||||
public static WebApplicationBuilder AddApiServices(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.AddSerilog((services, configuration) => configuration
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext(),
|
||||
preserveStaticLogger: true);
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext(),
|
||||
true);
|
||||
|
||||
builder.Services.AddApiPresentation();
|
||||
builder.Services.AddExceptionProblemDetailsMappers();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||
|
||||
var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString(
|
||||
var connectionString = OptionsValidation.ResolveDatabaseConnectionString(
|
||||
builder.Configuration,
|
||||
builder.Environment.IsDevelopment());
|
||||
builder.Services.AddInfrastructure(connectionString);
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||
var redisConnectionString =
|
||||
builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"];
|
||||
builder.Services.AddOptions<RedisSecurityConnectionOptions>()
|
||||
.Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty)
|
||||
.Validate(
|
||||
options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString),
|
||||
"Redis is required in Production.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<AuthorizationCacheOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AuthorizationCacheOptions.SectionName))
|
||||
.Validate(options => options.LocalSnapshotSeconds > 0 && options.DistributedStateSeconds > 0 &&
|
||||
options.DistributedSnapshotSeconds > 0 && options.JitterPercent is >= 0 and <= 50,
|
||||
"Authorization cache durations must be positive and jitter must be between 0 and 50 percent.")
|
||||
.ValidateOnStart();
|
||||
if (!string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName);
|
||||
}
|
||||
else if (builder.Environment.IsProduction())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||
}
|
||||
builder.Services.AddBusinessCaching(
|
||||
builder.Environment.EnvironmentName,
|
||||
!string.IsNullOrWhiteSpace(redisConnectionString));
|
||||
|
||||
builder.Services.AddOutputCache(options =>
|
||||
{
|
||||
@@ -59,13 +66,11 @@ public static class DependencyInjection
|
||||
builder.Services.RemoveAll<ITenantPublicCacheInvalidator>();
|
||||
builder.Services.AddSingleton<ITenantPublicCacheInvalidator, TenantPublicCacheInvalidator>();
|
||||
if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddStackExchangeRedisOutputCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnectionString;
|
||||
options.InstanceName = $"tiku:{builder.Environment.EnvironmentName.ToLowerInvariant()}:output:";
|
||||
});
|
||||
}
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
@@ -73,23 +78,9 @@ public static class DependencyInjection
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
|
||||
options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.AddOptions<BackgroundProcessingOptions>()
|
||||
.Bind(builder.Configuration.GetSection(BackgroundProcessingOptions.SectionName))
|
||||
.Validate(BackgroundProcessingOptions.BeValid, "Background processing settings are invalid.")
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("TenantDomains"));
|
||||
builder.Services.AddOptions<SaasSubscriptionLifecycleOptions>()
|
||||
.Bind(builder.Configuration.GetSection("SaasSubscriptions"));
|
||||
builder.Services.AddOptions<FeatureUsageReconciliationOptions>()
|
||||
.Bind(builder.Configuration.GetSection("FeatureUsageReconciliation"));
|
||||
builder.Services.AddHostedService<TenantDomainBackgroundService>();
|
||||
builder.Services.AddHostedService<SaasSubscriptionBackgroundService>();
|
||||
builder.Services.AddHostedService<FeatureUsageBackgroundService>();
|
||||
builder.Services.AddHostedService<BackgroundJobsBackgroundService>();
|
||||
|
||||
builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiAuthenticationAndAuthorization(builder.Configuration, builder.Environment);
|
||||
|
||||
29
Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs
Normal file
29
Tiku.Api/Configuration/ExceptionProblemDetailsExtensions.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.Modules.Platform.Errors;
|
||||
using Tiku.Api.Modules.Student.Content.Errors;
|
||||
using Tiku.Api.Modules.Student.Learning.Errors;
|
||||
using Tiku.Api.Modules.System.Auth.Errors;
|
||||
using Tiku.Api.Modules.System.Jobs.Errors;
|
||||
using Tiku.Api.Modules.System.Storage.Errors;
|
||||
using Tiku.Api.Modules.Tenant.Commerce.Errors;
|
||||
using Tiku.Api.Modules.Tenant.Management.Errors;
|
||||
using Tiku.Api.Modules.Tenant.Tenancy.Errors;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
internal static class ExceptionProblemDetailsExtensions
|
||||
{
|
||||
internal static IServiceCollection AddExceptionProblemDetailsMappers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, AuthExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, TenancyExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, JobsExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, ContentExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, LearningExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, CommerceExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, TenantAdminExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, PlatformExceptionProblemDetailsMapper>();
|
||||
services.AddSingleton<IExceptionProblemDetailsMapper, StorageExceptionProblemDetailsMapper>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Commerce;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
|
||||
@@ -15,9 +18,15 @@ internal static class ExternalServiceOptionsExtensions
|
||||
configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
services.Configure<AliyunOssOptions>(
|
||||
configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
services.Configure<S3CompatibleOptions>(
|
||||
configuration.GetSection(S3CompatibleOptions.SectionName));
|
||||
services.PostConfigure<ObjectStorageOptions>(options =>
|
||||
{
|
||||
options.DefaultProvider = configuration["STORAGE_DEFAULT_PROVIDER"] ?? options.DefaultProvider;
|
||||
if (!environment.IsProduction() &&
|
||||
options.DefaultProvider == ObjectStorageProviders.AliyunOss &&
|
||||
!HasConfiguredAliyunOss(configuration))
|
||||
options.DefaultProvider = ObjectStorageProviders.LocalDev;
|
||||
options.DefaultBucket = configuration["STORAGE_DEFAULT_BUCKET"] ?? options.DefaultBucket;
|
||||
options.PublicBaseUrl = configuration["STORAGE_PUBLIC_BASE_URL"] ?? options.PublicBaseUrl;
|
||||
options.AllowedMimePrefixes = SplitLegacyList(
|
||||
@@ -50,6 +59,43 @@ internal static class ExternalServiceOptionsExtensions
|
||||
? useInternalEndpoint
|
||||
: options.UseInternalEndpoint;
|
||||
});
|
||||
services.PostConfigure<S3CompatibleOptions>(options =>
|
||||
{
|
||||
options.Endpoint = configuration["S3_ENDPOINT"] ?? options.Endpoint;
|
||||
options.AccessKey = configuration["S3_ACCESS_KEY"] ?? options.AccessKey;
|
||||
options.SecretKey = configuration["S3_SECRET_KEY"] ?? options.SecretKey;
|
||||
options.Region = configuration["S3_REGION"] ?? options.Region;
|
||||
options.Secure = bool.TryParse(configuration["S3_SECURE"], out var secure)
|
||||
? secure
|
||||
: options.Secure;
|
||||
});
|
||||
services.AddOptions<ObjectStorageOptions>()
|
||||
.Validate(
|
||||
options => !environment.IsProduction() ||
|
||||
options.DefaultProvider == ObjectStorageProviders.AliyunOss,
|
||||
"Production managed storage must use the configured Aliyun OSS provider.")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<AliyunOssOptions>()
|
||||
.Validate<IOptions<ObjectStorageOptions>>(
|
||||
(aliyun, storage) =>
|
||||
!environment.IsProduction() ||
|
||||
storage.Value.DefaultProvider != ObjectStorageProviders.AliyunOss ||
|
||||
aliyun.IsConfigured,
|
||||
"Aliyun OSS credentials and region or endpoint are required when it is the default provider.")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<S3CompatibleOptions>()
|
||||
.Validate<IOptions<ObjectStorageOptions>>(
|
||||
(s3, storage) =>
|
||||
storage.Value.DefaultProvider != ObjectStorageProviders.LocalDev || s3.IsConfigured,
|
||||
"S3-compatible endpoint and credentials are required when local_dev is the default provider.")
|
||||
.ValidateOnStart();
|
||||
services.AddOptions<ClamAvOptions>()
|
||||
.Bind(configuration.GetSection(ClamAvOptions.SectionName))
|
||||
.Validate(ClamAvOptions.BeValid, "ClamAV settings are invalid.")
|
||||
.Validate<IOptions<ObjectStorageOptions>>(
|
||||
(clamAv, storage) => clamAv.StreamMaxLength >= storage.Value.MaxUploadBytes,
|
||||
"ClamAV StreamMaxLength must be greater than or equal to the storage max upload size.")
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<TenantSecretEncryptionOptions>()
|
||||
.Bind(configuration.GetSection(TenantSecretEncryptionOptions.SectionName))
|
||||
@@ -82,8 +128,22 @@ internal static class ExternalServiceOptionsExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
private static string[] SplitLegacyList(string? value, string[] fallback) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
private static string[] SplitLegacyList(string? value, string[] fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
private static bool HasConfiguredAliyunOss(IConfiguration configuration)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_ID"] ??
|
||||
configuration["Storage:AliyunOss:AccessKeyId"]) &&
|
||||
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ACCESS_KEY_SECRET"] ??
|
||||
configuration["Storage:AliyunOss:AccessKeySecret"]) &&
|
||||
(!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_REGION"] ??
|
||||
configuration["Storage:AliyunOss:Region"]) ||
|
||||
!string.IsNullOrWhiteSpace(configuration["ALIYUN_OSS_ENDPOINT"] ??
|
||||
configuration["Storage:AliyunOss:Endpoint"]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Tiku.Api.Background;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
@@ -15,7 +17,8 @@ internal static class NetworkConfigurationExtensions
|
||||
services.AddOptions<TenantResolutionOptions>()
|
||||
.Bind(configuration.GetSection(TenantResolutionOptions.SectionName))
|
||||
.Validate(
|
||||
options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()),
|
||||
options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration,
|
||||
environment.IsProduction()),
|
||||
"Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.")
|
||||
.ValidateOnStart();
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@@ -32,15 +35,35 @@ internal static class NetworkConfigurationExtensions
|
||||
.GetSection(TenantResolutionOptions.SectionName)
|
||||
.Get<TenantResolutionOptions>() ?? new TenantResolutionOptions();
|
||||
foreach (var address in resolution.TrustedProxyAddresses)
|
||||
{
|
||||
if (IPAddress.TryParse(address, out var proxy))
|
||||
{
|
||||
options.KnownProxies.Add(proxy);
|
||||
}
|
||||
}
|
||||
});
|
||||
services.AddOptions<DomainLifecycleOptions>()
|
||||
.Bind(configuration.GetSection("TenantDomains"));
|
||||
.Bind(configuration.GetSection("TenantDomains"))
|
||||
.Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass,
|
||||
"The .localhost domain lifecycle bypass can only be enabled in Development.")
|
||||
.Validate(options => options.PollSeconds is >= 1 and <= 3600 &&
|
||||
options.MaxIdlePollSeconds is >= 1 and <= 3600 &&
|
||||
options.MaxIdlePollSeconds >= options.PollSeconds,
|
||||
"Tenant domain polling intervals are invalid.")
|
||||
.ValidateOnStart();
|
||||
if (environment.IsDevelopment()) services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>();
|
||||
services.AddOptions<TenantProvisioningOptions>()
|
||||
.Bind(configuration.GetSection(TenantProvisioningOptions.SectionName))
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.DefaultBaseOfferingCode) &&
|
||||
options.DefaultTrialDays is >= 1 and <= 365 &&
|
||||
options.OwnerActivationMinutes is >= 5 and <= 1440 &&
|
||||
options.OwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate(
|
||||
options.OwnerActivationUrlTemplate.Replace("{host}", "tenant.example.com",
|
||||
StringComparison.Ordinal),
|
||||
UriKind.Absolute,
|
||||
out var activationOrigin) &&
|
||||
activationOrigin.Scheme == Uri.UriSchemeHttps &&
|
||||
ValidateDevelopmentActivationTemplate(options, environment.IsDevelopment()),
|
||||
"Tenant provisioning requires a default offering code, valid trial/activation durations, an HTTPS owner activation URL template, and permits an HTTP template only for Development .localhost sites.")
|
||||
.ValidateOnStart();
|
||||
if (environment.IsProduction()) services.AddHostedService<TenantProvisioningStartupValidator>();
|
||||
|
||||
services.AddOptions<CorsOptions>()
|
||||
.Bind(configuration.GetSection(CorsOptions.SectionName))
|
||||
@@ -63,22 +86,33 @@ internal static class NetworkConfigurationExtensions
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (origins.Length > 0)
|
||||
{
|
||||
policy.WithOrigins(origins);
|
||||
}
|
||||
if (origins.Length > 0) policy.WithOrigins(origins);
|
||||
|
||||
policy
|
||||
.WithHeaders(corsOptions.AllowedHeaders)
|
||||
.WithMethods(corsOptions.AllowedMethods);
|
||||
|
||||
if (corsOptions.AllowCredentials)
|
||||
{
|
||||
policy.AllowCredentials();
|
||||
}
|
||||
if (corsOptions.AllowCredentials) policy.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static bool ValidateDevelopmentActivationTemplate(
|
||||
TenantProvisioningOptions options,
|
||||
bool isDevelopment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.DevelopmentLocalhostOwnerActivationUrlTemplate)) return true;
|
||||
|
||||
return isDevelopment &&
|
||||
options.DevelopmentLocalhostOwnerActivationUrlTemplate.Contains("{host}", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate(
|
||||
options.DevelopmentLocalhostOwnerActivationUrlTemplate.Replace(
|
||||
"{host}", "tenant.localhost", StringComparison.Ordinal),
|
||||
UriKind.Absolute,
|
||||
out var developmentOrigin) &&
|
||||
(developmentOrigin.Scheme == Uri.UriSchemeHttp ||
|
||||
developmentOrigin.Scheme == Uri.UriSchemeHttps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -17,29 +18,31 @@ internal static class ObservabilityExtensions
|
||||
|
||||
services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName: environment.ApplicationName,
|
||||
environment.ApplicationName,
|
||||
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddFusionCacheInstrumentation()
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health"))
|
||||
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSource("Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
|
||||
.ApplyIf(hasOtlpEndpoint,
|
||||
builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddFusionCacheInstrumentation()
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName,
|
||||
AuthorizationCacheTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Learning", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint,
|
||||
builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static TBuilder ApplyIf<TBuilder>(this TBuilder builder, bool condition, Action<TBuilder> configure)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
configure(builder);
|
||||
}
|
||||
if (condition) configure(builder);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
@@ -34,7 +33,6 @@ internal static class RateLimitingExtensions
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
if (rateLimitOptions.Enabled)
|
||||
{
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
|
||||
{
|
||||
var partitionKey =
|
||||
@@ -49,7 +47,6 @@ internal static class RateLimitingExtensions
|
||||
rateLimitOptions.QueueLimit,
|
||||
rateLimitOptions.WindowSeconds));
|
||||
});
|
||||
}
|
||||
|
||||
options.AddPolicy(
|
||||
AuthRateLimitPolicies.Password,
|
||||
@@ -76,8 +73,9 @@ internal static class RateLimitingExtensions
|
||||
private static FixedWindowRateLimiterOptions CreateLimiterOptions(
|
||||
int permitLimit,
|
||||
int queueLimit,
|
||||
int windowSeconds) =>
|
||||
new()
|
||||
int windowSeconds)
|
||||
{
|
||||
return new FixedWindowRateLimiterOptions
|
||||
{
|
||||
AutoReplenishment = true,
|
||||
PermitLimit = permitLimit,
|
||||
@@ -85,15 +83,14 @@ internal static class RateLimitingExtensions
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
Window = TimeSpan.FromSeconds(windowSeconds)
|
||||
};
|
||||
}
|
||||
|
||||
private static async ValueTask WriteRateLimitProblemAsync(
|
||||
OnRejectedContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||
{
|
||||
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
@@ -107,4 +104,4 @@ internal static class RateLimitingExtensions
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs
Normal file
27
Tiku.Api/Configuration/TenantProvisioningStartupValidator.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
internal sealed class TenantProvisioningStartupValidator(
|
||||
ITenantProvisioningReadinessProbe readinessProbe,
|
||||
IOptions<TenantProvisioningOptions> options,
|
||||
ILogger<TenantProvisioningStartupValidator> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var offeringCode = options.Value.DefaultBaseOfferingCode.Trim().ToLowerInvariant();
|
||||
var available = await readinessProbe.IsPublishedBaseOfferingAvailableAsync(offeringCode, cancellationToken);
|
||||
|
||||
if (!available)
|
||||
throw new InvalidOperationException(
|
||||
$"TenantProvisioning:DefaultBaseOfferingCode '{offeringCode}' does not resolve to an effective published base offering version.");
|
||||
|
||||
logger.LogInformation("Validated default tenant provisioning offering {OfferingCode}", offeringCode);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -7,25 +7,25 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问签名查询参数。
|
||||
/// 资产访问签名查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetAccessQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(1, 7200)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问签名响应。
|
||||
/// 资产访问签名响应。
|
||||
/// </summary>
|
||||
public sealed record AssetAccessResponseDto(
|
||||
ContentAssetAccessSummaryDto Item,
|
||||
@@ -47,7 +47,7 @@ public sealed record AssetAccessResponseDto(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内容资产访问摘要。
|
||||
/// 内容资产访问摘要。
|
||||
/// </summary>
|
||||
public sealed record ContentAssetAccessSummaryDto(
|
||||
Guid Id,
|
||||
@@ -76,7 +76,7 @@ public sealed record ContentAssetAccessSummaryDto(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问Principal请求 DTO。
|
||||
/// 资产访问Principal请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record AssetAccessPrincipalDto(
|
||||
Guid? UserId,
|
||||
@@ -84,7 +84,7 @@ public sealed record AssetAccessPrincipalDto(
|
||||
bool HasSvip);
|
||||
|
||||
/// <summary>
|
||||
/// SignedStorageUrl请求 DTO。
|
||||
/// SignedStorageUrl请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record SignedStorageUrlDto(
|
||||
string Provider,
|
||||
@@ -110,4 +110,4 @@ public sealed record SignedStorageUrlDto(
|
||||
(int)signedUrl.ExpiresIn.TotalSeconds,
|
||||
signedUrl.SignatureMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,82 +4,82 @@ using Tiku.Application.Assets;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资产查询参数。
|
||||
/// 资产查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? AssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含锁定资源。
|
||||
/// 是否包含锁定资源。
|
||||
/// </summary>
|
||||
public bool IncludeLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -102,4 +102,4 @@ public sealed class AssetQueryDto
|
||||
IncludeInactive,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,128 +6,128 @@ using Tiku.Domain.Common;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建资产上传签名请求。
|
||||
/// 创建资产上传签名请求。
|
||||
/// </summary>
|
||||
public sealed class AssetUploadSignDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? AssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(500)]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
[RegularExpression("^[A-Fa-f0-9]{64}$")]
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否公开。
|
||||
/// 是否公开。
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 存储桶。
|
||||
/// 存储桶。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储键。
|
||||
/// 对象存储键。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(1, 3600)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -159,30 +159,30 @@ public sealed class AssetUploadSignDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确认资产上传请求。
|
||||
/// 确认资产上传请求。
|
||||
/// </summary>
|
||||
public sealed class AssetUploadConfirmDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
[RegularExpression("^[A-Fa-f0-9]{64}$")]
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
@@ -194,116 +194,142 @@ public sealed class AssetUploadConfirmDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容资产请求。
|
||||
/// 新增或更新内容资产请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertAssetDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
public string? AssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CDN 地址。
|
||||
/// CDN 地址。
|
||||
/// </summary>
|
||||
public string? CdnUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否公开。
|
||||
/// 是否公开。
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
public string? Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 存储桶。
|
||||
/// 存储桶。
|
||||
/// </summary>
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储键。
|
||||
/// 对象存储键。
|
||||
/// </summary>
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预览地址。
|
||||
/// 预览地址。
|
||||
/// </summary>
|
||||
public string? PreviewUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 预览对象存储键。
|
||||
/// 预览对象存储键。
|
||||
/// </summary>
|
||||
public string? PreviewObjectKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -341,18 +367,18 @@ public sealed class UpsertAssetDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建资产访问签名请求。
|
||||
/// 创建资产访问签名请求。
|
||||
/// </summary>
|
||||
public sealed class AssetAccessSignDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(60, 3600)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
@@ -364,62 +390,62 @@ public sealed class AssetAccessSignDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产管理查询参数。
|
||||
/// 资产管理查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetManagementQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传状态。
|
||||
/// 上传状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? UploadStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全扫描状态。
|
||||
/// 安全扫描状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SecurityScanStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -441,21 +467,22 @@ public sealed class AssetManagementQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产事件查询参数。
|
||||
/// 资产事件查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetEventQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -467,30 +494,30 @@ public sealed class AssetEventQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入任务查询参数。
|
||||
/// 导入任务查询参数。
|
||||
/// </summary>
|
||||
public sealed class ImportJobQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导入Type。
|
||||
/// 导入Type。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? ImportType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源格式。
|
||||
/// 来源格式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SourceFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -499,4 +526,4 @@ public sealed class ImportJobQueryDto
|
||||
{
|
||||
return new ImportJobFilter(Status, ImportType, SourceFormat, Limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,38 +6,39 @@ using Tiku.Domain.Tenancy;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// PasswordLogin请求 DTO。
|
||||
/// PasswordLogin请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class PasswordLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账号标识。
|
||||
/// 账号标识。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
[Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")]
|
||||
public string? Identifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兼容手机号字段;新客户端应使用 identifier。
|
||||
/// 兼容手机号字段;新客户端应使用 identifier。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
[Description("兼容手机号字段;新客户端应使用 identifier。")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密码。
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
@@ -46,24 +47,25 @@ public sealed class PasswordLoginDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SmsLogin请求 DTO。
|
||||
/// SmsLogin请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SmsLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
@@ -71,7 +73,7 @@ public sealed class SmsLoginDto
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(12, MinimumLength = 4)]
|
||||
@@ -80,55 +82,56 @@ public sealed class SmsLoginDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送短信验证码请求。
|
||||
/// 发送短信验证码请求。
|
||||
/// </summary>
|
||||
public sealed class SendSmsCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 设备 ID。
|
||||
/// 设备 ID。
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// O认证编码请求 DTO。
|
||||
/// O认证编码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class OAuthCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(512)]
|
||||
@@ -136,13 +139,13 @@ public sealed class OAuthCodeDto
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 公开用户资料。
|
||||
/// 公开用户资料。
|
||||
/// </summary>
|
||||
[Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")]
|
||||
public Dictionary<string, object?>? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 语言。
|
||||
/// 语言。
|
||||
/// </summary>
|
||||
[StringLength(20)]
|
||||
[Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")]
|
||||
@@ -150,12 +153,12 @@ public sealed class OAuthCodeDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新会话请求 DTO。
|
||||
/// 刷新会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RefreshSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 刷新令牌。
|
||||
/// 刷新令牌。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
@@ -164,42 +167,42 @@ public sealed class RefreshSessionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticated用户请求 DTO。
|
||||
/// Authenticated用户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedUserDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
public AuthRealm Realm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户成员摘要。
|
||||
/// 租户成员摘要。
|
||||
/// </summary>
|
||||
public TenantMembershipSummary? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问令牌和刷新令牌。
|
||||
/// 访问令牌和刷新令牌。
|
||||
/// </summary>
|
||||
public AuthTokenPair Tokens { get; init; } = default!;
|
||||
|
||||
@@ -219,52 +222,159 @@ public sealed class AuthenticatedUserDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录认证结果。
|
||||
/// 登录认证结果。
|
||||
/// </summary>
|
||||
public sealed class AuthenticationResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public AuthenticationStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户信息。
|
||||
/// 用户信息。
|
||||
/// </summary>
|
||||
public AuthenticatedUserDto? User { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 挑战令牌。
|
||||
/// 挑战令牌。
|
||||
/// </summary>
|
||||
public string? ChallengeToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 挑战令牌过期时间。
|
||||
/// 挑战令牌过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ChallengeExpiresAt { get; init; }
|
||||
|
||||
public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new()
|
||||
public static AuthenticationResultDto FromApplication(AuthenticationResult result)
|
||||
{
|
||||
Status = result.Status,
|
||||
User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User),
|
||||
ChallengeToken = result.ChallengeToken,
|
||||
ChallengeExpiresAt = result.ChallengeExpiresAt
|
||||
};
|
||||
return new AuthenticationResultDto
|
||||
{
|
||||
Status = result.Status,
|
||||
User = result.User is null ? null : AuthenticatedUserDto.FromApplication(result.User),
|
||||
ChallengeToken = result.ChallengeToken,
|
||||
ChallengeExpiresAt = result.ChallengeExpiresAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 首次登录必改密码请求。
|
||||
/// 首次登录必改密码请求。
|
||||
/// </summary>
|
||||
public sealed class RequiredPasswordChangeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战令牌。
|
||||
/// 挑战令牌。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
public string ChallengeToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 新密码。
|
||||
/// 新密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 请求租户短信密码重置验证码。
|
||||
/// </summary>
|
||||
public sealed class PasswordResetSmsSendDto
|
||||
{
|
||||
/// <summary>租户编码;使用租户自定义域名时可省略。</summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>绑定到账号的手机号。</summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>客户端设备标识,用于安全频控。</summary>
|
||||
[StringLength(256)]
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用短信验证码重置租户账号密码。
|
||||
/// </summary>
|
||||
public sealed class PasswordResetDto
|
||||
{
|
||||
/// <summary>租户编码;使用租户自定义域名时可省略。</summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>绑定到账号的手机号。</summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>短信验证码。</summary>
|
||||
[Required]
|
||||
[StringLength(12, MinimumLength = 4)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>符合当前密码策略的新密码。</summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 已登录用户修改密码。
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedPasswordChangeDto
|
||||
{
|
||||
/// <summary>当前密码。</summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 1)]
|
||||
public string CurrentPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>符合当前密码策略的新密码。</summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理员为用户设置一次性临时密码。
|
||||
/// </summary>
|
||||
public sealed class AdministrativePasswordResetDto
|
||||
{
|
||||
/// <summary>符合当前密码策略的临时密码。</summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 12)]
|
||||
public string TemporaryPassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 完成租户负责人一次性激活。
|
||||
/// </summary>
|
||||
public sealed class CompleteOwnerActivationDto
|
||||
{
|
||||
/// <summary>激活记录 ID。</summary>
|
||||
[Required]
|
||||
public Guid ActivationId { get; set; }
|
||||
|
||||
/// <summary>只显示一次的激活令牌。</summary>
|
||||
[Required]
|
||||
[StringLength(512, MinimumLength = 32)]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>符合当前密码策略的新密码。</summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
public CompleteOwnerActivationRequest ToRequest()
|
||||
{
|
||||
return new CompleteOwnerActivationRequest(ActivationId, Token, NewPassword);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Jobs;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建租户后台任务请求。
|
||||
/// 创建租户后台任务请求。
|
||||
/// </summary>
|
||||
public sealed class CreateBackgroundJobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务类型。
|
||||
/// 任务类型。
|
||||
/// </summary>
|
||||
public string JobType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 任务载荷。
|
||||
/// 任务载荷。
|
||||
/// </summary>
|
||||
public JsonElement Payload { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 计划运行时间。
|
||||
/// 计划运行时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? RunAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大重试次数。
|
||||
/// 最大重试次数。
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>同租户同任务类型内的可选幂等键。</summary>
|
||||
public string? IdempotencyKey { get; set; }
|
||||
|
||||
public CreateBackgroundJobCommand ToCommand(Guid tenantId)
|
||||
{
|
||||
return new CreateBackgroundJobCommand(
|
||||
@@ -32,6 +39,16 @@ public sealed class CreateBackgroundJobDto
|
||||
JobType,
|
||||
Payload.ValueKind == JsonValueKind.Undefined ? JsonSerializer.SerializeToElement(new { }) : Payload,
|
||||
RunAfter,
|
||||
MaxRetries);
|
||||
MaxRetries,
|
||||
IdempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>后台任务取消请求。</summary>
|
||||
public sealed class CancelBackgroundJobDto
|
||||
{
|
||||
/// <summary>取消原因。</summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -5,32 +5,37 @@ using Tiku.Domain.Operations;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新后台角色请求。
|
||||
/// 创建或更新后台角色请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertBackofficeRoleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据范围配置。
|
||||
/// 数据范围配置。
|
||||
/// </summary>
|
||||
public JsonElement? DataScope { get; set; }
|
||||
|
||||
@@ -41,16 +46,17 @@ public sealed class UpsertBackofficeRoleDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换角色权限绑定请求。
|
||||
/// 替换角色权限绑定请求。
|
||||
/// </summary>
|
||||
public sealed class ReplaceRoleBindingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 权限编码列表。
|
||||
/// 权限编码列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> PermissionCodes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 菜单编码列表。
|
||||
/// 菜单编码列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> MenuCodes { get; set; } = [];
|
||||
|
||||
@@ -61,12 +67,12 @@ public sealed class ReplaceRoleBindingsDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换用户角色请求。
|
||||
/// 替换用户角色请求。
|
||||
/// </summary>
|
||||
public sealed class ReplaceUserRolesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色 ID 列表。
|
||||
/// 角色 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
|
||||
|
||||
@@ -74,4 +80,4 @@ public sealed class ReplaceUserRolesDto
|
||||
{
|
||||
return new ReplaceUserRolesCommand(userId, RoleIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,67 +4,67 @@ using Tiku.Application.Catalog;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 目录查询参数。
|
||||
/// 目录查询参数。
|
||||
/// </summary>
|
||||
public sealed class CatalogQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ModuleId。
|
||||
/// ModuleId。
|
||||
/// </summary>
|
||||
public Guid? ModuleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -90,4 +90,4 @@ public sealed class CatalogQueryDto
|
||||
Type,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,47 +4,47 @@ using Tiku.Application.Commerce;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建交易订单请求 DTO。
|
||||
/// 创建交易订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateCommerceOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
[Range(1, 99)]
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? PayMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付渠道。
|
||||
/// 支付渠道。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? PayProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券领取记录 ID。
|
||||
/// 优惠券领取记录 ID。
|
||||
/// </summary>
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
@@ -62,63 +62,63 @@ public sealed class CreateCommerceOrderDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易订单查询参数。
|
||||
/// 交易订单查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommerceOrderQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建交易支付请求 DTO。
|
||||
/// 创建交易支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateCommercePaymentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方式。
|
||||
/// 方式。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Method { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信或支付渠道 openid。
|
||||
/// 微信或支付渠道 openid。
|
||||
/// </summary>
|
||||
[StringLength(255)]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付完成返回地址。
|
||||
/// 支付完成返回地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付取消返回地址。
|
||||
/// 支付取消返回地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? QuitUrl { get; set; }
|
||||
@@ -136,18 +136,18 @@ public sealed class CreateCommercePaymentDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易优惠券查询参数。
|
||||
/// 交易优惠券查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommerceCouponQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
@@ -159,12 +159,12 @@ public sealed class CommerceCouponQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 领取交易优惠券请求 DTO。
|
||||
/// 领取交易优惠券请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ClaimCommerceCouponDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
@@ -177,35 +177,35 @@ public sealed class ClaimCommerceCouponDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验交易优惠券请求 DTO。
|
||||
/// 校验交易优惠券请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CheckCommerceCouponDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券领取记录 ID。
|
||||
/// 优惠券领取记录 ID。
|
||||
/// </summary>
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
[Range(1, 99)]
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
@@ -213,4 +213,4 @@ public sealed class CheckCommerceCouponDto
|
||||
{
|
||||
return new CheckCommerceCouponCommand(CouponCode, CouponRedemptionId, PlanId, Quantity, RegionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,293 +6,373 @@ using Tiku.Application.Growth;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 保存佣金配置请求。
|
||||
/// 保存佣金配置请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认佣金比例。
|
||||
/// 默认佣金比例。
|
||||
/// </summary>
|
||||
[Range(0, 1)]
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最低结算金额,单位为分。
|
||||
/// 最低结算金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? MinSettlementCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结算周期。
|
||||
/// 结算周期。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SettlementCycle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配置内容。
|
||||
/// 配置内容。
|
||||
/// </summary>
|
||||
public JsonElement? Config { get; set; }
|
||||
public UpdateCommissionSettingsCommand ToCommand() => new(DefaultRate, MinSettlementCents, SettlementCycle, Config);
|
||||
|
||||
public UpdateCommissionSettingsCommand ToCommand()
|
||||
{
|
||||
return new UpdateCommissionSettingsCommand(DefaultRate, MinSettlementCents, SettlementCycle, Config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调整成员佣金比例请求。
|
||||
/// 调整成员佣金比例请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateMemberCommissionRateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成员佣金比例。
|
||||
/// 成员佣金比例。
|
||||
/// </summary>
|
||||
[Range(0, 1)]
|
||||
public decimal? CommissionRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 佣金扩展配置。
|
||||
/// 佣金扩展配置。
|
||||
/// </summary>
|
||||
public JsonElement? CommissionConfig { get; set; }
|
||||
public UpdateMemberCommissionRateCommand ToCommand() => new(UserId, CommissionRate, CommissionConfig);
|
||||
|
||||
public UpdateMemberCommissionRateCommand ToCommand()
|
||||
{
|
||||
return new UpdateMemberCommissionRateCommand(UserId, CommissionRate, CommissionConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金统计周期查询参数。
|
||||
/// 佣金统计周期查询参数。
|
||||
/// </summary>
|
||||
public class CommissionPeriodQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
public CommissionPeriodQuery ToQuery() => new(ParseDate(StartDate), ParseDate(EndDate), ReferrerUserId, Limit);
|
||||
protected static DateOnly? ParseDate(string? value) => string.IsNullOrWhiteSpace(value) ? null : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
public CommissionPeriodQuery ToQuery()
|
||||
{
|
||||
return new CommissionPeriodQuery(ParseDate(StartDate), ParseDate(EndDate), ReferrerUserId, Limit);
|
||||
}
|
||||
|
||||
protected static DateOnly? ParseDate(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单查询参数。
|
||||
/// 佣金结算单查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementsQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
public CommissionSettlementQuery ToQuery() => new(Status, ReferrerUserId, Limit);
|
||||
|
||||
public CommissionSettlementQuery ToQuery()
|
||||
{
|
||||
return new CommissionSettlementQuery(Status, ReferrerUserId, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成佣金结算单请求 DTO。
|
||||
/// 生成佣金结算单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class GenerateCommissionSettlementDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public GenerateCommissionSettlementCommand ToCommand() => new(Parse(StartDate), Parse(EndDate), ReferrerUserId, Status, Remark, Metadata);
|
||||
private static DateOnly Parse(string value) => DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
|
||||
public GenerateCommissionSettlementCommand ToCommand()
|
||||
{
|
||||
return new GenerateCommissionSettlementCommand(Parse(StartDate), Parse(EndDate), ReferrerUserId, Status, Remark,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private static DateOnly Parse(string value)
|
||||
{
|
||||
return DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新佣金结算单状态请求 DTO。
|
||||
/// 更新佣金结算单状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionSettlementStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ReviewNote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收款账号。
|
||||
/// 收款账号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? PaymentAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public UpdateCommissionSettlementStatusCommand ToCommand() => new(SettlementId, Status, ReviewNote, PaymentMethod, PaymentAccount, Metadata);
|
||||
|
||||
public UpdateCommissionSettlementStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdateCommissionSettlementStatusCommand(SettlementId, Status, ReviewNote, PaymentMethod,
|
||||
PaymentAccount, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单导出查询参数。
|
||||
/// 佣金结算单导出查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementExportQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导出格式。
|
||||
/// 导出格式。
|
||||
/// </summary>
|
||||
[StringLength(10)]
|
||||
public string? Format { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单凭证查询参数。
|
||||
/// 佣金结算单凭证查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementProofQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建佣金结算凭证请求。
|
||||
/// 创建佣金结算凭证请求。
|
||||
/// </summary>
|
||||
public sealed class CreateCommissionProofDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 凭证类型。
|
||||
/// 凭证类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? ProofType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 外部链接。
|
||||
/// 外部链接。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? ExternalUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额,单位为分。
|
||||
/// 金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? AmountCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? PaymentMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 收款账号。
|
||||
/// 收款账号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? PaymentAccount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public CreateCommissionProofCommand ToCommand() => new(SettlementId, ProofType, Title, Description, AssetId, ExternalUrl, AmountCents, PaymentMethod, PaymentAccount, PaidAt, Metadata);
|
||||
|
||||
public CreateCommissionProofCommand ToCommand()
|
||||
{
|
||||
return new CreateCommissionProofCommand(SettlementId, ProofType, Title, Description, AssetId, ExternalUrl,
|
||||
AmountCents, PaymentMethod,
|
||||
PaymentAccount, PaidAt, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新佣金结算凭证状态请求。
|
||||
/// 更新佣金结算凭证状态请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionProofStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 凭证 ID。
|
||||
/// 凭证 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ProofId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ReviewNote { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public UpdateCommissionProofStatusCommand ToCommand() => new(ProofId, Status, ReviewNote, Metadata);
|
||||
}
|
||||
|
||||
public UpdateCommissionProofStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdateCommissionProofStatusCommand(ProofId, Status, ReviewNote, Metadata);
|
||||
}
|
||||
}
|
||||
@@ -8,74 +8,74 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 内容管理查询参数。
|
||||
/// 内容管理查询参数。
|
||||
/// </summary>
|
||||
public sealed class ContentManagementQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -99,86 +99,86 @@ public sealed class ContentManagementQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容条目请求 DTO。
|
||||
/// 新增或更新内容条目请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertContentEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口键。
|
||||
/// 入口键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标。
|
||||
/// 图标。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 路由地址。
|
||||
/// 路由地址。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Route { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 布局配置。
|
||||
/// 布局配置。
|
||||
/// </summary>
|
||||
public JsonElement LayoutConfig { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
@@ -203,94 +203,94 @@ public sealed class UpsertContentEntryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容节点请求 DTO。
|
||||
/// 新增或更新内容节点请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertContentNodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID。
|
||||
/// 父节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点键。
|
||||
/// 节点键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? NodeKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 节点Type。
|
||||
/// 节点Type。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? NodeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记配置。
|
||||
/// 标记配置。
|
||||
/// </summary>
|
||||
public JsonElement MarkerConfig { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可选择。
|
||||
/// 是否可选择。
|
||||
/// </summary>
|
||||
public bool? IsSelectable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否叶子节点。
|
||||
/// 是否叶子节点。
|
||||
/// </summary>
|
||||
public bool? IsLeaf { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -317,103 +317,103 @@ public sealed class UpsertContentNodeDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新题目题集请求 DTO。
|
||||
/// 新增或更新题目题集请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertQuestionCollectionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 筛选条件。
|
||||
/// 筛选条件。
|
||||
/// </summary>
|
||||
public JsonElement Filters { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -442,45 +442,45 @@ public sealed class UpsertQuestionCollectionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题集题目请求 DTO。
|
||||
/// 题集题目请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CollectionQuestionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 分段键。
|
||||
/// 分段键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SectionKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分数。
|
||||
/// 分数。
|
||||
/// </summary>
|
||||
public decimal? Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否必填。
|
||||
/// 是否必填。
|
||||
/// </summary>
|
||||
public bool? Required { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -497,18 +497,18 @@ public sealed class CollectionQuestionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换题集Items请求 DTO。
|
||||
/// 替换题集Items请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReplaceCollectionItemsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目列表。
|
||||
/// 题目列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<CollectionQuestionDto> Questions { get; set; } = [];
|
||||
|
||||
@@ -521,103 +521,103 @@ public sealed class ReplaceCollectionItemsDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新练习Blueprint请求 DTO。
|
||||
/// 新增或更新练习Blueprint请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPracticeBlueprintDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 组卷方式。
|
||||
/// 组卷方式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssemblyType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目数量上限。
|
||||
/// 题目数量上限。
|
||||
/// </summary>
|
||||
public int? QuestionLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 及格分。
|
||||
/// 及格分。
|
||||
/// </summary>
|
||||
public decimal? PassScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分段配置。
|
||||
/// 分段配置。
|
||||
/// </summary>
|
||||
public JsonElement Sections { get; set; } = JsonDefaults.Array();
|
||||
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public JsonElement Rules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
@@ -646,20 +646,20 @@ public sealed class UpsertPracticeBlueprintDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入Template查询参数。
|
||||
/// 导入Template查询参数。
|
||||
/// </summary>
|
||||
public sealed class ImportTemplateQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入Type。
|
||||
/// 导入Type。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string ImportType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出格式。
|
||||
/// 导出格式。
|
||||
/// </summary>
|
||||
[StringLength(10)]
|
||||
public string? Format { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,85 +4,85 @@ using Tiku.Application.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 内容Navigation查询参数。
|
||||
/// 内容Navigation查询参数。
|
||||
/// </summary>
|
||||
public sealed class ContentNavigationQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含隐藏数据。
|
||||
/// 是否包含隐藏数据。
|
||||
/// </summary>
|
||||
public bool IncludeHidden { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -113,4 +113,4 @@ public sealed class ContentNavigationQueryDto
|
||||
IncludeInactive,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,69 +5,69 @@ using Tiku.Application.Growth;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新CRM配置请求 DTO。
|
||||
/// 新增或更新CRM配置请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertCrmConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问地址。
|
||||
/// 访问地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥内容。
|
||||
/// 密钥内容。
|
||||
/// </summary>
|
||||
public string? Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 表单名称。
|
||||
/// 表单名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? FormName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 考试类型。
|
||||
/// 考试类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ExamType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超时时长,单位为秒。
|
||||
/// 超时时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(1, 120)]
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 延迟秒数。
|
||||
/// 延迟秒数。
|
||||
/// </summary>
|
||||
[Range(0, 86400)]
|
||||
public int? DelaySeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配模式。
|
||||
/// 分配模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssignmentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配池。
|
||||
/// 分配池。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentPool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配配置。
|
||||
/// 分配配置。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentConfig { get; set; }
|
||||
|
||||
@@ -89,29 +89,29 @@ public sealed class UpsertCrmConfigDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列查询参数。
|
||||
/// CRM队列查询参数。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
public Guid? QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -123,17 +123,17 @@ public sealed class CrmQueueQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列日志查询参数。
|
||||
/// CRM队列日志查询参数。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueLogQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
public Guid? QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -145,30 +145,30 @@ public sealed class CrmQueueLogQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列Action请求 DTO。
|
||||
/// CRM队列Action请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueActionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作。
|
||||
/// 操作。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
@@ -176,4 +176,4 @@ public sealed class CrmQueueActionDto
|
||||
{
|
||||
return new CrmQueueActionCommand(QueueId, Action, Note, Metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,15 @@
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 健康检查响应。
|
||||
/// 健康检查响应。
|
||||
/// </summary>
|
||||
/// <param name="Status">服务状态,例如 ok。</param>
|
||||
/// <param name="Service">服务名称。</param>
|
||||
/// <param name="CheckedAt">检查时间。</param>
|
||||
/// <summary>
|
||||
/// 健康检查Response请求 DTO。
|
||||
/// 健康检查Response请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record HealthResponseDto(
|
||||
string Status,
|
||||
string Service,
|
||||
DateTimeOffset CheckedAt);
|
||||
DateTimeOffset CheckedAt);
|
||||
@@ -8,24 +8,24 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 学习Limit查询参数。
|
||||
/// 学习Limit查询参数。
|
||||
/// </summary>
|
||||
public sealed class LearningLimitQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
|
||||
@@ -36,34 +36,34 @@ public sealed class LearningLimitQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 练习会话查询参数。
|
||||
/// 练习会话查询参数。
|
||||
/// </summary>
|
||||
public sealed class PracticeSessionQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 练习会话 ID。
|
||||
/// 练习会话 ID。
|
||||
/// </summary>
|
||||
public Guid? PracticeSessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 练习蓝图 ID。
|
||||
/// 练习蓝图 ID。
|
||||
/// </summary>
|
||||
public Guid? BlueprintId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -80,67 +80,67 @@ public sealed class PracticeSessionQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建练习会话请求 DTO。
|
||||
/// 创建练习会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePracticeSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? TargetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标 ID。
|
||||
/// 目标 ID。
|
||||
/// </summary>
|
||||
public Guid? TargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 练习蓝图 ID。
|
||||
/// 练习蓝图 ID。
|
||||
/// </summary>
|
||||
public Guid? BlueprintId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目数量上限。
|
||||
/// 题目数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? QuestionLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
[Range(1, 1440)]
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "99999")]
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
@@ -162,78 +162,102 @@ public sealed class CreatePracticeSessionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交练习会话请求 DTO。
|
||||
/// 提交练习会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitPracticeSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 练习会话 ID。
|
||||
/// 练习会话 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PracticeSessionId { get; set; }
|
||||
|
||||
public PracticeSessionFilter ToFilter()
|
||||
[Required][Range(1, long.MaxValue)] public long ExpectedSessionVersion { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 1)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
public SubmitPracticeSessionCommand ToCommand()
|
||||
{
|
||||
return new PracticeSessionFilter(PracticeSessionId);
|
||||
return new SubmitPracticeSessionCommand(PracticeSessionId, ExpectedSessionVersion, IdempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交答案请求 DTO。
|
||||
/// 提交答案请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitAnswerDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会话题目 ID。
|
||||
/// 会话题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SessionQuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已选选项。
|
||||
/// 客户端读取会话时获得的版本。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
|
||||
[Required]
|
||||
[Range(1, long.MaxValue)]
|
||||
public long ExpectedSessionVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文字答案。
|
||||
/// 客户端在本会话内单调递增的序列。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(1, long.MaxValue)]
|
||||
public long ClientSequence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 网络重试幂等键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200, MinimumLength = 1)]
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 已选选项的零基索引。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<int>? SelectedOptionIndices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文字答案。
|
||||
/// </summary>
|
||||
[StringLength(10000)]
|
||||
public string? AnswerText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主观题自评是否正确。
|
||||
/// </summary>
|
||||
public bool? SelfJudgedCorrect { get; set; }
|
||||
|
||||
public SubmitAnswerCommand ToCommand()
|
||||
{
|
||||
return new SubmitAnswerCommand(
|
||||
SessionQuestionId,
|
||||
SelectedOptions,
|
||||
AnswerText,
|
||||
SelfJudgedCorrect);
|
||||
ExpectedSessionVersion,
|
||||
ClientSequence,
|
||||
IdempotencyKey,
|
||||
SelectedOptionIndices,
|
||||
AnswerText);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题目Action请求 DTO。
|
||||
/// 题目Action请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class QuestionActionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收藏。
|
||||
/// 是否收藏。
|
||||
/// </summary>
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
@@ -244,36 +268,36 @@ public sealed class QuestionActionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单词Progress请求 DTO。
|
||||
/// 单词Progress请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class WordProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 答对数量变化。
|
||||
/// 答对数量变化。
|
||||
/// </summary>
|
||||
[Range(0, 100)]
|
||||
public int? CorrectDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 答错数量变化。
|
||||
/// 答错数量变化。
|
||||
/// </summary>
|
||||
[Range(0, 100)]
|
||||
public int? WrongDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次复习时间。
|
||||
/// 下次复习时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
@@ -289,23 +313,23 @@ public sealed class WordProgressDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收藏单词请求 DTO。
|
||||
/// 收藏单词请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class FavoriteWordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否收藏。
|
||||
/// 是否收藏。
|
||||
/// </summary>
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
@@ -317,24 +341,24 @@ public sealed class FavoriteWordDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单词Review请求 DTO。
|
||||
/// 单词Review请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class WordReviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结果。
|
||||
/// 结果。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次复习时间。
|
||||
/// 下次复习时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
@@ -342,4 +366,4 @@ public sealed class WordReviewDto
|
||||
{
|
||||
return new WordReviewCommand(WordId, Result, NextReviewAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -10,443 +9,572 @@ using Tiku.Domain.Tenancy;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 平台管理端查询参数。
|
||||
/// 平台管理端查询参数。
|
||||
/// </summary>
|
||||
public sealed class PlatformAdminQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键字。
|
||||
/// 搜索关键字。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Search { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public PlatformAdminQuery ToQuery() => new(Status, Search, Limit);
|
||||
public PlatformAdminQuery ToQuery()
|
||||
{
|
||||
return new PlatformAdminQuery(Status, Search, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台租户请求 DTO。
|
||||
/// 创建平台租户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePlatformTenantDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 短编码。
|
||||
/// 短编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 法定名称。
|
||||
/// 法定名称。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? LegalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 账务状态。
|
||||
/// 账务状态。
|
||||
/// </summary>
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>租户首次开通使用的自定义主域名。</summary>
|
||||
[Required]
|
||||
[StringLength(253, MinimumLength = 4)]
|
||||
public string PrimaryDomainHost { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人邮箱。
|
||||
/// 租户负责人邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress, StringLength(320)]
|
||||
[EmailAddress]
|
||||
[StringLength(320)]
|
||||
public string? OwnerEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人手机号。
|
||||
/// 租户负责人手机号。
|
||||
/// </summary>
|
||||
[Phone, StringLength(32)]
|
||||
[Phone]
|
||||
[StringLength(32)]
|
||||
public string? OwnerPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人姓名。
|
||||
/// 租户负责人姓名。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string OwnerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 临时密码。
|
||||
/// </summary>
|
||||
[Required, StringLength(200, MinimumLength = 12)]
|
||||
public string TemporaryPassword { get; set; } = string.Empty;
|
||||
/// <summary>初始试用套餐版本;为空时使用平台配置的默认基础套餐。</summary>
|
||||
public Guid? InitialOfferingVersionId { get; set; }
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand() => new(
|
||||
Slug, Name, LegalName, Status, BillingStatus, Metadata,
|
||||
OwnerEmail, OwnerPhone, OwnerName, TemporaryPassword);
|
||||
/// <summary>试用天数;为空时使用平台配置的默认天数。</summary>
|
||||
[Range(1, 365)]
|
||||
public int? TrialDays { get; set; }
|
||||
|
||||
/// <summary>收款模式。</summary>
|
||||
public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
|
||||
|
||||
/// <summary>默认支付 Provider。</summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string DefaultPaymentProvider { get; set; } = "manual";
|
||||
|
||||
/// <summary>是否自动生成续费应收。</summary>
|
||||
public bool AutoGenerateRenewal { get; set; } = true;
|
||||
|
||||
/// <summary>续费应收提前生成天数。</summary>
|
||||
[Range(1, 90)]
|
||||
public int RenewalLeadDays { get; set; } = 14;
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand(string idempotencyKey)
|
||||
{
|
||||
return new CreatePlatformTenantCommand(
|
||||
Slug, Name, LegalName, Status, BillingStatus, Metadata, PrimaryDomainHost,
|
||||
OwnerEmail, OwnerPhone, OwnerName,
|
||||
InitialOfferingVersionId, TrialDays, CollectionMode, DefaultPaymentProvider,
|
||||
AutoGenerateRenewal, RenewalLeadDays, idempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ReplacePlatformPrimaryDomainDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(253, MinimumLength = 4)]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public ReplacePlatformPrimaryDomainCommand ToCommand(Guid tenantId)
|
||||
{
|
||||
return new ReplacePlatformPrimaryDomainCommand(tenantId, Host, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IssuePlatformOwnerActivationLinkDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public bool ReplaceExisting { get; set; }
|
||||
|
||||
public IssuePlatformOwnerActivationLinkCommand ToCommand(Guid tenantId, string idempotencyKey)
|
||||
{
|
||||
return new IssuePlatformOwnerActivationLinkCommand(tenantId, idempotencyKey, Reason, ReplaceExisting);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>更新租户收款策略。</summary>
|
||||
public sealed class UpsertTenantBillingPolicyDto
|
||||
{
|
||||
public TenantBillingCollectionMode CollectionMode { get; set; } = TenantBillingCollectionMode.Online;
|
||||
|
||||
[Required][StringLength(50)] public string DefaultPaymentProvider { get; set; } = "manual";
|
||||
|
||||
public bool AutoGenerateRenewal { get; set; } = true;
|
||||
|
||||
[Range(1, 90)] public int RenewalLeadDays { get; set; } = 14;
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public UpsertTenantBillingPolicyCommand ToCommand(Guid tenantId)
|
||||
{
|
||||
return new UpsertTenantBillingPolicyCommand(
|
||||
tenantId,
|
||||
CollectionMode,
|
||||
DefaultPaymentProvider,
|
||||
AutoGenerateRenewal,
|
||||
RenewalLeadDays,
|
||||
Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台租户状态请求 DTO。
|
||||
/// 更新平台租户状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformTenantStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
/// <summary>
|
||||
/// 账务状态。
|
||||
/// </summary>
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, BillingStatus, Reason);
|
||||
public UpdatePlatformTenantStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdatePlatformTenantStatusCommand(TenantId, Status, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台租户账务资料请求 DTO。
|
||||
/// 新增或更新平台租户账务资料请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformTenantBillingProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账务名称。
|
||||
/// 账务名称。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? BillingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 税号。
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TaxId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名。
|
||||
/// 联系人姓名。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人手机号。
|
||||
/// 联系人手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人邮箱。
|
||||
/// 联系人邮箱。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账务地址。
|
||||
/// 账务地址。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? BillingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票抬头。
|
||||
/// 发票抬头。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? InvoiceTitle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票类型。
|
||||
/// 发票类型。
|
||||
/// </summary>
|
||||
public TenantBillingInvoiceTitleType? InvoiceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? BankAccountMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformTenantBillingProfileCommand ToCommand() => new(
|
||||
TenantId,
|
||||
BillingName,
|
||||
TaxId,
|
||||
ContactName,
|
||||
ContactPhone,
|
||||
ContactEmail,
|
||||
BillingAddress,
|
||||
InvoiceTitle,
|
||||
InvoiceType,
|
||||
BankName,
|
||||
BankAccountMasked,
|
||||
Metadata);
|
||||
public UpsertPlatformTenantBillingProfileCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformTenantBillingProfileCommand(
|
||||
TenantId,
|
||||
BillingName,
|
||||
TaxId,
|
||||
ContactName,
|
||||
ContactPhone,
|
||||
ContactEmail,
|
||||
BillingAddress,
|
||||
InvoiceTitle,
|
||||
InvoiceType,
|
||||
BankName,
|
||||
BankAccountMasked,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台Staff请求 DTO。
|
||||
/// 新增或更新平台Staff请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformStaffDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID 列表。
|
||||
/// 角色 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
|
||||
|
||||
public UpsertPlatformStaffCommand ToCommand() => new(UserId, Email, Phone, Name, Status, RoleIds);
|
||||
public UpsertPlatformStaffCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformStaffCommand(UserId, Email, Phone, Name, Status, RoleIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台Staff状态请求 DTO。
|
||||
/// 更新平台Staff状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformStaffStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformStaffStatusCommand ToCommand() => new(UserId, Status, Reason);
|
||||
public UpdatePlatformStaffStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdatePlatformStaffStatusCommand(UserId, Status, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台审计Alert状态请求 DTO。
|
||||
/// 更新平台审计Alert状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformAuditAlertStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 告警 ID。
|
||||
/// 告警 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AlertId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PlatformAuditAlertStatus Status { get; set; } = PlatformAuditAlertStatus.Acknowledged;
|
||||
|
||||
/// <summary>
|
||||
/// 处理备注。
|
||||
/// 处理备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ResolutionNote { get; set; }
|
||||
|
||||
public UpdatePlatformAuditAlertStatusCommand ToCommand() => new(AlertId, Status, ResolutionNote);
|
||||
public UpdatePlatformAuditAlertStatusCommand ToCommand()
|
||||
{
|
||||
return new UpdatePlatformAuditAlertStatusCommand(AlertId, Status, ResolutionNote);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台DunningChannel请求 DTO。
|
||||
/// 新增或更新平台DunningChannel请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformBillingDunningChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知渠道 ID。
|
||||
/// 通知渠道 ID。
|
||||
/// </summary>
|
||||
public Guid? ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ChannelCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook 地址。
|
||||
/// Webhook 地址。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
public string WebhookUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒类型列表。
|
||||
/// 提醒类型列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReminderTypes { get; set; } = ["overdue", "final_notice"];
|
||||
|
||||
/// <summary>
|
||||
/// 提醒渠道列表。
|
||||
/// 提醒渠道列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReminderChannels { get; set; } = ["internal"];
|
||||
|
||||
/// <summary>
|
||||
/// 最低提醒级别。
|
||||
/// 最低提醒级别。
|
||||
/// </summary>
|
||||
[Range(1, 20)]
|
||||
public int MinReminderLevel { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID 列表。
|
||||
/// 租户 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> TenantIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 超时时长,单位为秒。
|
||||
/// 超时时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformBillingDunningChannelCommand ToCommand() => new(
|
||||
ChannelId,
|
||||
ChannelCode,
|
||||
Name,
|
||||
Description,
|
||||
Enabled,
|
||||
Provider,
|
||||
WebhookUrl,
|
||||
SecretRef,
|
||||
ReminderTypes,
|
||||
ReminderChannels,
|
||||
MinReminderLevel,
|
||||
TenantIds,
|
||||
TimeoutSeconds,
|
||||
Metadata);
|
||||
public UpsertPlatformBillingDunningChannelCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformBillingDunningChannelCommand(
|
||||
ChannelId,
|
||||
ChannelCode,
|
||||
Name,
|
||||
Description,
|
||||
Enabled,
|
||||
Provider,
|
||||
WebhookUrl,
|
||||
SecretRef,
|
||||
ReminderTypes,
|
||||
ReminderChannels,
|
||||
MinReminderLevel,
|
||||
TenantIds,
|
||||
TimeoutSeconds,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 禁用平台DunningChannel请求 DTO。
|
||||
/// 禁用平台DunningChannel请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class DisablePlatformBillingDunningChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知渠道 ID。
|
||||
/// 通知渠道 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DisablePlatformBillingDunningChannelCommand ToCommand() => new(ChannelId, Reason);
|
||||
public DisablePlatformBillingDunningChannelCommand ToCommand()
|
||||
{
|
||||
return new DisablePlatformBillingDunningChannelCommand(ChannelId, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重试平台Dunning事件请求 DTO。
|
||||
/// 重试平台Dunning事件请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RetryPlatformBillingDunningEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 事件 ID。
|
||||
/// 事件 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public RetryPlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason);
|
||||
public RetryPlatformBillingDunningEventCommand ToCommand()
|
||||
{
|
||||
return new RetryPlatformBillingDunningEventCommand(EventId, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>人工确认或忽略催缴投递事件。</summary>
|
||||
public sealed class ResolvePlatformBillingDunningEventDto
|
||||
{
|
||||
[Required] public Guid EventId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public ResolvePlatformBillingDunningEventCommand ToCommand()
|
||||
{
|
||||
return new ResolvePlatformBillingDunningEventCommand(EventId, Reason);
|
||||
}
|
||||
}
|
||||
27
Tiku.Api/Contracts/PlatformApprovalDtos.cs
Normal file
27
Tiku.Api/Contracts/PlatformApprovalDtos.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed record PlatformApprovalDecisionDto([Required][MaxLength(1000)] string Reason);
|
||||
|
||||
public sealed record UpdatePlatformApprovalPolicyDto(
|
||||
bool Enabled,
|
||||
bool AlwaysRequireApproval,
|
||||
[Range(1, int.MaxValue)] int? AmountThresholdCents,
|
||||
[Range(1, 720)] int ExpiresAfterHours,
|
||||
JsonElement? Conditions)
|
||||
{
|
||||
public UpdatePlatformApprovalPolicyCommand ToCommand(string code)
|
||||
{
|
||||
return new UpdatePlatformApprovalPolicyCommand(
|
||||
code,
|
||||
Enabled,
|
||||
AlwaysRequireApproval,
|
||||
AmountThresholdCents,
|
||||
ExpiresAfterHours,
|
||||
Conditions?.Clone() ?? JsonDefaults.Object());
|
||||
}
|
||||
}
|
||||
53
Tiku.Api/Contracts/PlatformGovernanceDtos.cs
Normal file
53
Tiku.Api/Contracts/PlatformGovernanceDtos.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed record SavePlatformConfigurationDraftDto(
|
||||
[Required][MaxLength(120)] string DefinitionCode,
|
||||
[Required][MaxLength(80)] string Environment,
|
||||
JsonElement? Value,
|
||||
[MaxLength(300)] string? SecretRef,
|
||||
[Required][MaxLength(1000)] string Reason)
|
||||
{
|
||||
public SavePlatformConfigurationDraftCommand ToCommand()
|
||||
{
|
||||
return new SavePlatformConfigurationDraftCommand(DefinitionCode, Environment, Value, SecretRef, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlatformRollbackDto([Required][MaxLength(1000)] string Reason);
|
||||
|
||||
public sealed record UpsertPlatformNotificationTemplateDto(
|
||||
Guid? Id,
|
||||
[Required][MaxLength(120)] string Code,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
PlatformNotificationChannel Channel,
|
||||
[Required][MaxLength(500)] string SubjectTemplate,
|
||||
[Required][MaxLength(8000)] string BodyTemplate,
|
||||
bool Enabled,
|
||||
JsonElement? Variables)
|
||||
{
|
||||
public UpsertPlatformNotificationTemplateCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformNotificationTemplateCommand(Id, Code, Name, Channel, SubjectTemplate, BodyTemplate,
|
||||
Enabled,
|
||||
Variables?.Clone() ?? JsonDefaults.Array());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SendPlatformNotificationDto(
|
||||
Guid TemplateId,
|
||||
[MinLength(1)] IReadOnlyCollection<string> RoleCodes,
|
||||
IReadOnlyDictionary<string, string>? Variables,
|
||||
[Required][MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public SendPlatformNotificationCommand ToCommand()
|
||||
{
|
||||
return new SendPlatformNotificationCommand(TemplateId, RoleCodes, Variables ?? new Dictionary<string, string>(),
|
||||
IdempotencyKey);
|
||||
}
|
||||
}
|
||||
@@ -9,398 +9,497 @@ using Tiku.Domain.Tenancy;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 平台租户能力通用查询参数。
|
||||
/// 平台租户能力通用查询参数。
|
||||
/// </summary>
|
||||
public class PlatformCapabilityQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public Guid? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int Limit { get; set; } = 100;
|
||||
|
||||
public PlatformCapabilityQuery ToQuery() => new(TenantId, Status, Limit);
|
||||
public PlatformCapabilityQuery ToQuery()
|
||||
{
|
||||
return new PlatformCapabilityQuery(TenantId, Status, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户 CRM 配置请求。
|
||||
/// 新增或更新租户 CRM 配置请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformCrmConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调地址。
|
||||
/// 回调地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 表单名称。
|
||||
/// 表单名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? FormName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 考试类型。
|
||||
/// 考试类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ExamType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超时时长,单位为秒。
|
||||
/// 超时时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(1, 120)]
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 延迟秒数。
|
||||
/// 延迟秒数。
|
||||
/// </summary>
|
||||
[Range(0, 86400)]
|
||||
public int? DelaySeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配模式。
|
||||
/// 分配模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssignmentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配池。
|
||||
/// 分配池。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentPool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配配置。
|
||||
/// 分配配置。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentConfig { get; set; }
|
||||
|
||||
public UpsertPlatformCrmConfigCommand ToCommand() =>
|
||||
new(Id, TenantId, Enabled, Url, SecretRef, FormName, ExamType, TimeoutSeconds, DelaySeconds, AssignmentMode, AssignmentPool, AssignmentConfig);
|
||||
public UpsertPlatformCrmConfigCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformCrmConfigCommand(Id, TenantId, Enabled, Url, SecretRef, FormName, ExamType,
|
||||
TimeoutSeconds, DelaySeconds,
|
||||
AssignmentMode, AssignmentPool, AssignmentConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重试租户 CRM 线索推送请求。
|
||||
/// 重试租户 CRM 线索推送请求。
|
||||
/// </summary>
|
||||
public sealed class RetryPlatformCrmLeadDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
public PlatformCrmLeadRetryCommand ToCommand() => new(QueueId, Note);
|
||||
public PlatformCrmLeadRetryCommand ToCommand()
|
||||
{
|
||||
return new PlatformCrmLeadRetryCommand(QueueId, Note);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 平台租户 CRM 日志查询参数。
|
||||
/// 平台租户 CRM 日志查询参数。
|
||||
/// </summary>
|
||||
public sealed class PlatformCrmLogQueryDto : PlatformCapabilityQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
public Guid? QueueId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户短信渠道请求。
|
||||
/// 新增或更新租户短信渠道请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformSmsChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required, StringLength(80)]
|
||||
[Required]
|
||||
[StringLength(80)]
|
||||
public string Provider { get; set; } = "generic";
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(200)]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 短信签名。
|
||||
/// 短信签名。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 使用场景。
|
||||
/// 使用场景。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Scene { get; set; } = "login";
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 月度配额。
|
||||
/// 月度配额。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? MonthlyQuota { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开配置内容。
|
||||
/// 公开配置内容。
|
||||
/// </summary>
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSmsChannelCommand ToCommand() =>
|
||||
new(Id, TenantId, Provider, Name, Signature, Scene, Status, SecretRef, Priority, MonthlyQuota, ConfigPublic, Metadata);
|
||||
public UpsertPlatformSmsChannelCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformSmsChannelCommand(Id, TenantId, Provider, Name, Signature, Scene, Status, SecretRef,
|
||||
Priority, MonthlyQuota,
|
||||
ConfigPublic, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户短信模板请求。
|
||||
/// 新增或更新租户短信模板请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformSmsTemplateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 短信渠道 ID。
|
||||
/// 短信渠道 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required, StringLength(120)]
|
||||
[Required]
|
||||
[StringLength(120)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(200)]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
public SmsTemplateType Type { get; set; } = SmsTemplateType.Notification;
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
public SmsTemplateAuditStatus AuditStatus { get; set; } = SmsTemplateAuditStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public SmsTemplateStatus Status { get; set; } = SmsTemplateStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道模板编码。
|
||||
/// 渠道模板编码。
|
||||
/// </summary>
|
||||
[StringLength(120)]
|
||||
public string? ProviderTemplateCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模板内容。
|
||||
/// 模板内容。
|
||||
/// </summary>
|
||||
[Required, StringLength(1000)]
|
||||
[Required]
|
||||
[StringLength(1000)]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Remark。
|
||||
/// Remark。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSmsTemplateCommand ToCommand() =>
|
||||
new(Id, TenantId, ChannelId, Code, Name, Type, AuditStatus, Status, ProviderTemplateCode, Content, Remark, Metadata);
|
||||
public UpsertPlatformSmsTemplateCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformSmsTemplateCommand(Id, TenantId, ChannelId, Code, Name, Type, AuditStatus, Status,
|
||||
ProviderTemplateCode, Content,
|
||||
Remark, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台支付应用请求。
|
||||
/// 新增或更新平台支付应用请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformPaymentAppDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付应用编码。
|
||||
/// 支付应用编码。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string AppCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 支付应用名称。
|
||||
/// 支付应用名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(200)]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PlatformPaymentAppStatus Status { get; set; } = PlatformPaymentAppStatus.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// 结算模式。
|
||||
/// 结算模式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string SettlementMode { get; set; } = "PlatformCollect";
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformPaymentAppCommand ToCommand() =>
|
||||
new(Id, AppCode, AppName, Status, SettlementMode, Description, Metadata);
|
||||
public UpsertPlatformPaymentAppCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformPaymentAppCommand(Id, AppCode, AppName, Status, SettlementMode, Description, Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台支付渠道请求。
|
||||
/// 新增或更新平台支付渠道请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformPaymentChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付应用 ID。
|
||||
/// 支付应用 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required, StringLength(80)]
|
||||
[Required]
|
||||
[StringLength(80)]
|
||||
public string Provider { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string Mode { get; set; } = "PlatformCollect";
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PlatformPaymentChannelStatus Status { get; set; } = PlatformPaymentChannelStatus.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// 显示名称。
|
||||
/// 显示名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(200)]
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调路径。
|
||||
/// 回调路径。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? CallbackPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开配置内容。
|
||||
/// 公开配置内容。
|
||||
/// </summary>
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformPaymentChannelCommand ToCommand() =>
|
||||
new(Id, AppId, Provider, Mode, Status, DisplayName, SecretRef, CallbackPath, Priority, ConfigPublic, Metadata);
|
||||
public UpsertPlatformPaymentChannelCommand ToCommand()
|
||||
{
|
||||
return new UpsertPlatformPaymentChannelCommand(Id, AppId, Provider, Mode, Status, DisplayName, SecretRef,
|
||||
CallbackPath, Priority, ConfigPublic,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户支付应用请求。
|
||||
/// 新增或更新租户支付应用请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformTenantPaymentAppDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required, StringLength(80)]
|
||||
[Required]
|
||||
[StringLength(80)]
|
||||
public string Provider { get; set; } = "manual";
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// 显示名称。
|
||||
/// 显示名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开配置内容。
|
||||
/// 公开配置内容。
|
||||
/// </summary>
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertTenantExternalProviderCommand ToCommand() =>
|
||||
new(TenantExternalProviderCapability.Payment, Provider, Status, DisplayName, SecretRef, Priority, ConfigPublic, Metadata);
|
||||
}
|
||||
public UpsertTenantExternalProviderCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantExternalProviderCommand(TenantExternalProviderCapability.Payment, Provider, Status,
|
||||
DisplayName, SecretRef, Priority,
|
||||
ConfigPublic, Metadata);
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,24 @@ using Tiku.Application.Points;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 积分查询参数。
|
||||
/// 积分查询参数。
|
||||
/// </summary>
|
||||
public sealed class PointQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
@@ -32,25 +32,25 @@ public sealed class PointQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 领取积分任务请求 DTO。
|
||||
/// 领取积分任务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ClaimPointTaskDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务键。
|
||||
/// 任务键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string TaskKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源 ID。
|
||||
/// 来源 ID。
|
||||
/// </summary>
|
||||
public Guid? SourceId { get; set; }
|
||||
|
||||
@@ -61,12 +61,12 @@ public sealed class ClaimPointTaskDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建积分兑换订单请求 DTO。
|
||||
/// 创建积分兑换订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePointExchangeOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换项 ID。
|
||||
/// 兑换项 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ItemId { get; set; }
|
||||
@@ -75,4 +75,4 @@ public sealed class CreatePointExchangeOrderDto
|
||||
{
|
||||
return new CreatePointExchangeOrderCommand(ItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,105 +5,111 @@ using Tiku.Application.Profile;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资料查询参数。
|
||||
/// 资料查询参数。
|
||||
/// </summary>
|
||||
public sealed class ProfileQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 最近记录数量上限。
|
||||
/// 最近记录数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 50)]
|
||||
public int? RecentLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 20)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含锁定资源。
|
||||
/// 是否包含锁定资源。
|
||||
/// </summary>
|
||||
public bool IncludeLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? From { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间。
|
||||
/// 结束时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? To { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新资料请求 DTO。
|
||||
/// 更新资料请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像预设。
|
||||
/// 头像预设。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? AvatarPreset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已选择院校 ID。
|
||||
/// 已选择院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SelectedSchoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已选择专业 ID。
|
||||
/// 已选择专业 ID。
|
||||
/// </summary>
|
||||
public Guid? SelectedMajorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public JsonElement? Stats { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进度数据。
|
||||
/// 进度数据。
|
||||
/// </summary>
|
||||
public JsonElement? Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模块选择数据。
|
||||
/// 模块选择数据。
|
||||
/// </summary>
|
||||
public JsonElement? ModuleSelections { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近活动数据。
|
||||
/// 最近活动数据。
|
||||
/// </summary>
|
||||
public JsonElement? RecentActivities { get; set; }
|
||||
|
||||
@@ -123,12 +129,12 @@ public sealed class UpdateProfileDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知状态请求 DTO。
|
||||
/// 通知状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class NotificationStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知 ID 列表。
|
||||
/// 通知 ID 列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
@@ -136,7 +142,7 @@ public sealed class NotificationStatusDto
|
||||
public IReadOnlyCollection<Guid> NotificationIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
@@ -148,58 +154,59 @@ public sealed class NotificationStatusDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交反馈请求 DTO。
|
||||
/// 提交反馈请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitFeedbackDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(5000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系方式。
|
||||
/// 联系方式。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Contact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 附件列表。
|
||||
/// 附件列表。
|
||||
/// </summary>
|
||||
public JsonElement Attachments { get; set; } = JsonSerializer.SerializeToElement(Array.Empty<object>());
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
@@ -216,4 +223,4 @@ public sealed class SubmitFeedbackDto
|
||||
Attachments,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,80 +5,80 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 题目题库查询参数。
|
||||
/// 题目题库查询参数。
|
||||
/// </summary>
|
||||
public sealed class QuestionBankQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public QuestionSource? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID 列表。
|
||||
/// 题目 ID 列表。
|
||||
/// </summary>
|
||||
public string? QuestionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -105,10 +105,7 @@ public sealed class QuestionBankQueryDto
|
||||
|
||||
private Guid[] ParseQuestionIds()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(QuestionIds))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(QuestionIds)) return [];
|
||||
|
||||
return QuestionIds
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
@@ -119,4 +116,4 @@ public sealed class QuestionBankQueryDto
|
||||
.Take(300)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,30 @@ using Tiku.Application.Growth;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 推荐租户查询参数。
|
||||
/// 推荐租户查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralTenantQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐邀请码请求 DTO。
|
||||
/// 推荐邀请码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReferralInviteDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 落地页路径。
|
||||
/// 落地页路径。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? LandingPath { get; set; }
|
||||
@@ -41,18 +41,18 @@ public sealed class ReferralInviteDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析推荐请求 DTO。
|
||||
/// 解析推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ResolveReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
@@ -64,41 +64,41 @@ public sealed class ResolveReferralDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录推荐事件请求 DTO。
|
||||
/// 记录推荐事件请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class TrackReferralEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐码。
|
||||
/// 推荐码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 事件类型。
|
||||
/// 事件类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标用户 ID。
|
||||
/// 目标用户 ID。
|
||||
/// </summary>
|
||||
public Guid? TargetUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
@@ -110,25 +110,25 @@ public sealed class TrackReferralEventDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定推荐请求 DTO。
|
||||
/// 绑定推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class BindReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐码。
|
||||
/// 推荐码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
@@ -139,36 +139,36 @@ public sealed class BindReferralDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐二维码请求 DTO。
|
||||
/// 推荐二维码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReferralQrcodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 场景参数。
|
||||
/// 场景参数。
|
||||
/// </summary>
|
||||
[StringLength(128)]
|
||||
public string? Scene { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 二维码地址。
|
||||
/// 二维码地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? QrcodeUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
@@ -179,17 +179,17 @@ public sealed class ReferralQrcodeDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐统计查询参数。
|
||||
/// 推荐统计查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralStatsQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -201,35 +201,35 @@ public sealed class ReferralStatsQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐Conversion查询参数。
|
||||
/// 推荐Conversion查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralConversionQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 天数。
|
||||
/// 天数。
|
||||
/// </summary>
|
||||
[Range(1, 365)]
|
||||
public int? Days { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -253,35 +253,35 @@ public sealed class ReferralConversionQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人工绑定推荐请求 DTO。
|
||||
/// 人工绑定推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ManualBindReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 学生用户 ID。
|
||||
/// 学生用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid StudentUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制执行。
|
||||
/// 是否强制执行。
|
||||
/// </summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
@@ -292,12 +292,12 @@ public sealed class ManualBindReferralDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐团队查询参数。
|
||||
/// 推荐团队查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralTeamQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 上级成员用户 ID。
|
||||
/// 上级成员用户 ID。
|
||||
/// </summary>
|
||||
public Guid? LeaderUserId { get; set; }
|
||||
|
||||
@@ -308,35 +308,35 @@ public sealed class ReferralTeamQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新推荐团队请求 DTO。
|
||||
/// 新增或更新推荐团队请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertReferralTeamDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 成员用户 ID。
|
||||
/// 成员用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid MemberUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上级成员用户 ID。
|
||||
/// 上级成员用户 ID。
|
||||
/// </summary>
|
||||
public Guid? LeaderUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关系类型。
|
||||
/// 关系类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? RelationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
@@ -344,4 +344,4 @@ public sealed class UpsertReferralTeamDto
|
||||
{
|
||||
return new UpsertReferralTeamCommand(MemberUserId, LeaderUserId, RelationType, Status, Metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,56 +6,66 @@ using Tiku.Domain.Platform;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS功能请求 DTO。
|
||||
/// 新增或更新SaaS功能请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasFeatureDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string Code,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
[Required, MaxLength(100)] string Category,
|
||||
[Required][MaxLength(120)] string Code,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
[Required][MaxLength(100)] string Category,
|
||||
[MaxLength(1000)] string? Description,
|
||||
[Range(0, int.MaxValue)] int ReferencePriceCents,
|
||||
[Required, MaxLength(10)] string Currency,
|
||||
[Required][MaxLength(10)] string Currency,
|
||||
SaasFeatureStatus Status,
|
||||
int SortOrder)
|
||||
{
|
||||
public UpsertSaasFeatureCommand ToCommand() => new(Id, Code, Name, Category, Description, ReferencePriceCents, Currency, Status, SortOrder);
|
||||
public UpsertSaasFeatureCommand ToCommand()
|
||||
{
|
||||
return new UpsertSaasFeatureCommand(Id, Code, Name, Category, Description, ReferencePriceCents, Currency,
|
||||
Status, SortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS套餐请求 DTO。
|
||||
/// 新增或更新SaaS套餐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasOfferingDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string Code,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
[Required][MaxLength(120)] string Code,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
SaasOfferingType Type,
|
||||
SaasOfferingStatus Status,
|
||||
[MaxLength(1000)] string? Description,
|
||||
int SortOrder)
|
||||
{
|
||||
public UpsertSaasOfferingCommand ToCommand() => new(Id, Code, Name, Type, Status, Description, SortOrder);
|
||||
public UpsertSaasOfferingCommand ToCommand()
|
||||
{
|
||||
return new UpsertSaasOfferingCommand(Id, Code, Name, Type, Status, Description, SortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新 SaaS 功能额度定义。
|
||||
/// 新增或更新 SaaS 功能额度定义。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasFeatureLimitDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string MetricCode,
|
||||
[Required, MaxLength(120)] string FeatureCode,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
[Required, MaxLength(50)] string Unit,
|
||||
[Required][MaxLength(120)] string MetricCode,
|
||||
[Required][MaxLength(120)] string FeatureCode,
|
||||
[Required][MaxLength(200)] string Name,
|
||||
[Required][MaxLength(50)] string Unit,
|
||||
SaasFeatureLimitKind Kind,
|
||||
[Range(1, 100)] int WarningPercent,
|
||||
bool IsHardLimit)
|
||||
{
|
||||
public UpsertSaasFeatureLimitCommand ToCommand() => new(
|
||||
Id, MetricCode, FeatureCode, Name, Unit, Kind, WarningPercent, IsHardLimit);
|
||||
public UpsertSaasFeatureLimitCommand ToCommand()
|
||||
{
|
||||
return new UpsertSaasFeatureLimitCommand(
|
||||
Id, MetricCode, FeatureCode, Name, Unit, Kind, WarningPercent, IsHardLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS套餐版本请求 DTO。
|
||||
/// 新增或更新SaaS套餐版本请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasOfferingVersionDto(
|
||||
Guid? Id,
|
||||
@@ -63,89 +73,164 @@ public sealed record UpsertSaasOfferingVersionDto(
|
||||
PlatformBillingCycle BillingCycle,
|
||||
[Range(0, int.MaxValue)] int OriginalAmountCents,
|
||||
[Range(0, int.MaxValue)] int AmountCents,
|
||||
[Required, MaxLength(10)] string Currency,
|
||||
[Required][MaxLength(10)] string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
IReadOnlyCollection<string>? FeatureCodes,
|
||||
IReadOnlyDictionary<string, long>? Limits,
|
||||
JsonElement Metadata)
|
||||
{
|
||||
public UpsertSaasOfferingVersionCommand ToCommand() => new(
|
||||
Id, OfferingId, BillingCycle, OriginalAmountCents, AmountCents, Currency, EffectiveAt,
|
||||
FeatureCodes ?? [], Limits ?? new Dictionary<string, long>(), Metadata);
|
||||
public UpsertSaasOfferingVersionCommand ToCommand()
|
||||
{
|
||||
return new UpsertSaasOfferingVersionCommand(
|
||||
Id, OfferingId, BillingCycle, OriginalAmountCents, AmountCents, Currency, EffectiveAt,
|
||||
FeatureCodes ?? [], Limits ?? new Dictionary<string, long>(), Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务报价请求 DTO。
|
||||
/// 创建平台账务报价请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingQuoteDto(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid>? AddOnOfferingVersionIds,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
[Required, MaxLength(200)] string IdempotencyKey)
|
||||
[Required][MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public CreatePlatformBillingQuoteCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? [], Purpose, IdempotencyKey);
|
||||
public CreatePlatformBillingQuoteCommand ToCommand()
|
||||
{
|
||||
return new CreatePlatformBillingQuoteCommand(BaseOfferingVersionId, AddOnOfferingVersionIds ?? [], Purpose,
|
||||
IdempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务订单请求 DTO。
|
||||
/// 创建平台账务订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required, MaxLength(200)] string IdempotencyKey)
|
||||
public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required][MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public CreatePlatformBillingOrderCommand ToCommand() => new(QuoteId, IdempotencyKey);
|
||||
public CreatePlatformBillingOrderCommand ToCommand()
|
||||
{
|
||||
return new CreatePlatformBillingOrderCommand(QuoteId, IdempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务支付请求 DTO。
|
||||
/// 创建平台账务支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingPaymentDto(
|
||||
[Required, MaxLength(50)] string Provider,
|
||||
[Required, MaxLength(50)] string Method,
|
||||
[Required, MaxLength(200)] string IdempotencyKey,
|
||||
[Required][MaxLength(50)] string Provider,
|
||||
[Required][MaxLength(50)] string Method,
|
||||
[Required][MaxLength(200)] string IdempotencyKey,
|
||||
string? OpenId,
|
||||
string? ReturnUrl,
|
||||
string? QuitUrl)
|
||||
{
|
||||
public CreatePlatformBillingPaymentCommand ToCommand(string orderNo) =>
|
||||
new(orderNo, Provider, Method, IdempotencyKey, OpenId, ReturnUrl, QuitUrl);
|
||||
public CreatePlatformBillingPaymentCommand ToCommand(string orderNo)
|
||||
{
|
||||
return new CreatePlatformBillingPaymentCommand(orderNo, Provider, Method, IdempotencyKey, OpenId, ReturnUrl,
|
||||
QuitUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变更租户订阅请求 DTO。
|
||||
/// 变更租户订阅请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record ChangeTenantSubscriptionDto(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid>? AddOnOfferingVersionIds,
|
||||
[Required, MaxLength(200)] string IdempotencyKey)
|
||||
[Required][MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public ChangeTenantSubscriptionCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? []);
|
||||
public ChangeTenantSubscriptionCommand ToCommand()
|
||||
{
|
||||
return new ChangeTenantSubscriptionCommand(BaseOfferingVersionId, AddOnOfferingVersionIds ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent租户账务请求 DTO。
|
||||
/// Idempotent租户账务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record IdempotentTenantBillingDto([Required, MaxLength(200)] string IdempotencyKey);
|
||||
public sealed record IdempotentTenantBillingDto([Required][MaxLength(200)] string IdempotencyKey);
|
||||
|
||||
/// <summary>
|
||||
/// 确认人工平台支付请求 DTO。
|
||||
/// 确认人工平台支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record ConfirmManualPlatformPaymentDto(
|
||||
Guid PaymentId,
|
||||
string? ProviderTradeNo,
|
||||
DateTimeOffset? PaidAt,
|
||||
[Required, MaxLength(1000)] string Reason)
|
||||
[Required][MaxLength(1000)] string Reason)
|
||||
{
|
||||
public ConfirmManualPaymentCommand ToCommand() => new(PaymentId, ProviderTradeNo, PaidAt, Reason);
|
||||
public ConfirmManualPaymentCommand ToCommand()
|
||||
{
|
||||
return new ConfirmManualPaymentCommand(PaymentId, ProviderTradeNo, PaidAt, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户功能覆盖规则请求 DTO。
|
||||
/// 新增或更新租户功能覆盖规则请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertTenantFeatureOverrideDto(
|
||||
Guid TenantId,
|
||||
[Required, MaxLength(120)] string FeatureCode,
|
||||
[Required][MaxLength(120)] string FeatureCode,
|
||||
TenantFeatureOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
[Required, MaxLength(1000)] string Reason)
|
||||
[Required][MaxLength(1000)] string Reason)
|
||||
{
|
||||
public UpsertTenantFeatureOverrideCommand ToCommand() => new(TenantId, FeatureCode, Mode, ExpiresAt, Reason);
|
||||
public UpsertTenantFeatureOverrideCommand ToCommand()
|
||||
{
|
||||
return new UpsertTenantFeatureOverrideCommand(TenantId, FeatureCode, Mode, ExpiresAt, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>为已有租户补录试用订阅。</summary>
|
||||
public sealed record GrantTenantTrialDto(
|
||||
Guid TenantId,
|
||||
Guid BaseOfferingVersionId,
|
||||
[Range(1, 365)] int TrialDays,
|
||||
[Required][MaxLength(200)] string IdempotencyKey,
|
||||
[Required][MaxLength(1000)] string Reason)
|
||||
{
|
||||
public GrantTenantTrialCommand ToCommand()
|
||||
{
|
||||
return new GrantTenantTrialCommand(TenantId, BaseOfferingVersionId, TrialDays, IdempotencyKey, Reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>平台修改订阅状态。</summary>
|
||||
public sealed record ChangePlatformSubscriptionDto(
|
||||
[Required][MaxLength(1000)] string Reason,
|
||||
[Range(1, 3650)] int? ExtendDays = null)
|
||||
{
|
||||
public ChangePlatformSubscriptionCommand ToCommand(Guid subscriptionId)
|
||||
{
|
||||
return new ChangePlatformSubscriptionCommand(subscriptionId, Reason, ExtendDays);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>申请 SaaS 退款。</summary>
|
||||
public sealed record RequestPlatformRefundDto(
|
||||
Guid PaymentId,
|
||||
[Range(1, int.MaxValue)] int AmountCents,
|
||||
[Required][MaxLength(1000)] string Reason,
|
||||
[Required][MaxLength(200)] string IdempotencyKey,
|
||||
PlatformBillingRefundSubscriptionEffect SubscriptionEffect)
|
||||
{
|
||||
public RequestPlatformRefundCommand ToCommand()
|
||||
{
|
||||
return new RequestPlatformRefundCommand(
|
||||
PaymentId,
|
||||
AmountCents,
|
||||
Reason,
|
||||
IdempotencyKey,
|
||||
SubscriptionEffect);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>审核或重试 SaaS 退款。</summary>
|
||||
public sealed record ReviewPlatformRefundDto([Required][MaxLength(1000)] string Reason)
|
||||
{
|
||||
public ReviewPlatformRefundCommand ToCommand(Guid refundId)
|
||||
{
|
||||
return new ReviewPlatformRefundCommand(refundId, Reason);
|
||||
}
|
||||
}
|
||||
@@ -4,61 +4,63 @@ using Tiku.Application.Scoreline;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 分数线查询参数。
|
||||
/// 分数线查询参数。
|
||||
/// </summary>
|
||||
public sealed class ScorelineQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
[Range(1900, 3000)]
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int? Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量。
|
||||
/// 每页数量。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 版本化的不透明游标,仅用于游标分页接口。
|
||||
/// 版本化的不透明游标,仅用于游标分页接口。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Cursor { get; set; }
|
||||
@@ -79,4 +81,4 @@ public sealed class ScorelineQueryDto
|
||||
Limit,
|
||||
dynamicFilters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,59 +4,59 @@ using Tiku.Application.StudyContent;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Study内容查询参数。
|
||||
/// Study内容查询参数。
|
||||
/// </summary>
|
||||
public sealed class StudyContentQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 章节 ID。
|
||||
/// 章节 ID。
|
||||
/// </summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含正文内容。
|
||||
/// 是否包含正文内容。
|
||||
/// </summary>
|
||||
public bool IncludeContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
@@ -75,4 +75,4 @@ public sealed class StudyContentQueryDto
|
||||
IncludeContent,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,48 +8,59 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建分类节点请求 DTO。
|
||||
/// 创建分类节点请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateTaxonomyNodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 父节点 ID。
|
||||
/// 父节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父级来源。
|
||||
/// 父级来源。
|
||||
/// </summary>
|
||||
public QuestionSource? ParentSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点Type。
|
||||
/// 节点Type。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public TaxonomyNodeType NodeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(300)]
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateTaxonomyNodeCommand ToCommand() => new(
|
||||
ParentId,
|
||||
ParentSource,
|
||||
NodeType,
|
||||
Code,
|
||||
Name,
|
||||
SortOrder,
|
||||
Metadata);
|
||||
}
|
||||
public CreateTaxonomyNodeCommand ToCommand()
|
||||
{
|
||||
return new CreateTaxonomyNodeCommand(
|
||||
ParentId,
|
||||
ParentSource,
|
||||
NodeType,
|
||||
Code,
|
||||
Name,
|
||||
SortOrder,
|
||||
Metadata);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,19 +7,19 @@ using Tiku.Domain.Tenancy;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 租户解析查询参数。
|
||||
/// 租户解析查询参数。
|
||||
/// </summary>
|
||||
public sealed class TenantResolveQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 访问域名。
|
||||
/// 访问域名。
|
||||
/// </summary>
|
||||
[StringLength(253)]
|
||||
[Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")]
|
||||
public string? Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("租户编码;本地开发或无独立域名时使用,例如 master。")]
|
||||
@@ -27,7 +27,7 @@ public sealed class TenantResolveQueryDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户解析响应。
|
||||
/// 租户解析响应。
|
||||
/// </summary>
|
||||
/// <param name="Tenant">公开租户基础信息。</param>
|
||||
/// <param name="Branding">公开品牌配置。</param>
|
||||
@@ -35,7 +35,7 @@ public sealed class TenantResolveQueryDto
|
||||
/// <param name="AdminFeatures">面向管理端公开的功能开关。</param>
|
||||
/// <param name="PublicConfig">可公开的租户配置,不包含密钥或内部配置。</param>
|
||||
/// <summary>
|
||||
/// 租户解析Response请求 DTO。
|
||||
/// 租户解析Response请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record TenantResolveResponseDto(
|
||||
PublicTenantDto Tenant,
|
||||
@@ -45,7 +45,7 @@ public sealed record TenantResolveResponseDto(
|
||||
JsonElement PublicConfig);
|
||||
|
||||
/// <summary>
|
||||
/// 可公开给客户端的租户基础信息。
|
||||
/// 可公开给客户端的租户基础信息。
|
||||
/// </summary>
|
||||
/// <param name="Id">租户 ID。</param>
|
||||
/// <param name="Slug">租户编码。</param>
|
||||
@@ -54,7 +54,7 @@ public sealed record TenantResolveResponseDto(
|
||||
/// <param name="Mode">租户模式。</param>
|
||||
/// <param name="Host">当前匹配到的访问域名。</param>
|
||||
/// <summary>
|
||||
/// 公开租户请求 DTO。
|
||||
/// 公开租户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record PublicTenantDto(
|
||||
Guid Id,
|
||||
@@ -65,7 +65,7 @@ public sealed record PublicTenantDto(
|
||||
string? Host);
|
||||
|
||||
/// <summary>
|
||||
/// 可公开给客户端的租户品牌信息。
|
||||
/// 可公开给客户端的租户品牌信息。
|
||||
/// </summary>
|
||||
/// <param name="BrandName">品牌名称。</param>
|
||||
/// <param name="ShortName">品牌短名称。</param>
|
||||
@@ -77,7 +77,7 @@ public sealed record PublicTenantDto(
|
||||
/// <param name="Theme">公开主题配置。</param>
|
||||
/// <param name="PublicAssets">公开资源配置。</param>
|
||||
/// <summary>
|
||||
/// 公开租户品牌请求 DTO。
|
||||
/// 公开租户品牌请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record PublicTenantBrandingDto(
|
||||
string? BrandName,
|
||||
@@ -100,4 +100,4 @@ public sealed record PublicTenantBrandingDto(
|
||||
null,
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object());
|
||||
}
|
||||
}
|
||||
@@ -6,47 +6,54 @@ using Tiku.Domain.Common;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 保存租户前端配置草稿请求。
|
||||
/// 保存租户前端配置草稿请求。
|
||||
/// </summary>
|
||||
public sealed class SaveTenantFrontendConfigDraftDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 品牌配置。
|
||||
/// 品牌配置。
|
||||
/// </summary>
|
||||
public JsonElement Branding { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 主题配置。
|
||||
/// 主题配置。
|
||||
/// </summary>
|
||||
public JsonElement Theme { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 功能配置。
|
||||
/// 功能配置。
|
||||
/// </summary>
|
||||
public JsonElement Features { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 导航配置。
|
||||
/// 导航配置。
|
||||
/// </summary>
|
||||
public JsonElement Navigation { get; set; } = JsonDefaults.Array();
|
||||
|
||||
/// <summary>
|
||||
/// 首页模块配置。
|
||||
/// 首页模块配置。
|
||||
/// </summary>
|
||||
public JsonElement HomeModules { get; set; } = JsonDefaults.Array();
|
||||
|
||||
public TenantFrontendConfigDraft ToDraft() => new(
|
||||
Branding,
|
||||
Theme,
|
||||
Features,
|
||||
Navigation,
|
||||
HomeModules);
|
||||
public TenantFrontendConfigDraft ToDraft()
|
||||
{
|
||||
return new TenantFrontendConfigDraft(
|
||||
Branding,
|
||||
Theme,
|
||||
Features,
|
||||
Navigation,
|
||||
HomeModules);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布租户前端配置请求。
|
||||
/// 发布租户前端配置请求。
|
||||
/// </summary>
|
||||
public sealed class PublishTenantFrontendConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 期望配置版本。
|
||||
/// 期望配置版本。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ExpectedVersion { get; set; }
|
||||
}
|
||||
}
|
||||
25
Tiku.Api/Contracts/TenantLifecycleDtos.cs
Normal file
25
Tiku.Api/Contracts/TenantLifecycleDtos.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>租户生命周期变更原因。</summary>
|
||||
public sealed class TenantLifecycleReasonDto
|
||||
{
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>租户所有者转移请求。</summary>
|
||||
public sealed class TenantOwnerTransferDto
|
||||
{
|
||||
/// <summary>新的所有者用户 ID;必须是现有活跃成员。</summary>
|
||||
[Required]
|
||||
public Guid TargetUserId { get; set; }
|
||||
|
||||
/// <summary>审计原因。</summary>
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 3)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -5,90 +5,96 @@ using Tiku.Application.Assets;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 视频Search查询参数。
|
||||
/// 视频Search查询参数。
|
||||
/// </summary>
|
||||
public sealed class VideoSearchQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public VideoSearchQuery ToQuery() => new(Keyword, SubjectId, Limit);
|
||||
public VideoSearchQuery ToQuery()
|
||||
{
|
||||
return new VideoSearchQuery(Keyword, SubjectId, Limit);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 视频Play请求 DTO。
|
||||
/// 视频Play请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class VideoPlayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频 ID。
|
||||
/// 视频 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
public VideoPlayCommand ToCommand() => new(VideoId, QuestionId);
|
||||
public VideoPlayCommand ToCommand()
|
||||
{
|
||||
return new VideoPlayCommand(VideoId, QuestionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 视频Progress请求 DTO。
|
||||
/// 视频Progress请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class VideoProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频 ID。
|
||||
/// 视频 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 播放位置,单位为秒。
|
||||
/// 播放位置,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int PositionSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为秒。
|
||||
/// 时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? DurationSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已观看时长,单位为秒。
|
||||
/// 已观看时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? WatchedSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已完成。
|
||||
/// 是否已完成。
|
||||
/// </summary>
|
||||
public bool? IsCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
@@ -106,26 +112,29 @@ public sealed class VideoProgressDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题目视频查询参数。
|
||||
/// 题目视频查询参数。
|
||||
/// </summary>
|
||||
public sealed class QuestionVideoQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID 列表。
|
||||
/// 题目 ID 列表。
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public IReadOnlyCollection<Guid>? QuestionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public QuestionVideoQuery ToQuery() => new(QuestionId, QuestionIds, Limit);
|
||||
}
|
||||
public QuestionVideoQuery ToQuery()
|
||||
{
|
||||
return new QuestionVideoQuery(QuestionId, QuestionIds, Limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-交易运营")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class ActivationCodeAdministrationController(
|
||||
IActivationCodeAdministrationService service,
|
||||
CommerceAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpPost("code-batches")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建兑换码批次")]
|
||||
[ProducesResponseType<CodeBatchItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CodeBatchItem>> CreateCodeBatch(
|
||||
CreateCodeBatchDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.CreateCodeBatchAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("activation-codes")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询兑换码")]
|
||||
[ProducesResponseType<ActivationCodeList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ActivationCodeList>> ActivationCodes(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetActivationCodesAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("activation-codes/redeem")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("后台核销兑换码")]
|
||||
[ProducesResponseType<ActivationCodeItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ActivationCodeItem>> RedeemActivationCode(
|
||||
RedeemActivationCodeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.RedeemActivationCodeAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
35
Tiku.Api/Controllers/AnsweringController.cs
Normal file
35
Tiku.Api/Controllers/AnsweringController.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-学习")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/student/learning")]
|
||||
public sealed class AnsweringController(
|
||||
IAnsweringService service,
|
||||
LearningActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpPost("answers")]
|
||||
[EndpointSummary("提交题目答案")]
|
||||
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AnswerRecordItem>> SubmitAnswer(
|
||||
SubmitAnswerDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.SubmitAnswerAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -13,12 +11,12 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-资源访问")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/assets")]
|
||||
[Route("api/student/assets")]
|
||||
public sealed class AssetsController(
|
||||
IAssetAccessService assetAccessService,
|
||||
ITenantContext currentTenant,
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpGet("{assetId:guid}/download")]
|
||||
[EndpointSummary("获取资源下载地址")]
|
||||
@@ -75,24 +73,12 @@ public sealed class AssetsController(
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue)
|
||||
{
|
||||
return currentTenant.TenantId.Value;
|
||||
}
|
||||
if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value;
|
||||
|
||||
var resolvedTenantCode = tenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(resolvedTenantCode))
|
||||
{
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(resolvedTenantCode)) throw new TenantNotFoundException();
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == resolvedTenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Tiku.Api/Controllers/AuthCapabilitySet.cs
Normal file
17
Tiku.Api/Controllers/AuthCapabilitySet.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Tiku.Application.Auth;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class AuthCapabilitySet(
|
||||
IPasswordLoginService passwordLogin,
|
||||
ISmsLoginService smsLogin,
|
||||
IWechatLoginService wechatLogin,
|
||||
IAuthSessionService sessions,
|
||||
IPasswordLifecycleService passwordLifecycle)
|
||||
{
|
||||
internal IPasswordLoginService PasswordLogin { get; } = passwordLogin;
|
||||
internal ISmsLoginService SmsLogin { get; } = smsLogin;
|
||||
internal IWechatLoginService WechatLogin { get; } = wechatLogin;
|
||||
internal IAuthSessionService Sessions { get; } = sessions;
|
||||
internal IPasswordLifecycleService PasswordLifecycle { get; } = passwordLifecycle;
|
||||
}
|
||||
@@ -1,31 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-认证")]
|
||||
[Route("api/auth")]
|
||||
[Route("api/platform/auth")]
|
||||
[Route("api/tenant/auth")]
|
||||
[Route("api/student/auth")]
|
||||
[Produces("application/json")]
|
||||
public sealed class AuthController(
|
||||
IAuthService authService,
|
||||
AuthCapabilitySet authCapabilities,
|
||||
IOwnerActivationService ownerActivationService,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
IAuthSessionStore sessionStore,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory,
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||
AuthRequestContextResolver requestContextResolver,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("activation/complete")]
|
||||
[EndpointSummary("完成租户负责人一次性激活")]
|
||||
[EndpointDescription("使用平台开通时签发的一次性令牌设置初始密码;令牌仅可消费一次。")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> CompleteOwnerActivation(
|
||||
CompleteOwnerActivationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ownerActivationService.CompleteAsync(request.ToRequest(), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
@@ -39,13 +53,13 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
if (realm != AuthRealm.Tenant)
|
||||
{
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
}
|
||||
|
||||
var tenantId = await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
var result = await smsVerificationService.CreateCodeAsync(
|
||||
new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
@@ -70,15 +84,14 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
throw new RequiredFieldException("identifier is required.");
|
||||
}
|
||||
var result = await authService.LoginWithPasswordAsync(
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authCapabilities.PasswordLogin.LoginWithPasswordAsync(
|
||||
new PasswordLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
identifier,
|
||||
request.Password,
|
||||
GetIpAddress(),
|
||||
@@ -100,10 +113,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithSmsAsync(
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authCapabilities.SmsLogin.LoginWithSmsAsync(
|
||||
new SmsLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Phone,
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
@@ -125,10 +140,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatWebAsync(
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authCapabilities.WechatLogin.LoginWithWechatWebAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -149,10 +166,12 @@ public sealed class AuthController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||
requestContextResolver.EnsureRouteRealm(realm, Request);
|
||||
var result = await authCapabilities.WechatLogin.LoginWithWechatMiniAppAsync(
|
||||
new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveRealmTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveRealmTenantIdAsync(realm, request.TenantCode, Request,
|
||||
cancellationToken),
|
||||
request.Code,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
@@ -169,8 +188,8 @@ public sealed class AuthController(
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
var result = await authService.RefreshAsync(
|
||||
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
|
||||
var result = await authCapabilities.Sessions.RefreshAsync(
|
||||
new RefreshSessionRequest(
|
||||
request.RefreshToken,
|
||||
GetIpAddress(),
|
||||
@@ -189,8 +208,8 @@ public sealed class AuthController(
|
||||
[FromBody] RefreshSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveRefreshTokenTenant(request.RefreshToken);
|
||||
await authService.LogoutAsync(
|
||||
requestContextResolver.ResolveRefreshTokenTenant(request.RefreshToken, Request);
|
||||
await authCapabilities.Sessions.LogoutAsync(
|
||||
new LogoutSessionRequest(request.RefreshToken),
|
||||
cancellationToken);
|
||||
|
||||
@@ -204,12 +223,9 @@ public sealed class AuthController(
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
|
||||
await authService.LogoutAllAsync(userId, cancellationToken);
|
||||
await authCapabilities.Sessions.LogoutAllAsync(userId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -222,65 +238,83 @@ public sealed class AuthController(
|
||||
[FromBody] RequiredPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ResolveAuthChallengeTenant(request.ChallengeToken);
|
||||
var result = await authService.ChangeRequiredPasswordAsync(
|
||||
requestContextResolver.ResolveAuthChallengeTenant(request.ChallengeToken, Request);
|
||||
var result = await authCapabilities.PasswordLifecycle.ChangeRequiredPasswordAsync(
|
||||
new PasswordChangeChallengeRequest(
|
||||
request.ChallengeToken, request.NewPassword, GetIpAddress(), Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
private void ResolveRefreshTokenTenant(string refreshToken)
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送密码重置短信验证码")]
|
||||
[EndpointDescription("仅适用于租户授权域;无论手机号是否存在均返回相同接受响应。")]
|
||||
[ProducesResponseType<SmsSendResult>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (locator.Realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved)
|
||||
{
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantContext.IsResolved)
|
||||
{
|
||||
throw new RequiredFieldException(
|
||||
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
|
||||
}
|
||||
|
||||
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
|
||||
Request, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authCapabilities.PasswordLifecycle.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
private void ResolveAuthChallengeTenant(string challengeToken)
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var parts = challengeToken.Split('.', 4, StringSplitOptions.None);
|
||||
if (parts.Length != 4 || parts[0] != "c1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
var tenantId = await requestContextResolver.ResolveRealmTenantIdAsync(AuthRealm.Tenant, request.TenantCode,
|
||||
Request, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authCapabilities.PasswordLifecycle.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
if (parts[1] == "p" && parts[2] == "-")
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved)
|
||||
{
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
}
|
||||
return;
|
||||
}
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("已登录用户修改密码")]
|
||||
[EndpointDescription("修改成功后撤销旧会话并返回新的令牌对。")]
|
||||
public async Task<ActionResult<AuthenticationResultDto>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized();
|
||||
|
||||
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
|
||||
{
|
||||
throw new RequiredFieldException(
|
||||
"tenant authentication challenge requires a tenant host or x-tenant-code.");
|
||||
}
|
||||
|
||||
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
|
||||
var result = await authCapabilities.PasswordLifecycle.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
GetIpAddress(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(AuthenticationResultDto.FromApplication(result));
|
||||
}
|
||||
|
||||
private string? GetIpAddress()
|
||||
@@ -288,63 +322,4 @@ public sealed class AuthController(
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveRealmTenantIdAsync(
|
||||
AuthRealm realm,
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost();
|
||||
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
throw new RequiredFieldException("platform realm does not accept tenantCode and must use a platform host.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantContext.TenantId.HasValue)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
||||
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
|
||||
if (supplied?.TenantId != tenantContext.TenantId.Value)
|
||||
{
|
||||
throw new TenantContextConflictException(
|
||||
tenantContext.TenantId.Value,
|
||||
supplied?.TenantId ?? Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
return tenantContext.TenantId.Value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
|
||||
}
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantContextInitializer.Initialize(
|
||||
tenant.TenantId,
|
||||
tenant.TenantCode,
|
||||
TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private void EnsurePlatformHost()
|
||||
{
|
||||
var requestHost = Request.Host.Host.Trim().TrimEnd('.');
|
||||
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||||
string.Equals(
|
||||
host.Trim().TrimEnd('.'),
|
||||
requestHost,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
Tiku.Api/Controllers/AuthRequestContextResolver.cs
Normal file
121
Tiku.Api/Controllers/AuthRequestContextResolver.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class AuthRequestContextResolver(
|
||||
IAuthSessionStore sessionStore,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
ITenantDirectory tenantDirectory,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions)
|
||||
{
|
||||
internal void ResolveRefreshTokenTenant(string refreshToken, HttpRequest request)
|
||||
{
|
||||
if (!sessionStore.TryParseRefreshToken(refreshToken, out var locator)) return;
|
||||
|
||||
if (locator.Realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost(request);
|
||||
if (tenantContext.IsResolved)
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantContext.IsResolved)
|
||||
throw new RequiredFieldException(
|
||||
"tenant refresh/logout requires a tenant host or x-tenant-code matching the refresh token.");
|
||||
|
||||
tenantContextInitializer.Initialize(locator.TenantId!.Value, null, TenantResolutionSource.RefreshToken);
|
||||
}
|
||||
|
||||
internal void ResolveAuthChallengeTenant(string challengeToken, HttpRequest request)
|
||||
{
|
||||
var parts = challengeToken.Split('.', 4);
|
||||
if (parts.Length != 4 || parts[0] != "c1") return;
|
||||
|
||||
if (parts[1] == "p" && parts[2] == "-")
|
||||
{
|
||||
EnsurePlatformHost(request);
|
||||
if (tenantContext.IsResolved)
|
||||
throw new TenantContextConflictException(tenantContext.TenantId!.Value, Guid.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts[1] != "t" || !Guid.TryParseExact(parts[2], "N", out var tenantId) || !tenantContext.IsResolved)
|
||||
throw new RequiredFieldException(
|
||||
"tenant authentication challenge requires a tenant host or x-tenant-code.");
|
||||
|
||||
tenantContextInitializer.Initialize(tenantId, null, TenantResolutionSource.RefreshToken);
|
||||
}
|
||||
|
||||
internal async Task<Guid?> ResolveRealmTenantIdAsync(
|
||||
AuthRealm realm,
|
||||
string? tenantCode,
|
||||
HttpRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
EnsurePlatformHost(request);
|
||||
if (tenantContext.IsResolved || !string.IsNullOrWhiteSpace(tenantCode))
|
||||
throw new RequiredFieldException(
|
||||
"platform realm does not accept tenantCode and must use a platform host.");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantContext.TenantId.HasValue)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode) &&
|
||||
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
|
||||
if (supplied?.TenantId != tenantContext.TenantId.Value)
|
||||
throw new TenantContextConflictException(
|
||||
tenantContext.TenantId.Value,
|
||||
supplied?.TenantId ?? Guid.Empty);
|
||||
}
|
||||
|
||||
return tenantContext.TenantId.Value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantContextInitializer.Initialize(
|
||||
tenant.TenantId,
|
||||
tenant.TenantCode,
|
||||
TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
internal void EnsureRouteRealm(AuthRealm realm, HttpRequest request)
|
||||
{
|
||||
var path = request.Path.Value ?? string.Empty;
|
||||
var expectedRealm = path.StartsWith("/api/platform/auth/", StringComparison.OrdinalIgnoreCase)
|
||||
? AuthRealm.Platform
|
||||
: AuthRealm.Tenant;
|
||||
if (realm != expectedRealm)
|
||||
throw new RequiredFieldException(
|
||||
$"{realm.ToString().ToLowerInvariant()} realm must use the {expectedRealm.ToString().ToLowerInvariant()} authentication route.");
|
||||
}
|
||||
|
||||
private void EnsurePlatformHost(HttpRequest request)
|
||||
{
|
||||
var requestHost = request.Host.Host.Trim().TrimEnd('.');
|
||||
if (!tenantResolutionOptions.Value.PlatformHosts.Any(host =>
|
||||
string.Equals(
|
||||
host.Trim().TrimEnd('.'),
|
||||
requestHost,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-后台任务")]
|
||||
[Route("api/backoffice/tenant/jobs")]
|
||||
[Route("api/tenant/access/jobs")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
public sealed class BackgroundJobsController(
|
||||
IBackgroundJobService backgroundJobService,
|
||||
ITenantContext tenantContext) : ControllerBase
|
||||
IBackgroundJobQueue backgroundJobQueue,
|
||||
IBackgroundJobOperations backgroundJobOperations,
|
||||
ITenantContext tenantContext,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询租户后台任务")]
|
||||
@@ -23,7 +25,7 @@ public sealed class BackgroundJobsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
return Ok(await backgroundJobService.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
|
||||
return Ok(await backgroundJobOperations.ListAsync(tenantId, jobType, limit ?? 50, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -34,11 +36,43 @@ public sealed class BackgroundJobsController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenantId();
|
||||
return Ok(await backgroundJobService.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
return Ok(await backgroundJobQueue.EnqueueAsync(request.ToCommand(tenantId), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:guid}")]
|
||||
[EndpointSummary("查询租户后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Detail(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobOperations.GetAsync(jobId, ResolveTenantId(), cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/cancel")]
|
||||
[EndpointSummary("取消租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Cancel(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobOperations.RequestCancellationAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), request.Reason, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{jobId:guid}/retry")]
|
||||
[EndpointSummary("重试失败或已取消的租户后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Retry(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobOperations.RetryAsync(
|
||||
jobId, ResolveTenantId(), ResolveUserId(), cancellationToken));
|
||||
}
|
||||
|
||||
private Guid ResolveTenantId()
|
||||
{
|
||||
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant context was not resolved.");
|
||||
}
|
||||
}
|
||||
|
||||
private Guid ResolveUserId()
|
||||
{
|
||||
return currentUser.UserId ?? throw new InvalidOperationException("Current user was not resolved.");
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,20 @@ using Microsoft.Extensions.Options;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-浏览器认证")]
|
||||
[Route("api/browser-auth")]
|
||||
[Route("api/tenant/auth/browser")]
|
||||
[Produces("application/json")]
|
||||
public sealed class BrowserAuthController(
|
||||
IAuthService authService,
|
||||
AuthCapabilitySet authCapabilities,
|
||||
IOwnerActivationService ownerActivationService,
|
||||
ISmsVerificationService smsVerificationService,
|
||||
ITenantContext tenantContext,
|
||||
ITenantContextInitializer tenantContextInitializer,
|
||||
@@ -26,6 +27,26 @@ public sealed class BrowserAuthController(
|
||||
ICurrentUser currentUser,
|
||||
IOptions<TenantResolutionOptions> tenantResolutionOptions) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("activation/complete")]
|
||||
[EndpointSummary("完成租户 Owner 激活并建立浏览器会话")]
|
||||
public async Task<ActionResult<object>> CompleteOwnerActivation(
|
||||
CompleteOwnerActivationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = tenantContext.TenantId ?? throw new TenantNotFoundException();
|
||||
var result = await ownerActivationService.CompleteAndAuthenticateAsync(
|
||||
request.ToRequest(),
|
||||
tenantId,
|
||||
Request.Host.Host,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("sms/send")]
|
||||
@@ -36,12 +57,11 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
if (realm != AuthRealm.Tenant)
|
||||
{
|
||||
throw new RequiredFieldException("SMS authentication is only available in the tenant realm.");
|
||||
}
|
||||
var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
?? throw new RequiredFieldException("tenantCode is required for SMS authentication.");
|
||||
var result = await smsVerificationService.CreateCodeAsync(new SendSmsCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
@@ -61,9 +81,10 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
EnsureTenantRealm(realm);
|
||||
var identifier = request.Identifier ?? request.Phone;
|
||||
if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required.");
|
||||
var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
var result = await authCapabilities.PasswordLogin.LoginWithPasswordAsync(new PasswordLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
identifier,
|
||||
@@ -83,7 +104,8 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authCapabilities.SmsLogin.LoginWithSmsAsync(new SmsLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Phone,
|
||||
@@ -102,7 +124,8 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest(
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authCapabilities.WechatLogin.LoginWithWechatWebAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
@@ -120,7 +143,8 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var realm = request.Realm!.Value;
|
||||
var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
EnsureTenantRealm(realm);
|
||||
var result = await authCapabilities.WechatLogin.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||
realm,
|
||||
await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken),
|
||||
request.Code,
|
||||
@@ -137,7 +161,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized();
|
||||
var tokens = await authService.RefreshAsync(new RefreshSessionRequest(
|
||||
var tokens = await authCapabilities.Sessions.RefreshAsync(new RefreshSessionRequest(
|
||||
refreshToken,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()), cancellationToken);
|
||||
@@ -153,9 +177,7 @@ public sealed class BrowserAuthController(
|
||||
{
|
||||
var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie];
|
||||
if (!string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
|
||||
}
|
||||
await authCapabilities.Sessions.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
@@ -166,29 +188,98 @@ public sealed class BrowserAuthController(
|
||||
public async Task<IActionResult> LogoutAll(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId) return Unauthorized();
|
||||
await authService.LogoutAllAsync(userId, cancellationToken);
|
||||
await authCapabilities.Sessions.LogoutAllAsync(userId, cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Sms)]
|
||||
[HttpPost("password/reset/sms/send")]
|
||||
[EndpointSummary("发送浏览器密码重置短信验证码")]
|
||||
public async Task<ActionResult<SmsSendResult>> SendPasswordResetCode(
|
||||
PasswordResetSmsSendDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
var result = await authCapabilities.PasswordLifecycle.RequestPasswordResetAsync(
|
||||
new PasswordResetCodeRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString(),
|
||||
request.DeviceId),
|
||||
cancellationToken);
|
||||
return Accepted(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/reset")]
|
||||
[EndpointSummary("使用短信验证码重置浏览器账号密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetPassword(
|
||||
PasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
var tenantId = await ResolveTenantIdAsync(AuthRealm.Tenant, request.TenantCode, cancellationToken)
|
||||
?? throw new RequiredFieldException("tenantCode is required for password reset.");
|
||||
await authCapabilities.PasswordLifecycle.ResetPasswordAsync(
|
||||
new PasswordResetRequest(
|
||||
tenantId,
|
||||
request.Phone,
|
||||
request.Code,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
ClearCookies();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableRateLimiting(AuthRateLimitPolicies.Password)]
|
||||
[HttpPost("password/change")]
|
||||
[EndpointSummary("浏览器已登录用户修改密码")]
|
||||
public async Task<ActionResult<object>> ChangePassword(
|
||||
AuthenticatedPasswordChangeDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureTrustedOrigin();
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized();
|
||||
|
||||
var result = await authCapabilities.PasswordLifecycle.ChangePasswordAsync(
|
||||
new AuthenticatedPasswordChangeRequest(
|
||||
userId,
|
||||
sessionId,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword,
|
||||
HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Request.Headers.UserAgent.ToString()),
|
||||
cancellationToken);
|
||||
return Ok(WriteResult(result));
|
||||
}
|
||||
|
||||
private object WriteResult(AuthenticationResult result)
|
||||
{
|
||||
if (result.User?.Tokens is { } tokens)
|
||||
{
|
||||
WriteCookies(tokens);
|
||||
}
|
||||
if (result.User?.Tokens is { } tokens) WriteCookies(tokens);
|
||||
return new
|
||||
{
|
||||
status = result.Status.ToString(),
|
||||
user = result.User is null ? null : new
|
||||
{
|
||||
result.User.UserId,
|
||||
result.User.Phone,
|
||||
result.User.Email,
|
||||
result.User.Name,
|
||||
result.User.Realm,
|
||||
result.User.Tenant
|
||||
},
|
||||
user = result.User is null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
result.User.UserId,
|
||||
result.User.Phone,
|
||||
result.User.Email,
|
||||
result.User.Name,
|
||||
result.User.Realm,
|
||||
result.User.Tenant
|
||||
},
|
||||
result.ChallengeToken,
|
||||
result.ChallengeExpiresAt
|
||||
};
|
||||
@@ -209,7 +300,7 @@ public sealed class BrowserAuthController(
|
||||
Secure = true,
|
||||
HttpOnly = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/browser-auth",
|
||||
Path = "/api/tenant/auth/browser",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
Response.Cookies.Append(BrowserAuthOptions.CsrfCookie,
|
||||
@@ -225,7 +316,8 @@ public sealed class BrowserAuthController(
|
||||
private void ClearCookies()
|
||||
{
|
||||
Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie,
|
||||
new CookieOptions { Secure = true, Path = "/api/tenant/auth/browser" });
|
||||
Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" });
|
||||
}
|
||||
|
||||
@@ -235,12 +327,11 @@ public sealed class BrowserAuthController(
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new BrowserOriginException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken)
|
||||
private async Task<Guid?> ResolveTenantIdAsync(AuthRealm realm, string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (realm == AuthRealm.Platform)
|
||||
{
|
||||
@@ -250,13 +341,20 @@ public sealed class BrowserAuthController(
|
||||
throw new RequiredFieldException("platform realm is only available on a configured platform host.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantContext.TenantId is { } resolved) return resolved;
|
||||
if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required.");
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new TenantNotFoundException();
|
||||
?? throw new TenantNotFoundException();
|
||||
tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private static void EnsureTenantRealm(AuthRealm realm)
|
||||
{
|
||||
if (realm != AuthRealm.Tenant)
|
||||
throw new RequiredFieldException("Browser tenant authentication only accepts the tenant realm.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request.");
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -18,7 +17,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("学生端-公开目录")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/catalog")]
|
||||
[Route("api/public/catalog")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
public sealed class CatalogController(
|
||||
ICatalogQueryService catalogQueryService,
|
||||
@@ -27,7 +26,7 @@ public sealed class CatalogController(
|
||||
IStudyContentQueryService studyContentQueryService,
|
||||
IAssetQueryService assetQueryService,
|
||||
ITenantContext currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
{
|
||||
[HttpGet("regions")]
|
||||
[EndpointSummary("查询可用地区")]
|
||||
@@ -149,7 +148,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-collections")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询可用题集")]
|
||||
[ProducesResponseType<CatalogList<QuestionCollectionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -163,7 +162,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-collections/questions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题集内题目")]
|
||||
[ProducesResponseType<CatalogList<CollectionQuestionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
@@ -178,7 +177,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("practice-blueprints")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询练习蓝图")]
|
||||
[ProducesResponseType<CatalogList<PracticeBlueprintCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -192,7 +191,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-banks")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题库列表")]
|
||||
[ProducesResponseType<CatalogList<QuestionBankCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -206,7 +205,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询已发布题目")]
|
||||
[EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")]
|
||||
[ProducesResponseType<CatalogList<QuestionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
@@ -221,7 +220,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions/{questionId:guid}")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题目详情")]
|
||||
[ProducesResponseType<QuestionCatalogItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -236,7 +235,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions/{questionId:guid}/versions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题目版本")]
|
||||
[ProducesResponseType<CatalogList<QuestionVersionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -251,7 +250,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-units")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询词汇单元")]
|
||||
[ProducesResponseType<CatalogList<VocabularyUnitCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -265,7 +264,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询词汇单词")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWordCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -279,7 +278,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-subjects")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubjectCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -293,7 +292,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapterCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -307,7 +306,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntryCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -360,7 +359,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("video-explanations")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("查询视频讲解")]
|
||||
[ProducesResponseType<CatalogList<VideoExplanationCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -374,7 +373,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-videos")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("查询题目关联视频")]
|
||||
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -388,7 +387,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("banners")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询首页横幅")]
|
||||
[ProducesResponseType<CatalogList<BannerCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -402,7 +401,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("faqs")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询常见问题")]
|
||||
[ProducesResponseType<CatalogList<FaqCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -416,7 +415,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("announcements")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询公告")]
|
||||
[ProducesResponseType<CatalogList<AnnouncementCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -430,7 +429,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("exam-dates")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询考试日期")]
|
||||
[ProducesResponseType<CatalogList<ExamDateCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -444,7 +443,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("products")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("查询可购买产品")]
|
||||
[ProducesResponseType<CatalogList<ProductCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -458,7 +457,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("svip-plans")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("查询 SVIP 套餐")]
|
||||
[ProducesResponseType<CatalogList<SvipPlanCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -475,25 +474,13 @@ public sealed class CatalogController(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue)
|
||||
{
|
||||
return currentTenant.TenantId.Value;
|
||||
}
|
||||
if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value;
|
||||
|
||||
var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
throw new TenantNotFoundException();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(tenantCode)) throw new TenantNotFoundException();
|
||||
|
||||
var tenantId = await dbContext.Tenants
|
||||
.Where(tenant =>
|
||||
tenant.Slug == tenantCode.Trim() &&
|
||||
tenant.Status == TenantStatus.Active)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
@@ -551,4 +538,4 @@ public sealed class TenantNotFoundException : Exception
|
||||
: base("Tenant was not found.")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
115
Tiku.Api/Controllers/CommerceAdjustmentController.cs
Normal file
115
Tiku.Api/Controllers/CommerceAdjustmentController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-交易运营")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class CommerceAdjustmentController(
|
||||
ICommerceAdjustmentService service,
|
||||
CommerceAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("adjustment-vouchers")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证")]
|
||||
[ProducesResponseType<TenantAdjustmentVoucherList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentVoucherList>> AdjustmentVouchers(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAdjustmentVouchersAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/detail")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证详情")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> AdjustmentVoucherDetail(
|
||||
[FromQuery] Guid voucherId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAdjustmentVoucherAsync(
|
||||
actorResolver.Resolve(),
|
||||
voucherId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("adjustment-vouchers")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("创建调账凭证")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> CreateAdjustmentVoucher(
|
||||
CreateAdjustmentVoucherDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.CreateAdjustmentVoucherAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("adjustment-vouchers/status")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("审核或关闭调账凭证")]
|
||||
[ProducesResponseType<CommerceAdjustmentVoucher>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CommerceAdjustmentVoucher>> UpdateAdjustmentVoucherStatus(
|
||||
UpdateAdjustmentVoucherStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpdateAdjustmentVoucherStatusAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/events")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账凭证事件")]
|
||||
[ProducesResponseType<TenantAdjustmentVoucherEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentVoucherEventList>> AdjustmentVoucherEvents(
|
||||
[FromQuery] Guid voucherId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAdjustmentVoucherEventsAsync(
|
||||
actorResolver.Resolve(),
|
||||
voucherId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("adjustment-vouchers/report")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询调账统计报告")]
|
||||
[ProducesResponseType<TenantAdjustmentReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantAdjustmentReport>> AdjustmentVoucherReport(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAdjustmentReportAsync(
|
||||
actorResolver.Resolve(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("reconciliation/anomalies")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询对账异常汇总")]
|
||||
[ProducesResponseType<TenantCommerceAnomalySummary>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCommerceAnomalySummary>> ReconciliationAnomalies(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAnomalySummaryAsync(
|
||||
actorResolver.Resolve(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
28
Tiku.Api/Controllers/CommerceAdminActorResolver.cs
Normal file
28
Tiku.Api/Controllers/CommerceAdminActorResolver.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class CommerceAdminActorResolver(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant)
|
||||
{
|
||||
internal CommerceAdminActor Resolve()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
throw new CommerceException("Tenant commerce actor was not resolved.", "tenant_admin_access_denied");
|
||||
|
||||
return new CommerceAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
|
||||
internal CommerceAdminQuery ToQuery(TenantCommerceQueryDto query)
|
||||
{
|
||||
return new CommerceAdminQuery(query.Provider, query.Status, query.Limit);
|
||||
}
|
||||
|
||||
internal TenantPointQuery ToPointQuery(TenantCommerceQueryDto query)
|
||||
{
|
||||
return new TenantPointQuery(query.Status, query.Limit, query.UserId, query.RegionId);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
@@ -14,16 +14,17 @@ namespace Tiku.Api.Controllers;
|
||||
[ApiController]
|
||||
[Tags("学生端-交易")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commerce")]
|
||||
[Route("api/student/commerce")]
|
||||
public sealed class CommerceController(
|
||||
ICommerceService commerceService,
|
||||
ICommerceAdminService commerceAdminService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
ICommerceOrderService commerceOrderService,
|
||||
ICommercePaymentService commercePaymentService,
|
||||
ICommerceEntitlementService commerceEntitlementService,
|
||||
ICommerceCouponService commerceCouponService,
|
||||
ICommercePaymentNotificationService commercePaymentNotificationService,
|
||||
IRefundAdministrationService refundAdministrationService,
|
||||
CommerceRequestContextResolver requestContextResolver) : ControllerBase
|
||||
{
|
||||
[HttpPost("orders")]
|
||||
[EndpointSummary("创建学生端订单")]
|
||||
@@ -32,8 +33,8 @@ public sealed class CommerceController(
|
||||
CreateCommerceOrderDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.CreateOrderAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceOrderService.CreateOrderAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -45,8 +46,8 @@ public sealed class CommerceController(
|
||||
[FromQuery] CommerceOrderQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.GetOrdersAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceOrderService.GetOrdersAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
new CommerceOrderQuery(query.Limit, query.Status),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -58,8 +59,8 @@ public sealed class CommerceController(
|
||||
string orderNo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.GetOrderAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceOrderService.GetOrderAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
orderNo,
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -71,8 +72,8 @@ public sealed class CommerceController(
|
||||
CreateCommercePaymentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.CreatePaymentAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commercePaymentService.CreatePaymentAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -83,8 +84,8 @@ public sealed class CommerceController(
|
||||
public async Task<ActionResult<CurrentEntitlementItem>> CurrentEntitlement(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.GetCurrentEntitlementAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceEntitlementService.GetCurrentEntitlementAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -95,8 +96,8 @@ public sealed class CommerceController(
|
||||
ClaimCommerceCouponDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.ClaimCouponAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceCouponService.ClaimCouponAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -108,8 +109,8 @@ public sealed class CommerceController(
|
||||
[FromQuery] CommerceCouponQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.GetCouponsAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceCouponService.GetCouponsAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
query.ToQuery(),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -121,8 +122,8 @@ public sealed class CommerceController(
|
||||
CheckCommerceCouponDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceService.CheckCouponAsync(
|
||||
ResolveActor(),
|
||||
return Ok(await commerceCouponService.CheckCouponAsync(
|
||||
requestContextResolver.ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -136,7 +137,7 @@ public sealed class CommerceController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await ProcessNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
PaymentProviders.WechatPay,
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -150,7 +151,7 @@ public sealed class CommerceController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await ProcessNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
PaymentProviders.Alipay,
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -164,8 +165,8 @@ public sealed class CommerceController(
|
||||
RefundNotificationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.ProcessRefundNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
return Ok(await refundAdministrationService.ProcessRefundNotificationAsync(
|
||||
await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
request.ToCommand(PaymentProviders.WechatPay),
|
||||
cancellationToken));
|
||||
}
|
||||
@@ -179,51 +180,19 @@ public sealed class CommerceController(
|
||||
RefundNotificationDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commerceAdminService.ProcessRefundNotificationAsync(
|
||||
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
return Ok(await refundAdministrationService.ProcessRefundNotificationAsync(
|
||||
await requestContextResolver.ResolveNotificationTenantAsync(tenantCode, cancellationToken),
|
||||
request.ToCommand(PaymentProviders.Alipay),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveNotificationTenantAsync(
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue)
|
||||
{
|
||||
return currentTenant.TenantId.Value;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
throw new CommerceException("Tenant code is required for payment notification.", "tenant_required");
|
||||
}
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new CommerceException("Tenant was not found.", "tenant_not_found");
|
||||
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
|
||||
private CommerceActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new CommerceException("Current commerce actor was not resolved.", "commerce_access_denied");
|
||||
}
|
||||
|
||||
return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
|
||||
private async Task<PaymentNotificationProcessResult> ProcessNotificationAsync(
|
||||
Guid tenantId,
|
||||
string provider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
throw new CommerceException("Tenant id is required for payment notification.", "tenant_required");
|
||||
}
|
||||
|
||||
var rawBody = await ReadRawBodyAsync(Request, cancellationToken);
|
||||
using var body = ParseNotificationBody(Request, rawBody, cancellationToken);
|
||||
@@ -232,7 +201,7 @@ public sealed class CommerceController(
|
||||
pair => pair.Value.ToString(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return await commerceService.ProcessPaymentNotificationAsync(
|
||||
return await commercePaymentNotificationService.ProcessPaymentNotificationAsync(
|
||||
tenantId,
|
||||
provider,
|
||||
headers,
|
||||
@@ -246,7 +215,7 @@ public sealed class CommerceController(
|
||||
using var reader = new StreamReader(
|
||||
request.Body,
|
||||
Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: false,
|
||||
false,
|
||||
leaveOpen: false);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
@@ -267,10 +236,7 @@ public sealed class CommerceController(
|
||||
return JsonDocument.Parse(JsonSerializer.Serialize(values));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawBody))
|
||||
{
|
||||
return JsonDocument.Parse("{}");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(rawBody)) return JsonDocument.Parse("{}");
|
||||
|
||||
return JsonDocument.Parse(rawBody);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-交易运营")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class CommerceOrderAdministrationController(
|
||||
ICommerceOrderAdministrationService service,
|
||||
CommerceAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("orders")]
|
||||
[EndpointSummary("查询租户订单")]
|
||||
[ProducesResponseType<AdminOrderList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AdminOrderList>> Orders(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetOrdersAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("payments")]
|
||||
[EndpointSummary("查询租户支付记录")]
|
||||
[ProducesResponseType<AdminPaymentList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<AdminPaymentList>> Payments(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetPaymentsAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
37
Tiku.Api/Controllers/CommerceRequestContextResolver.cs
Normal file
37
Tiku.Api/Controllers/CommerceRequestContextResolver.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class CommerceRequestContextResolver(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant,
|
||||
ITenantContextInitializer tenantInitializer,
|
||||
ITenantDirectory tenantDirectory)
|
||||
{
|
||||
internal CommerceActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
throw new CommerceException("Current commerce actor was not resolved.", "commerce_access_denied");
|
||||
|
||||
return new CommerceActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
|
||||
internal async Task<Guid> ResolveNotificationTenantAsync(
|
||||
string? tenantCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantCode))
|
||||
throw new CommerceException("Tenant code is required for payment notification.", "tenant_required");
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
|
||||
?? throw new CommerceException("Tenant was not found.", "tenant_not_found");
|
||||
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
|
||||
return tenant.TenantId;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
@@ -9,9 +10,9 @@ namespace Tiku.Api.Controllers;
|
||||
[ApiController]
|
||||
[Tags("租户端-佣金")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commission")]
|
||||
[Route("api/tenant/commission")]
|
||||
public sealed class CommissionController(
|
||||
ICommissionService commissionService,
|
||||
ICurrentUser currentUser,
|
||||
@@ -19,71 +20,109 @@ public sealed class CommissionController(
|
||||
{
|
||||
[HttpGet("settings")]
|
||||
[EndpointSummary("查询佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("settings")]
|
||||
[EndpointSummary("保存佣金配置")]
|
||||
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionSettingsItem>> UpdateSettings(UpdateCommissionSettingsDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("member-rate")]
|
||||
[EndpointSummary("调整成员佣金比例")]
|
||||
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<object>> MemberRate(UpdateMemberCommissionRateDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
[EndpointSummary("查询佣金统计摘要")]
|
||||
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionSummaryItem>> Summary([FromQuery] CommissionPeriodQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("orders")]
|
||||
[EndpointSummary("查询佣金来源订单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionList<CommissionSourceItem>>> Orders(
|
||||
[FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("settlements")]
|
||||
[EndpointSummary("查询佣金结算单")]
|
||||
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionList<CommissionSettlementItemDto>>> Settlements(
|
||||
[FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("settlements/export")]
|
||||
[EndpointSummary("导出佣金结算单")]
|
||||
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken));
|
||||
public async Task<ActionResult<CommissionExportItem>> Export([FromQuery] CommissionSettlementExportQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("settlements/generate")]
|
||||
[EndpointSummary("生成佣金结算单")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> Generate(GenerateCommissionSettlementDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("settlements/status")]
|
||||
[EndpointSummary("更新佣金结算单状态")]
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionSettlementItemDto>> UpdateStatus(
|
||||
UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("settlements/proofs")]
|
||||
[EndpointSummary("查询佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
||||
public async Task<ActionResult<CommissionList<CommissionProofItem>>> Proofs(
|
||||
[FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("settlements/proofs")]
|
||||
[EndpointSummary("创建佣金结算凭证")]
|
||||
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionProofItem>> CreateProof(CreateCommissionProofDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("settlements/proofs/status")]
|
||||
[EndpointSummary("更新佣金结算凭证状态")]
|
||||
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) =>
|
||||
Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
public async Task<ActionResult<CommissionProofItem>> UpdateProofStatus(UpdateCommissionProofStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(
|
||||
await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private CommissionAdminActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new CommissionException("Commission admin actor was not resolved.", "commission_access_denied");
|
||||
}
|
||||
|
||||
return new CommissionAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
Tiku.Api/Controllers/ContentImportController.cs
Normal file
129
Tiku.Api/Controllers/ContentImportController.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-内容直接管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class ContentImportController(
|
||||
IContentImportService service,
|
||||
IBackgroundJobQueue backgroundJobService,
|
||||
DirectContentActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpPost("imports/preview/{importType}")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeatureFromRoute("importType")]
|
||||
[EndpointSummary("预览内容导入数据")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
||||
string importType,
|
||||
DirectImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.PreviewImportAsync(actorResolver.Resolve(), request.ToCommand(importType, true),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/{importType}")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeatureFromRoute("importType")]
|
||||
[EndpointSummary("执行或排队内容导入")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<object>> ExecuteImport(
|
||||
string importType,
|
||||
DirectImportDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
var command = request.ToCommand(importType, false);
|
||||
if (request.Async == true || command.Items.Count > 100)
|
||||
{
|
||||
var job = await backgroundJobService.EnqueueAsync(
|
||||
new CreateBackgroundJobCommand(
|
||||
actor.TenantId,
|
||||
"content_import",
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
createdBy = actor.UserId,
|
||||
importType = command.ImportType,
|
||||
sourceFormat = command.SourceFormat,
|
||||
sourceName = command.SourceName,
|
||||
regionId = command.RegionId,
|
||||
entryId = command.EntryId,
|
||||
contentNodeId = command.ContentNodeId,
|
||||
subjectId = command.SubjectId,
|
||||
categoryId = command.CategoryId,
|
||||
questionBankId = command.QuestionBankId,
|
||||
collectionId = command.CollectionId,
|
||||
items = command.Items
|
||||
})),
|
||||
cancellationToken);
|
||||
return Accepted(job);
|
||||
}
|
||||
|
||||
return Ok(await service.ExecuteImportAsync(actor, command, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/detail")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入任务详情")]
|
||||
[ProducesResponseType<ContentImportJobDetail>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentImportJobDetail>> GetImportDetail(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetImportJobAsync(actorResolver.Resolve(), query.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/issues")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入问题明细")]
|
||||
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetImportIssuesAsync(actorResolver.Resolve(), query.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/post-check")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("执行内容导入后完整性检查")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
||||
DirectImportJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.RunImportPostCheckAsync(actorResolver.Resolve(), request.JobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/post-check")]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入后检查状态")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
||||
[FromQuery] DirectImportJobDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetImportPostCheckAsync(actorResolver.Resolve(), query.JobId, cancellationToken));
|
||||
}
|
||||
}
|
||||
78
Tiku.Api/Controllers/CouponAdministrationController.cs
Normal file
78
Tiku.Api/Controllers/CouponAdministrationController.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-交易运营")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class CouponAdministrationController(
|
||||
ICouponAdministrationService service,
|
||||
CommerceAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("coupons")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询租户优惠券")]
|
||||
[ProducesResponseType<TenantCouponList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponList>> Coupons(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetCouponsAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("coupons")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新租户优惠券")]
|
||||
[ProducesResponseType<object>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<object>> UpsertCoupon(
|
||||
UpsertTenantCouponDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertCouponAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("coupons/redemptions")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询优惠券领取和核销记录")]
|
||||
[ProducesResponseType<TenantCouponRedemptionList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponRedemptionList>> CouponRedemptions(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetCouponRedemptionsAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("coupons/report")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询优惠券基础报表")]
|
||||
[ProducesResponseType<TenantCouponReport>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantCouponReport>> CouponReport(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetCouponReportAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
@@ -9,9 +10,9 @@ namespace Tiku.Api.Controllers;
|
||||
[ApiController]
|
||||
[Tags("租户端-CRM")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Crm)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Crm)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/crm")]
|
||||
[Route("api/tenant/crm")]
|
||||
public sealed class CrmController(
|
||||
ICrmService crmService,
|
||||
ICurrentUser currentUser,
|
||||
@@ -78,10 +79,8 @@ public sealed class CrmController(
|
||||
private CrmAdminActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new CrmException("CRM admin actor was not resolved.", "crm_access_denied");
|
||||
}
|
||||
|
||||
return new CrmAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Tiku.Api/Controllers/DirectContentActorResolver.cs
Normal file
18
Tiku.Api/Controllers/DirectContentActorResolver.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class DirectContentActorResolver(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant)
|
||||
{
|
||||
internal DirectContentActor Resolve()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
throw new ContentManagementException("Tenant content actor was not resolved.",
|
||||
"tenant_content_access_denied");
|
||||
|
||||
return new DirectContentActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
71
Tiku.Api/Controllers/EducationCatalogManagementController.cs
Normal file
71
Tiku.Api/Controllers/EducationCatalogManagementController.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-内容直接管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class EducationCatalogManagementController(
|
||||
IEducationCatalogManagementService service,
|
||||
DirectContentActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("scoreline/schools")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线院校")]
|
||||
[ProducesResponseType<CatalogList<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<School>>> GetSchools(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetSchoolsAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/schools")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新分数线院校")]
|
||||
[ProducesResponseType<ContentManagementResult<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<School>>> UpsertSchool(
|
||||
DirectSchoolDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertSchoolAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/majors")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线专业")]
|
||||
[ProducesResponseType<CatalogList<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<Major>>> GetMajors(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetMajorsAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/majors")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新分数线专业")]
|
||||
[ProducesResponseType<ContentManagementResult<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<Major>>> UpsertMajor(
|
||||
DirectMajorDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertMajorAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
}
|
||||
105
Tiku.Api/Controllers/HandbookManagementController.cs
Normal file
105
Tiku.Api/Controllers/HandbookManagementController.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-内容直接管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class HandbookManagementController(
|
||||
IHandbookManagementService service,
|
||||
DirectContentActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("handbook-subjects")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookSubject>>> GetHandbookSubjects(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetHandbookSubjectsAsync(actorResolver.Resolve(), query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-subjects")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册科目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookSubject>>> UpsertHandbookSubject(
|
||||
DirectHandbookSubjectDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertHandbookSubjectAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetHandbookChaptersAsync(actorResolver.Resolve(), query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-chapters")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册章节")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
||||
DirectHandbookChapterDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertHandbookChapterAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetHandbookEntriesAsync(actorResolver.Resolve(), query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("handbook-entries")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册条目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
||||
DirectHandbookEntryDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertHandbookEntryAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -11,10 +9,8 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-系统健康")]
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/health")]
|
||||
public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore) : ControllerBase
|
||||
[Route("api/system/health")]
|
||||
public sealed class HealthController(IDependencyReadinessProbe readinessProbe) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -32,16 +28,8 @@ public sealed class HealthController(
|
||||
[EndpointSummary("依赖就绪检查")]
|
||||
public async Task<ActionResult<object>> Ready(CancellationToken cancellationToken)
|
||||
{
|
||||
var database = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken);
|
||||
var ready = database && redis;
|
||||
var response = new
|
||||
{
|
||||
status = ready ? "ready" : "not_ready",
|
||||
database,
|
||||
redis = new { configured = redisSecurityStore.IsConfigured, ready = redis },
|
||||
checkedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
var readiness = await readinessProbe.CheckAsync(cancellationToken);
|
||||
var response = new { status = readiness.Ready ? "ready" : "not_ready", readiness.CheckedAt };
|
||||
return readiness.Ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Tiku.Api/Controllers/LearningActorResolver.cs
Normal file
19
Tiku.Api/Controllers/LearningActorResolver.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class LearningActorResolver(
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant)
|
||||
{
|
||||
internal LearningActor Resolve()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null) throw new LearningAccessDeniedException();
|
||||
|
||||
return new LearningActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LearningAccessDeniedException()
|
||||
: Exception("Learning actor was not resolved.");
|
||||
48
Tiku.Api/Controllers/LearningAnalyticsController.cs
Normal file
48
Tiku.Api/Controllers/LearningAnalyticsController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-学习")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/student/learning")]
|
||||
public sealed class LearningAnalyticsController(
|
||||
ILearningAnalyticsService service,
|
||||
LearningActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("stats")]
|
||||
[EndpointSummary("查询学习统计")]
|
||||
[ProducesResponseType<LearningStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningStatsItem>> GetStats(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetStatsAsync(actorResolver.Resolve(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
[EndpointSummary("查询学习趋势")]
|
||||
[ProducesResponseType<LearningList<LearningTrendItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<LearningTrendItem>>> GetTrend(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetTrendAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("leaderboard")]
|
||||
[EndpointSummary("查询学习排行榜")]
|
||||
[ProducesResponseType<LearningLeaderboardResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningLeaderboardResult>> GetLeaderboard(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(
|
||||
await service.GetLeaderboardAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-学习")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/learning")]
|
||||
public sealed class LearningController(
|
||||
ILearningActivityService learningActivityService,
|
||||
ICurrentUser currentUser,
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpGet("stats")]
|
||||
[EndpointSummary("查询学习统计")]
|
||||
[ProducesResponseType<LearningStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningStatsItem>> GetStats(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetStatsAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
[EndpointSummary("查询学习趋势")]
|
||||
[ProducesResponseType<LearningList<LearningTrendItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<LearningTrendItem>>> GetTrend(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("leaderboard")]
|
||||
[EndpointSummary("查询学习排行榜")]
|
||||
[ProducesResponseType<LearningLeaderboardResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningLeaderboardResult>> GetLeaderboard(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("practice-sessions")]
|
||||
[EndpointSummary("创建练习会话")]
|
||||
[ProducesResponseType<PracticeSessionItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<PracticeSessionItem>> CreatePracticeSession(
|
||||
CreatePracticeSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.CreatePracticeSessionAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("practice-sessions/detail")]
|
||||
[EndpointSummary("获取练习会话详情")]
|
||||
[ProducesResponseType<PracticeSessionDetailItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PracticeSessionDetailItem>> GetPracticeSessionDetail(
|
||||
[FromQuery] PracticeSessionQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetPracticeSessionDetailAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("practice-sessions/submit")]
|
||||
[EndpointSummary("提交练习会话并生成报告")]
|
||||
[ProducesResponseType<PracticeSessionReportItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PracticeSessionReportItem>> SubmitPracticeSession(
|
||||
SubmitPracticeSessionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.SubmitPracticeSessionAsync(
|
||||
ResolveActor(),
|
||||
request.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("practice-sessions/report")]
|
||||
[EndpointSummary("获取练习会话报告")]
|
||||
[ProducesResponseType<PracticeSessionReportItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<PracticeSessionReportItem>> GetPracticeSessionReport(
|
||||
[FromQuery] PracticeSessionQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetPracticeSessionReportAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("practice-reports")]
|
||||
[EndpointSummary("查询练习报告列表")]
|
||||
[ProducesResponseType<LearningList<PracticeSessionReportItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<PracticeSessionReportItem>>> GetPracticeReports(
|
||||
[FromQuery] PracticeSessionQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetPracticeReportsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("practice-sessions/history")]
|
||||
[EndpointSummary("查询练习历史")]
|
||||
[ProducesResponseType<LearningList<PracticeHistoryItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<PracticeHistoryItem>>> GetPracticeHistory(
|
||||
[FromQuery] PracticeSessionQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetPracticeHistoryAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("answers")]
|
||||
[EndpointSummary("提交题目答案")]
|
||||
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<AnswerRecordItem>> SubmitAnswer(
|
||||
SubmitAnswerDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.SubmitAnswerAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("favorites/questions")]
|
||||
[EndpointSummary("查询收藏题目")]
|
||||
[ProducesResponseType<LearningList<FavoriteQuestionItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<FavoriteQuestionItem>>> GetFavoriteQuestions(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetFavoriteQuestionsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/questions")]
|
||||
[EndpointSummary("收藏或取消收藏题目")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteQuestion(
|
||||
QuestionActionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ToggleFavoriteQuestionAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("wrong-questions")]
|
||||
[EndpointSummary("查询错题列表")]
|
||||
[ProducesResponseType<LearningList<WrongQuestionItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<WrongQuestionItem>>> GetWrongQuestions(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWrongQuestionsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("wrong-questions/review-plan")]
|
||||
[EndpointSummary("生成错题复习计划")]
|
||||
[ProducesResponseType<WrongQuestionReviewPlan>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WrongQuestionReviewPlan>> GetWrongQuestionReviewPlan(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWrongQuestionReviewPlanAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("wrong-questions/resolve")]
|
||||
[EndpointSummary("将错题标记为已解决")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ResolveWrongQuestion(
|
||||
QuestionActionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ResolveWrongQuestionAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/progress")]
|
||||
[EndpointSummary("查询单词学习进度")]
|
||||
[ProducesResponseType<LearningList<WordProgressItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<WordProgressItem>>> GetWordProgress(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordProgressAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/review-plan")]
|
||||
[EndpointSummary("生成单词复习计划")]
|
||||
[ProducesResponseType<WordReviewPlan>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WordReviewPlan>> GetWordReviewPlan(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordReviewPlanAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/progress")]
|
||||
[EndpointSummary("更新单词学习进度")]
|
||||
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<WordProgressItem>> UpdateWordProgress(
|
||||
WordProgressDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.UpdateWordProgressAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/review")]
|
||||
[EndpointSummary("提交单词复习结果")]
|
||||
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<WordProgressItem>> ReviewWord(
|
||||
WordReviewDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ReviewWordAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/stats")]
|
||||
[EndpointSummary("查询单词学习统计")]
|
||||
[ProducesResponseType<WordStatsItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<WordStatsItem>> GetWordStats(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetWordStatsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary/favorites")]
|
||||
[EndpointSummary("查询收藏单词")]
|
||||
[ProducesResponseType<LearningList<FavoriteWordItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<LearningList<FavoriteWordItem>>> GetFavoriteWords(
|
||||
[FromQuery] LearningLimitQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.GetFavoriteWordsAsync(
|
||||
ResolveActor(),
|
||||
query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("vocabulary/favorites")]
|
||||
[EndpointSummary("收藏或取消收藏单词")]
|
||||
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteWord(
|
||||
FavoriteWordDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await learningActivityService.ToggleFavoriteWordAsync(
|
||||
ResolveActor(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private LearningActor ResolveActor()
|
||||
{
|
||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||
{
|
||||
throw new LearningAccessDeniedException();
|
||||
}
|
||||
|
||||
return new LearningActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LearningAccessDeniedException()
|
||||
: Exception("Learning actor was not resolved.");
|
||||
@@ -1,63 +1,70 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-当前用户")]
|
||||
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
|
||||
[Route("api/me")]
|
||||
[Route("api/tenant/me")]
|
||||
public sealed class MeController(
|
||||
ICurrentUser currentUser,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
IAuthSessionStore sessionStore,
|
||||
ICurrentIdentityQueryService identityQueries) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("获取当前登录用户")]
|
||||
[EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")]
|
||||
public async Task<ActionResult<MeResponse>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
if (currentUser.UserId is null) return Unauthorized();
|
||||
|
||||
var user = await dbContext.Users.FindAsync([currentUser.UserId.Value], cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var memberships = await dbContext.TenantMemberships
|
||||
.Where(membership =>
|
||||
membership.UserId == user.Id &&
|
||||
membership.Status == MembershipStatus.Active)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
membership => membership.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(membership, tenant) => new TenantMembershipResponse(
|
||||
tenant.Id,
|
||||
tenant.Name,
|
||||
tenant.Slug,
|
||||
membership.Role,
|
||||
membership.Status))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var user = await identityQueries.GetUserAsync(currentUser.UserId.Value, cancellationToken);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
user.Id,
|
||||
user.UserId,
|
||||
user.Phone,
|
||||
user.Email,
|
||||
user.Name,
|
||||
memberships));
|
||||
user.Tenants.Select(item => new TenantMembershipResponse(
|
||||
item.TenantId,
|
||||
item.TenantName,
|
||||
item.TenantSlug,
|
||||
item.Role,
|
||||
item.Status)).ToArray()));
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[EndpointSummary("查询当前授权域的登录设备")]
|
||||
[ProducesResponseType<IReadOnlyCollection<AuthSessionSummary>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AuthSessionSummary>>> Sessions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized();
|
||||
|
||||
return Ok(await sessionStore.ListActiveAsync(userId, sessionId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{sessionFamilyId:guid}")]
|
||||
[EndpointSummary("撤销其他设备的登录会话")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RevokeSession(
|
||||
Guid sessionFamilyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId || currentUser.SessionId is not { } sessionId) return Unauthorized();
|
||||
|
||||
await sessionStore.RevokeOwnedFamilyAsync(userId, sessionId, sessionFamilyId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户基础信息和租户成员摘要。
|
||||
/// 当前用户基础信息和租户成员摘要。
|
||||
/// </summary>
|
||||
/// <param name="UserId">用户 ID。</param>
|
||||
/// <param name="Phone">手机号。</param>
|
||||
@@ -72,7 +79,7 @@ public sealed record MeResponse(
|
||||
IReadOnlyCollection<TenantMembershipResponse> Tenants);
|
||||
|
||||
/// <summary>
|
||||
/// 用户在某个租户内的成员摘要。
|
||||
/// 用户在某个租户内的成员摘要。
|
||||
/// </summary>
|
||||
/// <param name="TenantId">租户 ID。</param>
|
||||
/// <param name="TenantName">租户名称。</param>
|
||||
@@ -84,4 +91,4 @@ public sealed record TenantMembershipResponse(
|
||||
string TenantName,
|
||||
string TenantSlug,
|
||||
TenantRole Role,
|
||||
MembershipStatus Status);
|
||||
MembershipStatus Status);
|
||||
53
Tiku.Api/Controllers/OperationContentManagementController.cs
Normal file
53
Tiku.Api/Controllers/OperationContentManagementController.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-内容直接管理")]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/content")]
|
||||
public sealed class OperationContentManagementController(
|
||||
IOperationContentManagementService service,
|
||||
DirectContentActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("operations/{kind}")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询运营内容")]
|
||||
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
||||
string kind,
|
||||
[FromQuery] DirectContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetOperationContentAsync(actorResolver.Resolve(), kind, query.ToFilter(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("operations/{kind}")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("新增或更新运营内容")]
|
||||
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
||||
string kind,
|
||||
DirectOperationContentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertOperationContentAsync(actorResolver.Resolve(), kind, request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
64
Tiku.Api/Controllers/PaymentConfigurationController.cs
Normal file
64
Tiku.Api/Controllers/PaymentConfigurationController.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-交易运营")]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/commerce")]
|
||||
public sealed class PaymentConfigurationController(
|
||||
IPaymentConfigurationService service,
|
||||
CommerceAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("payment-accounts")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("查询租户支付账号")]
|
||||
[ProducesResponseType<IReadOnlyCollection<TenantPaymentProviderItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyCollection<TenantPaymentProviderItem>>> PaymentAccounts(
|
||||
[FromQuery] TenantCommerceQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetPaymentAccountsAsync(
|
||||
actorResolver.Resolve(),
|
||||
actorResolver.ToQuery(query),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("payment-accounts")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("新增或更新租户支付账号")]
|
||||
[ProducesResponseType<TenantPaymentProviderItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantPaymentProviderItem>> UpsertPaymentAccount(
|
||||
UpsertPaymentAccountDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertPaymentAccountAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("secrets")]
|
||||
[Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)]
|
||||
[EndpointSummary("写入或轮换租户密钥")]
|
||||
[ProducesResponseType<TenantSecretItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantSecretItem>> UpsertSecret(
|
||||
UpsertTenantSecretDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertTenantSecretAsync(
|
||||
actorResolver.Resolve(),
|
||||
request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
15
Tiku.Api/Controllers/PlatformAdminActorResolver.cs
Normal file
15
Tiku.Api/Controllers/PlatformAdminActorResolver.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
public sealed class PlatformAdminActorResolver(ICurrentUser currentUser)
|
||||
{
|
||||
internal PlatformAdminActor Resolve()
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
throw new PlatformAdminException("Platform admin actor was not resolved.", "platform_access_denied");
|
||||
|
||||
return new PlatformAdminActor(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin")]
|
||||
public sealed class PlatformAdminController(
|
||||
IPlatformAdminService platformAdminService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
[EndpointSummary("查询平台经营概览")]
|
||||
[ProducesResponseType<PlatformOverview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformOverview>> Overview(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetOverviewAsync(ResolveActor(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tenants")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台租户列表")]
|
||||
[ProducesResponseType<PlatformTenantList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantList>> Tenants(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetTenantsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("tenants")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建平台租户")]
|
||||
[ProducesResponseType<PlatformTenantProvisioningResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantProvisioningResult>> CreateTenant(
|
||||
CreatePlatformTenantDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.CreateTenantAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("tenants/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台租户详情")]
|
||||
[ProducesResponseType<PlatformTenantDetail>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantDetail>> TenantDetail(
|
||||
[FromQuery] Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetTenantDetailAsync(ResolveActor(), tenantId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("tenants/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("更新租户业务与账务状态")]
|
||||
[ProducesResponseType<PlatformTenantItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantItem>> TenantStatus(
|
||||
UpdatePlatformTenantStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenants/billing-profile")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("保存租户账务与开票资料")]
|
||||
[ProducesResponseType<TenantBillingProfileItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TenantBillingProfileItem>> BillingProfile(
|
||||
UpsertPlatformTenantBillingProfileDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("domains")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询租户域名状态")]
|
||||
[ProducesResponseType<PlatformDomainList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDomainList>> Domains(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDomainsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("domains/{domainId:guid}/recheck")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("重新触发租户域名 DNS/TLS 验证")]
|
||||
[ProducesResponseType<PlatformDomainRecheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDomainRecheckResult>> RecheckDomain(
|
||||
Guid domainId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.RecheckDomainAsync(ResolveActor(), domainId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("查询平台员工列表")]
|
||||
[ProducesResponseType<PlatformStaffList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffList>> Staff(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetStaffAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("创建或更新平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> UpsertStaff(
|
||||
UpsertPlatformStaffDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertStaffAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("staff/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("启用或禁用平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> StaffStatus(
|
||||
UpdatePlatformStaffStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计日志")]
|
||||
[ProducesResponseType<PlatformAuditLogList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditLogList>> AuditLogs(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetAuditLogsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("audit-alerts")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计告警")]
|
||||
[ProducesResponseType<PlatformAuditAlertList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlertList>> AuditAlerts(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetAuditAlertsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("audit-alerts/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("更新平台审计告警状态")]
|
||||
[ProducesResponseType<PlatformAuditAlert>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlert>> AuditAlertStatus(
|
||||
UpdatePlatformAuditAlertStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelList>> BillingDunningChannels(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetBillingDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("创建或更新平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> UpsertBillingDunningChannel(
|
||||
UpsertPlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/channels/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("禁用平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> DisableBillingDunningChannel(
|
||||
DisablePlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.DisableBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("saas/dunning/events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventList>> BillingDunningEvents(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetBillingDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("saas/dunning/events/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件详情")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> BillingDunningEventDetail(
|
||||
[FromQuery] Guid eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetBillingDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/events/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("重新标记平台催缴通知事件待发送")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> RetryBillingDunningEvent(
|
||||
RetryPlatformBillingDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.RetryBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private PlatformAdminActor ResolveActor()
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
{
|
||||
throw new PlatformAdminException("Platform admin actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
|
||||
return new PlatformAdminActor(userId);
|
||||
}
|
||||
}
|
||||
94
Tiku.Api/Controllers/PlatformApprovalsController.cs
Normal file
94
Tiku.Api/Controllers/PlatformApprovalsController.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-审批中心")]
|
||||
[Route("api/platform/approvals")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformApprovalView)]
|
||||
public sealed class PlatformApprovalsController(
|
||||
IPlatformApprovalService approvalService,
|
||||
ICurrentAccessContext currentAccessContext) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询平台审批任务")]
|
||||
public async Task<IReadOnlyCollection<PlatformApprovalRequestItem>> Requests(
|
||||
PlatformApprovalRequestStatus? status,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await approvalService.ListAsync(await ActorAsync(cancellationToken), status, limit, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("{requestId:guid}")]
|
||||
[EndpointSummary("查询平台审批详情")]
|
||||
public async Task<PlatformApprovalRequestItem> Details(Guid requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.GetAsync(await ActorAsync(cancellationToken), requestId, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("policies")]
|
||||
[EndpointSummary("查询平台审批策略")]
|
||||
public async Task<IReadOnlyCollection<PlatformApprovalPolicyItem>> Policies(CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.ListPoliciesAsync(await ActorAsync(cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("policies/{code}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformApprovalPolicyManage)]
|
||||
[EndpointSummary("更新平台审批策略")]
|
||||
[PlatformOperationRisk("high", "approval.policy-change")]
|
||||
public async Task<PlatformApprovalPolicyItem> UpdatePolicy(
|
||||
string code,
|
||||
UpdatePlatformApprovalPolicyDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.UpdatePolicyAsync(await ActorAsync(cancellationToken), request.ToCommand(code),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{requestId:guid}/approve")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformApprovalDecide)]
|
||||
[EndpointSummary("批准并执行平台审批任务")]
|
||||
[PlatformOperationRisk("high")]
|
||||
public async Task<PlatformApprovalRequestItem> Approve(Guid requestId, PlatformApprovalDecisionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.ApproveAsync(await ActorAsync(cancellationToken), requestId, request.Reason,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{requestId:guid}/reject")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformApprovalDecide)]
|
||||
[EndpointSummary("拒绝平台审批任务")]
|
||||
[PlatformOperationRisk("high")]
|
||||
public async Task<PlatformApprovalRequestItem> Reject(Guid requestId, PlatformApprovalDecisionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.RejectAsync(await ActorAsync(cancellationToken), requestId, request.Reason,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{requestId:guid}/cancel")]
|
||||
[EndpointSummary("撤销本人提交的平台审批任务")]
|
||||
public async Task<PlatformApprovalRequestItem> Cancel(Guid requestId, PlatformApprovalDecisionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await approvalService.CancelAsync(await ActorAsync(cancellationToken), requestId, request.Reason,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlatformApprovalActor> ActorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
return access.UserId is { } userId && access.IsUserActive
|
||||
? new PlatformApprovalActor(userId, access.PlatformPermissions)
|
||||
: throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
55
Tiku.Api/Controllers/PlatformAuditAlertController.cs
Normal file
55
Tiku.Api/Controllers/PlatformAuditAlertController.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform")]
|
||||
public sealed class PlatformAuditAlertController(
|
||||
IPlatformAuditAlertService service,
|
||||
PlatformAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("audit-logs")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计日志")]
|
||||
[ProducesResponseType<PlatformAuditLogList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditLogList>> AuditLogs(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAuditLogsAsync(actorResolver.Resolve(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("audit-alerts")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("查询平台审计告警")]
|
||||
[ProducesResponseType<PlatformAuditAlertList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlertList>> AuditAlerts(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetAuditAlertsAsync(actorResolver.Resolve(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("audit-alerts/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformAuditView)]
|
||||
[EndpointSummary("更新平台审计告警状态")]
|
||||
[ProducesResponseType<PlatformAuditAlert>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformAuditAlert>> AuditAlertStatus(
|
||||
UpdatePlatformAuditAlertStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpdateAuditAlertStatusAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-后台权限")]
|
||||
[Route("api/backoffice/platform")]
|
||||
[Route("api/platform/access")]
|
||||
public sealed class PlatformBackofficeController(
|
||||
IBackofficeService backofficeService,
|
||||
IPlatformApprovalService approvalService,
|
||||
ICurrentAccessContext currentAccessContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("ui-bootstrap")]
|
||||
@@ -31,7 +35,8 @@ public sealed class PlatformBackofficeController(
|
||||
[ProducesResponseType<BackofficeBootstrap>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeBootstrap>> GetBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolveActorAsync(cancellationToken), cancellationToken));
|
||||
return Ok(await backofficeService.GetPlatformBootstrapAsync(await ResolveActorAsync(cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("roles")]
|
||||
@@ -42,19 +47,26 @@ public sealed class PlatformBackofficeController(
|
||||
UpsertBackofficeRoleDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(), cancellationToken));
|
||||
return Ok(await backofficeService.UpsertPlatformRoleAsync(await ResolveActorAsync(cancellationToken),
|
||||
request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("roles/{roleId:guid}/bindings")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformRoleManage)]
|
||||
[EndpointSummary("替换平台后台角色权限绑定")]
|
||||
[ProducesResponseType<BackofficeRoleItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BackofficeRoleItem>> ReplaceRoleBindings(
|
||||
[PlatformOperationRisk("critical", PlatformApprovalPolicyCodes.SuperAdminGrant)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<PlatformCommandSubmission>> ReplaceRoleBindings(
|
||||
Guid roleId,
|
||||
ReplaceRoleBindingsDto request,
|
||||
[FromHeader(Name = "Idempotency-Key")] [Required]
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backofficeService.ReplacePlatformRoleBindingsAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(roleId), cancellationToken));
|
||||
var result = await approvalService.ReplaceRoleBindingsAsync(await ResolveActorAsync(cancellationToken),
|
||||
request.ToCommand(roleId), idempotencyKey, cancellationToken);
|
||||
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("users/{userId:guid}/roles")]
|
||||
@@ -66,7 +78,8 @@ public sealed class PlatformBackofficeController(
|
||||
ReplaceUserRolesDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await backofficeService.ReplacePlatformUserRolesAsync(await ResolveActorAsync(cancellationToken), request.ToCommand(userId), cancellationToken);
|
||||
await backofficeService.ReplacePlatformUserRolesAsync(await ResolveActorAsync(cancellationToken),
|
||||
request.ToCommand(userId), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -74,4 +87,4 @@ public sealed class PlatformBackofficeController(
|
||||
{
|
||||
return BackofficeActor.FromPlatformAccess(await currentAccessContext.GetAsync(cancellationToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-账务回调")]
|
||||
[Route("api/platform-billing/callbacks")]
|
||||
[Route("api/integrations/platform-billing/callbacks")]
|
||||
public sealed class PlatformBillingCallbackController(
|
||||
IPlatformBillingNotificationService notificationService) : ControllerBase
|
||||
{
|
||||
@@ -33,16 +33,15 @@ public sealed class PlatformBillingCallbackController(
|
||||
{
|
||||
body = JsonDocument.Parse("{}").RootElement.Clone();
|
||||
}
|
||||
|
||||
var normalizedProvider = provider.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
await notificationService.ProcessAsync(new PlatformBillingNotification(
|
||||
normalizedProvider,
|
||||
Request.Headers.ToDictionary(value => value.Key, value => value.Value.ToString(), StringComparer.OrdinalIgnoreCase),
|
||||
Request.Headers.ToDictionary(value => value.Key, value => value.Value.ToString(),
|
||||
StringComparer.OrdinalIgnoreCase),
|
||||
rawBody,
|
||||
body), cancellationToken);
|
||||
if (normalizedProvider is "alipay" or "ali_pay")
|
||||
{
|
||||
return Content("success", "text/plain", Encoding.UTF8);
|
||||
}
|
||||
if (normalizedProvider is "alipay" or "ali_pay") return Content("success", "text/plain", Encoding.UTF8);
|
||||
return Ok(new { code = "SUCCESS", message = "成功" });
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Tiku.Api/Controllers/PlatformDashboardController.cs
Normal file
29
Tiku.Api/Controllers/PlatformDashboardController.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform")]
|
||||
public sealed class PlatformDashboardController(
|
||||
IPlatformDashboardService service,
|
||||
PlatformAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("overview")]
|
||||
[EndpointSummary("查询平台经营概览")]
|
||||
[ProducesResponseType<PlatformOverview>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformOverview>> Overview(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetOverviewAsync(actorResolver.Resolve(), cancellationToken));
|
||||
}
|
||||
}
|
||||
115
Tiku.Api/Controllers/PlatformDunningController.cs
Normal file
115
Tiku.Api/Controllers/PlatformDunningController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform")]
|
||||
public sealed class PlatformDunningController(
|
||||
IPlatformDunningService service,
|
||||
PlatformAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelList>> BillingDunningChannels(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetBillingDunningChannelsAsync(actorResolver.Resolve(), query.ToQuery(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("创建或更新平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> UpsertBillingDunningChannel(
|
||||
UpsertPlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertBillingDunningChannelAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/channels/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("禁用平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> DisableBillingDunningChannel(
|
||||
DisablePlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.DisableBillingDunningChannelAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("saas/dunning/events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventList>> BillingDunningEvents(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetBillingDunningEventsAsync(actorResolver.Resolve(), query.ToQuery(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("saas/dunning/events/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件详情")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> BillingDunningEventDetail(
|
||||
[FromQuery] Guid eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetBillingDunningEventDetailAsync(actorResolver.Resolve(), eventId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/events/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("重新标记平台催缴通知事件待发送")]
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> RetryBillingDunningEvent(
|
||||
RetryPlatformBillingDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.RetryBillingDunningEventAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/events/acknowledge")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("人工确认平台催缴通知事件")]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> AcknowledgeBillingDunningEvent(
|
||||
ResolvePlatformBillingDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.AcknowledgeBillingDunningEventAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("saas/dunning/events/ignore")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("人工忽略平台催缴通知事件")]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> IgnoreBillingDunningEvent(
|
||||
ResolvePlatformBillingDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.IgnoreBillingDunningEventAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
129
Tiku.Api/Controllers/PlatformGovernanceController.cs
Normal file
129
Tiku.Api/Controllers/PlatformGovernanceController.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-治理配置")]
|
||||
[Route("api/platform/governance")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
public sealed class PlatformGovernanceController(
|
||||
IPlatformGovernanceService governanceService,
|
||||
ICurrentAccessContext currentAccessContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("configuration/definitions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[EndpointSummary("查询类型化平台配置定义")]
|
||||
public async Task<IReadOnlyCollection<PlatformConfigurationDefinitionItem>> ConfigurationDefinitions(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.GetConfigurationDefinitionsAsync(await ActorAsync(cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("configuration/versions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[EndpointSummary("查询平台配置版本")]
|
||||
public async Task<IReadOnlyCollection<PlatformConfigurationVersionItem>> ConfigurationVersions(
|
||||
string definitionCode, string? environment, CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.GetConfigurationVersionsAsync(await ActorAsync(cancellationToken),
|
||||
definitionCode, environment, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("configuration/drafts")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[EndpointSummary("保存平台配置草稿")]
|
||||
public async Task<PlatformConfigurationVersionItem> SaveConfigurationDraft(
|
||||
SavePlatformConfigurationDraftDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.SaveConfigurationDraftAsync(await ActorAsync(cancellationToken),
|
||||
request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("configuration/versions/{versionId:guid}/publish")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[EndpointSummary("发布平台配置版本")]
|
||||
[PlatformOperationRisk("high")]
|
||||
public async Task<PlatformConfigurationVersionItem> PublishConfiguration(Guid versionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.PublishConfigurationAsync(await ActorAsync(cancellationToken), versionId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("configuration/versions/{versionId:guid}/rollback")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[EndpointSummary("回滚平台配置版本")]
|
||||
[PlatformOperationRisk("high")]
|
||||
public async Task<PlatformConfigurationVersionItem> RollbackConfiguration(Guid versionId,
|
||||
PlatformRollbackDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.RollbackConfigurationAsync(await ActorAsync(cancellationToken), versionId,
|
||||
request.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("notifications/templates")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
|
||||
[EndpointSummary("查询平台通知模板")]
|
||||
public async Task<IReadOnlyCollection<PlatformNotificationTemplateItem>> NotificationTemplates(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.GetNotificationTemplatesAsync(await ActorAsync(cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("notifications/templates")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
|
||||
[EndpointSummary("新增或更新平台通知模板")]
|
||||
public async Task<PlatformNotificationTemplateItem> UpsertNotificationTemplate(
|
||||
UpsertPlatformNotificationTemplateDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.UpsertNotificationTemplateAsync(await ActorAsync(cancellationToken),
|
||||
request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("notifications/send")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
|
||||
[EndpointSummary("按平台岗位发送通知")]
|
||||
public async Task<IReadOnlyCollection<PlatformNotificationDeliveryItem>> SendNotification(
|
||||
SendPlatformNotificationDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.SendNotificationAsync(await ActorAsync(cancellationToken), request.ToCommand(),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("notifications/deliveries")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
|
||||
[EndpointSummary("分页查询平台通知投递")]
|
||||
public async Task<PagedResult<PlatformNotificationDeliveryItem>> NotificationDeliveries(
|
||||
int page = 1, int pageSize = 50, string? search = null, PlatformNotificationDeliveryStatus? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await governanceService.GetNotificationDeliveriesAsync(await ActorAsync(cancellationToken),
|
||||
new PagedQuery(page, pageSize, search), status, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("notifications/deliveries/{deliveryId:guid}/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformNotificationManage)]
|
||||
[EndpointSummary("重试平台通知投递")]
|
||||
public async Task<PlatformNotificationDeliveryItem> RetryNotification(Guid deliveryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await governanceService.RetryNotificationAsync(await ActorAsync(cancellationToken), deliveryId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PlatformApprovalActor> ActorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await currentAccessContext.GetAsync(cancellationToken);
|
||||
return access.UserId is { } userId && access.IsUserActive
|
||||
? new PlatformApprovalActor(userId, access.PlatformPermissions)
|
||||
: throw new PlatformApprovalException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
136
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
136
Tiku.Api/Controllers/PlatformOperationsController.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.PlatformAdmin.Operations;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-运维")]
|
||||
[Route("api/platform/operations")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsView)]
|
||||
public sealed class PlatformOperationsController(
|
||||
IBackgroundJobOperations backgroundJobService,
|
||||
ICurrentUser currentUser,
|
||||
IPlatformOperationsQueryService operationsQueries) : ControllerBase
|
||||
{
|
||||
[HttpGet("health")]
|
||||
[EndpointSummary("查询受保护的依赖深度健康状态")]
|
||||
public async Task<ActionResult<object>> Health(CancellationToken cancellationToken)
|
||||
{
|
||||
var health = await operationsQueries.GetHealthAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
status = health.Healthy ? "healthy" : "degraded",
|
||||
database = health.Database,
|
||||
redis = new { configured = health.RedisConfigured, ready = health.RedisReady },
|
||||
worker = new { ready = health.WorkerReady, lastHeartbeatAt = health.LastHeartbeatAt },
|
||||
clamAv = health.ClamAv,
|
||||
storage = new { provider = health.StorageProvider, configured = health.StorageConfigured },
|
||||
checkedAt = health.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("workers")]
|
||||
[EndpointSummary("查询 Worker 与周期循环状态")]
|
||||
public async Task<ActionResult<object>> Workers(CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await operationsQueries.GetWorkersAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
staleAfterSeconds = 120,
|
||||
items = items.Select(item => new
|
||||
{
|
||||
item.WorkerId,
|
||||
item.Processor,
|
||||
item.StartedAt,
|
||||
item.LastHeartbeatAt,
|
||||
item.LastIterationStartedAt,
|
||||
item.LastIterationCompletedAt,
|
||||
item.LastSucceededAt,
|
||||
item.LastError,
|
||||
item.IsRunning,
|
||||
stale = item.Stale
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("job-metrics")]
|
||||
[EndpointSummary("查询后台任务队列指标")]
|
||||
public async Task<ActionResult<object>> JobMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var metrics = await operationsQueries.GetJobMetricsAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
counts = metrics.Counts.Select(item => new { status = item.Status, count = item.Count }),
|
||||
oldestPendingAt = metrics.OldestPendingAt,
|
||||
queueAgeSeconds = metrics.QueueAgeSeconds,
|
||||
expiredLeases = metrics.ExpiredLeases,
|
||||
checkedAt = metrics.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("governance-metrics")]
|
||||
[EndpointSummary("查询审批、配置与通知治理指标")]
|
||||
public async Task<ActionResult<object>> GovernanceMetrics(CancellationToken cancellationToken)
|
||||
{
|
||||
var metrics = await operationsQueries.GetGovernanceMetricsAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
approvals = metrics.Approvals.Select(item => new { status = item.Status, count = item.Count }),
|
||||
expiredPending = metrics.ExpiredPending,
|
||||
configurationDrafts = metrics.ConfigurationDrafts,
|
||||
notifications = metrics.Notifications.Select(item => new { status = item.Status, count = item.Count }),
|
||||
checkedAt = metrics.CheckedAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
[EndpointSummary("查询全平台后台任务")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<BackgroundJobItem>>> Jobs(
|
||||
[FromQuery] Guid? tenantId,
|
||||
[FromQuery] string? jobType,
|
||||
[FromQuery] BackgroundJobStatus? status,
|
||||
[FromQuery] int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.ListPlatformAsync(
|
||||
tenantId, jobType, status, limit ?? 100, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("jobs/{jobId:guid}")]
|
||||
[EndpointSummary("查询平台后台任务详情")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> Job(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await backgroundJobService.GetAsync(jobId, null, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/cancel")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("取消平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> CancelJob(
|
||||
Guid jobId,
|
||||
CancelBackgroundJobDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RequestCancellationAsync(
|
||||
jobId, null, ResolveUserId(), request.Reason, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:guid}/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformOperationsManage)]
|
||||
[EndpointSummary("重试平台后台任务")]
|
||||
public async Task<ActionResult<BackgroundJobItem>> RetryJob(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await backgroundJobService.RetryAsync(jobId, null, ResolveUserId(), cancellationToken));
|
||||
}
|
||||
|
||||
private Guid ResolveUserId()
|
||||
{
|
||||
return currentUser.UserId ?? throw new InvalidOperationException("Current platform user was not resolved.");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
@@ -11,54 +13,85 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-支付设置")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/payment-settings")]
|
||||
[Route("api/platform/payment-settings")]
|
||||
public sealed class PlatformPaymentSettingsController(
|
||||
IPlatformPaymentSettingsService paymentSettingsService,
|
||||
IPlatformApprovalService approvalService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("apps")]
|
||||
[EndpointSummary("查询平台支付应用")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformPaymentApp>> Apps(string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetAppsAsync(Actor(), status, limit, cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformPaymentApp>> Apps(string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return paymentSettingsService.GetAppsAsync(Actor(), status, limit, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("apps")]
|
||||
[EndpointSummary("新增或更新平台支付应用")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentApp> UpsertApp(UpsertPlatformPaymentAppDto request, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.UpsertAppAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
public Task<PlatformPaymentApp> UpsertApp(UpsertPlatformPaymentAppDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return paymentSettingsService.UpsertAppAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("channels")]
|
||||
[EndpointSummary("查询平台支付渠道")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformPaymentChannel>> Channels(Guid? appId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetChannelsAsync(Actor(), appId, status, limit, cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformPaymentChannel>> Channels(Guid? appId, string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return paymentSettingsService.GetChannelsAsync(Actor(), appId, status, limit, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("channels")]
|
||||
[EndpointSummary("新增或更新平台支付渠道")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentChannel> UpsertChannel(UpsertPlatformPaymentChannelDto request, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.UpsertChannelAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
[PlatformOperationRisk("critical", PlatformApprovalPolicyCodes.PaymentChannelChange)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<PlatformCommandSubmission>> UpsertChannel(
|
||||
UpsertPlatformPaymentChannelDto request,
|
||||
[FromHeader(Name = "Idempotency-Key")] [Required]
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result =
|
||||
await approvalService.UpsertPaymentChannelAsync(Actor(), request.ToCommand(), idempotencyKey,
|
||||
cancellationToken);
|
||||
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("channels/{id:guid}/disable")]
|
||||
[EndpointSummary("禁用平台支付渠道")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentWrite)]
|
||||
public Task<PlatformPaymentChannel> DisableChannel(Guid id, CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.DisableChannelAsync(Actor(), id, cancellationToken);
|
||||
public Task<PlatformPaymentChannel> DisableChannel(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return paymentSettingsService.DisableChannelAsync(Actor(), id, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("events")]
|
||||
[EndpointSummary("查询平台支付事件")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingPaymentEvent>> Events(string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
paymentSettingsService.GetEventsAsync(Actor(), status, limit, cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingPaymentEvent>> Events(string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return paymentSettingsService.GetEventsAsync(Actor(), status, limit, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("rebates/summary")]
|
||||
[EndpointSummary("查询平台返佣汇总")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformPaymentRead)]
|
||||
public Task<PlatformRebateSummary> RebateSummary(CancellationToken cancellationToken) =>
|
||||
paymentSettingsService.GetRebateSummaryAsync(Actor(), cancellationToken);
|
||||
public Task<PlatformRebateSummary> RebateSummary(CancellationToken cancellationToken)
|
||||
{
|
||||
return paymentSettingsService.GetRebateSummaryAsync(Actor(), cancellationToken);
|
||||
}
|
||||
|
||||
private PlatformCapabilityActor Actor() => currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
private PlatformCapabilityActor Actor()
|
||||
{
|
||||
return currentUser.UserId is { } userId
|
||||
? new PlatformCapabilityActor(userId)
|
||||
: throw new PlatformCapabilityException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,13 @@ namespace Tiku.Api.Controllers;
|
||||
[Tags("平台端-公共题库")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform-admin/question-banks")]
|
||||
[Route("api/platform/question-banks")]
|
||||
public sealed class PlatformQuestionBanksController(
|
||||
IPlatformQuestionBankService service,
|
||||
IPlatformQuestionBankCatalogService catalogService,
|
||||
IPlatformQuestionBankNodeService nodeService,
|
||||
IPlatformQuestionAdministrationService questionService,
|
||||
IPlatformQuestionImportService importService,
|
||||
IPlatformQuestionAssetService assetService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -23,7 +27,8 @@ public sealed class PlatformQuestionBanksController(
|
||||
[FromQuery] string? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetBanksAsync(ResolveActor(), new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken));
|
||||
return Ok(await catalogService.GetBanksAsync(ResolveActor(),
|
||||
new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
@@ -32,21 +37,23 @@ public sealed class PlatformQuestionBanksController(
|
||||
UpsertPlatformQuestionBankCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertBankAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await catalogService.UpsertBankAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("{bankId:guid}/archive")]
|
||||
[EndpointSummary("归档平台公共题库")]
|
||||
public async Task<ActionResult<PlatformQuestionBankItem>> ArchiveBank(Guid bankId, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<PlatformQuestionBankItem>> ArchiveBank(Guid bankId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken));
|
||||
return Ok(await catalogService.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("{bankId:guid}/nodes")]
|
||||
[EndpointSummary("查询公共题库内容结构")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<PlatformQuestionBankNodeItem>>> GetNodes(Guid bankId, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<IReadOnlyCollection<PlatformQuestionBankNodeItem>>> GetNodes(Guid bankId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetNodesAsync(ResolveActor(), bankId, cancellationToken));
|
||||
return Ok(await nodeService.GetNodesAsync(ResolveActor(), bankId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("nodes")]
|
||||
@@ -55,7 +62,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
UpsertPlatformQuestionBankNodeCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertNodeAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await nodeService.UpsertNodeAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("nodes/batch")]
|
||||
@@ -64,14 +71,15 @@ public sealed class PlatformQuestionBanksController(
|
||||
BatchCreatePlatformQuestionBankNodesCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await nodeService.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("nodes/{nodeId:guid}/archive")]
|
||||
[EndpointSummary("归档公共题库内容节点")]
|
||||
public async Task<ActionResult<PlatformQuestionBankNodeItem>> ArchiveNode(Guid nodeId, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<PlatformQuestionBankNodeItem>> ArchiveNode(Guid nodeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken));
|
||||
return Ok(await nodeService.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("questions")]
|
||||
@@ -87,7 +95,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Ok(await service.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter(
|
||||
return Ok(await questionService.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter(
|
||||
questionBankId, contentNodeId, keyword, type, difficulty, status, page, pageSize), cancellationToken));
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
UpsertPlatformQuestionCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertQuestionAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await questionService.UpsertQuestionAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("questions/archive")]
|
||||
@@ -106,7 +114,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
ArchivePlatformQuestionsCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await questionService.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports/preview")]
|
||||
@@ -115,7 +123,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
PlatformQuestionImportCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.PreviewImportAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await importService.PreviewImportAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("imports")]
|
||||
@@ -124,14 +132,14 @@ public sealed class PlatformQuestionBanksController(
|
||||
PlatformQuestionImportCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.ExecuteImportAsync(ResolveActor(), request, cancellationToken));
|
||||
return Ok(await importService.ExecuteImportAsync(ResolveActor(), request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("imports/{jobId:guid}")]
|
||||
[EndpointSummary("查询公共题库导入结果")]
|
||||
public async Task<ActionResult<ContentImportJobDetail>> GetImport(Guid jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetImportAsync(ResolveActor(), jobId, cancellationToken));
|
||||
return Ok(await importService.GetImportAsync(ResolveActor(), jobId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/upload-sign")]
|
||||
@@ -140,7 +148,7 @@ public sealed class PlatformQuestionBanksController(
|
||||
AssetUploadSignDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
return Ok(await assetService.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/upload-confirm")]
|
||||
@@ -149,7 +157,8 @@ public sealed class PlatformQuestionBanksController(
|
||||
AssetUploadConfirmDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
return Ok(await assetService.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private PlatformAdminActor ResolveActor()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
@@ -9,118 +12,276 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-SaaS 套餐")]
|
||||
[Route("api/platform-admin/saas")]
|
||||
[Route("api/platform/saas")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
public sealed class PlatformSaasController(
|
||||
ISaasCatalogAdminService catalogService,
|
||||
IPlatformBillingAdminService billingService,
|
||||
IPlatformApprovalService approvalService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("catalog")]
|
||||
[EndpointSummary("查询平台 SaaS 商品目录")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasCatalogSnapshot> Catalog(CancellationToken cancellationToken) =>
|
||||
catalogService.GetCatalogAsync(Actor(), cancellationToken);
|
||||
public Task<SaasCatalogSnapshot> Catalog(CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.GetCatalogAsync(Actor(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("features")]
|
||||
[EndpointSummary("新增或更新 SaaS 功能")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasFeature> UpsertFeature(UpsertSaasFeatureDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertFeatureAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
public Task<SaasFeature> UpsertFeature(UpsertSaasFeatureDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.UpsertFeatureAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("feature-limits")]
|
||||
[EndpointSummary("新增或更新 SaaS 功能限额")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasFeatureLimitDefinition> UpsertFeatureLimit(
|
||||
UpsertSaasFeatureLimitDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertLimitDefinitionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.UpsertLimitDefinitionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("offerings")]
|
||||
[EndpointSummary("新增或更新 SaaS 套餐")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOffering> UpsertOffering(UpsertSaasOfferingDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertOfferingAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
public Task<SaasOffering> UpsertOffering(UpsertSaasOfferingDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.UpsertOfferingAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("offering-versions")]
|
||||
[EndpointSummary("新增或更新 SaaS 套餐版本草稿")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> UpsertVersion(UpsertSaasOfferingVersionDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertDraftVersionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
public Task<SaasOfferingVersionItem> UpsertVersion(UpsertSaasOfferingVersionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.UpsertDraftVersionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/publish")]
|
||||
[EndpointSummary("发布 SaaS 套餐版本")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> PublishVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.PublishVersionAsync(Actor(), versionId, cancellationToken);
|
||||
public Task<SaasOfferingVersionItem> PublishVersion(Guid versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.PublishVersionAsync(Actor(), versionId, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/clone")]
|
||||
[EndpointSummary("克隆 SaaS 套餐版本")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> CloneVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.CloneVersionAsync(Actor(), versionId, cancellationToken);
|
||||
public Task<SaasOfferingVersionItem> CloneVersion(Guid versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.CloneVersionAsync(Actor(), versionId, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/retire")]
|
||||
[EndpointSummary("下架 SaaS 套餐版本")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> RetireVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.RetireVersionAsync(Actor(), versionId, cancellationToken);
|
||||
public Task<SaasOfferingVersionItem> RetireVersion(Guid versionId, CancellationToken cancellationToken)
|
||||
{
|
||||
return catalogService.RetireVersionAsync(Actor(), versionId, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("orders")]
|
||||
[EndpointSummary("查询平台 SaaS 订单")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingOrder>> Orders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetOrdersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingOrder>> Orders(Guid? tenantId, string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetOrdersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("payments")]
|
||||
[EndpointSummary("查询平台 SaaS 支付记录")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingPayment>> Payments(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetPaymentsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingPayment>> Payments(Guid? tenantId, string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetPaymentsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("refunds")]
|
||||
[EndpointSummary("查询平台 SaaS 退款记录")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingRefund>> Refunds(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetRefundsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingRefund>> Refunds(Guid? tenantId, string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetRefundsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("invoices")]
|
||||
[EndpointSummary("查询平台 SaaS 发票")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(Guid? tenantId, string? status, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("usage")]
|
||||
[EndpointSummary("查询租户 SaaS 用量")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<TenantFeatureUsage>> Usage(Guid? tenantId, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetUsageAsync(Actor(), new PlatformBillingAdminQuery(tenantId, null, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<TenantFeatureUsage>> Usage(Guid? tenantId, int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetUsageAsync(Actor(), new PlatformBillingAdminQuery(tenantId, null, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("invoices/reminders")]
|
||||
[EndpointSummary("查询平台 SaaS 账单提醒")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingInvoiceReminder>> InvoiceReminders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<PlatformBillingInvoiceReminder>> InvoiceReminders(Guid? tenantId, string? status,
|
||||
int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetInvoiceRemindersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("subscriptions")]
|
||||
[EndpointSummary("查询租户 SaaS 订阅")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<TenantSaasSubscription>> Subscriptions(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetSubscriptionsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
public Task<IReadOnlyCollection<TenantSaasSubscription>> Subscriptions(Guid? tenantId, string? status,
|
||||
int limit = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return billingService.GetSubscriptionsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("payments/manual/confirm")]
|
||||
[EndpointSummary("确认平台手工支付")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<PlatformBillingPayment> ConfirmManualPayment(ConfirmManualPlatformPaymentDto request, CancellationToken cancellationToken) =>
|
||||
billingService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
[PlatformOperationRisk("high", PlatformApprovalPolicyCodes.FinancialAdjustment)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<PlatformCommandSubmission>> ConfirmManualPayment(
|
||||
ConfirmManualPlatformPaymentDto request,
|
||||
[FromHeader(Name = "Idempotency-Key")] [Required]
|
||||
string idempotencyKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CommandResult(await approvalService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(),
|
||||
idempotencyKey, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenant-feature-overrides")]
|
||||
[EndpointSummary("新增或更新租户功能覆盖规则")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantFeatureOverride> UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request, CancellationToken cancellationToken) =>
|
||||
billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
public Task<TenantFeatureOverride> UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
private SaasCatalogActor Actor() => currentUser.UserId is { } userId
|
||||
? new SaasCatalogActor(userId)
|
||||
: throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
[HttpGet("metrics")]
|
||||
[EndpointSummary("查询 SaaS 商业经营指标")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<CommercialMetrics> Metrics(CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.GetCommercialMetricsAsync(Actor(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions/trial")]
|
||||
[EndpointSummary("为已有租户补录试用订阅")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantSaasSubscription> GrantTrial(GrantTenantTrialDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.GrantTrialAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions/{subscriptionId:guid}/suspend")]
|
||||
[EndpointSummary("暂停租户 SaaS 订阅")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantSaasSubscription> SuspendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.SuspendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions/{subscriptionId:guid}/resume")]
|
||||
[EndpointSummary("恢复租户 SaaS 订阅")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantSaasSubscription> ResumeSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.ResumeSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions/{subscriptionId:guid}/cancel")]
|
||||
[EndpointSummary("立即取消租户 SaaS 订阅")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantSaasSubscription> CancelSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.CancelSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions/{subscriptionId:guid}/extend")]
|
||||
[EndpointSummary("延长租户 SaaS 订阅账期")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantSaasSubscription> ExtendSubscription(Guid subscriptionId, ChangePlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.ExtendSubscriptionAsync(Actor(), request.ToCommand(subscriptionId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("refunds")]
|
||||
[EndpointSummary("申请平台 SaaS 退款")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
[PlatformOperationRisk("high", PlatformApprovalPolicyCodes.FinancialAdjustment)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<PlatformCommandSubmission>(StatusCodes.Status202Accepted)]
|
||||
public async Task<ActionResult<PlatformCommandSubmission>> RequestRefund(RequestPlatformRefundDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CommandResult(await approvalService.SubmitRefundAsync(Actor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("refunds/{refundId:guid}/approve")]
|
||||
[EndpointSummary("批准平台 SaaS 退款")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<PlatformBillingRefund> ApproveRefund(Guid refundId, ReviewPlatformRefundDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.ApproveRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("refunds/{refundId:guid}/reject")]
|
||||
[EndpointSummary("拒绝平台 SaaS 退款")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<PlatformBillingRefund> RejectRefund(Guid refundId, ReviewPlatformRefundDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.RejectRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("refunds/{refundId:guid}/retry")]
|
||||
[EndpointSummary("重试失败的平台 SaaS 退款")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<PlatformBillingRefund> RetryRefund(Guid refundId, ReviewPlatformRefundDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return billingService.RetryRefundAsync(Actor(), request.ToCommand(refundId), cancellationToken);
|
||||
}
|
||||
|
||||
private SaasCatalogActor Actor()
|
||||
{
|
||||
return currentUser.UserId is { } userId
|
||||
? new SaasCatalogActor(userId)
|
||||
: throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
|
||||
private ActionResult<PlatformCommandSubmission> CommandResult(PlatformCommandSubmission result)
|
||||
{
|
||||
return result.ExecutionStatus == "pending_approval" ? Accepted(result) : Ok(result);
|
||||
}
|
||||
}
|
||||
77
Tiku.Api/Controllers/PlatformStaffAccessController.cs
Normal file
77
Tiku.Api/Controllers/PlatformStaffAccessController.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.OpenApi;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-平台管理")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformDashboardView)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform")]
|
||||
public sealed class PlatformStaffAccessController(
|
||||
IPlatformStaffAccessService service,
|
||||
IAuthAdministrationService authAdministrationService,
|
||||
PlatformAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("查询平台员工列表")]
|
||||
[ProducesResponseType<PlatformStaffList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffList>> Staff(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.GetStaffAsync(actorResolver.Resolve(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("staff")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("创建或更新平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> UpsertStaff(
|
||||
UpsertPlatformStaffDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpsertStaffAsync(actorResolver.Resolve(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("staff/status")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("启用或禁用平台员工")]
|
||||
[ProducesResponseType<PlatformStaffItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformStaffItem>> StaffStatus(
|
||||
UpdatePlatformStaffStatusDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await service.UpdateStaffStatusAsync(actorResolver.Resolve(), request.ToCommand(),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("staff/{userId:guid}/password-reset")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformStaffManage)]
|
||||
[EndpointSummary("为平台员工设置一次性临时密码")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> ResetStaffPassword(
|
||||
Guid userId,
|
||||
AdministrativePasswordResetDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
await authAdministrationService.ResetPasswordAsync(
|
||||
new AdministrativePasswordResetRequest(
|
||||
actor.UserId,
|
||||
userId,
|
||||
null,
|
||||
request.TemporaryPassword,
|
||||
request.Reason),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user