312
This commit is contained in:
parent
1a799caadc
commit
2a52aacc0c
35
server/HoneyBox/scripts/create_user_poster_cache.sql
Normal file
35
server/HoneyBox/scripts/create_user_poster_cache.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- 创建用户海报缓存表
|
||||||
|
-- 用于存储用户生成的推广海报,避免重复生成
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[user_poster_cache]') AND type in (N'U'))
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE [dbo].[user_poster_cache] (
|
||||||
|
[id] INT IDENTITY(1,1) NOT NULL,
|
||||||
|
[user_id] INT NOT NULL,
|
||||||
|
[app_id] NVARCHAR(100) NULL,
|
||||||
|
[template_hash] NVARCHAR(64) NOT NULL,
|
||||||
|
[cos_url] NVARCHAR(500) NOT NULL,
|
||||||
|
[file_size] BIGINT NOT NULL DEFAULT 0,
|
||||||
|
[mime_type] NVARCHAR(50) NOT NULL DEFAULT 'image/png',
|
||||||
|
[status] INT NOT NULL DEFAULT 1,
|
||||||
|
[expires_at] DATETIME2 NOT NULL,
|
||||||
|
[platform] NVARCHAR(50) NOT NULL DEFAULT 'MP-WEIXIN',
|
||||||
|
[created_at] DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||||
|
[updated_at] DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||||
|
CONSTRAINT [pk_user_poster_cache] PRIMARY KEY CLUSTERED ([id] ASC)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
CREATE INDEX [ix_user_poster_cache_user_id] ON [dbo].[user_poster_cache] ([user_id]);
|
||||||
|
CREATE INDEX [ix_user_poster_cache_template_hash] ON [dbo].[user_poster_cache] ([template_hash]);
|
||||||
|
CREATE INDEX [ix_user_poster_cache_platform] ON [dbo].[user_poster_cache] ([platform]);
|
||||||
|
CREATE INDEX [ix_user_poster_cache_status] ON [dbo].[user_poster_cache] ([status]);
|
||||||
|
CREATE INDEX [ix_user_poster_cache_expires_at] ON [dbo].[user_poster_cache] ([expires_at]);
|
||||||
|
|
||||||
|
PRINT '表 user_poster_cache 创建成功';
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
PRINT '表 user_poster_cache 已存在';
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
@ -427,6 +427,36 @@ public class BaseSetting
|
||||||
[JsonPropertyName("share_image")]
|
[JsonPropertyName("share_image")]
|
||||||
public string? ShareImage { get; set; }
|
public string? ShareImage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报模板图片URL
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("poster_template")]
|
||||||
|
public string? PosterTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报二维码X坐标
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("poster_qr_x")]
|
||||||
|
public string? PosterQrX { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报二维码Y坐标
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("poster_qr_y")]
|
||||||
|
public string? PosterQrY { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报二维码大小
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("poster_qr_size")]
|
||||||
|
public string? PosterQrSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 站点URL
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("site_url")]
|
||||||
|
public string? SiteUrl { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 抽奖券拉人上限
|
/// 抽奖券拉人上限
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,16 @@ export interface BaseSetting {
|
||||||
share_title?: string
|
share_title?: string
|
||||||
/** 分享图片 */
|
/** 分享图片 */
|
||||||
share_image?: string
|
share_image?: string
|
||||||
|
/** 海报模板图片URL */
|
||||||
|
poster_template?: string
|
||||||
|
/** 海报二维码X坐标 */
|
||||||
|
poster_qr_x?: string
|
||||||
|
/** 海报二维码Y坐标 */
|
||||||
|
poster_qr_y?: string
|
||||||
|
/** 海报二维码大小 */
|
||||||
|
poster_qr_size?: string
|
||||||
|
/** 站点URL */
|
||||||
|
site_url?: string
|
||||||
/** 抽奖券拉人上限 */
|
/** 抽奖券拉人上限 */
|
||||||
draw_people_num?: string
|
draw_people_num?: string
|
||||||
/** 首页是否弹窗 0关闭 1开启 */
|
/** 首页是否弹窗 0关闭 1开启 */
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,70 @@
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 海报配置 -->
|
||||||
|
<el-divider content-position="left">海报配置</el-divider>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="海报模板图片" prop="poster_template">
|
||||||
|
<ImageUpload
|
||||||
|
v-model="formData.poster_template"
|
||||||
|
placeholder="点击上传海报模板"
|
||||||
|
:show-url-input="true"
|
||||||
|
tip="推荐尺寸 750x1334,二维码将合成到模板上"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="站点URL" prop="site_url">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.site_url"
|
||||||
|
placeholder="请输入站点URL,如 https://example.com"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">用于生成海报二维码的推广链接</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="二维码X坐标" prop="poster_qr_x">
|
||||||
|
<el-input-number
|
||||||
|
v-model.number="formData.poster_qr_x"
|
||||||
|
:min="0"
|
||||||
|
:max="2000"
|
||||||
|
placeholder="二维码左上角X坐标"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">二维码在海报上的X坐标(像素)</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="二维码Y坐标" prop="poster_qr_y">
|
||||||
|
<el-input-number
|
||||||
|
v-model.number="formData.poster_qr_y"
|
||||||
|
:min="0"
|
||||||
|
:max="3000"
|
||||||
|
placeholder="二维码左上角Y坐标"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">二维码在海报上的Y坐标(像素)</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="二维码大小" prop="poster_qr_size">
|
||||||
|
<el-input-number
|
||||||
|
v-model.number="formData.poster_qr_size"
|
||||||
|
:min="50"
|
||||||
|
:max="500"
|
||||||
|
placeholder="二维码大小"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">二维码宽高(像素)</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<!-- 开关配置 -->
|
<!-- 开关配置 -->
|
||||||
<el-divider content-position="left">开关配置</el-divider>
|
<el-divider content-position="left">开关配置</el-divider>
|
||||||
|
|
||||||
|
|
@ -303,6 +367,11 @@ interface FormDataType {
|
||||||
erweima: string
|
erweima: string
|
||||||
share_title: string
|
share_title: string
|
||||||
share_image: string
|
share_image: string
|
||||||
|
poster_template: string
|
||||||
|
poster_qr_x: number
|
||||||
|
poster_qr_y: number
|
||||||
|
poster_qr_size: number
|
||||||
|
site_url: string
|
||||||
draw_people_num: number
|
draw_people_num: number
|
||||||
is_shou_tan: number
|
is_shou_tan: number
|
||||||
is_exchange: number
|
is_exchange: number
|
||||||
|
|
@ -325,6 +394,11 @@ const formData = reactive<FormDataType>({
|
||||||
erweima: '',
|
erweima: '',
|
||||||
share_title: '',
|
share_title: '',
|
||||||
share_image: '',
|
share_image: '',
|
||||||
|
poster_template: '',
|
||||||
|
poster_qr_x: 104,
|
||||||
|
poster_qr_y: 1180,
|
||||||
|
poster_qr_size: 200,
|
||||||
|
site_url: '',
|
||||||
draw_people_num: 10,
|
draw_people_num: 10,
|
||||||
is_shou_tan: 0,
|
is_shou_tan: 0,
|
||||||
is_exchange: 1
|
is_exchange: 1
|
||||||
|
|
@ -372,6 +446,11 @@ const loadData = async () => {
|
||||||
erweima: data.erweima || '',
|
erweima: data.erweima || '',
|
||||||
share_title: data.share_title || '',
|
share_title: data.share_title || '',
|
||||||
share_image: data.share_image || '',
|
share_image: data.share_image || '',
|
||||||
|
poster_template: data.poster_template || '',
|
||||||
|
poster_qr_x: Number(data.poster_qr_x) || 104,
|
||||||
|
poster_qr_y: Number(data.poster_qr_y) || 1180,
|
||||||
|
poster_qr_size: Number(data.poster_qr_size) || 200,
|
||||||
|
site_url: data.site_url || '',
|
||||||
draw_people_num: Number(data.draw_people_num) || 10,
|
draw_people_num: Number(data.draw_people_num) || 10,
|
||||||
is_shou_tan: Number(data.is_shou_tan) || 0,
|
is_shou_tan: Number(data.is_shou_tan) || 0,
|
||||||
is_exchange: Number(data.is_exchange) || 1
|
is_exchange: Number(data.is_exchange) || 1
|
||||||
|
|
@ -416,6 +495,11 @@ const handleSave = async () => {
|
||||||
erweima: formData.erweima,
|
erweima: formData.erweima,
|
||||||
share_title: formData.share_title,
|
share_title: formData.share_title,
|
||||||
share_image: formData.share_image,
|
share_image: formData.share_image,
|
||||||
|
poster_template: formData.poster_template,
|
||||||
|
poster_qr_x: String(formData.poster_qr_x),
|
||||||
|
poster_qr_y: String(formData.poster_qr_y),
|
||||||
|
poster_qr_size: String(formData.poster_qr_size),
|
||||||
|
site_url: formData.site_url,
|
||||||
draw_people_num: String(formData.draw_people_num),
|
draw_people_num: String(formData.draw_people_num),
|
||||||
is_shou_tan: String(formData.is_shou_tan),
|
is_shou_tan: String(formData.is_shou_tan),
|
||||||
is_exchange: String(formData.is_exchange)
|
is_exchange: String(formData.is_exchange)
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,12 @@ public class InvitationController : ControllerBase
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取推荐信息
|
/// 获取推荐信息
|
||||||
/// POST /api/invitation
|
/// GET /api/invitation
|
||||||
/// Requirements: 9.1-9.2
|
/// Requirements: 9.1-9.2
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("invitation")]
|
[HttpGet("invitation")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ApiResponse<InvitationInfoResponse>> GetInvitationInfo([FromBody] InvitationInfoRequest? request)
|
public async Task<ApiResponse<InvitationInfoResponse>> GetInvitationInfo([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
|
||||||
{
|
{
|
||||||
var userId = GetCurrentUserId();
|
var userId = GetCurrentUserId();
|
||||||
if (userId == null)
|
if (userId == null)
|
||||||
|
|
@ -45,10 +45,16 @@ public class InvitationController : ControllerBase
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var page = request?.Page ?? 1;
|
|
||||||
if (page < 1) page = 1;
|
if (page < 1) page = 1;
|
||||||
|
|
||||||
var result = await _invitationService.GetInvitationInfoAsync(userId.Value, page);
|
// 从请求头获取平台类型
|
||||||
|
var platform = Request.Headers["client"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrEmpty(platform))
|
||||||
|
{
|
||||||
|
platform = Request.Headers["platform"].FirstOrDefault() ?? "H5";
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _invitationService.GetInvitationInfoAsync(userId.Value, page, platform);
|
||||||
return ApiResponse<InvitationInfoResponse>.Success(result);
|
return ApiResponse<InvitationInfoResponse>.Success(result);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ public interface IInvitationService
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户ID</param>
|
/// <param name="userId">用户ID</param>
|
||||||
/// <param name="page">页码</param>
|
/// <param name="page">页码</param>
|
||||||
|
/// <param name="platform">平台类型</param>
|
||||||
/// <returns>推荐信息响应</returns>
|
/// <returns>推荐信息响应</returns>
|
||||||
Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page);
|
Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page, string? platform = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 绑定邀请码
|
/// 绑定邀请码
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace HoneyBox.Core.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报生成服务接口
|
||||||
|
/// </summary>
|
||||||
|
public interface IPosterService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或生成用户推广海报
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">用户ID</param>
|
||||||
|
/// <param name="platform">平台类型(MP-WEIXIN/H5/APP等)</param>
|
||||||
|
/// <returns>海报生成结果</returns>
|
||||||
|
Task<PosterResult> GetUserPosterAsync(int userId, string? platform = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报生成结果
|
||||||
|
/// </summary>
|
||||||
|
public class PosterResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否成功
|
||||||
|
/// </summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 消息
|
||||||
|
/// </summary>
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报图片URL
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -13,17 +13,22 @@ namespace HoneyBox.Core.Services;
|
||||||
public class InvitationService : IInvitationService
|
public class InvitationService : IInvitationService
|
||||||
{
|
{
|
||||||
private readonly HoneyBoxDbContext _dbContext;
|
private readonly HoneyBoxDbContext _dbContext;
|
||||||
|
private readonly IPosterService _posterService;
|
||||||
private readonly ILogger<InvitationService> _logger;
|
private readonly ILogger<InvitationService> _logger;
|
||||||
private const int PageSize = 15;
|
private const int PageSize = 15;
|
||||||
|
|
||||||
public InvitationService(HoneyBoxDbContext dbContext, ILogger<InvitationService> logger)
|
public InvitationService(
|
||||||
|
HoneyBoxDbContext dbContext,
|
||||||
|
IPosterService posterService,
|
||||||
|
ILogger<InvitationService> logger)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
|
_posterService = posterService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page)
|
public async Task<InvitationInfoResponse> GetInvitationInfoAsync(int userId, int page, string? platform = null)
|
||||||
{
|
{
|
||||||
// 获取被邀请用户列表(pid = userId 且 status = 1)
|
// 获取被邀请用户列表(pid = userId 且 status = 1)
|
||||||
var query = _dbContext.Users
|
var query = _dbContext.Users
|
||||||
|
|
@ -72,7 +77,20 @@ public class InvitationService : IInvitationService
|
||||||
|
|
||||||
// 获取分享配置
|
// 获取分享配置
|
||||||
var shareTitle = await GetShareTitleAsync();
|
var shareTitle = await GetShareTitleAsync();
|
||||||
var shareImage = await GetShareImageAsync();
|
|
||||||
|
// 生成用户专属海报
|
||||||
|
var shareImage = string.Empty;
|
||||||
|
var posterResult = await _posterService.GetUserPosterAsync(userId, platform);
|
||||||
|
if (posterResult.Success && !string.IsNullOrEmpty(posterResult.ImageUrl))
|
||||||
|
{
|
||||||
|
shareImage = posterResult.ImageUrl;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 如果海报生成失败,使用静态分享图片作为备选
|
||||||
|
shareImage = await GetShareImageAsync();
|
||||||
|
_logger.LogWarning("用户 {UserId} 海报生成失败: {Message},使用静态分享图片", userId, posterResult.Message);
|
||||||
|
}
|
||||||
|
|
||||||
return new InvitationInfoResponse
|
return new InvitationInfoResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
454
server/HoneyBox/src/HoneyBox.Infrastructure/External/Poster/PosterService.cs
vendored
Normal file
454
server/HoneyBox/src/HoneyBox.Infrastructure/External/Poster/PosterService.cs
vendored
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using HoneyBox.Core.Interfaces;
|
||||||
|
using HoneyBox.Model.Data;
|
||||||
|
using HoneyBox.Model.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using QRCoder;
|
||||||
|
using SkiaSharp;
|
||||||
|
|
||||||
|
namespace HoneyBox.Infrastructure.External.Poster;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报生成服务实现
|
||||||
|
/// </summary>
|
||||||
|
public class PosterService : IPosterService
|
||||||
|
{
|
||||||
|
private readonly HoneyBoxDbContext _dbContext;
|
||||||
|
private readonly IImageUploadService _imageUploadService;
|
||||||
|
private readonly ILogger<PosterService> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
// 默认缓存100天
|
||||||
|
private const int DefaultCacheExpireDays = 100;
|
||||||
|
// 二维码在海报上的位置(可通过配置调整)
|
||||||
|
private const int DefaultQrCodeX = 104;
|
||||||
|
private const int DefaultQrCodeY = 1180;
|
||||||
|
private const int DefaultQrCodeSize = 200;
|
||||||
|
|
||||||
|
public PosterService(
|
||||||
|
HoneyBoxDbContext dbContext,
|
||||||
|
IImageUploadService imageUploadService,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<PosterService> logger)
|
||||||
|
{
|
||||||
|
_dbContext = dbContext;
|
||||||
|
_imageUploadService = imageUploadService;
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PosterResult> GetUserPosterAsync(int userId, string? platform = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 默认平台
|
||||||
|
platform ??= "H5";
|
||||||
|
|
||||||
|
// 1. 验证用户ID
|
||||||
|
if (userId <= 0)
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "无效的用户ID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取海报配置
|
||||||
|
var posterConfig = await GetPosterConfigAsync();
|
||||||
|
if (posterConfig == null || string.IsNullOrEmpty(posterConfig.TemplateUrl))
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "海报模板未配置" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 计算模板哈希(用于缓存失效判断)
|
||||||
|
var templateHash = ComputeHash(posterConfig.TemplateUrl);
|
||||||
|
|
||||||
|
// 4. 获取小程序配置(用于生成小程序码)
|
||||||
|
var appId = await GetDefaultAppIdAsync();
|
||||||
|
|
||||||
|
// 5. 查询缓存记录
|
||||||
|
var cacheRecord = await FindValidCacheAsync(userId, templateHash, appId, platform);
|
||||||
|
if (cacheRecord != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("用户 {UserId} 海报缓存命中", userId);
|
||||||
|
return new PosterResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "海报获取成功",
|
||||||
|
ImageUrl = cacheRecord.CosUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 生成推广链接
|
||||||
|
var qrContent = await GeneratePromotionUrlAsync(userId, platform);
|
||||||
|
if (string.IsNullOrEmpty(qrContent))
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "生成推广链接失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 下载海报模板
|
||||||
|
var templateBytes = await DownloadImageAsync(posterConfig.TemplateUrl);
|
||||||
|
if (templateBytes == null)
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "下载海报模板失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 生成带二维码的海报
|
||||||
|
var posterBytes = GeneratePosterWithQrCode(
|
||||||
|
templateBytes,
|
||||||
|
qrContent,
|
||||||
|
posterConfig.QrCodeX ?? DefaultQrCodeX,
|
||||||
|
posterConfig.QrCodeY ?? DefaultQrCodeY,
|
||||||
|
posterConfig.QrCodeSize ?? DefaultQrCodeSize);
|
||||||
|
|
||||||
|
if (posterBytes == null)
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "海报生成失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 上传到COS
|
||||||
|
using var stream = new MemoryStream(posterBytes);
|
||||||
|
var cosUrl = await _imageUploadService.UploadStreamAsync(
|
||||||
|
stream,
|
||||||
|
$"poster_{userId}_{platform}.png",
|
||||||
|
"image/png",
|
||||||
|
"poster");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(cosUrl))
|
||||||
|
{
|
||||||
|
return new PosterResult { Success = false, Message = "海报上传失败" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. 失效旧缓存
|
||||||
|
await InvalidateOldCachesAsync(userId, templateHash, platform);
|
||||||
|
|
||||||
|
// 11. 保存新缓存记录
|
||||||
|
var newCache = new UserPosterCache
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
AppId = appId,
|
||||||
|
TemplateHash = templateHash,
|
||||||
|
CosUrl = cosUrl,
|
||||||
|
FileSize = posterBytes.Length,
|
||||||
|
MimeType = "image/png",
|
||||||
|
Status = 1,
|
||||||
|
ExpiresAt = DateTime.Now.AddDays(DefaultCacheExpireDays),
|
||||||
|
Platform = platform,
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
UpdatedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
_dbContext.Set<UserPosterCache>().Add(newCache);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 12. 随机清理过期缓存(5%概率)
|
||||||
|
if (Random.Shared.Next(100) < 5)
|
||||||
|
{
|
||||||
|
_ = CleanExpiredCachesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("用户 {UserId} 海报生成成功: {Url}", userId, cosUrl);
|
||||||
|
return new PosterResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = "海报生成成功",
|
||||||
|
ImageUrl = cosUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "获取用户海报异常: UserId={UserId}", userId);
|
||||||
|
return new PosterResult { Success = false, Message = "系统异常,请稍后重试" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 私有方法
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取海报配置
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PosterConfig?> GetPosterConfigAsync()
|
||||||
|
{
|
||||||
|
var config = await _dbContext.Configs
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigKey == "base");
|
||||||
|
|
||||||
|
if (config?.ConfigValue == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||||
|
return new PosterConfig
|
||||||
|
{
|
||||||
|
TemplateUrl = GetJsonString(jsonDoc, "poster_template"),
|
||||||
|
QrCodeX = GetJsonInt(jsonDoc, "poster_qr_x"),
|
||||||
|
QrCodeY = GetJsonInt(jsonDoc, "poster_qr_y"),
|
||||||
|
QrCodeSize = GetJsonInt(jsonDoc, "poster_qr_size"),
|
||||||
|
SiteUrl = GetJsonString(jsonDoc, "site_url")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "解析海报配置失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取默认小程序AppId
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GetDefaultAppIdAsync()
|
||||||
|
{
|
||||||
|
var config = await _dbContext.Configs
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigKey == "miniprogram_setting");
|
||||||
|
|
||||||
|
if (config?.ConfigValue == null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||||
|
if (jsonDoc.TryGetProperty("miniprograms", out var miniprograms) &&
|
||||||
|
miniprograms.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var mp in miniprograms.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (mp.TryGetProperty("is_default", out var isDefault) &&
|
||||||
|
isDefault.GetInt32() == 1 &&
|
||||||
|
mp.TryGetProperty("appid", out var appid))
|
||||||
|
{
|
||||||
|
return appid.GetString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "解析小程序配置失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成推广链接
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GeneratePromotionUrlAsync(int userId, string platform)
|
||||||
|
{
|
||||||
|
// 获取站点URL配置
|
||||||
|
var config = await _dbContext.Configs
|
||||||
|
.FirstOrDefaultAsync(c => c.ConfigKey == "base");
|
||||||
|
|
||||||
|
var siteUrl = "https://zfunbox.cn";
|
||||||
|
if (config?.ConfigValue != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(config.ConfigValue);
|
||||||
|
if (jsonDoc.TryGetProperty("site_url", out var siteUrlProp))
|
||||||
|
{
|
||||||
|
var url = siteUrlProp.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(url))
|
||||||
|
{
|
||||||
|
siteUrl = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// H5和APP使用URL链接
|
||||||
|
return $"{siteUrl}?pid={userId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 下载图片
|
||||||
|
/// </summary>
|
||||||
|
private async Task<byte[]?> DownloadImageAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return await response.Content.ReadAsByteArrayAsync();
|
||||||
|
}
|
||||||
|
_logger.LogWarning("下载图片失败: {Url}, StatusCode: {StatusCode}", url, response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "下载图片异常: {Url}", url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生成带二维码的海报
|
||||||
|
/// </summary>
|
||||||
|
private byte[]? GeneratePosterWithQrCode(byte[] templateBytes, string qrContent, int qrX, int qrY, int qrSize)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 加载模板图片
|
||||||
|
using var templateBitmap = SKBitmap.Decode(templateBytes);
|
||||||
|
if (templateBitmap == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("无法解码海报模板图片");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成二维码
|
||||||
|
using var qrGenerator = new QRCodeGenerator();
|
||||||
|
var qrCodeData = qrGenerator.CreateQrCode(qrContent, QRCodeGenerator.ECCLevel.M);
|
||||||
|
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||||
|
var qrCodeBytes = qrCode.GetGraphic(20);
|
||||||
|
|
||||||
|
// 解码二维码图片
|
||||||
|
using var qrBitmap = SKBitmap.Decode(qrCodeBytes);
|
||||||
|
if (qrBitmap == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("无法解码二维码图片");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放二维码到指定大小
|
||||||
|
using var scaledQrBitmap = qrBitmap.Resize(new SKImageInfo(qrSize, qrSize), SKFilterQuality.High);
|
||||||
|
|
||||||
|
// 创建画布
|
||||||
|
using var surface = SKSurface.Create(new SKImageInfo(templateBitmap.Width, templateBitmap.Height));
|
||||||
|
var canvas = surface.Canvas;
|
||||||
|
|
||||||
|
// 绘制模板
|
||||||
|
canvas.DrawBitmap(templateBitmap, 0, 0);
|
||||||
|
|
||||||
|
// 绘制二维码
|
||||||
|
canvas.DrawBitmap(scaledQrBitmap, qrX, qrY);
|
||||||
|
|
||||||
|
// 导出为PNG
|
||||||
|
using var image = surface.Snapshot();
|
||||||
|
using var data = image.Encode(SKEncodedImageFormat.Png, 90);
|
||||||
|
return data.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "生成海报失败");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查找有效缓存
|
||||||
|
/// </summary>
|
||||||
|
private async Task<UserPosterCache?> FindValidCacheAsync(int userId, string templateHash, string appId, string platform)
|
||||||
|
{
|
||||||
|
return await _dbContext.Set<UserPosterCache>()
|
||||||
|
.Where(c => c.UserId == userId)
|
||||||
|
.Where(c => c.TemplateHash == templateHash)
|
||||||
|
.Where(c => c.Platform == platform)
|
||||||
|
.Where(c => c.Status == 1)
|
||||||
|
.Where(c => c.ExpiresAt > DateTime.Now)
|
||||||
|
.OrderByDescending(c => c.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 失效旧缓存
|
||||||
|
/// </summary>
|
||||||
|
private async Task InvalidateOldCachesAsync(int userId, string templateHash, string platform)
|
||||||
|
{
|
||||||
|
var oldCaches = await _dbContext.Set<UserPosterCache>()
|
||||||
|
.Where(c => c.UserId == userId)
|
||||||
|
.Where(c => c.Platform == platform)
|
||||||
|
.Where(c => c.TemplateHash != templateHash || c.Status == 1)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cache in oldCaches)
|
||||||
|
{
|
||||||
|
cache.Status = 0;
|
||||||
|
cache.UpdatedAt = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldCaches.Count > 0)
|
||||||
|
{
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理过期缓存
|
||||||
|
/// </summary>
|
||||||
|
private async Task CleanExpiredCachesAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expiredCaches = await _dbContext.Set<UserPosterCache>()
|
||||||
|
.Where(c => c.ExpiresAt < DateTime.Now || c.Status == 0)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (expiredCaches.Count > 0)
|
||||||
|
{
|
||||||
|
_dbContext.Set<UserPosterCache>().RemoveRange(expiredCaches);
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("清理了 {Count} 条过期海报缓存", expiredCaches.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "清理过期海报缓存失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 计算字符串哈希
|
||||||
|
/// </summary>
|
||||||
|
private static string ComputeHash(string input)
|
||||||
|
{
|
||||||
|
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
||||||
|
var hash = MD5.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetJsonString(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return prop.GetString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetJsonInt(JsonElement element, string propertyName)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(propertyName, out var prop))
|
||||||
|
{
|
||||||
|
if (prop.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
return prop.GetInt32();
|
||||||
|
}
|
||||||
|
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out var val))
|
||||||
|
{
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 海报配置
|
||||||
|
/// </summary>
|
||||||
|
private class PosterConfig
|
||||||
|
{
|
||||||
|
public string? TemplateUrl { get; set; }
|
||||||
|
public int? QrCodeX { get; set; }
|
||||||
|
public int? QrCodeY { get; set; }
|
||||||
|
public int? QrCodeSize { get; set; }
|
||||||
|
public string? SiteUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
|
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
|
||||||
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
|
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.44" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Autofac;
|
using Autofac;
|
||||||
using HoneyBox.Core.Interfaces;
|
using HoneyBox.Core.Interfaces;
|
||||||
using HoneyBox.Infrastructure.Cache;
|
using HoneyBox.Infrastructure.Cache;
|
||||||
|
using HoneyBox.Infrastructure.External.Poster;
|
||||||
using HoneyBox.Infrastructure.External.Storage;
|
using HoneyBox.Infrastructure.External.Storage;
|
||||||
|
|
||||||
namespace HoneyBox.Infrastructure.Modules;
|
namespace HoneyBox.Infrastructure.Modules;
|
||||||
|
|
@ -27,6 +28,11 @@ public class InfrastructureModule : Module
|
||||||
.As<IImageUploadService>()
|
.As<IImageUploadService>()
|
||||||
.InstancePerLifetimeScope();
|
.InstancePerLifetimeScope();
|
||||||
|
|
||||||
|
// 注册海报生成服务
|
||||||
|
builder.RegisterType<PosterService>()
|
||||||
|
.As<IPosterService>()
|
||||||
|
.InstancePerLifetimeScope();
|
||||||
|
|
||||||
// 后续可在此注册其他基础设施服务
|
// 后续可在此注册其他基础设施服务
|
||||||
// 如: 外部服务客户端、消息队列等
|
// 如: 外部服务客户端、消息队列等
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,9 @@ public class ServiceModule : Module
|
||||||
builder.Register(c =>
|
builder.Register(c =>
|
||||||
{
|
{
|
||||||
var dbContext = c.Resolve<HoneyBoxDbContext>();
|
var dbContext = c.Resolve<HoneyBoxDbContext>();
|
||||||
|
var posterService = c.Resolve<IPosterService>();
|
||||||
var logger = c.Resolve<ILogger<InvitationService>>();
|
var logger = c.Resolve<ILogger<InvitationService>>();
|
||||||
return new InvitationService(dbContext, logger);
|
return new InvitationService(dbContext, posterService, logger);
|
||||||
}).As<IInvitationService>().InstancePerLifetimeScope();
|
}).As<IInvitationService>().InstancePerLifetimeScope();
|
||||||
|
|
||||||
// 注册排行榜服务
|
// 注册排行榜服务
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,8 @@ public partial class HoneyBoxDbContext : DbContext
|
||||||
|
|
||||||
public virtual DbSet<PrizeAnnouncement> PrizeAnnouncements { get; set; }
|
public virtual DbSet<PrizeAnnouncement> PrizeAnnouncements { get; set; }
|
||||||
|
|
||||||
|
public virtual DbSet<UserPosterCache> UserPosterCaches { get; set; }
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
// Connection string is configured in Program.cs via dependency injection
|
// Connection string is configured in Program.cs via dependency injection
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace HoneyBox.Model.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户海报缓存实体
|
||||||
|
/// </summary>
|
||||||
|
[Table("user_poster_cache")]
|
||||||
|
public class UserPosterCache
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户ID
|
||||||
|
/// </summary>
|
||||||
|
[Column("user_id")]
|
||||||
|
public int UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 小程序AppId
|
||||||
|
/// </summary>
|
||||||
|
[Column("app_id")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? AppId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 模板文件哈希值
|
||||||
|
/// </summary>
|
||||||
|
[Column("template_hash")]
|
||||||
|
[StringLength(64)]
|
||||||
|
public string TemplateHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// COS存储URL
|
||||||
|
/// </summary>
|
||||||
|
[Column("cos_url")]
|
||||||
|
[StringLength(500)]
|
||||||
|
public string CosUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件大小(字节)
|
||||||
|
/// </summary>
|
||||||
|
[Column("file_size")]
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME类型
|
||||||
|
/// </summary>
|
||||||
|
[Column("mime_type")]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string MimeType { get; set; } = "image/png";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态:1有效 0无效
|
||||||
|
/// </summary>
|
||||||
|
[Column("status")]
|
||||||
|
public int Status { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 过期时间
|
||||||
|
/// </summary>
|
||||||
|
[Column("expires_at")]
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 平台类型:MP-WEIXIN/H5/APP等
|
||||||
|
/// </summary>
|
||||||
|
[Column("platform")]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string Platform { get; set; } = "MP-WEIXIN";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建时间
|
||||||
|
/// </summary>
|
||||||
|
[Column("created_at")]
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新时间
|
||||||
|
/// </summary>
|
||||||
|
[Column("updated_at")]
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.Now;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user