This commit is contained in:
gpu 2026-02-02 07:59:16 +08:00
parent 8f19923b16
commit 81fb507fab
24 changed files with 3843 additions and 51 deletions

View File

@ -6,97 +6,97 @@
## Tasks
- [ ] 1. 创建数据库实体和表结构
- [ ] 1.1 创建 PrizeAnnouncement 实体类
- [x] 1. 创建数据库实体和表结构
- [x] 1.1 创建 PrizeAnnouncement 实体类
- 在 `HoneyBox.Model/Entities/` 创建 `PrizeAnnouncement.cs`
- 包含 Id, UserAvatar, UserName, PrizeLevel, PrizeName, Sort, IsEnabled, CreatedAt, UpdatedAt 字段
- _Requirements: 1.1, 1.6, 1.7_
- [ ] 1.2 更新 HoneyBoxDbContext 添加 DbSet
- [x] 1.2 更新 HoneyBoxDbContext 添加 DbSet
- 在 `HoneyBoxDbContext.cs` 添加 `DbSet<PrizeAnnouncement>` 和实体配置
- 配置表名、索引、字段约束
- _Requirements: 1.1_
- [ ] 1.3 创建数据库建表 SQL 脚本
- [x] 1.3 创建数据库建表 SQL 脚本
- 在 `server/HoneyBox/scripts/` 创建 `create_prize_announcements.sql`
- _Requirements: 1.1_
- [ ] 2. 实现后台管理 API
- [ ] 2.1 创建 DTO 模型
- [x] 2. 实现后台管理 API
- [x] 2.1 创建 DTO 模型
- 在 `HoneyBox.Admin.Business/Models/` 创建 `Announcement/AnnouncementModels.cs`
- 包含 CreateAnnouncementRequest, UpdateAnnouncementRequest, AnnouncementAdminDto
- _Requirements: 1.1, 1.2, 1.5_
- [ ] 2.2 创建 Service 接口和实现
- [x] 2.2 创建 Service 接口和实现
- 在 `HoneyBox.Admin.Business/Services/Interfaces/` 创建 `IAnnouncementService.cs`
- 在 `HoneyBox.Admin.Business/Services/` 创建 `AnnouncementService.cs`
- 实现 GetListAsync, CreateAsync, UpdateAsync, DeleteAsync, ToggleStatusAsync
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [ ] 2.3 编写 Service 属性测试
- [x] 2.3 编写 Service 属性测试
- **Property 1: CRUD Round-Trip Consistency**
- **Property 4: Required Field Validation**
- **Validates: Requirements 1.1, 1.2, 1.3, 1.5, 2.4**
- [ ] 2.4 创建 Admin Controller
- [x] 2.4 创建 Admin Controller
- 在 `HoneyBox.Admin.Business/Controllers/` 创建 `AnnouncementController.cs`
- 实现 GET/POST/PUT/DELETE/PATCH 端点
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6_
- [ ] 3. Checkpoint - 后台管理 API 验证
- [x] 3. Checkpoint - 后台管理 API 验证
- Ensure all tests pass, ask the user if questions arise.
- [ ] 4. 实现用户端 API
- [ ] 4.1 创建用户端 DTO
- [x] 4. 实现用户端 API
- [x] 4.1 创建用户端 DTO
- 在 `HoneyBox.Model/Models/Announcement/` 创建 `PrizeAnnouncementDto.cs`
- _Requirements: 2.4_
- [ ] 4.2 创建用户端 Service 接口和实现
- [x] 4.2 创建用户端 Service 接口和实现
- 在 `HoneyBox.Core/Interfaces/` 创建 `IPrizeAnnouncementService.cs`
- 在 `HoneyBox.Core/Services/` 创建 `PrizeAnnouncementService.cs`
- 实现 GetEnabledAnnouncementsAsync 方法
- _Requirements: 2.1, 2.2, 2.3, 2.4_
- [ ] 4.3 编写用户端 Service 属性测试
- [x] 4.3 编写用户端 Service 属性测试
- **Property 2: Enabled Filter Correctness**
- **Property 3: Sort Ordering Preservation**
- **Validates: Requirements 2.2, 2.3, 1.6, 1.7**
- [ ] 4.4 在 ConfigController 添加 API 端点
- [x] 4.4 在 ConfigController 添加 API 端点
- 添加 `GET /api/getPrizeAnnouncements` 端点
- _Requirements: 2.1_
- [ ] 4.5 注册依赖注入
- [x] 4.5 注册依赖注入
- 在 Autofac 模块中注册 IPrizeAnnouncementService
- _Requirements: 2.1_
- [ ] 5. Checkpoint - 用户端 API 验证
- [x] 5. Checkpoint - 用户端 API 验证
- Ensure all tests pass, ask the user if questions arise.
- [ ] 6. 实现前端公告组件
- [ ] 6.1 创建 API 接口文件
- [x] 6. 实现前端公告组件
- [x] 6.1 创建 API 接口文件
- 在 `honey_box/common/server/` 创建 `announcement.js`
- 导出 getPrizeAnnouncements 函数
- _Requirements: 2.1_
- [ ] 6.2 创建公告轮播组件
- [x] 6.2 创建公告轮播组件
- 在 `honey_box/components/` 创建 `prize-announcement/prize-announcement.vue`
- 实现4秒自动轮播、进出动画、循环展示
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
- [ ] 6.3 创建中奖详情弹窗组件
- [x] 6.3 创建中奖详情弹窗组件
- 在 `honey_box/components/` 创建 `prize-detail-popup/prize-detail-popup.vue`
- 实现弹窗展示、奖品名称显示、【我也要玩】按钮
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 6.4 集成到首页
- [x] 6.4 集成到首页
- 修改 `honey_box/pages/shouye/index.vue`
- 替换静态公告为动态轮播组件
- 添加点击弹窗交互
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1_
- [ ] 7. 实现后台管理前端页面
- [ ] 7.1 创建 API 接口文件
- [x] 7. 实现后台管理前端页面
- [x] 7.1 创建 API 接口文件
- 在 `admin-web/src/api/business/` 创建 `announcement.ts`
- 导出 CRUD API 函数
- _Requirements: 5.1_
- [ ] 7.2 创建公告管理页面
- [x] 7.2 创建公告管理页面
- 在 `admin-web/src/views/business/` 创建 `announcement/index.vue`
- 实现列表展示、新增/编辑弹窗、删除确认、状态切换
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
- [ ] 7.3 添加路由和菜单配置
- [x] 7.3 添加路由和菜单配置
- 在路由配置中添加公告管理页面路由
- _Requirements: 5.1_
- [ ] 8. Final Checkpoint - 全功能验证
- [x] 8. Final Checkpoint - 全功能验证
- Ensure all tests pass, ask the user if questions arise.
## Notes

View File

@ -0,0 +1,12 @@
import RequestManager from '../request';
/**
* 获取中奖公告列表
* 返回启用状态的公告列表按排序字段升序
* @returns {Promise<Array>} 公告列表
*/
export const getPrizeAnnouncements = async () => {
const res = await RequestManager.get("getPrizeAnnouncements", {});
return res.data;
}

View File

@ -0,0 +1,349 @@
<template>
<view class="prize-announcement" v-if="announcements && announcements.length > 0" @click="handleClick">
<!-- 左侧头像区域 -->
<view class="avatar-container">
<image
class="avatar"
:src="currentAnnouncement.userAvatar || defaultAvatar"
mode="aspectFill"
@error="onAvatarError"
></image>
</view>
<!-- 右侧装饰图标 -->
<image
class="reward-icon"
:src="$img1('common/reward_notice.png')"
></image>
<!-- 中间公告内容区域 -->
<view class="content-container">
<view class="content-bg"></view>
<!-- 公告文字 - 带动画 -->
<view class="announcement-text-wrapper">
<text
class="announcement-text"
:class="animationClass"
>{{ formattedText }}</text>
</view>
</view>
</view>
<!-- 空状态隐藏公告区域或显示默认内容 -->
<view class="prize-announcement prize-announcement--empty" v-else-if="showEmpty">
<view class="avatar-container">
<image class="avatar" :src="defaultAvatar" mode="aspectFill"></image>
</view>
<image class="reward-icon" :src="$img1('common/reward_notice.png')"></image>
<view class="content-container">
<view class="content-bg"></view>
<view class="announcement-text-wrapper">
<text class="announcement-text">暂无中奖公告</text>
</view>
</view>
</view>
</template>
<script>
/**
* 中奖公告轮播组件
*
* @description 在首页展示中奖公告支持自动轮播进出动画循环展示
* @property {Array} announcements - 公告列表数据
* @property {Number} interval - 轮播间隔时间毫秒默认4000
* @property {Boolean} showEmpty - 列表为空时是否显示默认内容默认false隐藏
* @event {Function} click - 点击公告时触发返回当前公告数据
*/
export default {
name: 'PrizeAnnouncement',
props: {
//
announcements: {
type: Array,
default: () => []
},
//
interval: {
type: Number,
default: 4000
},
//
showEmpty: {
type: Boolean,
default: false
}
},
data() {
return {
currentIndex: 0, //
timer: null, //
animationClass: '', //
isAnimating: false //
};
},
computed: {
/**
* 默认头像URL
*/
defaultAvatar() {
return this.$img1('common/logo.png');
},
/**
* 当前展示的公告
*/
currentAnnouncement() {
if (!this.announcements || this.announcements.length === 0) {
return {};
}
return this.announcements[this.currentIndex] || {};
},
/**
* 格式化的公告文字
* 格式用户{用户名}抽中了{奖品等级}{奖品名称}
* Validates: Requirements 3.5
*/
formattedText() {
const { userName, prizeLevel, prizeName } = this.currentAnnouncement;
if (!userName || !prizeLevel || !prizeName) {
return '';
}
return `用户${userName}抽中了${prizeLevel}${prizeName}`;
}
},
watch: {
/**
* 监听公告列表变化重新开始轮播
*/
announcements: {
handler(newVal) {
this.resetCarousel();
},
deep: true
}
},
mounted() {
this.startCarousel();
},
beforeDestroy() {
this.stopCarousel();
},
methods: {
/**
* 开始轮播
* Validates: Requirements 3.1
*/
startCarousel() {
//
this.stopCarousel();
//
if (!this.announcements || this.announcements.length <= 1) {
return;
}
// interval
this.timer = setInterval(() => {
this.nextAnnouncement();
}, this.interval);
},
/**
* 停止轮播
*/
stopCarousel() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
/**
* 重置轮播
*/
resetCarousel() {
this.currentIndex = 0;
this.animationClass = '';
this.isAnimating = false;
this.startCarousel();
},
/**
* 切换到下一条公告
* Validates: Requirements 3.2, 3.3
*/
nextAnnouncement() {
if (this.isAnimating) return;
if (!this.announcements || this.announcements.length <= 1) return;
this.isAnimating = true;
// 退
this.animationClass = 'slide-out';
//
setTimeout(() => {
//
// Property 5: Carousel Index Cycling - (currentIndex + 1) % length
this.currentIndex = (this.currentIndex + 1) % this.announcements.length;
//
this.animationClass = 'slide-in';
//
setTimeout(() => {
this.animationClass = '';
this.isAnimating = false;
}, 300);
}, 300);
},
/**
* 点击公告
*/
handleClick() {
if (this.currentAnnouncement && this.currentAnnouncement.id) {
this.$emit('click', this.currentAnnouncement);
}
},
/**
* 头像加载失败时使用默认头像
*/
onAvatarError() {
// Vue使 || defaultAvatar
}
}
};
</script>
<style lang="scss" scoped>
.prize-announcement {
width: 686rpx;
height: 100rpx;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: 10rpx auto 0;
//
.avatar-container {
width: 88rpx;
height: 88rpx;
background-color: #1e88e5;
position: absolute;
border-radius: 50%;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
.avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #ffffff;
}
}
//
.reward-icon {
width: 84rpx;
height: 94rpx;
position: absolute;
right: 0;
z-index: 2;
}
//
.content-container {
width: 566rpx;
height: 72rpx;
position: relative;
display: flex;
align-items: center;
overflow: hidden;
.content-bg {
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(circle at center, #FFE95B 0%, #FFC402 100%);
border-radius: 8rpx;
}
.announcement-text-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
padding-left: 50rpx;
padding-right: 20rpx;
box-sizing: border-box;
overflow: hidden;
}
.announcement-text {
font-size: 26rpx;
font-weight: bold;
color: #5a4a2a;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
}
//
&--empty {
opacity: 0.6;
.announcement-text {
color: #8a7a5a;
}
}
}
//
.slide-out {
animation: slideOut 0.3s ease-out forwards;
}
.slide-in {
animation: slideIn 0.3s ease-out forwards;
}
@keyframes slideOut {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-20rpx);
}
}
@keyframes slideIn {
0% {
opacity: 0;
transform: translateY(20rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,263 @@
<template>
<uni-popup ref="prizePopup" type="center" maskBackgroundColor="rgba(0,0,0,0.8)">
<view class="prize-detail-popup" v-if="visible">
<!-- 顶部装饰 -->
<view class="popup-header">
<image class="header-decoration" :src="$img1('common/reward_notice.png')" mode="aspectFit"></image>
</view>
<!-- 中奖信息区域 -->
<view class="prize-info">
<!-- 用户头像 -->
<view class="avatar-wrapper">
<image
class="avatar"
:src="announcement.userAvatar || defaultAvatar"
mode="aspectFill"
></image>
</view>
<!-- 用户名 -->
<view class="user-name">{{ announcement.userName || '幸运用户' }}</view>
<!-- 中奖提示文字 -->
<view class="prize-tip">恭喜抽中</view>
<!-- 奖品等级 -->
<view class="prize-level" v-if="announcement.prizeLevel">
<text class="level-text">{{ announcement.prizeLevel }}</text>
</view>
<!-- 奖品名称 - Requirements 4.2 -->
<view class="prize-name">{{ announcement.prizeName || '神秘奖品' }}</view>
</view>
<!-- 我也要玩按钮 - Requirements 4.3, 4.4 -->
<view class="action-area">
<view class="play-btn" @click="handlePlayClick">
<text class="btn-text">我也要玩</text>
</view>
</view>
<!-- 关闭按钮 -->
<view class="close-btn" @click="close">
<image :src="$img1('common/close.png')" mode="aspectFit"></image>
</view>
</view>
</uni-popup>
</template>
<script>
/**
* 中奖详情弹窗组件
*
* @description 展示中奖公告的详细信息包含奖品名称和"我也要玩"按钮
* @property {Object} announcement - 公告数据对象
* @property {String} announcement.userAvatar - 用户头像URL
* @property {String} announcement.userName - 用户名称
* @property {String} announcement.prizeLevel - 奖品等级
* @property {String} announcement.prizeName - 奖品名称
* @event {Function} play - 点击"我也要玩"按钮时触发
*
* Validates: Requirements 4.1, 4.2, 4.3, 4.4
*/
export default {
name: 'PrizeDetailPopup',
props: {
//
announcement: {
type: Object,
default: () => ({})
}
},
data() {
return {
visible: false
};
},
computed: {
/**
* 默认头像URL
*/
defaultAvatar() {
return this.$img1('common/logo.png');
}
},
methods: {
/**
* 打开弹窗
* Validates: Requirements 4.1
*/
open() {
this.visible = true;
this.$nextTick(() => {
if (this.$refs.prizePopup) {
//
setTimeout(() => {
this.$refs.prizePopup.open();
}, 50);
}
});
},
/**
* 关闭弹窗
*/
close() {
this.visible = false;
if (this.$refs.prizePopup) {
this.$refs.prizePopup.close();
}
},
/**
* 点击"我也要玩"按钮
* Validates: Requirements 4.3, 4.4
*/
handlePlayClick() {
// - Requirements 4.4
this.close();
// play
this.$emit('play', this.announcement);
}
}
};
</script>
<style lang="scss" scoped>
.prize-detail-popup {
width: 600rpx;
background: linear-gradient(180deg, #FFF8E1 0%, #FFFFFF 100%);
border-radius: 30rpx;
padding: 40rpx 30rpx;
box-sizing: border-box;
position: relative;
//
.popup-header {
display: flex;
justify-content: center;
margin-bottom: 20rpx;
.header-decoration {
width: 120rpx;
height: 120rpx;
}
}
//
.prize-info {
display: flex;
flex-direction: column;
align-items: center;
//
.avatar-wrapper {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
border: 4rpx solid #FFD700;
box-shadow: 0 4rpx 12rpx rgba(255, 215, 0, 0.3);
margin-bottom: 20rpx;
.avatar {
width: 100%;
height: 100%;
}
}
//
.user-name {
font-size: 32rpx;
font-weight: bold;
color: #333333;
margin-bottom: 16rpx;
}
//
.prize-tip {
font-size: 28rpx;
color: #666666;
margin-bottom: 12rpx;
}
//
.prize-level {
background: linear-gradient(90deg, #FFD700 0%, #FFA500 100%);
padding: 8rpx 30rpx;
border-radius: 20rpx;
margin-bottom: 16rpx;
.level-text {
font-size: 26rpx;
font-weight: bold;
color: #FFFFFF;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
}
}
// - Requirements 4.2
.prize-name {
font-size: 36rpx;
font-weight: bold;
color: #E65100;
text-align: center;
padding: 0 20rpx;
margin-bottom: 30rpx;
}
}
//
.action-area {
display: flex;
justify-content: center;
margin-top: 20rpx;
// - Requirements 4.3
.play-btn {
width: 400rpx;
height: 88rpx;
background: linear-gradient(90deg, #FF6B00 0%, #FF9500 100%);
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 16rpx rgba(255, 107, 0, 0.4);
&:active {
transform: scale(0.98);
opacity: 0.9;
}
.btn-text {
font-size: 32rpx;
font-weight: bold;
color: #FFFFFF;
letter-spacing: 4rpx;
}
}
}
//
.close-btn {
width: 48rpx;
height: 48rpx;
position: absolute;
left: 50%;
bottom: -80rpx;
transform: translateX(-50%);
image {
width: 100%;
height: 100%;
}
}
}
</style>

View File

@ -17,26 +17,12 @@
<text style="font-size: 36rpx; margin-top: 30rpx;">哈尼盲盒</text>
<!-- 抽奖广播 -->
<view class=""
style="width: 686rpx; height: 100rpx; position: relative; display: flex; align-items: center; justify-content: center; margin-top: 10rpx;">
<view class=""
style="width: 88rpx; height: 88rpx; background-color: #1e88e5; position: absolute; border-radius: 50%; left: 0rpx;">
</view>
<image class="" :src="$img1('common/reward_notice.png')"
style="width: 84rpx; height: 94rpx; position: absolute; right: 0rpx;">
</image>
<view class="row"
style="width: 566rpx; height: 72rpx; background: radial-gradient(circle at center, #FFE95B 0%, #FFC402 100%);">
</view>
<text
style="position: absolute; left: 100rpx; font-size: 26rpx; font-weight: bold; color: #FFCC00; text-shadow: 1px 1px 0 #5a4a2a, -1px -1px 0 #5a4a2a, 1px -1px 0 #5a4a2a, -1px 1px 0 #5a4a2a; text-align: left;">用户123456抽中了一等级的限定盲盒</text>
</view>
<!-- 中奖公告轮播 - Requirements 3.1, 3.2, 3.3, 3.4, 3.5 -->
<prize-announcement
:announcements="prizeAnnouncements"
:interval="4000"
@click="onAnnouncementClick"
></prize-announcement>
</view>
</view>
@ -146,12 +132,21 @@
<!-- 隐私政策弹窗 -->
<priv-pop></priv-pop>
<!-- #endif -->
<!-- 中奖详情弹窗 - Requirements 4.1, 4.2, 4.3, 4.4 -->
<prize-detail-popup
ref="prizeDetailPopup"
:announcement="currentAnnouncement"
@play="onPlayClick"
></prize-detail-popup>
</view>
</template>
<script>
import lffBarrage from "@/components/lff-barrage/lff-barrage.vue";
import FloatBall from "@/components/float-ball/FloatBall.vue";
import PrizeAnnouncement from "@/components/prize-announcement/prize-announcement.vue";
import PrizeDetailPopup from "@/components/prize-detail-popup/prize-detail-popup.vue";
import {
getAdvert,
getDanYe
@ -163,10 +158,15 @@
receiveCoupons,
getAvailableCoupons
} from "@/common/server/coupon";
import {
getPrizeAnnouncements
} from "@/common/server/announcement";
export default {
components: {
lffBarrage,
FloatBall
FloatBall,
PrizeAnnouncement,
PrizeDetailPopup
},
data() {
let tabList = []; // this.$config.getGoodType();
@ -178,6 +178,9 @@
keyword: "",
listdata: [],
isLoading: false,
//
prizeAnnouncements: [],
currentAnnouncement: {},
// (, )
downOption: {
auto: false,
@ -235,6 +238,8 @@
this.tabCur = 0;
}
this.canGetCoupon = true;
//
this.fetchPrizeAnnouncements();
},
onShareAppMessage() {
@ -250,6 +255,46 @@
// getBallStylegetPopupStyle
},
methods: {
/**
* @description: 获取中奖公告列表
* @return {*}
*/
async fetchPrizeAnnouncements() {
try {
const res = await getPrizeAnnouncements();
if (res && Array.isArray(res)) {
this.prizeAnnouncements = res;
}
} catch (error) {
//
console.log('获取中奖公告失败', error);
this.prizeAnnouncements = [];
}
},
/**
* @description: 点击公告显示详情弹窗
* @param {Object} announcement - 公告数据
* Validates: Requirements 4.1
*/
onAnnouncementClick(announcement) {
this.currentAnnouncement = announcement;
this.$nextTick(() => {
if (this.$refs.prizeDetailPopup) {
this.$refs.prizeDetailPopup.open();
}
});
},
/**
* @description: 点击"我也要玩"按钮
* @param {Object} announcement - 公告数据
*/
onPlayClick(announcement) {
//
//
},
// BallClickgetFloatBall
/**
* @description: 是否弹公告

View File

@ -0,0 +1,189 @@
-- 创建中奖公告配置表
-- 用于首页展示假中奖公告,营造热闹氛围
-- 创建中奖公告配置表
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'prize_announcements')
BEGIN
CREATE TABLE prize_announcements (
id INT IDENTITY(1,1) NOT NULL,
user_avatar NVARCHAR(500) NULL, -- 用户头像URL
user_name NVARCHAR(50) NOT NULL, -- 用户名称
prize_level NVARCHAR(20) NOT NULL, -- 奖品等级(如:无上、传说、史诗、稀有)
prize_name NVARCHAR(100) NOT NULL, -- 奖品名称
sort INT NOT NULL DEFAULT 0, -- 排序值,越小越靠前
is_enabled BIT NOT NULL DEFAULT 1, -- 是否启用
created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
updated_at DATETIME2 NOT NULL DEFAULT GETDATE(),
CONSTRAINT pk_prize_announcements PRIMARY KEY (id)
);
-- 创建索引
CREATE INDEX ix_prize_announcements_is_enabled ON prize_announcements(is_enabled);
CREATE INDEX ix_prize_announcements_sort ON prize_announcements(sort);
PRINT N'创建中奖公告配置表 prize_announcements 成功';
END
ELSE
BEGIN
PRINT N'中奖公告配置表 prize_announcements 已存在';
END
GO
-- 添加表注释
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'prize_announcements')
BEGIN
-- 检查是否已存在表注释
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = 0
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'中奖公告配置表,用于首页展示假中奖公告,营造热闹氛围',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements';
END
-- 添加列注释
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'id')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'主键ID',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'id';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'user_avatar')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'用户头像URL',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'user_avatar';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'user_name')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'用户名称',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'user_name';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'prize_level')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'奖品等级(如:无上、传说、史诗、稀有)',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'prize_level';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'prize_name')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'奖品名称',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'prize_name';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'sort')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'排序值,越小越靠前',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'sort';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'is_enabled')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'是否启用 1-启用 0-禁用',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'is_enabled';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'created_at')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'创建时间',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'created_at';
END
IF NOT EXISTS (
SELECT * FROM sys.extended_properties
WHERE major_id = OBJECT_ID('prize_announcements')
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'updated_at')
AND name = 'MS_Description'
)
BEGIN
EXEC sp_addextendedproperty
@name = N'MS_Description',
@value = N'更新时间',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'prize_announcements',
@level2type = N'COLUMN', @level2name = N'updated_at';
END
END
GO
PRINT N'中奖公告配置表迁移脚本执行完成';
GO

View File

@ -0,0 +1,168 @@
-- =============================================
-- 中奖公告管理菜单初始化脚本
-- 用于在后台管理系统中添加中奖公告管理菜单
--
-- 注意:此脚本需要在 Admin 数据库 (honey_box_admin) 中执行
-- 表名使用小写menus, roles, permissions, role_menus, role_permissions
-- =============================================
-- 注意:执行此脚本前请确保:
-- 1. 已存在超级管理员角色 (Code = 'super_admin')
-- 2. 数据库中已有基础菜单数据
-- 3. 已存在内容管理目录菜单
-- 声明变量
DECLARE @ContentMenuId BIGINT;
DECLARE @SuperAdminRoleId BIGINT;
DECLARE @AnnouncementMenuId BIGINT;
-- 获取超级管理员角色ID
SELECT @SuperAdminRoleId = Id FROM roles WHERE Code = 'super_admin';
-- 获取内容管理目录ID
SELECT @ContentMenuId = Id FROM menus WHERE Path = '/business/content';
-- 如果内容管理目录不存在,则创建它
IF @ContentMenuId IS NULL
BEGIN
INSERT INTO menus (ParentId, Name, Path, Component, Icon, MenuType, Permission, SortOrder, Status, IsExternal, IsCache, CreatedAt)
VALUES (0, N'内容管理', '/business/content', 'Layout', 'Document', 1, NULL, 70, 1, 0, 0, GETDATE());
SET @ContentMenuId = SCOPE_IDENTITY();
PRINT N'创建内容管理目录ID: ' + CAST(@ContentMenuId AS VARCHAR);
END
ELSE
BEGIN
PRINT N'内容管理目录已存在ID: ' + CAST(@ContentMenuId AS VARCHAR);
END
-- =============================================
-- 1. 创建中奖公告管理菜单
-- =============================================
IF NOT EXISTS (SELECT 1 FROM menus WHERE Path = '/business/announcement/list')
BEGIN
INSERT INTO menus (ParentId, Name, Path, Component, Icon, MenuType, Permission, SortOrder, Status, IsExternal, IsCache, CreatedAt)
VALUES (@ContentMenuId, N'中奖公告管理', '/business/announcement/list', 'business/announcement/index', 'Bell', 2, 'announcement:list', 4, 1, 0, 1, GETDATE());
SET @AnnouncementMenuId = SCOPE_IDENTITY();
PRINT N'创建中奖公告管理菜单ID: ' + CAST(@AnnouncementMenuId AS VARCHAR);
END
ELSE
BEGIN
SELECT @AnnouncementMenuId = Id FROM menus WHERE Path = '/business/announcement/list';
PRINT N'中奖公告管理菜单已存在ID: ' + CAST(@AnnouncementMenuId AS VARCHAR);
END
-- =============================================
-- 2. 添加中奖公告管理相关权限
-- =============================================
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:list')
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'公告列表', 'announcement:list', N'内容管理', GETDATE());
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:add')
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'新增公告', 'announcement:add', N'内容管理', GETDATE());
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:edit')
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'编辑公告', 'announcement:edit', N'内容管理', GETDATE());
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:delete')
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'删除公告', 'announcement:delete', N'内容管理', GETDATE());
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:status')
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'切换公告状态', 'announcement:status', N'内容管理', GETDATE());
PRINT N'中奖公告管理权限创建完成';
-- =============================================
-- 3. 为超级管理员角色分配新菜单和权限
-- =============================================
IF @SuperAdminRoleId IS NOT NULL
BEGIN
-- 分配内容管理目录(如果尚未分配)
IF NOT EXISTS (SELECT 1 FROM role_menus WHERE RoleId = @SuperAdminRoleId AND MenuId = @ContentMenuId)
BEGIN
INSERT INTO role_menus (RoleId, MenuId) VALUES (@SuperAdminRoleId, @ContentMenuId);
PRINT N'为超级管理员分配内容管理目录';
END
-- 分配中奖公告管理菜单
IF @AnnouncementMenuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM role_menus WHERE RoleId = @SuperAdminRoleId AND MenuId = @AnnouncementMenuId)
BEGIN
INSERT INTO role_menus (RoleId, MenuId) VALUES (@SuperAdminRoleId, @AnnouncementMenuId);
PRINT N'为超级管理员分配中奖公告管理菜单';
END
-- 分配新增的权限
INSERT INTO role_permissions (RoleId, PermissionId)
SELECT @SuperAdminRoleId, p.Id
FROM permissions p
WHERE p.Code IN (
'announcement:list', 'announcement:add', 'announcement:edit',
'announcement:delete', 'announcement:status'
)
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.RoleId = @SuperAdminRoleId AND rp.PermissionId = p.Id
);
PRINT N'为超级管理员分配中奖公告管理权限';
END
ELSE
BEGIN
PRINT N'警告:未找到超级管理员角色,请手动分配菜单和权限';
END
-- =============================================
-- 4. 验证结果
-- =============================================
PRINT N'';
PRINT N'========== 菜单创建结果 ==========';
SELECT
m.Id,
m.ParentId,
m.Name,
m.Path,
m.Component,
m.MenuType,
m.Permission,
m.SortOrder
FROM menus m
WHERE m.Path LIKE '/business/announcement%'
OR m.Path = '/business/content'
ORDER BY m.ParentId, m.SortOrder;
PRINT N'';
PRINT N'========== 权限创建结果 ==========';
SELECT
p.Id,
p.Name,
p.Code,
p.Module
FROM permissions p
WHERE p.Code LIKE 'announcement:%'
ORDER BY p.Code;
PRINT N'';
PRINT N'========== 角色菜单分配结果 ==========';
SELECT
r.Name AS RoleName,
m.Name AS MenuName,
m.Path
FROM role_menus rm
INNER JOIN roles r ON rm.RoleId = r.Id
INNER JOIN menus m ON rm.MenuId = m.Id
WHERE m.Path LIKE '/business/announcement%'
OR m.Path = '/business/content'
ORDER BY r.Name, m.Path;
PRINT N'';
PRINT N'========== 角色权限分配结果 ==========';
SELECT
r.Name AS RoleName,
p.Name AS PermissionName,
p.Code
FROM role_permissions rp
INNER JOIN roles r ON rp.RoleId = r.Id
INNER JOIN permissions p ON rp.PermissionId = p.Id
WHERE p.Code LIKE 'announcement:%'
ORDER BY r.Name, p.Code;
PRINT N'';
PRINT N'中奖公告管理菜单初始化完成';

View File

@ -0,0 +1,144 @@
using HoneyBox.Admin.Business.Attributes;
using HoneyBox.Admin.Business.Models;
using HoneyBox.Admin.Business.Models.Announcement;
using HoneyBox.Admin.Business.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace HoneyBox.Admin.Business.Controllers;
/// <summary>
/// 中奖公告管理控制器
/// </summary>
[Route("api/admin/announcements")]
public class AnnouncementController : BusinessControllerBase
{
private readonly IAnnouncementService _announcementService;
public AnnouncementController(IAnnouncementService announcementService)
{
_announcementService = announcementService;
}
/// <summary>
/// 获取公告列表(分页)
/// </summary>
/// <param name="page">页码</param>
/// <param name="pageSize">每页数量</param>
/// <param name="keyword">搜索关键词(可选,搜索用户名或奖品名称)</param>
/// <returns>分页公告列表</returns>
[HttpGet]
[BusinessPermission("announcement:list")]
public async Task<IActionResult> GetList([FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
{
try
{
var result = await _announcementService.GetListAsync(page, pageSize, keyword);
return Ok(result);
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 根据ID获取公告详情
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>公告详情</returns>
[HttpGet("{id}")]
[BusinessPermission("announcement:list")]
public async Task<IActionResult> GetById(int id)
{
try
{
var result = await _announcementService.GetByIdAsync(id);
return Ok(result);
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 创建公告
/// </summary>
/// <param name="request">创建请求</param>
/// <returns>创建的公告</returns>
[HttpPost]
[BusinessPermission("announcement:add")]
public async Task<IActionResult> Create([FromBody] CreateAnnouncementRequest request)
{
try
{
var result = await _announcementService.CreateAsync(request);
return Ok(result, "创建成功");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 更新公告
/// </summary>
/// <param name="id">公告ID</param>
/// <param name="request">更新请求</param>
/// <returns>更新后的公告</returns>
[HttpPut("{id}")]
[BusinessPermission("announcement:edit")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateAnnouncementRequest request)
{
try
{
var result = await _announcementService.UpdateAsync(id, request);
return Ok(result, "更新成功");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 删除公告
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>操作结果</returns>
[HttpDelete("{id}")]
[BusinessPermission("announcement:delete")]
public async Task<IActionResult> Delete(int id)
{
try
{
var result = await _announcementService.DeleteAsync(id);
return result ? Ok("删除成功") : Error(BusinessErrorCodes.InternalError, "删除失败");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
/// <summary>
/// 切换公告启用状态
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>更新后的公告</returns>
[HttpPatch("{id}/status")]
[BusinessPermission("announcement:edit")]
public async Task<IActionResult> ToggleStatus(int id)
{
try
{
var result = await _announcementService.ToggleStatusAsync(id);
return Ok(result, "状态切换成功");
}
catch (BusinessException ex)
{
return Error(ex.Code, ex.Message);
}
}
}

View File

@ -0,0 +1,132 @@
namespace HoneyBox.Admin.Business.Models.Announcement;
#region Response Models
/// <summary>
/// 公告管理响应DTO
/// </summary>
public class AnnouncementAdminDto
{
/// <summary>
/// 公告ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 用户头像URL
/// </summary>
public string UserAvatar { get; set; } = string.Empty;
/// <summary>
/// 用户名称
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 奖品等级(如:无上、传说、史诗、稀有)
/// </summary>
public string PrizeLevel { get; set; } = string.Empty;
/// <summary>
/// 奖品名称
/// </summary>
public string PrizeName { get; set; } = string.Empty;
/// <summary>
/// 排序值,越小越靠前
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdatedAt { get; set; }
}
#endregion
#region Request Models
/// <summary>
/// 创建公告请求
/// </summary>
public class CreateAnnouncementRequest
{
/// <summary>
/// 用户头像URL
/// </summary>
public string UserAvatar { get; set; } = string.Empty;
/// <summary>
/// 用户名称(必填)
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 奖品等级(必填,如:无上、传说、史诗、稀有)
/// </summary>
public string PrizeLevel { get; set; } = string.Empty;
/// <summary>
/// 奖品名称(必填)
/// </summary>
public string PrizeName { get; set; } = string.Empty;
/// <summary>
/// 排序值,越小越靠前
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; } = true;
}
/// <summary>
/// 更新公告请求
/// </summary>
public class UpdateAnnouncementRequest
{
/// <summary>
/// 用户头像URL可选不传则不更新
/// </summary>
public string? UserAvatar { get; set; }
/// <summary>
/// 用户名称(可选,不传则不更新)
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// 奖品等级(可选,不传则不更新)
/// </summary>
public string? PrizeLevel { get; set; }
/// <summary>
/// 奖品名称(可选,不传则不更新)
/// </summary>
public string? PrizeName { get; set; }
/// <summary>
/// 排序值(可选,不传则不更新)
/// </summary>
public int? Sort { get; set; }
/// <summary>
/// 是否启用(可选,不传则不更新)
/// </summary>
public bool? IsEnabled { get; set; }
}
#endregion

View File

@ -0,0 +1,251 @@
using HoneyBox.Admin.Business.Models;
using HoneyBox.Admin.Business.Models.Announcement;
using HoneyBox.Admin.Business.Services.Interfaces;
using HoneyBox.Model.Data;
using HoneyBox.Model.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace HoneyBox.Admin.Business.Services;
/// <summary>
/// 中奖公告管理服务实现
/// </summary>
public class AnnouncementService : IAnnouncementService
{
private readonly HoneyBoxDbContext _dbContext;
private readonly ILogger<AnnouncementService> _logger;
public AnnouncementService(
HoneyBoxDbContext dbContext,
ILogger<AnnouncementService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<PagedResult<AnnouncementAdminDto>> GetListAsync(int page = 1, int pageSize = 20, string? keyword = null)
{
var query = _dbContext.PrizeAnnouncements.AsNoTracking();
// 关键词搜索
if (!string.IsNullOrWhiteSpace(keyword))
{
query = query.Where(a => a.UserName.Contains(keyword) || a.PrizeName.Contains(keyword));
}
// 获取总数
var total = await query.CountAsync();
// 分页查询,按排序值升序、创建时间降序
var list = await query
.OrderBy(a => a.Sort)
.ThenByDescending(a => a.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var dtoList = list.Select(MapToDto).ToList();
return PagedResult<AnnouncementAdminDto>.Create(dtoList, total, page, pageSize);
}
/// <inheritdoc />
public async Task<AnnouncementAdminDto> GetByIdAsync(int id)
{
var announcement = await _dbContext.PrizeAnnouncements
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == id);
if (announcement == null)
{
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
}
return MapToDto(announcement);
}
/// <inheritdoc />
public async Task<AnnouncementAdminDto> CreateAsync(CreateAnnouncementRequest request)
{
// 验证必填字段
ValidateRequiredFields(request.UserName, request.PrizeLevel, request.PrizeName);
var now = DateTime.Now;
var announcement = new PrizeAnnouncement
{
UserAvatar = request.UserAvatar,
UserName = request.UserName,
PrizeLevel = request.PrizeLevel,
PrizeName = request.PrizeName,
Sort = request.Sort,
IsEnabled = request.IsEnabled,
CreatedAt = now,
UpdatedAt = now
};
_dbContext.PrizeAnnouncements.Add(announcement);
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"创建中奖公告成功: Id={Id}, UserName={UserName}, PrizeName={PrizeName}",
announcement.Id, announcement.UserName, announcement.PrizeName);
return MapToDto(announcement);
}
/// <inheritdoc />
public async Task<AnnouncementAdminDto> UpdateAsync(int id, UpdateAnnouncementRequest request)
{
var announcement = await _dbContext.PrizeAnnouncements
.FirstOrDefaultAsync(a => a.Id == id);
if (announcement == null)
{
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
}
// 更新字段只更新非null的字段
if (request.UserAvatar != null)
{
announcement.UserAvatar = request.UserAvatar;
}
if (request.UserName != null)
{
if (string.IsNullOrWhiteSpace(request.UserName))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "用户名称不能为空");
}
announcement.UserName = request.UserName;
}
if (request.PrizeLevel != null)
{
if (string.IsNullOrWhiteSpace(request.PrizeLevel))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品等级不能为空");
}
announcement.PrizeLevel = request.PrizeLevel;
}
if (request.PrizeName != null)
{
if (string.IsNullOrWhiteSpace(request.PrizeName))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品名称不能为空");
}
announcement.PrizeName = request.PrizeName;
}
if (request.Sort.HasValue)
{
announcement.Sort = request.Sort.Value;
}
if (request.IsEnabled.HasValue)
{
announcement.IsEnabled = request.IsEnabled.Value;
}
announcement.UpdatedAt = DateTime.Now;
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"更新中奖公告成功: Id={Id}, UserName={UserName}",
id, announcement.UserName);
return MapToDto(announcement);
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(int id)
{
var announcement = await _dbContext.PrizeAnnouncements
.FirstOrDefaultAsync(a => a.Id == id);
if (announcement == null)
{
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
}
_dbContext.PrizeAnnouncements.Remove(announcement);
var result = await _dbContext.SaveChangesAsync() > 0;
_logger.LogInformation(
"删除中奖公告成功: Id={Id}, UserName={UserName}, PrizeName={PrizeName}",
id, announcement.UserName, announcement.PrizeName);
return result;
}
/// <inheritdoc />
public async Task<AnnouncementAdminDto> ToggleStatusAsync(int id)
{
var announcement = await _dbContext.PrizeAnnouncements
.FirstOrDefaultAsync(a => a.Id == id);
if (announcement == null)
{
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
}
announcement.IsEnabled = !announcement.IsEnabled;
announcement.UpdatedAt = DateTime.Now;
await _dbContext.SaveChangesAsync();
_logger.LogInformation(
"切换中奖公告状态成功: Id={Id}, IsEnabled={IsEnabled}",
id, announcement.IsEnabled);
return MapToDto(announcement);
}
#region Private Helper Methods
/// <summary>
/// 验证必填字段
/// </summary>
private static void ValidateRequiredFields(string userName, string prizeLevel, string prizeName)
{
if (string.IsNullOrWhiteSpace(userName))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "用户名称不能为空");
}
if (string.IsNullOrWhiteSpace(prizeLevel))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品等级不能为空");
}
if (string.IsNullOrWhiteSpace(prizeName))
{
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品名称不能为空");
}
}
/// <summary>
/// 映射实体到DTO
/// </summary>
private static AnnouncementAdminDto MapToDto(PrizeAnnouncement entity)
{
return new AnnouncementAdminDto
{
Id = entity.Id,
UserAvatar = entity.UserAvatar ?? string.Empty,
UserName = entity.UserName,
PrizeLevel = entity.PrizeLevel,
PrizeName = entity.PrizeName,
Sort = entity.Sort,
IsEnabled = entity.IsEnabled,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
#endregion
}

View File

@ -0,0 +1,55 @@
using HoneyBox.Admin.Business.Models;
using HoneyBox.Admin.Business.Models.Announcement;
namespace HoneyBox.Admin.Business.Services.Interfaces;
/// <summary>
/// 中奖公告管理服务接口
/// </summary>
public interface IAnnouncementService
{
/// <summary>
/// 获取公告列表(分页)
/// </summary>
/// <param name="page">页码</param>
/// <param name="pageSize">每页数量</param>
/// <param name="keyword">搜索关键词(可选,搜索用户名或奖品名称)</param>
/// <returns>分页结果</returns>
Task<PagedResult<AnnouncementAdminDto>> GetListAsync(int page = 1, int pageSize = 20, string? keyword = null);
/// <summary>
/// 根据ID获取公告详情
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>公告详情</returns>
Task<AnnouncementAdminDto> GetByIdAsync(int id);
/// <summary>
/// 创建公告
/// </summary>
/// <param name="request">创建请求</param>
/// <returns>创建的公告</returns>
Task<AnnouncementAdminDto> CreateAsync(CreateAnnouncementRequest request);
/// <summary>
/// 更新公告
/// </summary>
/// <param name="id">公告ID</param>
/// <param name="request">更新请求</param>
/// <returns>更新后的公告</returns>
Task<AnnouncementAdminDto> UpdateAsync(int id, UpdateAnnouncementRequest request);
/// <summary>
/// 删除公告
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>是否成功</returns>
Task<bool> DeleteAsync(int id);
/// <summary>
/// 切换公告启用状态
/// </summary>
/// <param name="id">公告ID</param>
/// <returns>更新后的公告</returns>
Task<AnnouncementAdminDto> ToggleStatusAsync(int id);
}

View File

@ -0,0 +1,96 @@
import { request, type ApiResponse, type PagedResult } from '@/utils/request'
// ==================== 公告相关类型定义 ====================
/** 公告列表项 */
export interface AnnouncementItem {
id: number
userAvatar: string
userName: string
prizeLevel: string
prizeName: string
sort: number
isEnabled: boolean
createdAt: string
updatedAt: string
}
/** 公告列表查询参数 */
export interface AnnouncementListQuery {
page: number
pageSize: number
keyword?: string
}
/** 创建公告请求 */
export interface CreateAnnouncementRequest {
userAvatar?: string
userName: string
prizeLevel: string
prizeName: string
sort?: number
isEnabled?: boolean
}
/** 更新公告请求 */
export interface UpdateAnnouncementRequest {
userAvatar?: string
userName?: string
prizeLevel?: string
prizeName?: string
sort?: number
isEnabled?: boolean
}
// ==================== 公告管理 API ====================
/** 获取公告列表(分页) */
export function getAnnouncementList(params: AnnouncementListQuery): Promise<ApiResponse<PagedResult<AnnouncementItem>>> {
return request({
url: '/admin/announcements',
method: 'get',
params
})
}
/** 获取公告详情 */
export function getAnnouncementById(id: number): Promise<ApiResponse<AnnouncementItem>> {
return request({
url: `/admin/announcements/${id}`,
method: 'get'
})
}
/** 创建公告 */
export function createAnnouncement(data: CreateAnnouncementRequest): Promise<ApiResponse<AnnouncementItem>> {
return request({
url: '/admin/announcements',
method: 'post',
data
})
}
/** 更新公告 */
export function updateAnnouncement(id: number, data: UpdateAnnouncementRequest): Promise<ApiResponse<AnnouncementItem>> {
return request({
url: `/admin/announcements/${id}`,
method: 'put',
data
})
}
/** 删除公告 */
export function deleteAnnouncement(id: number): Promise<ApiResponse<string>> {
return request({
url: `/admin/announcements/${id}`,
method: 'delete'
})
}
/** 切换公告启用状态 */
export function toggleAnnouncementStatus(id: number): Promise<ApiResponse<AnnouncementItem>> {
return request({
url: `/admin/announcements/${id}/status`,
method: 'patch'
})
}

View File

@ -507,6 +507,16 @@ export const businessRoutes: RouteRecordRaw[] = [
permission: 'welfarehouse:list',
keepAlive: true
}
},
{
path: '/business/announcement/list',
name: 'AnnouncementList',
component: () => import('@/views/business/announcement/index.vue'),
meta: {
title: '中奖公告管理',
permission: 'announcement:list',
keepAlive: true
}
}
]
},
@ -1042,6 +1052,14 @@ export const rankPermissions = {
* - menuType: 2 ()
* - permission: welfarehouse:list
* - sortOrder: 3
*
* 5.
* - name: 中奖公告管理
* - path: /business/announcement/list
* - component: business/announcement/index
* - menuType: 2 ()
* - permission: announcement:list
* - sortOrder: 4
*/
/**
@ -1063,7 +1081,14 @@ export const contentPermissions = {
welfareHouseList: 'welfarehouse:list',
welfareHouseAdd: 'welfarehouse:add',
welfareHouseEdit: 'welfarehouse:edit',
welfareHouseDelete: 'welfarehouse:delete'
welfareHouseDelete: 'welfarehouse:delete',
// 中奖公告管理
announcementList: 'announcement:list',
announcementAdd: 'announcement:add',
announcementEdit: 'announcement:edit',
announcementDelete: 'announcement:delete',
announcementStatus: 'announcement:status'
}
/**

View File

@ -0,0 +1,486 @@
<template>
<div class="page-container">
<el-card>
<template #header>
<div class="card-header">
<span>中奖公告管理</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</template>
<!-- 搜索表单 -->
<div class="search-form">
<el-form :inline="true" :model="queryParams">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="用户名/奖品名称"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 公告表格 -->
<el-table :data="announcementList" v-loading="loading" border stripe>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="用户头像" width="100" align="center">
<template #default="{ row }">
<el-image
v-if="row.userAvatar"
:src="row.userAvatar"
:preview-src-list="[row.userAvatar]"
fit="cover"
class="avatar-image"
preview-teleported
>
<template #error>
<div class="image-error">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
<el-avatar v-else :size="40" :icon="UserFilled" />
</template>
</el-table-column>
<el-table-column prop="userName" label="用户名称" min-width="120" />
<el-table-column label="奖品等级" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getPrizeLevelType(row.prizeLevel)" size="small">
{{ row.prizeLevel }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="prizeName" label="奖品名称" min-width="150" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.isEnabled"
:loading="row.statusLoading"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column label="创建时间" width="160" align="center">
<template #default="{ row }">
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
class="pagination"
/>
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑公告' : '新增公告'"
width="550px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="用户头像" prop="userAvatar">
<ImageUpload
v-model="formData.userAvatar"
placeholder="点击上传头像"
url-placeholder="或输入头像URL"
tip="支持 jpg、png、gif、webp 格式"
/>
</el-form-item>
<el-form-item label="用户名称" prop="userName">
<el-input
v-model="formData.userName"
placeholder="请输入用户名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="奖品等级" prop="prizeLevel">
<el-select v-model="formData.prizeLevel" placeholder="请选择奖品等级" style="width: 100%">
<el-option label="无上" value="无上" />
<el-option label="传说" value="传说" />
<el-option label="史诗" value="史诗" />
<el-option label="稀有" value="稀有" />
<el-option label="普通" value="普通" />
</el-select>
</el-form-item>
<el-form-item label="奖品名称" prop="prizeName">
<el-input
v-model="formData.prizeName"
placeholder="请输入奖品名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="排序值" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="9999"
placeholder="请输入排序值"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">数值越小排序越靠前</div>
</el-form-item>
<el-form-item label="启用状态" prop="isEnabled">
<el-switch v-model="formData.isEnabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleDialogClose">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Search, Refresh, Picture, UserFilled } from '@element-plus/icons-vue'
import ImageUpload from '@/components/ImageUpload/index.vue'
import {
getAnnouncementList,
createAnnouncement,
updateAnnouncement,
deleteAnnouncement,
toggleAnnouncementStatus,
type AnnouncementItem,
type AnnouncementListQuery,
type CreateAnnouncementRequest
} from '@/api/business/announcement'
//
const loading = ref(false)
const announcementList = ref<(AnnouncementItem & { statusLoading?: boolean })[]>([])
const total = ref(0)
//
const queryParams = reactive<AnnouncementListQuery>({
page: 1,
pageSize: 20,
keyword: ''
})
//
const formDialogVisible = ref(false)
const isEdit = ref(false)
const currentId = ref<number | null>(null)
const submitLoading = ref(false)
//
const formRef = ref<FormInstance>()
//
interface FormDataType {
userAvatar: string
userName: string
prizeLevel: string
prizeName: string
sort: number
isEnabled: boolean
}
const formData = reactive<FormDataType>({
userAvatar: '',
userName: '',
prizeLevel: '',
prizeName: '',
sort: 0,
isEnabled: true
})
//
const formRules: FormRules = {
userName: [
{ required: true, message: '请输入用户名称', trigger: 'blur' },
{ max: 50, message: '用户名称不能超过50个字符', trigger: 'blur' }
],
prizeLevel: [
{ required: true, message: '请选择奖品等级', trigger: 'change' }
],
prizeName: [
{ required: true, message: '请输入奖品名称', trigger: 'blur' },
{ max: 100, message: '奖品名称不能超过100个字符', trigger: 'blur' }
],
sort: [
{ required: true, message: '请输入排序值', trigger: 'blur' }
]
}
//
const formatDateTime = (dateStr: string): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
//
const getPrizeLevelType = (level: string): '' | 'success' | 'warning' | 'info' | 'danger' => {
const typeMap: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
'无上': 'danger',
'传说': 'warning',
'史诗': '',
'稀有': 'success',
'普通': 'info'
}
return typeMap[level] || 'info'
}
//
const fetchData = async () => {
loading.value = true
try {
const res = await getAnnouncementList(queryParams)
announcementList.value = res.data.list.map(item => ({ ...item, statusLoading: false }))
total.value = res.data.total
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
queryParams.page = 1
fetchData()
}
//
const handleReset = () => {
queryParams.keyword = ''
queryParams.page = 1
fetchData()
}
//
const handlePageChange = (page: number) => {
queryParams.page = page
fetchData()
}
const handleSizeChange = (size: number) => {
queryParams.pageSize = size
queryParams.page = 1
fetchData()
}
//
const resetForm = () => {
Object.assign(formData, {
userAvatar: '',
userName: '',
prizeLevel: '',
prizeName: '',
sort: 0,
isEnabled: true
})
formRef.value?.resetFields()
}
//
const handleAdd = () => {
isEdit.value = false
currentId.value = null
resetForm()
formDialogVisible.value = true
}
//
const handleEdit = (row: AnnouncementItem) => {
isEdit.value = true
currentId.value = row.id
Object.assign(formData, {
userAvatar: row.userAvatar || '',
userName: row.userName,
prizeLevel: row.prizeLevel,
prizeName: row.prizeName,
sort: row.sort,
isEnabled: row.isEnabled
})
formDialogVisible.value = true
}
//
const handleDelete = async (row: AnnouncementItem) => {
try {
await ElMessageBox.confirm(
`确定要删除公告 "${row.userName} - ${row.prizeName}" 吗?删除后不可恢复!`,
'删除确认',
{ type: 'warning' }
)
await deleteAnnouncement(row.id)
ElMessage.success('删除成功')
fetchData()
} catch {
//
}
}
//
const handleStatusChange = async (row: AnnouncementItem & { statusLoading?: boolean }) => {
row.statusLoading = true
try {
await toggleAnnouncementStatus(row.id)
ElMessage.success(row.isEnabled ? '已启用' : '已禁用')
} catch {
//
row.isEnabled = !row.isEnabled
} finally {
row.statusLoading = false
}
}
//
const handleDialogClose = () => {
formDialogVisible.value = false
resetForm()
}
//
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
submitLoading.value = true
try {
const submitData: CreateAnnouncementRequest = {
userAvatar: formData.userAvatar || undefined,
userName: formData.userName,
prizeLevel: formData.prizeLevel,
prizeName: formData.prizeName,
sort: formData.sort,
isEnabled: formData.isEnabled
}
if (isEdit.value && currentId.value) {
await updateAnnouncement(currentId.value, submitData)
ElMessage.success('更新成功')
} else {
await createAnnouncement(submitData)
ElMessage.success('创建成功')
}
handleDialogClose()
fetchData()
} finally {
submitLoading.value = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.page-container {
padding: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.search-form {
margin-bottom: 16px;
}
.avatar-image {
width: 40px;
height: 40px;
border-radius: 50%;
}
.image-error {
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
background: #f5f7fa;
color: #909399;
font-size: 16px;
border-radius: 50%;
}
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@ -1,5 +1,6 @@
using HoneyBox.Core.Interfaces;
using HoneyBox.Model.Base;
using HoneyBox.Model.Models.Announcement;
using HoneyBox.Model.Models.Config;
using HoneyBox.Model.Models.FloatBall;
using Microsoft.AspNetCore.Authorization;
@ -19,15 +20,18 @@ public class ConfigController : ControllerBase
{
private readonly IConfigService _configService;
private readonly IFloatBallService _floatBallService;
private readonly IPrizeAnnouncementService _prizeAnnouncementService;
private readonly ILogger<ConfigController> _logger;
public ConfigController(
IConfigService configService,
IFloatBallService floatBallService,
IPrizeAnnouncementService prizeAnnouncementService,
ILogger<ConfigController> logger)
{
_configService = configService;
_floatBallService = floatBallService;
_prizeAnnouncementService = prizeAnnouncementService;
_logger = logger;
}
@ -217,4 +221,32 @@ public class ConfigController : ControllerBase
return ApiResponse<List<FloatBallResponse>>.Fail("获取悬浮球配置失败");
}
}
/// <summary>
/// 获取中奖公告列表
/// </summary>
/// <remarks>
/// GET /api/getPrizeAnnouncements
///
/// 返回启用状态的公告列表,按排序字段升序排列
/// 支持未登录访问
/// Requirements: 2.1
/// </remarks>
/// <returns>中奖公告列表</returns>
[HttpGet("getPrizeAnnouncements")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<List<PrizeAnnouncementDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<List<PrizeAnnouncementDto>>> GetPrizeAnnouncements()
{
try
{
var result = await _prizeAnnouncementService.GetEnabledAnnouncementsAsync();
return ApiResponse<List<PrizeAnnouncementDto>>.Success(result, "获取中奖公告成功");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get prize announcements");
return ApiResponse<List<PrizeAnnouncementDto>>.Fail("获取中奖公告失败");
}
}
}

View File

@ -0,0 +1,18 @@
using HoneyBox.Model.Models.Announcement;
namespace HoneyBox.Core.Interfaces;
/// <summary>
/// 中奖公告服务接口(用户端)
/// </summary>
public interface IPrizeAnnouncementService
{
/// <summary>
/// 获取启用状态的公告列表
/// </summary>
/// <remarks>
/// 只返回启用状态的公告,按排序字段升序排列
/// </remarks>
/// <returns>公告列表</returns>
Task<List<PrizeAnnouncementDto>> GetEnabledAnnouncementsAsync();
}

View File

@ -0,0 +1,52 @@
using HoneyBox.Core.Interfaces;
using HoneyBox.Model.Data;
using HoneyBox.Model.Models.Announcement;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace HoneyBox.Core.Services;
/// <summary>
/// 中奖公告服务实现(用户端)
/// </summary>
public class PrizeAnnouncementService : IPrizeAnnouncementService
{
private readonly HoneyBoxDbContext _dbContext;
private readonly ILogger<PrizeAnnouncementService> _logger;
public PrizeAnnouncementService(
HoneyBoxDbContext dbContext,
ILogger<PrizeAnnouncementService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<List<PrizeAnnouncementDto>> GetEnabledAnnouncementsAsync()
{
try
{
// 只返回启用状态的公告,按排序字段升序排列
var announcements = await _dbContext.PrizeAnnouncements
.Where(a => a.IsEnabled)
.OrderBy(a => a.Sort)
.Select(a => new PrizeAnnouncementDto
{
Id = a.Id,
UserAvatar = a.UserAvatar ?? string.Empty,
UserName = a.UserName,
PrizeLevel = a.PrizeLevel,
PrizeName = a.PrizeName
})
.ToListAsync();
return announcements;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get enabled prize announcements");
return new List<PrizeAnnouncementDto>();
}
}
}

View File

@ -351,5 +351,15 @@ public class ServiceModule : Module
var logger = c.Resolve<ILogger<MallService>>();
return new MallService(dbContext, wechatPayService, logger);
}).As<IMallService>().InstancePerLifetimeScope();
// ========== 公告系统服务注册 ==========
// 注册中奖公告服务(用户端)
builder.Register(c =>
{
var dbContext = c.Resolve<HoneyBoxDbContext>();
var logger = c.Resolve<ILogger<PrizeAnnouncementService>>();
return new PrizeAnnouncementService(dbContext, logger);
}).As<IPrizeAnnouncementService>().InstancePerLifetimeScope();
}
}

View File

@ -128,6 +128,8 @@ public partial class HoneyBoxDbContext : DbContext
public virtual DbSet<GoodsDesignatedPrize> GoodsDesignatedPrizes { get; set; }
public virtual DbSet<PrizeAnnouncement> PrizeAnnouncements { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Connection string is configured in Program.cs via dependency injection
@ -3531,6 +3533,55 @@ public partial class HoneyBoxDbContext : DbContext
.HasConstraintName("fk_goods_designated_prizes_users");
});
modelBuilder.Entity<PrizeAnnouncement>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk_prize_announcements");
entity.ToTable("prize_announcements", tb => tb.HasComment("中奖公告配置表,存储首页假中奖公告信息"));
entity.HasIndex(e => e.IsEnabled, "ix_prize_announcements_is_enabled");
entity.HasIndex(e => e.Sort, "ix_prize_announcements_sort");
entity.Property(e => e.Id)
.HasComment("主键ID")
.HasColumnName("id");
entity.Property(e => e.UserAvatar)
.HasMaxLength(500)
.HasComment("用户头像URL")
.HasColumnName("user_avatar");
entity.Property(e => e.UserName)
.HasMaxLength(50)
.IsRequired()
.HasComment("用户名称")
.HasColumnName("user_name");
entity.Property(e => e.PrizeLevel)
.HasMaxLength(20)
.IsRequired()
.HasComment("奖品等级(如:无上、传说、史诗、稀有)")
.HasColumnName("prize_level");
entity.Property(e => e.PrizeName)
.HasMaxLength(100)
.IsRequired()
.HasComment("奖品名称")
.HasColumnName("prize_name");
entity.Property(e => e.Sort)
.HasDefaultValue(0)
.HasComment("排序值,越小越靠前")
.HasColumnName("sort");
entity.Property(e => e.IsEnabled)
.HasDefaultValue(true)
.HasComment("是否启用")
.HasColumnName("is_enabled");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("(getdate())")
.HasComment("创建时间")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("(getdate())")
.HasComment("更新时间")
.HasColumnName("updated_at");
});
OnModelCreatingPartial(modelBuilder);
}

View File

@ -0,0 +1,54 @@
using System;
namespace HoneyBox.Model.Entities;
/// <summary>
/// 中奖公告配置表,存储首页假中奖公告信息
/// </summary>
public partial class PrizeAnnouncement
{
/// <summary>
/// 主键ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 用户头像URL
/// </summary>
public string? UserAvatar { get; set; }
/// <summary>
/// 用户名称
/// </summary>
public string UserName { get; set; } = null!;
/// <summary>
/// 奖品等级(如:无上、传说、史诗、稀有)
/// </summary>
public string PrizeLevel { get; set; } = null!;
/// <summary>
/// 奖品名称
/// </summary>
public string PrizeName { get; set; } = null!;
/// <summary>
/// 排序值,越小越靠前
/// </summary>
public int Sort { get; set; }
/// <summary>
/// 是否启用
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间
/// </summary>
public DateTime UpdatedAt { get; set; }
}

View File

@ -0,0 +1,39 @@
namespace HoneyBox.Model.Models.Announcement;
using System.Text.Json.Serialization;
/// <summary>
/// 中奖公告响应DTO用户端
/// </summary>
public class PrizeAnnouncementDto
{
/// <summary>
/// 公告ID
/// </summary>
[JsonPropertyName("id")]
public int Id { get; set; }
/// <summary>
/// 用户头像URL
/// </summary>
[JsonPropertyName("user_avatar")]
public string UserAvatar { get; set; } = string.Empty;
/// <summary>
/// 用户名称
/// </summary>
[JsonPropertyName("user_name")]
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 奖品等级(如:无上、传说、史诗、稀有)
/// </summary>
[JsonPropertyName("prize_level")]
public string PrizeLevel { get; set; } = string.Empty;
/// <summary>
/// 奖品名称
/// </summary>
[JsonPropertyName("prize_name")]
public string PrizeName { get; set; } = string.Empty;
}

View File

@ -0,0 +1,830 @@
using FsCheck;
using FsCheck.Xunit;
using HoneyBox.Admin.Business.Models;
using HoneyBox.Admin.Business.Models.Announcement;
using HoneyBox.Admin.Business.Services;
using HoneyBox.Model.Data;
using HoneyBox.Model.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace HoneyBox.Tests.Services;
/// <summary>
/// AnnouncementService 属性测试
/// 测试首页中大奖公告功能的核心属性
/// </summary>
public class AnnouncementServicePropertyTests
{
private readonly Mock<ILogger<AnnouncementService>> _mockLogger = new();
private (HoneyBoxDbContext dbContext, AnnouncementService service) CreateService()
{
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var dbContext = new HoneyBoxDbContext(options);
var service = new AnnouncementService(dbContext, _mockLogger.Object);
return (dbContext, service);
}
/// <summary>
/// 生成有效的非空字符串(用于必填字段)
/// </summary>
private static string GenerateValidString(string prefix, int seed)
{
return $"{prefix}_{Math.Abs(seed) % 1000}";
}
#region Property 1: CRUD Round-Trip Consistency
/// <summary>
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
/// For any valid announcement data, creating an announcement and then reading it back
/// should return the same data.
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
/// </summary>
[Property(MaxTest = 100)]
public bool CrudRoundTrip_CreateAndRead_ShouldReturnSameData(
NonEmptyString userName,
NonEmptyString prizeLevel,
NonEmptyString prizeName,
PositiveInt sort,
bool isEnabled)
{
var actualUserName = userName.Get.Trim();
var actualPrizeLevel = prizeLevel.Get.Trim();
var actualPrizeName = prizeName.Get.Trim();
var actualSort = sort.Get % 1000;
// Skip if any required field becomes empty after trim
if (string.IsNullOrWhiteSpace(actualUserName) ||
string.IsNullOrWhiteSpace(actualPrizeLevel) ||
string.IsNullOrWhiteSpace(actualPrizeName))
{
return true; // Skip this test case
}
var (dbContext, service) = CreateService();
try
{
// Create announcement
var createRequest = new CreateAnnouncementRequest
{
UserAvatar = "http://test.com/avatar.jpg",
UserName = actualUserName,
PrizeLevel = actualPrizeLevel,
PrizeName = actualPrizeName,
Sort = actualSort,
IsEnabled = isEnabled
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
// Read it back
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
// Verify data consistency
return retrieved.UserName == actualUserName &&
retrieved.PrizeLevel == actualPrizeLevel &&
retrieved.PrizeName == actualPrizeName &&
retrieved.Sort == actualSort &&
retrieved.IsEnabled == isEnabled &&
retrieved.UserAvatar == "http://test.com/avatar.jpg";
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
/// For any valid announcement data, updating it and reading again should reflect the updates.
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
/// </summary>
[Property(MaxTest = 100)]
public bool CrudRoundTrip_UpdateAndRead_ShouldReflectUpdates(
PositiveInt seed1,
PositiveInt seed2,
PositiveInt sort1,
PositiveInt sort2,
bool isEnabled1,
bool isEnabled2)
{
var userName1 = GenerateValidString("User", seed1.Get);
var prizeLevel1 = GenerateValidString("Level", seed1.Get);
var prizeName1 = GenerateValidString("Prize", seed1.Get);
var userName2 = GenerateValidString("UpdatedUser", seed2.Get);
var prizeLevel2 = GenerateValidString("UpdatedLevel", seed2.Get);
var prizeName2 = GenerateValidString("UpdatedPrize", seed2.Get);
var actualSort1 = sort1.Get % 1000;
var actualSort2 = sort2.Get % 1000;
var (dbContext, service) = CreateService();
try
{
// Create initial announcement
var createRequest = new CreateAnnouncementRequest
{
UserAvatar = "http://test.com/avatar1.jpg",
UserName = userName1,
PrizeLevel = prizeLevel1,
PrizeName = prizeName1,
Sort = actualSort1,
IsEnabled = isEnabled1
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
// Update the announcement
var updateRequest = new UpdateAnnouncementRequest
{
UserAvatar = "http://test.com/avatar2.jpg",
UserName = userName2,
PrizeLevel = prizeLevel2,
PrizeName = prizeName2,
Sort = actualSort2,
IsEnabled = isEnabled2
};
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
// Read it back
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
// Verify updates are reflected
return retrieved.UserName == userName2 &&
retrieved.PrizeLevel == prizeLevel2 &&
retrieved.PrizeName == prizeName2 &&
retrieved.Sort == actualSort2 &&
retrieved.IsEnabled == isEnabled2 &&
retrieved.UserAvatar == "http://test.com/avatar2.jpg";
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
/// For any valid announcement, deleting it should make it no longer retrievable.
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
/// </summary>
[Property(MaxTest = 100)]
public bool CrudRoundTrip_Delete_ShouldMakeUnretrievable(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
// Create announcement
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
var createdId = created.Id;
// Delete the announcement
var deleteResult = service.DeleteAsync(createdId).GetAwaiter().GetResult();
if (!deleteResult)
{
return false;
}
// Try to retrieve it - should throw NotFound exception
try
{
service.GetByIdAsync(createdId).GetAwaiter().GetResult();
return false; // Should not reach here
}
catch (BusinessException ex)
{
return ex.Code == BusinessErrorCodes.NotFound;
}
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
/// Partial updates should only modify specified fields, leaving others unchanged.
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
/// </summary>
[Property(MaxTest = 100)]
public bool CrudRoundTrip_PartialUpdate_ShouldOnlyModifySpecifiedFields(
PositiveInt seed,
PositiveInt newSort)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var originalSort = seed.Get % 500;
var updatedSort = (newSort.Get % 500) + 500; // Ensure different value
var (dbContext, service) = CreateService();
try
{
// Create announcement
var createRequest = new CreateAnnouncementRequest
{
UserAvatar = "http://test.com/original.jpg",
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = originalSort,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
// Partial update - only update Sort
var updateRequest = new UpdateAnnouncementRequest
{
Sort = updatedSort
};
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
// Read it back
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
// Verify only Sort changed, other fields remain the same
return retrieved.Sort == updatedSort &&
retrieved.UserName == userName &&
retrieved.PrizeLevel == prizeLevel &&
retrieved.PrizeName == prizeName &&
retrieved.UserAvatar == "http://test.com/original.jpg" &&
retrieved.IsEnabled == true;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
/// Multiple CRUD operations should maintain data consistency.
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
/// </summary>
[Property(MaxTest = 50)]
public bool CrudRoundTrip_MultipleCrudOperations_ShouldMaintainConsistency(PositiveInt operationCount)
{
var actualCount = Math.Max(2, operationCount.Get % 5 + 2);
var (dbContext, service) = CreateService();
try
{
var createdIds = new List<int>();
// Create multiple announcements
for (int i = 0; i < actualCount; i++)
{
var createRequest = new CreateAnnouncementRequest
{
UserName = $"User_{i}",
PrizeLevel = $"Level_{i}",
PrizeName = $"Prize_{i}",
Sort = i,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
createdIds.Add(created.Id);
}
// Update each announcement
for (int i = 0; i < actualCount; i++)
{
var updateRequest = new UpdateAnnouncementRequest
{
UserName = $"UpdatedUser_{i}",
Sort = i + 100
};
service.UpdateAsync(createdIds[i], updateRequest).GetAwaiter().GetResult();
}
// Verify all updates
for (int i = 0; i < actualCount; i++)
{
var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
if (retrieved.UserName != $"UpdatedUser_{i}" || retrieved.Sort != i + 100)
{
return false;
}
}
// Delete half of them
for (int i = 0; i < actualCount / 2; i++)
{
service.DeleteAsync(createdIds[i]).GetAwaiter().GetResult();
}
// Verify deleted ones are not retrievable
for (int i = 0; i < actualCount / 2; i++)
{
try
{
service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.NotFound)
{
return false;
}
}
}
// Verify remaining ones are still retrievable
for (int i = actualCount / 2; i < actualCount; i++)
{
var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
if (retrieved.UserName != $"UpdatedUser_{i}")
{
return false;
}
}
return true;
}
finally
{
dbContext.Dispose();
}
}
#endregion
#region Property 4: Required Field Validation
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any create request where UserName is empty or whitespace-only,
/// the system should reject the request and return a validation error.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyUserName_ShouldRejectCreate(PositiveInt seed)
{
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
var emptyUserNames = new[] { "", " ", "\t", "\n", " \t\n " };
foreach (var emptyUserName in emptyUserNames)
{
var createRequest = new CreateAnnouncementRequest
{
UserName = emptyUserName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
try
{
service.CreateAsync(createRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify database is not modified
var count = dbContext.PrizeAnnouncements.Count();
return count == 0;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any create request where PrizeLevel is empty or whitespace-only,
/// the system should reject the request and return a validation error.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectCreate(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
var emptyPrizeLevels = new[] { "", " ", "\t", "\n", " \t\n " };
foreach (var emptyPrizeLevel in emptyPrizeLevels)
{
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = emptyPrizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
try
{
service.CreateAsync(createRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify database is not modified
var count = dbContext.PrizeAnnouncements.Count();
return count == 0;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any create request where PrizeName is empty or whitespace-only,
/// the system should reject the request and return a validation error.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectCreate(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var (dbContext, service) = CreateService();
try
{
var emptyPrizeNames = new[] { "", " ", "\t", "\n", " \t\n " };
foreach (var emptyPrizeName in emptyPrizeNames)
{
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = emptyPrizeName,
Sort = 0,
IsEnabled = true
};
try
{
service.CreateAsync(createRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify database is not modified
var count = dbContext.PrizeAnnouncements.Count();
return count == 0;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any update request where UserName is set to empty or whitespace-only,
/// the system should reject the request and return a validation error without modifying the database.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyUserName_ShouldRejectUpdate(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
// First create a valid announcement
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
var emptyUserNames = new[] { "", " ", "\t", "\n" };
foreach (var emptyUserName in emptyUserNames)
{
var updateRequest = new UpdateAnnouncementRequest
{
UserName = emptyUserName
};
try
{
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify original data is not modified
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
return retrieved.UserName == userName;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any update request where PrizeLevel is set to empty or whitespace-only,
/// the system should reject the request and return a validation error without modifying the database.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectUpdate(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
// First create a valid announcement
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
var emptyPrizeLevels = new[] { "", " ", "\t", "\n" };
foreach (var emptyPrizeLevel in emptyPrizeLevels)
{
var updateRequest = new UpdateAnnouncementRequest
{
PrizeLevel = emptyPrizeLevel
};
try
{
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify original data is not modified
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
return retrieved.PrizeLevel == prizeLevel;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any update request where PrizeName is set to empty or whitespace-only,
/// the system should reject the request and return a validation error without modifying the database.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectUpdate(PositiveInt seed)
{
var userName = GenerateValidString("User", seed.Get);
var prizeLevel = GenerateValidString("Level", seed.Get);
var prizeName = GenerateValidString("Prize", seed.Get);
var (dbContext, service) = CreateService();
try
{
// First create a valid announcement
var createRequest = new CreateAnnouncementRequest
{
UserName = userName,
PrizeLevel = prizeLevel,
PrizeName = prizeName,
Sort = 0,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
var emptyPrizeNames = new[] { "", " ", "\t", "\n" };
foreach (var emptyPrizeName in emptyPrizeNames)
{
var updateRequest = new UpdateAnnouncementRequest
{
PrizeName = emptyPrizeName
};
try
{
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
}
// Verify original data is not modified
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
return retrieved.PrizeName == prizeName;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// For any create request with all required fields empty,
/// the system should reject the request without modifying the database.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 50)]
public bool RequiredFieldValidation_AllFieldsEmpty_ShouldRejectCreate(PositiveInt seed)
{
var (dbContext, service) = CreateService();
try
{
var createRequest = new CreateAnnouncementRequest
{
UserName = "",
PrizeLevel = "",
PrizeName = "",
Sort = 0,
IsEnabled = true
};
try
{
service.CreateAsync(createRequest).GetAwaiter().GetResult();
return false; // Should have thrown
}
catch (BusinessException ex)
{
if (ex.Code != BusinessErrorCodes.ValidationFailed)
{
return false;
}
}
// Verify database is not modified
var count = dbContext.PrizeAnnouncements.Count();
return count == 0;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
/// Valid requests with non-empty required fields should succeed.
/// **Validates: Requirements 1.5**
/// </summary>
[Property(MaxTest = 100)]
public bool RequiredFieldValidation_ValidFields_ShouldSucceed(
NonEmptyString userName,
NonEmptyString prizeLevel,
NonEmptyString prizeName)
{
var actualUserName = userName.Get.Trim();
var actualPrizeLevel = prizeLevel.Get.Trim();
var actualPrizeName = prizeName.Get.Trim();
// Skip if any required field becomes empty after trim
if (string.IsNullOrWhiteSpace(actualUserName) ||
string.IsNullOrWhiteSpace(actualPrizeLevel) ||
string.IsNullOrWhiteSpace(actualPrizeName))
{
return true; // Skip this test case
}
var (dbContext, service) = CreateService();
try
{
var createRequest = new CreateAnnouncementRequest
{
UserName = actualUserName,
PrizeLevel = actualPrizeLevel,
PrizeName = actualPrizeName,
Sort = 0,
IsEnabled = true
};
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
// Verify creation succeeded
return created.Id > 0 &&
created.UserName == actualUserName &&
created.PrizeLevel == actualPrizeLevel &&
created.PrizeName == actualPrizeName;
}
catch (BusinessException)
{
return false; // Should not throw for valid data
}
finally
{
dbContext.Dispose();
}
}
#endregion
}

View File

@ -371,8 +371,6 @@ public class DesignatedPrizeServicePropertyTests
}
#endregion
}
#region Property 8: Prize Data Immutability
@ -799,3 +797,5 @@ public class DesignatedPrizeServicePropertyTests
}
#endregion
}

View File

@ -0,0 +1,491 @@
using FsCheck;
using FsCheck.Xunit;
using HoneyBox.Core.Services;
using HoneyBox.Model.Data;
using HoneyBox.Model.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace HoneyBox.Tests.Services;
/// <summary>
/// PrizeAnnouncementService 属性测试(用户端)
/// 测试首页中大奖公告功能的用户端核心属性
/// </summary>
public class PrizeAnnouncementServicePropertyTests
{
private readonly Mock<ILogger<PrizeAnnouncementService>> _mockLogger = new();
private (HoneyBoxDbContext dbContext, PrizeAnnouncementService service) CreateService()
{
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var dbContext = new HoneyBoxDbContext(options);
var service = new PrizeAnnouncementService(dbContext, _mockLogger.Object);
return (dbContext, service);
}
/// <summary>
/// 生成有效的非空字符串(用于必填字段)
/// </summary>
private static string GenerateValidString(string prefix, int seed)
{
return $"{prefix}_{Math.Abs(seed) % 1000}";
}
/// <summary>
/// 创建测试用的公告实体
/// </summary>
private static PrizeAnnouncement CreateAnnouncement(int seed, bool isEnabled, int sort)
{
return new PrizeAnnouncement
{
UserAvatar = $"http://test.com/avatar_{seed}.jpg",
UserName = GenerateValidString("User", seed),
PrizeLevel = GenerateValidString("Level", seed),
PrizeName = GenerateValidString("Prize", seed),
Sort = sort,
IsEnabled = isEnabled,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
#region Property 2: Enabled Filter Correctness
/// <summary>
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
/// For any set of announcements with mixed enabled/disabled states, the user-facing API
/// should only return announcements where IsEnabled is true.
/// **Validates: Requirements 2.2, 1.6**
/// </summary>
[Property(MaxTest = 100)]
public bool EnabledFilterCorrectness_OnlyReturnsEnabledAnnouncements(
PositiveInt enabledCount,
PositiveInt disabledCount)
{
var actualEnabledCount = Math.Max(1, enabledCount.Get % 10);
var actualDisabledCount = disabledCount.Get % 10;
var (dbContext, service) = CreateService();
try
{
// Create enabled announcements
for (int i = 0; i < actualEnabledCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: i);
dbContext.PrizeAnnouncements.Add(announcement);
}
// Create disabled announcements
for (int i = 0; i < actualDisabledCount; i++)
{
var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: i + 1000);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: all returned items should be enabled
// Since we're testing the service, we verify by checking the count matches enabled count
return result.Count == actualEnabledCount;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
/// For any set of announcements, the count of returned items should equal
/// the count of enabled announcements in the database.
/// **Validates: Requirements 2.2, 1.6**
/// </summary>
[Property(MaxTest = 100)]
public bool EnabledFilterCorrectness_CountMatchesEnabledInDatabase(PositiveInt totalCount)
{
var actualTotalCount = Math.Max(1, totalCount.Get % 20);
var random = new Random(totalCount.Get);
var (dbContext, service) = CreateService();
try
{
var expectedEnabledCount = 0;
// Create announcements with random enabled states
for (int i = 0; i < actualTotalCount; i++)
{
var isEnabled = random.Next(2) == 1;
if (isEnabled) expectedEnabledCount++;
var announcement = CreateAnnouncement(i, isEnabled, sort: i);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: count should match expected enabled count
return result.Count == expectedEnabledCount;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
/// When all announcements are disabled, the API should return an empty list.
/// **Validates: Requirements 2.2, 1.6**
/// </summary>
[Property(MaxTest = 100)]
public bool EnabledFilterCorrectness_AllDisabled_ReturnsEmptyList(PositiveInt count)
{
var actualCount = Math.Max(1, count.Get % 10);
var (dbContext, service) = CreateService();
try
{
// Create only disabled announcements
for (int i = 0; i < actualCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: false, sort: i);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: should return empty list
return result.Count == 0;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
/// When all announcements are enabled, the API should return all of them.
/// **Validates: Requirements 2.2, 1.6**
/// </summary>
[Property(MaxTest = 100)]
public bool EnabledFilterCorrectness_AllEnabled_ReturnsAll(PositiveInt count)
{
var actualCount = Math.Max(1, count.Get % 10);
var (dbContext, service) = CreateService();
try
{
// Create only enabled announcements
for (int i = 0; i < actualCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: i);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: should return all announcements
return result.Count == actualCount;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
/// When database is empty, the API should return an empty list.
/// **Validates: Requirements 2.2, 1.6**
/// </summary>
[Fact]
public void EnabledFilterCorrectness_EmptyDatabase_ReturnsEmptyList()
{
var (dbContext, service) = CreateService();
try
{
// Get enabled announcements from empty database
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: should return empty list
Assert.Empty(result);
}
finally
{
dbContext.Dispose();
}
}
#endregion
#region Property 3: Sort Ordering Preservation
/// <summary>
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
/// For any set of enabled announcements with different sort values, the user-facing API
/// should return them in ascending order by the sort field.
/// **Validates: Requirements 2.3, 1.7**
/// </summary>
[Property(MaxTest = 100)]
public bool SortOrderingPreservation_ReturnsInAscendingOrder(PositiveInt count, PositiveInt seed)
{
var actualCount = Math.Max(2, count.Get % 10 + 2);
var random = new Random(seed.Get);
var (dbContext, service) = CreateService();
try
{
// Create announcements with random sort values
var sortValues = Enumerable.Range(0, actualCount)
.Select(_ => random.Next(1000))
.ToList();
for (int i = 0; i < actualCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: sortValues[i]);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: for any two consecutive items, first item's sort should be <= second item's sort
// Since DTO doesn't have Sort field, we verify by checking the order matches expected
var expectedOrder = sortValues.OrderBy(s => s).ToList();
// We need to verify the order by checking the UserName pattern which contains the index
// The announcements should be returned in the order of their sort values
for (int i = 0; i < result.Count - 1; i++)
{
// Extract the original index from UserName (format: "User_{index}")
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
var currentSort = sortValues[currentIndex];
var nextSort = sortValues[nextIndex];
if (currentSort > nextSort)
{
return false;
}
}
return true;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
/// For any two consecutive items in the result, the first item's sort value
/// should be less than or equal to the second item's sort value.
/// **Validates: Requirements 2.3, 1.7**
/// </summary>
[Property(MaxTest = 100)]
public bool SortOrderingPreservation_ConsecutiveItemsOrdered(PositiveInt count)
{
var actualCount = Math.Max(2, count.Get % 15 + 2);
var (dbContext, service) = CreateService();
try
{
// Create announcements with sequential sort values in reverse order
// This tests that the service correctly orders them
for (int i = 0; i < actualCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualCount - i);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: items should be in ascending order by sort
// The first item should have sort = 1, last should have sort = actualCount
for (int i = 0; i < result.Count - 1; i++)
{
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
var currentSort = actualCount - currentIndex;
var nextSort = actualCount - nextIndex;
if (currentSort > nextSort)
{
return false;
}
}
return true;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
/// Announcements with the same sort value should be returned (order among them is stable).
/// **Validates: Requirements 2.3, 1.7**
/// </summary>
[Property(MaxTest = 100)]
public bool SortOrderingPreservation_SameSortValues_AllReturned(PositiveInt count)
{
var actualCount = Math.Max(2, count.Get % 10 + 2);
var (dbContext, service) = CreateService();
try
{
// Create announcements with the same sort value
for (int i = 0; i < actualCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: 100);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: all announcements should be returned
return result.Count == actualCount;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
/// Only enabled announcements should be sorted and returned.
/// **Validates: Requirements 2.3, 1.7**
/// </summary>
[Property(MaxTest = 100)]
public bool SortOrderingPreservation_OnlyEnabledAreSorted(
PositiveInt enabledCount,
PositiveInt disabledCount)
{
var actualEnabledCount = Math.Max(2, enabledCount.Get % 10 + 2);
var actualDisabledCount = disabledCount.Get % 5;
var (dbContext, service) = CreateService();
try
{
// Create enabled announcements with specific sort values
for (int i = 0; i < actualEnabledCount; i++)
{
var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualEnabledCount - i);
dbContext.PrizeAnnouncements.Add(announcement);
}
// Create disabled announcements with sort values that would appear first if included
for (int i = 0; i < actualDisabledCount; i++)
{
var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: 0);
dbContext.PrizeAnnouncements.Add(announcement);
}
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: only enabled announcements are returned and sorted
if (result.Count != actualEnabledCount)
{
return false;
}
// Verify ordering
for (int i = 0; i < result.Count - 1; i++)
{
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
var currentSort = actualEnabledCount - currentIndex;
var nextSort = actualEnabledCount - nextIndex;
if (currentSort > nextSort)
{
return false;
}
}
return true;
}
finally
{
dbContext.Dispose();
}
}
/// <summary>
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
/// Single announcement should be returned correctly.
/// **Validates: Requirements 2.3, 1.7**
/// </summary>
[Property(MaxTest = 100)]
public bool SortOrderingPreservation_SingleAnnouncement_ReturnsCorrectly(PositiveInt sort)
{
var actualSort = sort.Get % 1000;
var (dbContext, service) = CreateService();
try
{
// Create single enabled announcement
var announcement = CreateAnnouncement(0, isEnabled: true, sort: actualSort);
dbContext.PrizeAnnouncements.Add(announcement);
dbContext.SaveChanges();
// Get enabled announcements via service
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
// Verify: single announcement is returned
return result.Count == 1 && result[0].UserName == "User_0";
}
finally
{
dbContext.Dispose();
}
}
#endregion
}