Compare commits
19 Commits
754dd788ea
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f44197164 | |||
| 2bf33e753f | |||
|
|
c02a15f1b9 | ||
|
|
2eb309ccec | ||
|
|
e7e420be32 | ||
|
|
f40e74bb9c | ||
|
|
df75597985 | ||
|
|
9d0a525893 | ||
|
|
1ea6af0ec2 | ||
|
|
863c3986b5 | ||
|
|
bbd513c789 | ||
|
|
b187e94906 | ||
|
|
b3af5a4eef | ||
|
|
f21c881f6f | ||
|
|
e1a94222e4 | ||
|
|
8efa4bc53f | ||
|
|
8f0a667de2 | ||
|
|
7a371d5d19 | ||
|
|
f69904e195 |
54
.gitea/workflows/deploy.yml
Normal file
54
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Build and Deploy AdminApi
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout code
|
||||
run: |
|
||||
cd /opt/deploy/adminapi || mkdir -p /opt/deploy/adminapi
|
||||
cd /opt/deploy/adminapi
|
||||
|
||||
# 如果已有仓库就 pull,否则 clone
|
||||
if [ -d ".git" ]; then
|
||||
git fetch origin dev
|
||||
git reset --hard origin/dev
|
||||
git submodule update --init --recursive
|
||||
else
|
||||
git clone --branch dev ssh://git@git.laosankeji.com:2222/msumshk/TakeoutSaaS.AdminApi.git .
|
||||
git submodule init
|
||||
git config submodule.TakeoutSaaS.BuildingBlocks.url ssh://git@git.laosankeji.com:2222/msumshk/TakeoutSaaS.BuildingBlocks.git
|
||||
git config submodule.TakeoutSaaS.Docs.url ssh://git@git.laosankeji.com:2222/msumshk/TakeoutSaaS.Docs.git
|
||||
git submodule update
|
||||
fi
|
||||
|
||||
- name: Build on host
|
||||
run: |
|
||||
cd /opt/deploy/adminapi
|
||||
dotnet restore src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj
|
||||
dotnet publish src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj -c Release -o /opt/deploy/adminapi/publish --no-restore
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd /opt/deploy/adminapi
|
||||
docker build -t takeoutsaas-adminapi:latest -f src/Api/TakeoutSaaS.AdminApi/Dockerfile .
|
||||
|
||||
- name: Deploy container
|
||||
run: |
|
||||
docker stop adminapi || true
|
||||
docker rm adminapi || true
|
||||
docker run -d \
|
||||
--name adminapi \
|
||||
--restart unless-stopped \
|
||||
-p 7801:7801 \
|
||||
-e ASPNETCORE_ENVIRONMENT=Development \
|
||||
takeoutsaas-adminapi:latest
|
||||
|
||||
- name: Clean up old images
|
||||
run: |
|
||||
docker image prune -f
|
||||
180
.github/workflows/ci-cd.yml
vendored
180
.github/workflows/ci-cd.yml
vendored
@@ -1,180 +0,0 @@
|
||||
name: TakeoutSaaS CI/CD
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }}
|
||||
REGISTRY_NAMESPACE: kjkj-saas
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
services: ${{ steps.collect.outputs.services }}
|
||||
image_tag: ${{ steps.collect.outputs.image_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: collect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${{ github.event.before }}"
|
||||
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
|
||||
if git rev-parse HEAD^ >/dev/null 2>&1; then
|
||||
BASE="$(git rev-parse HEAD^)"
|
||||
else
|
||||
BASE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$BASE" ]; then
|
||||
CHANGED=$(git ls-tree -r --name-only HEAD)
|
||||
else
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD || true)
|
||||
fi
|
||||
|
||||
echo "本次变更文件:"
|
||||
echo "$CHANGED"
|
||||
|
||||
deploy_all=false
|
||||
services=()
|
||||
|
||||
hit() { echo "$CHANGED" | grep -qE "$1"; }
|
||||
|
||||
if hit '^src/(Domain|Application|Infrastructure|Core|Modules)/'; then deploy_all=true; fi
|
||||
if hit '^Directory\.Build\.props$'; then deploy_all=true; fi
|
||||
|
||||
if hit '^src/Api/TakeoutSaaS.AdminApi/'; then services+=("admin-api"); fi
|
||||
if hit '^src/Api/TakeoutSaaS.MiniApi/'; then services+=("mini-api"); fi
|
||||
if hit '^src/Api/TakeoutSaaS.UserApi/'; then services+=("user-api"); fi
|
||||
|
||||
if $deploy_all || [ ${#services[@]} -eq 0 ]; then
|
||||
services=("admin-api" "mini-api" "user-api")
|
||||
fi
|
||||
|
||||
printf '需要处理的服务: %s\n' "${services[*]}"
|
||||
|
||||
SERVICES_LIST="${services[*]}"
|
||||
export SERVICES_LIST
|
||||
SERVICES_JSON=$(python -c "import json, os; print(json.dumps(os.environ.get('SERVICES_LIST','').split()))")
|
||||
|
||||
echo "services=$SERVICES_JSON" >> "$GITHUB_OUTPUT"
|
||||
TAG=$(date +%Y%m%d%H%M%S)
|
||||
echo "image_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect
|
||||
if: needs.detect.outputs.services != '[]'
|
||||
strategy:
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.detect.outputs.services) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USERNAME }}
|
||||
password: ${{ env.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push ${{ matrix.service }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.service }}
|
||||
IMAGE_TAG: ${{ needs.detect.outputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$SERVICE" in
|
||||
admin-api)
|
||||
DOCKERFILE="src/Api/TakeoutSaaS.AdminApi/Dockerfile"
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG"
|
||||
;;
|
||||
mini-api)
|
||||
DOCKERFILE="src/Api/TakeoutSaaS.MiniApi/Dockerfile"
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG"
|
||||
;;
|
||||
user-api)
|
||||
DOCKERFILE="src/Api/TakeoutSaaS.UserApi/Dockerfile"
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG"
|
||||
;;
|
||||
*)
|
||||
echo "未知服务:$SERVICE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -f "$DOCKERFILE" ]; then
|
||||
echo "未找到 Dockerfile: $DOCKERFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker build -f "$DOCKERFILE" -t "$IMAGE" .
|
||||
docker push "$IMAGE"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- detect
|
||||
- build
|
||||
if: needs.detect.outputs.services != '[]'
|
||||
strategy:
|
||||
matrix:
|
||||
service: ${{ fromJson(needs.detect.outputs.services) }}
|
||||
steps:
|
||||
- name: Install sshpass
|
||||
run: sudo apt-get update && sudo apt-get install -y sshpass
|
||||
|
||||
- name: Deploy ${{ matrix.service }}
|
||||
env:
|
||||
SERVICE: ${{ matrix.service }}
|
||||
IMAGE_TAG: ${{ needs.detect.outputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
case "$SERVICE" in
|
||||
admin-api)
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG"
|
||||
PORT=7801
|
||||
;;
|
||||
mini-api)
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG"
|
||||
PORT=7701
|
||||
;;
|
||||
user-api)
|
||||
IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG"
|
||||
PORT=7901
|
||||
;;
|
||||
*)
|
||||
echo "未知服务:$SERVICE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$DEPLOY_USER@$DEPLOY_HOST" "
|
||||
set -e
|
||||
echo \"$REGISTRY_PASSWORD\" | docker login \"$REGISTRY\" -u \"$REGISTRY_USERNAME\" --password-stdin
|
||||
docker pull $IMAGE
|
||||
docker stop $SERVICE 2>/dev/null || true
|
||||
docker rm $SERVICE 2>/dev/null || true
|
||||
docker run -d --name $SERVICE --restart=always -p $PORT:$PORT $IMAGE
|
||||
# 清理同一服务旧镜像,避免磁盘被历史 tag 占满
|
||||
docker images \"$REGISTRY/$REGISTRY_NAMESPACE/$SERVICE\" --format '{{.Repository}}:{{.Tag}}' \
|
||||
| grep -v -x \"$IMAGE\" \
|
||||
| xargs -r docker rmi -f
|
||||
"
|
||||
8
NuGet.Config
Normal file
8
NuGet.Config
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="huaweicloud" value="https://repo.huaweicloud.com/repository/nuget/v3/index.json" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
Submodule TakeoutSaaS.Docs updated: 657849a5f7...de7aefd0ff
@@ -1,65 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存监控指标接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize(Roles = "PlatformAdmin")]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionary/metrics")]
|
||||
public sealed class CacheMetricsController(
|
||||
CacheMetricsCollector metricsCollector,
|
||||
ICacheInvalidationLogRepository invalidationLogRepository)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取缓存统计信息。
|
||||
/// </summary>
|
||||
[HttpGet("cache-stats")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CacheStatsSnapshot>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<CacheStatsSnapshot> GetCacheStats([FromQuery] string? timeRange = "1h")
|
||||
{
|
||||
var window = timeRange?.ToLowerInvariant() switch
|
||||
{
|
||||
"24h" => TimeSpan.FromHours(24),
|
||||
"7d" => TimeSpan.FromDays(7),
|
||||
_ => TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
var snapshot = metricsCollector.GetSnapshot(window);
|
||||
return ApiResponse<CacheStatsSnapshot>.Ok(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存失效事件列表。
|
||||
/// </summary>
|
||||
[HttpGet("invalidation-events")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<CacheInvalidationLog>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<CacheInvalidationLog>>> GetInvalidationEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateTime? startDate = null,
|
||||
[FromQuery] DateTime? endDate = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var safePage = page <= 0 ? 1 : page;
|
||||
var safePageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
|
||||
var (items, total) = await invalidationLogRepository.GetPagedAsync(
|
||||
safePage,
|
||||
safePageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
cancellationToken);
|
||||
|
||||
var result = new PagedResult<CacheInvalidationLog>(items, safePage, safePageSize, total);
|
||||
return ApiResponse<PagedResult<CacheInvalidationLog>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
|
||||
return ApiResponse<UpdateMerchantResultDto>.Error(StatusCodes.Status400BadRequest, "路由 merchantId 与请求体 merchantId 不一致");
|
||||
}
|
||||
|
||||
command = command with { MerchantId = merchantId };
|
||||
// 1. Admin 端调用,跳过敏感字段审核
|
||||
command = command with { MerchantId = merchantId, SkipReview = true };
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
@@ -447,4 +448,88 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
|
||||
// 2. 返回类目列表
|
||||
return ApiResponse<IReadOnlyList<string>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户经营模式。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="request">请求体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPut("{merchantId:long}/operating-mode")]
|
||||
[PermissionAuthorize("merchant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> UpdateOperatingMode(
|
||||
long merchantId,
|
||||
[FromBody] UpdateMerchantOperatingModeCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定商户标识
|
||||
var command = request with { MerchantId = merchantId };
|
||||
|
||||
// 2. 执行更新
|
||||
var success = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回结果
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 冻结商户。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="request">请求体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>冻结结果。</returns>
|
||||
[HttpPut("{merchantId:long}/freeze")]
|
||||
[PermissionAuthorize("merchant:freeze")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Freeze(
|
||||
long merchantId,
|
||||
[FromBody] FreezeMerchantCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定商户标识
|
||||
var command = request with { MerchantId = merchantId };
|
||||
|
||||
// 2. 执行冻结
|
||||
var success = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回结果
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解冻商户。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="request">请求体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>解冻结果。</returns>
|
||||
[HttpPut("{merchantId:long}/unfreeze")]
|
||||
[PermissionAuthorize("merchant:unfreeze")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Unfreeze(
|
||||
long merchantId,
|
||||
[FromBody] UnfreezeMerchantCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定商户标识
|
||||
var command = request with { MerchantId = merchantId };
|
||||
|
||||
// 2. 执行解冻
|
||||
var success = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回结果
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy only what's needed for restore first, so `dotnet restore` can be cached.
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
COPY ["TakeoutSaaS.sln", "./"]
|
||||
COPY ["stylecop.json", "./"]
|
||||
COPY ["src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj", "src/Api/TakeoutSaaS.AdminApi/"]
|
||||
COPY ["src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj", "src/Application/TakeoutSaaS.Application/"]
|
||||
COPY ["TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj", "TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Abstractions/"]
|
||||
COPY ["TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj", "TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Kernel/"]
|
||||
COPY ["TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj", "TakeoutSaaS.BuildingBlocks/src/Core/TakeoutSaaS.Shared.Web/"]
|
||||
COPY ["src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj", "src/Domain/TakeoutSaaS.Domain/"]
|
||||
COPY ["src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj", "src/Infrastructure/TakeoutSaaS.Infrastructure/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj", "src/Modules/TakeoutSaaS.Module.Authorization/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj", "src/Modules/TakeoutSaaS.Module.Delivery/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj", "src/Modules/TakeoutSaaS.Module.Dictionary/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj", "src/Modules/TakeoutSaaS.Module.Messaging/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj", "src/Modules/TakeoutSaaS.Module.Scheduler/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj", "src/Modules/TakeoutSaaS.Module.Sms/"]
|
||||
COPY ["src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj", "src/Modules/TakeoutSaaS.Module.Storage/"]
|
||||
|
||||
RUN dotnet restore "src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj"
|
||||
|
||||
# Copy the rest of the source after restore for best cache reuse.
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
COPY publish/ .
|
||||
EXPOSE 7801
|
||||
ENV ASPNETCORE_URLS=http://+:7801
|
||||
ENTRYPOINT ["dotnet", "TakeoutSaaS.AdminApi.dll"]
|
||||
|
||||
@@ -61,20 +61,6 @@
|
||||
"Users": []
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"CacheWarmup": {
|
||||
"DictionaryCodes": [
|
||||
"order_status",
|
||||
"payment_method",
|
||||
"shipping_method",
|
||||
"product_category",
|
||||
"user_role"
|
||||
]
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://image-admin.laosankeji.com",
|
||||
|
||||
@@ -61,20 +61,6 @@
|
||||
"Users": []
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"CacheWarmup": {
|
||||
"DictionaryCodes": [
|
||||
"order_status",
|
||||
"payment_method",
|
||||
"shipping_method",
|
||||
"product_category",
|
||||
"user_role"
|
||||
]
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 冻结商户命令。
|
||||
/// </summary>
|
||||
public sealed record FreezeMerchantCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冻结原因。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 解冻商户命令。
|
||||
/// </summary>
|
||||
public sealed record UnfreezeMerchantCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -18,21 +18,36 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// </summary>
|
||||
public string? BrandAlias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logo URL。
|
||||
/// </summary>
|
||||
public string? LogoUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业执照号。
|
||||
/// </summary>
|
||||
public string? LicenseNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
public string? TaxNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 法人或负责人。
|
||||
/// </summary>
|
||||
public string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
@@ -44,7 +59,42 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// 客服电话。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public string? ServicePhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服邮箱。
|
||||
/// </summary>
|
||||
public string? SupportEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否跳过敏感字段审核(Admin 端设置为 true)。
|
||||
/// </summary>
|
||||
public bool SkipReview { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户经营模式命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateMerchantOperatingModeCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式。
|
||||
/// </summary>
|
||||
public OperatingMode OperatingMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -28,10 +28,25 @@ public sealed class MerchantDetailDto
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// 商户名称(品牌名)。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// </summary>
|
||||
public string? BrandAlias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logo 地址。
|
||||
/// </summary>
|
||||
public string? LogoUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式。
|
||||
/// </summary>
|
||||
@@ -48,9 +63,29 @@ public sealed class MerchantDetailDto
|
||||
public string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
public string? TaxNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
@@ -62,6 +97,16 @@ public sealed class MerchantDetailDto
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服电话。
|
||||
/// </summary>
|
||||
public string? ServicePhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服邮箱。
|
||||
/// </summary>
|
||||
public string? SupportEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
@@ -99,9 +144,9 @@ public sealed class MerchantDetailDto
|
||||
public IReadOnlyList<MerchantStoreDto> Stores { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建商户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
|
||||
public sealed class CreateMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<CreateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<CreateMerchantCommand, MerchantDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantDto> Handle(CreateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建商户实体
|
||||
// 1. 构建商户实体(RowVersion 由 PostgreSQL xmin 自动管理)
|
||||
var merchant = new Merchant
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
@@ -28,16 +32,39 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
|
||||
ContactPhone = request.ContactPhone.Trim(),
|
||||
ContactEmail = request.ContactEmail?.Trim(),
|
||||
Status = request.Status,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16),
|
||||
JoinedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// 2. 持久化
|
||||
// 2. 如果状态为已通过,设置审核通过时间和审核人
|
||||
if (request.Status == MerchantStatus.Approved)
|
||||
{
|
||||
merchant.ApprovedAt = DateTime.UtcNow;
|
||||
merchant.ApprovedBy = currentUserAccessor.UserId;
|
||||
}
|
||||
|
||||
// 3. 持久化商户
|
||||
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. 记录日志
|
||||
logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName);
|
||||
// 4. 如果状态为已通过,添加默认审核通过记录
|
||||
if (request.Status == MerchantStatus.Approved)
|
||||
{
|
||||
var auditLog = new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.ReviewApproved,
|
||||
Title = "商户创建时直接通过审核",
|
||||
Description = "平台管理员创建商户时选择无需审核,系统自动通过",
|
||||
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
OperatorName = currentUserAccessor.UserId == 0 ? "system" : $"user:{currentUserAccessor.UserId}"
|
||||
};
|
||||
await merchantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 记录日志
|
||||
logger.LogInformation("创建商户 {MerchantId} - {BrandName},状态:{Status}", merchant.Id, merchant.BrandName, merchant.Status);
|
||||
return MapToDto(merchant);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 冻结商户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class FreezeMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<FreezeMerchantCommandHandler> logger)
|
||||
: IRequestHandler<FreezeMerchantCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(FreezeMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查是否已冻结
|
||||
if (merchant.IsFrozen)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "商户已处于冻结状态");
|
||||
}
|
||||
|
||||
// 4. 执行冻结
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
|
||||
merchant.IsFrozen = true;
|
||||
merchant.FrozenReason = request.Reason?.Trim();
|
||||
merchant.FrozenAt = now;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 记录审核日志
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.Frozen,
|
||||
Title = "商户冻结",
|
||||
Description = string.IsNullOrWhiteSpace(request.Reason) ? "管理员冻结商户" : $"冻结原因:{request.Reason.Trim()}",
|
||||
OperatorId = actorId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
|
||||
// 6. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("冻结商户 {MerchantId},原因:{Reason}", merchant.Id, request.Reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 解冻商户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UnfreezeMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<UnfreezeMerchantCommandHandler> logger)
|
||||
: IRequestHandler<UnfreezeMerchantCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(UnfreezeMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查是否已解冻
|
||||
if (!merchant.IsFrozen)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "商户未处于冻结状态");
|
||||
}
|
||||
|
||||
// 4. 执行解冻
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
|
||||
merchant.IsFrozen = false;
|
||||
merchant.FrozenReason = null;
|
||||
merchant.FrozenAt = null;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 记录审核日志
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.Unfrozen,
|
||||
Title = "商户解冻",
|
||||
Description = "管理员解冻商户",
|
||||
OperatorId = actorId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
|
||||
// 6. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("解冻商户 {MerchantId}", merchant.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UpdateMerchantResultDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
@@ -43,10 +43,19 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
// 2. (空行后) 规范化输入
|
||||
var name = NormalizeRequired(request.Name, "商户名称");
|
||||
var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话");
|
||||
var brandAlias = NormalizeOptional(request.BrandAlias);
|
||||
var logoUrl = NormalizeOptional(request.LogoUrl);
|
||||
var category = NormalizeOptional(request.Category);
|
||||
var licenseNumber = NormalizeOptional(request.LicenseNumber);
|
||||
var taxNumber = NormalizeOptional(request.TaxNumber);
|
||||
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
|
||||
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
|
||||
var contactEmail = NormalizeOptional(request.ContactEmail);
|
||||
var servicePhone = NormalizeOptional(request.ServicePhone);
|
||||
var supportEmail = NormalizeOptional(request.SupportEmail);
|
||||
var province = NormalizeOptional(request.Province);
|
||||
var city = NormalizeOptional(request.City);
|
||||
var district = NormalizeOptional(request.District);
|
||||
var address = NormalizeOptional(request.Address);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
@@ -54,23 +63,44 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
var changes = new List<MerchantChangeLog>();
|
||||
var criticalChanged = false;
|
||||
|
||||
// 判断是否为管理员操作(Admin 端修改不触发审核)
|
||||
var isAdminOperation = request.SkipReview;
|
||||
|
||||
TrackChange("name", merchant.BrandName, name, isCritical: true);
|
||||
TrackChange("brandAlias", merchant.BrandAlias, brandAlias, isCritical: false);
|
||||
TrackChange("logoUrl", merchant.LogoUrl, logoUrl, isCritical: false);
|
||||
TrackChange("category", merchant.Category, category, isCritical: false);
|
||||
TrackChange("licenseNumber", merchant.BusinessLicenseNumber, licenseNumber, isCritical: true);
|
||||
TrackChange("taxNumber", merchant.TaxNumber, taxNumber, isCritical: false);
|
||||
TrackChange("legalRepresentative", merchant.LegalPerson, legalRepresentative, isCritical: true);
|
||||
TrackChange("registeredAddress", merchant.Address, registeredAddress, isCritical: true);
|
||||
TrackChange("contactPhone", merchant.ContactPhone, contactPhone, isCritical: false);
|
||||
TrackChange("contactEmail", merchant.ContactEmail, contactEmail, isCritical: false);
|
||||
TrackChange("servicePhone", merchant.ServicePhone, servicePhone, isCritical: false);
|
||||
TrackChange("supportEmail", merchant.SupportEmail, supportEmail, isCritical: false);
|
||||
TrackChange("province", merchant.Province, province, isCritical: false);
|
||||
TrackChange("city", merchant.City, city, isCritical: false);
|
||||
TrackChange("district", merchant.District, district, isCritical: false);
|
||||
TrackChange("address", merchant.Address, address, isCritical: true);
|
||||
|
||||
// 3. (空行后) 写入字段
|
||||
merchant.BrandName = name;
|
||||
merchant.BrandAlias = brandAlias;
|
||||
merchant.LogoUrl = logoUrl;
|
||||
merchant.Category = category;
|
||||
merchant.BusinessLicenseNumber = licenseNumber;
|
||||
merchant.TaxNumber = taxNumber;
|
||||
merchant.LegalPerson = legalRepresentative;
|
||||
merchant.Address = registeredAddress;
|
||||
merchant.ContactPhone = contactPhone;
|
||||
merchant.ContactEmail = contactEmail;
|
||||
merchant.ServicePhone = servicePhone;
|
||||
merchant.SupportEmail = supportEmail;
|
||||
merchant.Province = province;
|
||||
merchant.City = city;
|
||||
merchant.District = district;
|
||||
merchant.Address = address;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
var requiresReview = !isAdminOperation && merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
if (requiresReview)
|
||||
{
|
||||
merchant.Status = MerchantStatus.Pending;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户经营模式命令处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateMerchantOperatingModeCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<UpdateMerchantOperatingModeCommandHandler> logger)
|
||||
: IRequestHandler<UpdateMerchantOperatingModeCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(UpdateMerchantOperatingModeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 记录变更日志
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
var oldValue = merchant.OperatingMode?.ToString();
|
||||
var newValue = request.OperatingMode.ToString();
|
||||
|
||||
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
|
||||
{
|
||||
await merchantRepository.AddChangeLogAsync(new MerchantChangeLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
FieldName = "operatingMode",
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
ChangedBy = actorId,
|
||||
ChangedByName = actorName,
|
||||
ChangeType = "Update"
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 4. 更新经营模式
|
||||
merchant.OperatingMode = request.OperatingMode;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("更新商户 {MerchantId} 经营模式为 {OperatingMode}", merchant.Id, request.OperatingMode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -56,12 +56,21 @@ internal static class MerchantMapping
|
||||
TenantId = merchant.TenantId,
|
||||
TenantName = tenantName,
|
||||
Name = merchant.BrandName,
|
||||
BrandAlias = merchant.BrandAlias,
|
||||
LogoUrl = merchant.LogoUrl,
|
||||
Category = merchant.Category,
|
||||
OperatingMode = merchant.OperatingMode,
|
||||
LicenseNumber = merchant.BusinessLicenseNumber,
|
||||
LegalRepresentative = merchant.LegalPerson,
|
||||
RegisteredAddress = merchant.Address,
|
||||
TaxNumber = merchant.TaxNumber,
|
||||
Province = merchant.Province,
|
||||
City = merchant.City,
|
||||
District = merchant.District,
|
||||
Address = merchant.Address,
|
||||
ContactPhone = merchant.ContactPhone,
|
||||
ContactEmail = merchant.ContactEmail,
|
||||
ServicePhone = merchant.ServicePhone,
|
||||
SupportEmail = merchant.SupportEmail,
|
||||
Status = merchant.Status,
|
||||
IsFrozen = merchant.IsFrozen,
|
||||
FrozenReason = merchant.FrozenReason,
|
||||
|
||||
@@ -15,12 +15,22 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMer
|
||||
{
|
||||
RuleFor(x => x.MerchantId).GreaterThan(0);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.BrandAlias).MaximumLength(64);
|
||||
RuleFor(x => x.LogoUrl).MaximumLength(512);
|
||||
RuleFor(x => x.Category).MaximumLength(64);
|
||||
RuleFor(x => x.LicenseNumber).MaximumLength(64);
|
||||
RuleFor(x => x.TaxNumber).MaximumLength(64);
|
||||
RuleFor(x => x.LegalRepresentative).MaximumLength(64);
|
||||
RuleFor(x => x.RegisteredAddress).MaximumLength(256);
|
||||
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.ContactEmail).EmailAddress().MaximumLength(128)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||
RuleFor(x => x.ServicePhone).MaximumLength(32);
|
||||
RuleFor(x => x.SupportEmail).EmailAddress().MaximumLength(128)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.SupportEmail));
|
||||
RuleFor(x => x.Province).MaximumLength(64);
|
||||
RuleFor(x => x.City).MaximumLength(64);
|
||||
RuleFor(x => x.District).MaximumLength(64);
|
||||
RuleFor(x => x.Address).MaximumLength(256);
|
||||
RuleFor(x => x.RowVersion).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,19 +54,10 @@ public sealed class UpdateStoreFeeCommandHandler(
|
||||
fee.OrderPackagingFeeMode = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||
? request.OrderPackagingFeeMode
|
||||
: OrderPackagingFeeMode.Fixed;
|
||||
if (request.PackagingFeeMode == PackagingFeeMode.Fixed && request.OrderPackagingFeeMode == OrderPackagingFeeMode.Tiered)
|
||||
{
|
||||
var normalizedTiers = StoreFeeTierHelper.Normalize(request.PackagingFeeTiers);
|
||||
fee.FixedPackagingFee = 0m;
|
||||
fee.PackagingFeeTiersJson = StoreFeeTierHelper.Serialize(normalizedTiers);
|
||||
}
|
||||
else
|
||||
{
|
||||
fee.FixedPackagingFee = request.PackagingFeeMode == PackagingFeeMode.Fixed
|
||||
? request.FixedPackagingFee ?? 0m
|
||||
: 0m;
|
||||
fee.PackagingFeeTiersJson = null;
|
||||
}
|
||||
fee.FixedPackagingFee = request.FixedPackagingFee ?? 0m;
|
||||
// 非生效模式下也保留配置,避免模式切换后历史阶梯被清空。
|
||||
var normalizedTiers = StoreFeeTierHelper.Normalize(request.PackagingFeeTiers);
|
||||
fee.PackagingFeeTiersJson = StoreFeeTierHelper.Serialize(normalizedTiers);
|
||||
fee.FreeDeliveryThreshold = request.FreeDeliveryThreshold;
|
||||
|
||||
// 4. (空行后) 保存并返回
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存读写接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryCache
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取缓存。
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 移除缓存。
|
||||
/// </summary>
|
||||
Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典两级缓存访问接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryHybridCache
|
||||
{
|
||||
/// <summary>
|
||||
/// 读取缓存,不存在时通过工厂生成并回填。
|
||||
/// </summary>
|
||||
Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀失效缓存。
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -32,5 +32,5 @@ public sealed class UpdateDictionaryGroupRequest
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@ public sealed class UpdateDictionaryItemRequest
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class DictionaryGroupDto
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
|
||||
@@ -59,5 +59,5 @@ public sealed class DictionaryItemDto
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
@@ -17,7 +16,6 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
/// </summary>
|
||||
public sealed class DictionaryAppService(
|
||||
IDictionaryRepository repository,
|
||||
IDictionaryCache cache,
|
||||
ILogger<DictionaryAppService> logger) : IDictionaryAppService
|
||||
{
|
||||
/// <summary>
|
||||
@@ -49,8 +47,7 @@ public sealed class DictionaryAppService(
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
// 4. 持久化并返回
|
||||
@@ -72,12 +69,12 @@ public sealed class DictionaryAppService(
|
||||
// 1. 读取分组并校验权限
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(group.RowVersion))
|
||||
if (request.RowVersion != group.RowVersion)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
@@ -87,9 +84,8 @@ public sealed class DictionaryAppService(
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
// 3. 持久化
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
@@ -98,7 +94,6 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典分组:{GroupId}", group.Id);
|
||||
return MapGroup(group, includeItems: false);
|
||||
}
|
||||
@@ -113,10 +108,9 @@ public sealed class DictionaryAppService(
|
||||
// 1. 读取分组并校验权限
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
|
||||
// 2. 删除并失效缓存
|
||||
// 2. 删除
|
||||
await repository.RemoveGroupAsync(group, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("删除字典分组:{GroupId}", group.Id);
|
||||
}
|
||||
|
||||
@@ -175,14 +169,12 @@ public sealed class DictionaryAppService(
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
// 3. 持久化
|
||||
await repository.AddItemAsync(item, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("新增字典项:{ItemId}", item.Id);
|
||||
return MapItem(item);
|
||||
}
|
||||
@@ -200,12 +192,12 @@ public sealed class DictionaryAppService(
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(item.RowVersion))
|
||||
if (request.RowVersion != item.RowVersion)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
@@ -217,9 +209,8 @@ public sealed class DictionaryAppService(
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
// 3. 持久化
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
@@ -228,7 +219,6 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典项:{ItemId}", item.Id);
|
||||
return MapItem(item);
|
||||
}
|
||||
@@ -244,15 +234,14 @@ public sealed class DictionaryAppService(
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
|
||||
// 2. 删除并失效缓存
|
||||
// 2. 删除
|
||||
await repository.RemoveItemAsync(item, cancellationToken);
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("删除字典项:{ItemId}", item.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取缓存中的字典项。
|
||||
/// 批量获取字典项。
|
||||
/// </summary>
|
||||
/// <param name="request">批量查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
@@ -277,14 +266,14 @@ public sealed class DictionaryAppService(
|
||||
|
||||
foreach (var code in normalizedCodes)
|
||||
{
|
||||
var systemItems = await GetOrLoadCacheAsync(0, code, cancellationToken);
|
||||
var systemItems = await LoadItemsAsync(0, code, cancellationToken);
|
||||
if (tenantId == 0)
|
||||
{
|
||||
result[code] = systemItems;
|
||||
continue;
|
||||
}
|
||||
|
||||
var tenantItems = await GetOrLoadCacheAsync(tenantId, code, cancellationToken);
|
||||
var tenantItems = await LoadItemsAsync(tenantId, code, cancellationToken);
|
||||
result[code] = MergeItems(systemItems, tenantItems);
|
||||
}
|
||||
|
||||
@@ -342,36 +331,15 @@ public sealed class DictionaryAppService(
|
||||
return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
}
|
||||
|
||||
private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
private async Task<IReadOnlyList<DictionaryItemDto>> LoadItemsAsync(long tenantId, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken);
|
||||
if (group.Scope == DictionaryScope.Business)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DictionaryItemDto>> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 先查缓存
|
||||
var cached = await cache.GetAsync(tenantId, code, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 2. 从仓储加载并写入缓存
|
||||
// 从仓储加载
|
||||
var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken);
|
||||
var items = entities
|
||||
return entities
|
||||
.Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true))
|
||||
.Select(MapItem)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToList();
|
||||
|
||||
await cache.SetAsync(tenantId, code, items, cancellationToken);
|
||||
return items;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DictionaryItemDto> MergeItems(IReadOnlyList<DictionaryItemDto> systemItems, IReadOnlyList<DictionaryItemDto> tenantItems)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存键生成器。
|
||||
/// </summary>
|
||||
internal static class DictionaryCacheKeys
|
||||
{
|
||||
internal const string DictionaryPrefix = "dict:";
|
||||
internal const string GroupPrefix = "dict:groups:";
|
||||
internal const string ItemPrefix = "dict:items:";
|
||||
|
||||
internal static string BuildDictionaryKey(long tenantId, DictionaryCode code)
|
||||
=> $"{DictionaryPrefix}{tenantId}:{code.Value}";
|
||||
|
||||
internal static string BuildGroupKey(
|
||||
long tenantId,
|
||||
DictionaryScope scope,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return $"{GroupPrefix}{tenantId}:{scope}:{page}:{pageSize}:{Normalize(keyword)}:{Normalize(isEnabled)}:{Normalize(sortBy)}:{(sortDescending ? "desc" : "asc")}";
|
||||
}
|
||||
|
||||
internal static string BuildGroupPrefix(long tenantId)
|
||||
=> $"{GroupPrefix}{tenantId}:";
|
||||
|
||||
internal static string BuildItemKey(long groupId)
|
||||
=> $"{ItemPrefix}{groupId}";
|
||||
|
||||
private static string Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "all"
|
||||
: value.Trim().ToLowerInvariant().Replace(":", "_", StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(bool? value)
|
||||
=> value.HasValue ? (value.Value ? "1" : "0") : "all";
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
@@ -18,7 +16,6 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
public sealed class DictionaryCommandService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ILogger<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -43,16 +40,11 @@ public sealed class DictionaryCommandService(
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
await groupRepository.AddAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildGroupPrefix(targetTenantId),
|
||||
CacheInvalidationOperation.Create,
|
||||
cancellationToken);
|
||||
|
||||
logger.LogInformation("创建字典分组 {GroupCode}", group.Code);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
@@ -71,7 +63,6 @@ public sealed class DictionaryCommandService(
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -82,7 +73,6 @@ public sealed class DictionaryCommandService(
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典分组 {GroupId}", group.Id);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
@@ -106,7 +96,6 @@ public sealed class DictionaryCommandService(
|
||||
|
||||
await groupRepository.RemoveAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典分组 {GroupId}", group.Id);
|
||||
return true;
|
||||
@@ -141,13 +130,11 @@ public sealed class DictionaryCommandService(
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = sortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Create, cancellationToken);
|
||||
|
||||
logger.LogInformation("新增字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
@@ -179,7 +166,6 @@ public sealed class DictionaryCommandService(
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -190,7 +176,6 @@ public sealed class DictionaryCommandService(
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
}
|
||||
@@ -206,11 +191,8 @@ public sealed class DictionaryCommandService(
|
||||
return false;
|
||||
}
|
||||
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典项 {ItemId}", item.Id);
|
||||
return true;
|
||||
@@ -231,14 +213,14 @@ public sealed class DictionaryCommandService(
|
||||
return tenantId.Value;
|
||||
}
|
||||
|
||||
private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName)
|
||||
private static void EnsureRowVersion(uint requestVersion, uint entityVersion, string resourceName)
|
||||
{
|
||||
if (requestVersion == null || requestVersion.Length == 0)
|
||||
if (requestVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!requestVersion.SequenceEqual(entityVersion))
|
||||
if (requestVersion != entityVersion)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"{resourceName}已被修改,请刷新后重试");
|
||||
}
|
||||
@@ -266,45 +248,6 @@ public sealed class DictionaryCommandService(
|
||||
return item;
|
||||
}
|
||||
|
||||
private Task InvalidateGroupCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private Task InvalidateItemCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
@@ -25,7 +24,6 @@ public sealed class DictionaryImportExportService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryImportLogRepository importLogRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<DictionaryImportExportService> logger)
|
||||
{
|
||||
@@ -163,8 +161,7 @@ public sealed class DictionaryImportExportService(
|
||||
SortOrder = sortOrder,
|
||||
IsEnabled = row.IsEnabled ?? true,
|
||||
IsDefault = false,
|
||||
Description = row.Description,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
Description = row.Description
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
@@ -173,7 +170,6 @@ public sealed class DictionaryImportExportService(
|
||||
}
|
||||
|
||||
await itemRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, cancellationToken);
|
||||
|
||||
var result = BuildResult(successCount, skipCount, errors, stopwatch.Elapsed);
|
||||
await RecordImportLogAsync(request, group, format, result, stopwatch.Elapsed, cancellationToken);
|
||||
@@ -380,23 +376,6 @@ public sealed class DictionaryImportExportService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InvalidateGroupCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), CacheInvalidationOperation.Update, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, CacheInvalidationOperation.Update, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
@@ -16,8 +15,7 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
public sealed class DictionaryOverrideService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
ITenantDictionaryOverrideRepository overrideRepository,
|
||||
IDictionaryHybridCache cache)
|
||||
ITenantDictionaryOverrideRepository overrideRepository)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
@@ -106,10 +104,6 @@ public sealed class DictionaryOverrideService(
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
@@ -134,10 +128,6 @@ public sealed class DictionaryOverrideService(
|
||||
config.OverrideEnabled = false;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -191,10 +181,6 @@ public sealed class DictionaryOverrideService(
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
@@ -245,10 +231,6 @@ public sealed class DictionaryOverrideService(
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
@@ -16,11 +14,8 @@ namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
/// </summary>
|
||||
public sealed class DictionaryQueryService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache)
|
||||
IDictionaryItemRepository itemRepository)
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组分页数据。
|
||||
/// </summary>
|
||||
@@ -34,87 +29,55 @@ public sealed class DictionaryQueryService(
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "Scope=Business 时必须指定 TenantId");
|
||||
}
|
||||
|
||||
// 2. (空行后) 确定作用域与目标租户
|
||||
|
||||
// 2. 确定作用域与目标租户
|
||||
var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business);
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
tenantId = 0;
|
||||
}
|
||||
|
||||
// 3. (空行后) 构建缓存键并加载分页数据
|
||||
|
||||
// 3. 查询分页数据
|
||||
var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
var targetTenant = scope == DictionaryScope.System ? 0 : tenantId;
|
||||
|
||||
var cacheKey = DictionaryCacheKeys.BuildGroupKey(
|
||||
var groups = await groupRepository.GetPagedAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.SortBy,
|
||||
sortDescending);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<DictionaryGroupPage>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var groups = await groupRepository.GetPagedAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.SortBy,
|
||||
sortDescending,
|
||||
token);
|
||||
|
||||
var total = await groupRepository.CountAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
token);
|
||||
|
||||
var items = new List<DictionaryGroupDto>(groups.Count);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
IReadOnlyList<DictionaryItemDto>? groupItems = null;
|
||||
if (query.IncludeItems)
|
||||
{
|
||||
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, token);
|
||||
groupItems = groupItemEntities
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
|
||||
}
|
||||
|
||||
return new DictionaryGroupPage
|
||||
{
|
||||
Items = items,
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = total
|
||||
};
|
||||
},
|
||||
sortDescending,
|
||||
cancellationToken);
|
||||
|
||||
var page = cached ?? new DictionaryGroupPage
|
||||
{
|
||||
Items = Array.Empty<DictionaryGroupDto>(),
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = 0
|
||||
};
|
||||
var total = await groupRepository.CountAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
cancellationToken);
|
||||
|
||||
return new PagedResult<DictionaryGroupDto>(page.Items, page.Page, page.PageSize, page.TotalCount);
|
||||
// 4. 转换为 DTO
|
||||
var items = new List<DictionaryGroupDto>(groups.Count);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
IReadOnlyList<DictionaryItemDto>? groupItems = null;
|
||||
if (query.IncludeItems)
|
||||
{
|
||||
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
groupItems = groupItemEntities
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
|
||||
}
|
||||
|
||||
return new PagedResult<DictionaryGroupDto>(items, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -136,28 +99,18 @@ public sealed class DictionaryQueryService(
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId);
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, token);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, token);
|
||||
return items
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, cancellationToken);
|
||||
return items
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,31 +124,20 @@ public sealed class DictionaryQueryService(
|
||||
}
|
||||
|
||||
// 1. 管理端默认读取系统字典(TenantId=0)
|
||||
var tenantId = 0;
|
||||
var normalized = new DictionaryCode(code);
|
||||
var cacheKey = DictionaryCacheKeys.BuildDictionaryKey(tenantId, normalized);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token);
|
||||
if (systemGroup == null || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken);
|
||||
if (systemGroup == null || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -230,12 +172,4 @@ public sealed class DictionaryQueryService(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class DictionaryGroupPage
|
||||
{
|
||||
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,5 @@ public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
|
||||
@@ -95,5 +95,5 @@ public sealed record UserDetailDto
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
|
||||
@@ -43,9 +43,9 @@ public sealed class DictionaryItem : MultiTenantEntityBase
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性:所属分组。
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class IdentityUser : AuditableEntityBase
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin 系统列)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ public sealed class InventoryBatch : MultiTenantEntityBase
|
||||
public int RemainingQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ public sealed class InventoryItem : MultiTenantEntityBase
|
||||
public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo;
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@ public sealed class InventoryLockRecord : MultiTenantEntityBase
|
||||
public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked;
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ public sealed class Merchant : MultiTenantEntityBase
|
||||
public DateTime? ClaimExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// 并发控制版本(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -68,5 +68,15 @@ public enum MerchantAuditAction
|
||||
/// <summary>
|
||||
/// 强制接管审核。
|
||||
/// </summary>
|
||||
ReviewForceClaimed = 12
|
||||
ReviewForceClaimed = 12,
|
||||
|
||||
/// <summary>
|
||||
/// 商户冻结。
|
||||
/// </summary>
|
||||
Frozen = 13,
|
||||
|
||||
/// <summary>
|
||||
/// 商户解冻。
|
||||
/// </summary>
|
||||
Unfrozen = 14
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ public sealed class StorePickupSetting : MultiTenantEntityBase
|
||||
public int? MaxQuantityPerOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -54,8 +54,7 @@ public sealed class StorePickupSlot : MultiTenantEntityBase
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// 并发控制字段(映射到 PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
[Timestamp]
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
@@ -18,6 +20,7 @@ namespace TakeoutSaaS.Infrastructure.App.Consumers;
|
||||
/// </remarks>
|
||||
public sealed class TenantCreatedEventConsumer(
|
||||
IMerchantRepository merchantRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IIdGenerator idGenerator,
|
||||
ILogger<TenantCreatedEventConsumer> logger) : IConsumer<TenantCreatedEvent>
|
||||
{
|
||||
@@ -32,10 +35,13 @@ public sealed class TenantCreatedEventConsumer(
|
||||
if (existingMerchant is not null)
|
||||
{
|
||||
logger.LogInformation("租户 {TenantId} 的商户已存在(商户 ID:{MerchantId}),跳过创建", tenantId, existingMerchant.Id);
|
||||
|
||||
// 2. 已存在商户时,仍然确保管理员账号已绑定商户
|
||||
await BindAdminUserMerchantAsync(message.AdminUserId, tenantId, existingMerchant.Id, context.CancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 创建商户实体
|
||||
// 3. 创建商户实体
|
||||
var merchantId = idGenerator.NextId();
|
||||
var merchantStatus = message.IsSkipApproval ? MerchantStatus.Approved : MerchantStatus.Pending;
|
||||
var documentStatus = message.IsSkipApproval ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Pending;
|
||||
@@ -65,7 +71,7 @@ public sealed class TenantCreatedEventConsumer(
|
||||
await merchantRepository.AddMerchantAsync(merchant, context.CancellationToken);
|
||||
logger.LogInformation("租户 {TenantId} 的商户 {MerchantId} 创建成功", tenantId, merchantId);
|
||||
|
||||
// 3. 创建证照记录(营业执照)
|
||||
// 4. 创建证照记录(营业执照)
|
||||
if (!string.IsNullOrWhiteSpace(message.BusinessLicenseUrl))
|
||||
{
|
||||
var businessLicenseDoc = new MerchantDocument
|
||||
@@ -83,7 +89,7 @@ public sealed class TenantCreatedEventConsumer(
|
||||
logger.LogInformation("商户 {MerchantId} 营业执照证照记录创建成功", merchantId);
|
||||
}
|
||||
|
||||
// 4. 创建证照记录(法人身份证正面)
|
||||
// 5. 创建证照记录(法人身份证正面)
|
||||
if (!string.IsNullOrWhiteSpace(message.LegalPersonIdFrontUrl))
|
||||
{
|
||||
var idFrontDoc = new MerchantDocument
|
||||
@@ -102,7 +108,7 @@ public sealed class TenantCreatedEventConsumer(
|
||||
logger.LogInformation("商户 {MerchantId} 法人身份证正面证照记录创建成功", merchantId);
|
||||
}
|
||||
|
||||
// 5. 创建证照记录(法人身份证背面)
|
||||
// 6. 创建证照记录(法人身份证背面)
|
||||
if (!string.IsNullOrWhiteSpace(message.LegalPersonIdBackUrl))
|
||||
{
|
||||
var idBackDoc = new MerchantDocument
|
||||
@@ -119,8 +125,53 @@ public sealed class TenantCreatedEventConsumer(
|
||||
logger.LogInformation("商户 {MerchantId} 法人身份证背面证照记录创建成功", merchantId);
|
||||
}
|
||||
|
||||
// 6. 持久化所有变更
|
||||
// 7. 持久化所有变更
|
||||
await merchantRepository.SaveChangesAsync(context.CancellationToken);
|
||||
logger.LogInformation("租户 {TenantId} 的商户 {MerchantId} 及证照记录全部创建完成", tenantId, merchantId);
|
||||
|
||||
// 8. 创建商户成功后,绑定管理员账号与商户关系
|
||||
await BindAdminUserMerchantAsync(message.AdminUserId, tenantId, merchantId, context.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task BindAdminUserMerchantAsync(long adminUserId, long tenantId, long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 忽略无效管理员用户标识
|
||||
if (adminUserId <= 0)
|
||||
{
|
||||
logger.LogWarning("租户 {TenantId} 的管理员用户 ID 无效,跳过商户绑定", tenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 查询管理员用户(可跟踪)
|
||||
var adminUser = await identityUserRepository.GetForUpdateAsync(adminUserId, cancellationToken);
|
||||
if (adminUser is null)
|
||||
{
|
||||
logger.LogWarning("租户 {TenantId} 的管理员用户 {AdminUserId} 不存在,跳过商户绑定", tenantId, adminUserId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 校验用户归属
|
||||
if (adminUser.Portal != PortalType.Tenant || adminUser.TenantId != tenantId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"管理员用户 {AdminUserId} 归属不匹配,预期租户 {TenantId},实际 Portal={Portal} TenantId={UserTenantId},跳过商户绑定",
|
||||
adminUserId,
|
||||
tenantId,
|
||||
adminUser.Portal,
|
||||
adminUser.TenantId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 已绑定相同商户则直接返回
|
||||
if (adminUser.MerchantId == merchantId)
|
||||
{
|
||||
logger.LogInformation("管理员用户 {AdminUserId} 已绑定商户 {MerchantId},无需重复绑定", adminUserId, merchantId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 更新并保存商户绑定
|
||||
adminUser.MerchantId = merchantId;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("管理员用户 {AdminUserId} 已绑定商户 {MerchantId}", adminUserId, merchantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,9 +646,10 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.FrozenReason).HasMaxLength(500);
|
||||
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status });
|
||||
builder.HasIndex(x => x.ClaimedBy);
|
||||
@@ -978,6 +979,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
@@ -991,6 +995,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.CutoffMinutes).HasDefaultValue(30);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
|
||||
}
|
||||
@@ -1079,6 +1086,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.BatchNumber).HasMaxLength(64);
|
||||
builder.Property(x => x.Location).HasMaxLength(64);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber });
|
||||
}
|
||||
@@ -1101,6 +1111,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.ProductSkuId).IsRequired();
|
||||
builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique();
|
||||
}
|
||||
@@ -1115,6 +1128,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status });
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存命中/耗时指标采集器。
|
||||
/// </summary>
|
||||
public sealed class CacheMetricsCollector
|
||||
{
|
||||
private const string MeterName = "TakeoutSaaS.DictionaryCache";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
private readonly Counter<long> _hitCounter;
|
||||
private readonly Counter<long> _missCounter;
|
||||
private readonly Counter<long> _invalidationCounter;
|
||||
private readonly Histogram<double> _durationHistogram;
|
||||
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
|
||||
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
|
||||
|
||||
private long _hitTotal;
|
||||
private long _missTotal;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化指标采集器。
|
||||
/// </summary>
|
||||
public CacheMetricsCollector()
|
||||
{
|
||||
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
|
||||
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
|
||||
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
|
||||
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"cache_hit_ratio",
|
||||
() => new Measurement<double>(CalculateHitRatio()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存命中。
|
||||
/// </summary>
|
||||
public void RecordHit(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _hitTotal);
|
||||
_hitCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存未命中。
|
||||
/// </summary>
|
||||
public void RecordMiss(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _missTotal);
|
||||
_missCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存查询耗时。
|
||||
/// </summary>
|
||||
public void RecordDuration(string dictionaryCode, double durationMs)
|
||||
{
|
||||
_durationHistogram.Record(durationMs, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录查询详情,用于统计窗口分析。
|
||||
/// </summary>
|
||||
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
|
||||
{
|
||||
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
|
||||
_queries.Enqueue(record);
|
||||
PruneOldRecords();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存失效事件。
|
||||
/// </summary>
|
||||
public void RecordInvalidation(string dictionaryCode)
|
||||
{
|
||||
_invalidationCounter.Add(1, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间范围内的统计快照。
|
||||
/// </summary>
|
||||
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
|
||||
{
|
||||
var since = DateTime.UtcNow.Subtract(window);
|
||||
var records = _queries.Where(record => record.Timestamp >= since).ToList();
|
||||
|
||||
var l1Hits = records.Count(record => record.L1Hit);
|
||||
var l1Misses = records.Count(record => !record.L1Hit);
|
||||
var l2Hits = records.Count(record => record.L2Hit);
|
||||
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
|
||||
|
||||
var totalHits = l1Hits + l2Hits;
|
||||
var totalMisses = l1Misses + l2Misses;
|
||||
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
|
||||
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
|
||||
|
||||
var topQueried = records
|
||||
.GroupBy(record => record.DictionaryCode)
|
||||
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
|
||||
.OrderByDescending(item => item.QueryCount)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return new CacheStatsSnapshot(
|
||||
totalHits,
|
||||
totalMisses,
|
||||
hitRatio,
|
||||
new CacheLevelStats(l1Hits, l2Hits),
|
||||
new CacheLevelStats(l1Misses, l2Misses),
|
||||
averageDuration,
|
||||
topQueried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从缓存键解析字典编码。
|
||||
/// </summary>
|
||||
public static string ExtractDictionaryCode(string cacheKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
return "groups";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
return "items";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
return parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string? code)
|
||||
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
|
||||
|
||||
private double CalculateHitRatio()
|
||||
{
|
||||
var hits = Interlocked.Read(ref _hitTotal);
|
||||
var misses = Interlocked.Read(ref _missTotal);
|
||||
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
|
||||
}
|
||||
|
||||
private void PruneOldRecords()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.Subtract(_retention);
|
||||
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
|
||||
{
|
||||
_queries.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CacheQueryRecord(
|
||||
DateTime Timestamp,
|
||||
string DictionaryCode,
|
||||
bool L1Hit,
|
||||
bool L2Hit,
|
||||
double DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 缓存统计快照。
|
||||
/// </summary>
|
||||
public sealed record CacheStatsSnapshot(
|
||||
long TotalHits,
|
||||
long TotalMisses,
|
||||
double HitRatio,
|
||||
CacheLevelStats HitsByLevel,
|
||||
CacheLevelStats MissesByLevel,
|
||||
double AverageQueryDurationMs,
|
||||
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
|
||||
|
||||
/// <summary>
|
||||
/// 命中统计。
|
||||
/// </summary>
|
||||
public sealed record CacheLevelStats(long L1, long L2);
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询次数统计。
|
||||
/// </summary>
|
||||
public sealed record DictionaryQueryCount(string Code, int QueryCount);
|
||||
@@ -1,57 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热服务。
|
||||
/// </summary>
|
||||
public sealed class CacheWarmupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DictionaryCacheWarmupOptions> options,
|
||||
ILogger<CacheWarmupService> logger) : IHostedService
|
||||
{
|
||||
private const int MaxWarmupCount = 10;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var codes = options.Value.DictionaryCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxWarmupCount)
|
||||
.ToArray();
|
||||
|
||||
if (codes.Length == 0)
|
||||
{
|
||||
logger.LogInformation("未配置字典缓存预热列表。");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 两级缓存封装:L1 内存 + L2 Redis。
|
||||
/// </summary>
|
||||
public sealed class HybridCacheService : IDictionaryHybridCache
|
||||
{
|
||||
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
|
||||
|
||||
private readonly MemoryCacheService _memoryCache;
|
||||
private readonly RedisCacheService _redisCache;
|
||||
private readonly ISubscriber? _subscriber;
|
||||
private readonly ILogger<HybridCacheService>? _logger;
|
||||
private readonly CacheMetricsCollector? _metrics;
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化两级缓存服务。
|
||||
/// </summary>
|
||||
public HybridCacheService(
|
||||
MemoryCacheService memoryCache,
|
||||
RedisCacheService redisCache,
|
||||
IConnectionMultiplexer? multiplexer = null,
|
||||
ILogger<HybridCacheService>? logger = null,
|
||||
CacheMetricsCollector? metrics = null,
|
||||
IServiceScopeFactory? scopeFactory = null)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_redisCache = redisCache;
|
||||
_logger = logger;
|
||||
_subscriber = multiplexer?.GetSubscriber();
|
||||
_metrics = metrics;
|
||||
_scopeFactory = scopeFactory;
|
||||
|
||||
if (_subscriber != null)
|
||||
{
|
||||
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
|
||||
{
|
||||
var prefix = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
_memoryCache.RemoveByPrefix(prefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存,如果不存在则创建并回填。
|
||||
/// </summary>
|
||||
public async Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
|
||||
var l1Hit = false;
|
||||
var l2Hit = false;
|
||||
|
||||
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l1Hit = true;
|
||||
_metrics?.RecordHit("L1", dictionaryCode);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L1", dictionaryCode);
|
||||
|
||||
try
|
||||
{
|
||||
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l2Hit = true;
|
||||
_metrics?.RecordHit("L2", dictionaryCode);
|
||||
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
|
||||
}
|
||||
|
||||
var created = await factory(cancellationToken);
|
||||
if (created == null)
|
||||
{
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return default;
|
||||
}
|
||||
|
||||
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失效指定前缀的缓存键。
|
||||
/// </summary>
|
||||
public async Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
|
||||
_metrics?.RecordInvalidation(dictionaryCode);
|
||||
|
||||
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
|
||||
long redisRemoved = 0;
|
||||
try
|
||||
{
|
||||
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
|
||||
|
||||
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
await _subscriber.PublishAsync(InvalidationChannel, prefix);
|
||||
}
|
||||
|
||||
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
|
||||
}
|
||||
|
||||
private async Task WriteInvalidationLogAsync(
|
||||
string prefix,
|
||||
string dictionaryCode,
|
||||
int removedCount,
|
||||
CacheInvalidationOperation operation)
|
||||
{
|
||||
if (_scopeFactory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
|
||||
if (repo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
|
||||
var tenantId = TryExtractTenantId(prefix) ?? 0;
|
||||
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
|
||||
var log = new CacheInvalidationLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
DictionaryCode = dictionaryCode,
|
||||
Scope = scopeType,
|
||||
AffectedCacheKeyCount = removedCount,
|
||||
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
|
||||
Operation = operation
|
||||
};
|
||||
|
||||
await repo.AddAsync(log);
|
||||
await repo.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static long? TryExtractTenantId(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 本地内存缓存封装。
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheService(IMemoryCache cache)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.Set(key, value, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = ttl
|
||||
});
|
||||
_keys.TryAdd(key, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
cache.Remove(key);
|
||||
_keys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
=> RemoveByPrefixWithCount(prefix);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public int RemoveByPrefixWithCount(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
Remove(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有缓存。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 缓存访问封装。
|
||||
/// </summary>
|
||||
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDatabase? _database = multiplexer?.GetDatabase();
|
||||
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await cache.GetAsync(key, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetAsync(key, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
|
||||
long removed = 0;
|
||||
foreach (var endpoint in _multiplexer.GetEndPoints())
|
||||
{
|
||||
var server = _multiplexer.GetServer(endpoint);
|
||||
foreach (var key in server.Keys(pattern: pattern))
|
||||
{
|
||||
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
@@ -32,9 +25,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
|
||||
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// 1. 注册数据库上下文
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
|
||||
|
||||
// 2. 注册仓储
|
||||
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
|
||||
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
|
||||
@@ -43,69 +38,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
|
||||
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
|
||||
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
|
||||
// 3. 注册导入导出解析器
|
||||
services.AddScoped<ICsvDictionaryParser, CsvDictionaryParser>();
|
||||
services.AddScoped<IJsonDictionaryParser, JsonDictionaryParser>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
var hasDistributedCache = services.Any(descriptor => descriptor.ServiceType == typeof(IDistributedCache));
|
||||
if (!hasDistributedCache)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection) && !services.Any(descriptor => descriptor.ServiceType == typeof(IConnectionMultiplexer)))
|
||||
{
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
|
||||
}
|
||||
|
||||
services.AddSingleton<MemoryCacheService>();
|
||||
services.AddSingleton<CacheMetricsCollector>();
|
||||
services.AddSingleton(sp => new RedisCacheService(
|
||||
sp.GetRequiredService<IDistributedCache>(),
|
||||
sp.GetService<IConnectionMultiplexer>()));
|
||||
services.AddSingleton(sp => new HybridCacheService(
|
||||
sp.GetRequiredService<MemoryCacheService>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
sp.GetService<IConnectionMultiplexer>(),
|
||||
sp.GetService<ILogger<HybridCacheService>>(),
|
||||
sp.GetService<CacheMetricsCollector>(),
|
||||
sp.GetService<IServiceScopeFactory>()));
|
||||
services.AddSingleton<IDictionaryHybridCache>(sp => sp.GetRequiredService<HybridCacheService>());
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddOptions<DictionaryCacheWarmupOptions>()
|
||||
.Bind(configuration.GetSection("CacheWarmup"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddHostedService<CacheWarmupService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 缓存滑动过期时间。
|
||||
/// </summary>
|
||||
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheWarmupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 预热字典编码列表(最多前 10 个)。
|
||||
/// </summary>
|
||||
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -60,10 +60,8 @@ public sealed class DictionaryDbContext(
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var provider = Database.ProviderName;
|
||||
var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
|
||||
ConfigureLabelOverride(modelBuilder.Entity<DictionaryLabelOverride>());
|
||||
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
|
||||
@@ -75,7 +73,7 @@ public sealed class DictionaryDbContext(
|
||||
/// 配置字典分组。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_groups");
|
||||
builder.HasKey(x => x.Id);
|
||||
@@ -92,19 +90,12 @@ public sealed class DictionaryDbContext(
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code })
|
||||
.IsUnique()
|
||||
@@ -116,7 +107,7 @@ public sealed class DictionaryDbContext(
|
||||
/// 配置字典项。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_items");
|
||||
builder.HasKey(x => x.Id);
|
||||
@@ -130,19 +121,12 @@ public sealed class DictionaryDbContext(
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasOne(x => x.Group)
|
||||
.WithMany(g => g.Items)
|
||||
.HasForeignKey(x => x.GroupId)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
@@ -123,11 +122,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
}
|
||||
|
||||
entry.State = EntityState.Modified;
|
||||
var originalVersion = item.RowVersion;
|
||||
var nextVersion = RandomNumberGenerator.GetBytes(16);
|
||||
entry.Property(x => x.RowVersion).OriginalValue = originalVersion;
|
||||
entry.Property(x => x.RowVersion).CurrentValue = nextVersion;
|
||||
item.RowVersion = nextVersion;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 IDistributedCache 的字典缓存实现。
|
||||
/// </summary>
|
||||
public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions<DictionaryCacheOptions> options) : IDictionaryCache
|
||||
{
|
||||
private readonly DictionaryCacheOptions _options = options.Value;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项集合或 null。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 拼装缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = await cache.GetAsync(cacheKey, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 反序列化
|
||||
return JsonSerializer.Deserialize<List<DictionaryItemDto>>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="items">字典项集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 序列化并写入缓存
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = _options.SlidingExpiration
|
||||
};
|
||||
return cache.SetAsync(cacheKey, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除指定租户与编码的缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 删除缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
return cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(long tenantId, string code)
|
||||
=> $"dictionary:{tenantId}:{code.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -108,9 +108,10 @@ public sealed class IdentityDbContext(
|
||||
builder.Property(x => x.MustChangePassword).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasColumnType("text");
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.TenantId);
|
||||
ConfigureAuditableEntity(builder);
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
{
|
||||
[DbContext(typeof(IdentityDbContext))]
|
||||
[Migration("20260205131953_UseXminConcurrency")]
|
||||
partial class UseXminConcurrency
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.1")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime?>("Consumed")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("ConsumerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ReceiveCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("Received")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("InboxState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.Property<long>("SequenceNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("SequenceNumber"));
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<Guid?>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("DestinationAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTime?>("EnqueueTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("ExpirationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FaultAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Headers")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("InboxConsumerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("InboxMessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("InitiatorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("MessageType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("OutboxId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Properties")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("RequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ResponseAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<DateTime>("SentTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("SourceAddress")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("SequenceNumber");
|
||||
|
||||
b.HasIndex("EnqueueTime");
|
||||
|
||||
b.HasIndex("ExpirationTime");
|
||||
|
||||
b.HasIndex("OutboxId", "SequenceNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OutboxMessage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
|
||||
{
|
||||
b.Property<Guid>("OutboxId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Delivered")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("LastSequenceNumber")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("LockId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.HasKey("OutboxId");
|
||||
|
||||
b.HasIndex("Created");
|
||||
|
||||
b.ToTable("OutboxState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("登录账号。");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("展示名称。");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("邮箱(租户内唯一)。");
|
||||
|
||||
b.Property<int>("FailedLoginCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("登录失败次数。");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近登录时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("LockedUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("锁定截止时间(UTC)。");
|
||||
|
||||
b.Property<long?>("MerchantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属商户(平台管理员为空)。");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否强制修改密码。");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("密码哈希。");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("手机号(租户内唯一)。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账号所属 Portal。");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin")
|
||||
.HasComment("并发控制字段(映射到 PostgreSQL xmin 系统列)。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账号状态。");
|
||||
|
||||
b.Property<long?>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Account")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0 AND \"Email\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0 AND \"Phone\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.HasIndex("TenantId", "Account")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.HasIndex("TenantId", "Email")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1 AND \"Email\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("TenantId", "Phone")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1 AND \"Phone\" IS NOT NULL");
|
||||
|
||||
b.ToTable("identity_users", null, t =>
|
||||
{
|
||||
t.HasComment("后台账户实体(按 Portal 区分平台管理员与租户后台账号)。");
|
||||
|
||||
t.HasCheckConstraint("CK_identity_users_Portal_Tenant", "(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AuthListJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。");
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("组件路径(不含 .vue)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("图标标识。");
|
||||
|
||||
b.Property<bool>("IsIframe")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否 iframe。");
|
||||
|
||||
b.Property<bool>("KeepAlive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否缓存。");
|
||||
|
||||
b.Property<string>("Link")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("外链或 iframe 地址。");
|
||||
|
||||
b.Property<string>("MetaPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.permissions(逗号分隔)。");
|
||||
|
||||
b.Property<string>("MetaRoles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.roles(逗号分隔)。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("菜单名称(前端路由 name)。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级菜单 ID,根节点为 0。");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("路由路径。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("菜单所属 Portal。");
|
||||
|
||||
b.Property<string>("RequiredPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("访问该菜单所需的权限集合(逗号分隔)。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序。");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("标题。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Portal", "ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("menu_definitions", null, t =>
|
||||
{
|
||||
t.HasComment("后台菜单定义(按 Portal 区分 Admin/Tenant 两套菜单树)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("昵称。");
|
||||
|
||||
b.Property<string>("OpenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 OpenId。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("UnionId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 UnionId,可能为空。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.HasIndex("TenantId", "OpenId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("mini_users", null, t =>
|
||||
{
|
||||
t.HasComment("小程序用户实体。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码(全局唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("权限名称。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级权限 ID,根节点为 0。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("权限所属 Portal。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序值,值越小越靠前。");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("权限类型(group/leaf)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("Portal", "ParentId", "SortOrder");
|
||||
|
||||
b.ToTable("permissions", null, t =>
|
||||
{
|
||||
t.HasComment("权限定义。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色编码(租户内唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色名称。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("角色所属 Portal。");
|
||||
|
||||
b.Property<long?>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.HasIndex("TenantId", "Code")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.ToTable("roles", null, t =>
|
||||
{
|
||||
t.HasComment("角色定义。");
|
||||
|
||||
t.HasCheckConstraint("CK_roles_Portal_Tenant", "(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<long>("PermissionId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("权限 ID。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("关系所属 Portal。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long?>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.HasIndex("RoleId", "PermissionId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0");
|
||||
|
||||
b.HasIndex("TenantId", "RoleId", "PermissionId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.ToTable("role_permissions", null, t =>
|
||||
{
|
||||
t.HasComment("角色-权限关系。");
|
||||
|
||||
t.HasCheckConstraint("CK_role_permissions_Portal_Tenant", "(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("模板描述。");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("模板名称。");
|
||||
|
||||
b.Property<string>("TemplateCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("模板编码(唯一)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TemplateCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("role_templates", null, t =>
|
||||
{
|
||||
t.HasComment("角色模板定义(平台级)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("PermissionCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码。");
|
||||
|
||||
b.Property<long>("RoleTemplateId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("模板 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleTemplateId", "PermissionCode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("role_template_permissions", null, t =>
|
||||
{
|
||||
t.HasComment("角色模板-权限关系(平台级)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<int>("Portal")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("关系所属 Portal。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long?>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("用户 ID。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId")
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.HasIndex("UserId", "RoleId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 0");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "RoleId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"Portal\" = 1");
|
||||
|
||||
b.ToTable("user_roles", null, t =>
|
||||
{
|
||||
t.HasComment("用户-角色关系。");
|
||||
|
||||
t.HasCheckConstraint("CK_user_roles_Portal_Tenant", "(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
{
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("OutboxId");
|
||||
|
||||
b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InboxMessageId", "InboxConsumerId")
|
||||
.HasPrincipalKey("MessageId", "ConsumerId");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UseXminConcurrency : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 删除旧的 RowVersion 列,改用 PostgreSQL 原生 xmin 系统列
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RowVersion",
|
||||
table: "identity_users");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// 恢复 RowVersion 列
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "identity_users",
|
||||
type: "bytea",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,12 +267,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账号所属 Portal。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin")
|
||||
.HasComment("并发控制字段(映射到 PostgreSQL xmin 系统列)。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
|
||||
Reference in New Issue
Block a user