mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-23 22:50:51 +08:00
feat: 赞赏后台
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.DonationRequest;
|
||||
import com.openisle.dto.DonationResponse;
|
||||
import com.openisle.service.PointService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/posts/{postId}/donations")
|
||||
@RequiredArgsConstructor
|
||||
public class PostDonationController {
|
||||
|
||||
private final PointService pointService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List donations", description = "Get recent donations for a post")
|
||||
@ApiResponse(responseCode = "200", description = "Donation summary")
|
||||
public DonationResponse list(@PathVariable Long postId) {
|
||||
return pointService.getPostDonations(postId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@SecurityRequirement(name = "JWT")
|
||||
@Operation(summary = "Donate", description = "Donate points to the post author")
|
||||
@ApiResponse(responseCode = "200", description = "Donation result")
|
||||
public DonationResponse donate(
|
||||
@PathVariable Long postId,
|
||||
@RequestBody DonationRequest req,
|
||||
Authentication auth
|
||||
) {
|
||||
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationDto {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private int amount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationRequest {
|
||||
|
||||
private int amount;
|
||||
}
|
||||
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationResponse {
|
||||
|
||||
private int totalAmount;
|
||||
private List<DonationDto> donations = new ArrayList<>();
|
||||
private Integer balance;
|
||||
}
|
||||
@@ -48,6 +48,8 @@ public enum NotificationType {
|
||||
POLL_RESULT_PARTICIPANT,
|
||||
/** Your post was featured */
|
||||
POST_FEATURED,
|
||||
/** Someone donated to your post */
|
||||
DONATION,
|
||||
/** You were mentioned in a post or comment */
|
||||
MENTION,
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ public enum PointHistoryType {
|
||||
REDEEM,
|
||||
LOTTERY_JOIN,
|
||||
LOTTERY_REWARD,
|
||||
DONATE_SENT,
|
||||
DONATE_RECEIVED,
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.PointHistoryType;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
||||
List<PointHistory> findByComment(Comment comment);
|
||||
|
||||
List<PointHistory> findByPost(Post post);
|
||||
|
||||
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
|
||||
)
|
||||
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.DonationDto;
|
||||
import com.openisle.dto.DonationResponse;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.*;
|
||||
@@ -8,8 +10,10 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -20,6 +24,7 @@ public class PointService {
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public int awardForPost(String userName, Long postId) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
@@ -272,4 +277,75 @@ public class PointService {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return recalculateUserPoints(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
|
||||
if (amount <= 0) {
|
||||
throw new FieldException("amount", "打赏积分必须大于0");
|
||||
}
|
||||
User donor = userRepository.findByUsername(donorName).orElseThrow();
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
User author = post.getAuthor();
|
||||
if (author.getId().equals(donor.getId())) {
|
||||
throw new FieldException("post", "不能给自己打赏");
|
||||
}
|
||||
if (donor.getPoint() < amount) {
|
||||
throw new FieldException("point", "积分不足");
|
||||
}
|
||||
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
|
||||
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
|
||||
notificationService.createNotification(
|
||||
author,
|
||||
NotificationType.DONATION,
|
||||
post,
|
||||
null,
|
||||
null,
|
||||
donor,
|
||||
null,
|
||||
String.valueOf(amount)
|
||||
);
|
||||
DonationResponse response = buildDonationResponse(post);
|
||||
response.setBalance(donor.getPoint());
|
||||
return response;
|
||||
}
|
||||
|
||||
public DonationResponse getPostDonations(Long postId) {
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
return buildDonationResponse(post);
|
||||
}
|
||||
|
||||
private DonationResponse buildDonationResponse(Post post) {
|
||||
List<PointHistory> histories =
|
||||
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
|
||||
post,
|
||||
PointHistoryType.DONATE_RECEIVED
|
||||
);
|
||||
List<DonationDto> donations = histories
|
||||
.stream()
|
||||
.map(history -> {
|
||||
DonationDto dto = new DonationDto();
|
||||
User donor = history.getFromUser();
|
||||
if (donor != null) {
|
||||
dto.setUserId(donor.getId());
|
||||
dto.setUsername(donor.getUsername());
|
||||
dto.setAvatar(donor.getAvatar());
|
||||
}
|
||||
dto.setAmount(history.getAmount());
|
||||
dto.setCreatedAt(history.getCreatedAt());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
Long total = pointHistoryRepository.sumAmountByPostAndType(
|
||||
post,
|
||||
PointHistoryType.DONATE_RECEIVED
|
||||
);
|
||||
int safeTotal = 0;
|
||||
if (total != null) {
|
||||
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
|
||||
}
|
||||
DonationResponse response = new DonationResponse();
|
||||
response.setDonations(donations);
|
||||
response.setTotalAmount(safeTotal);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
<template>
|
||||
<div class="donate-container">
|
||||
<ToolTip content="打赏作者" placement="bottom" v-if="donateItems.length > 0">
|
||||
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
||||
<div class="donate-viewer" @click="openPanel">
|
||||
<div
|
||||
class="donate-viewer-item-container"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<BaseItemGroup :items="donateItems" :overlap="10" :expanded-gap="2" :direction="vertical">
|
||||
<BaseItemGroup
|
||||
:items="donationList"
|
||||
:overlap="10"
|
||||
:expanded-gap="2"
|
||||
:direction="vertical"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<BaseUserAvatar
|
||||
:user-id="1"
|
||||
:src="avatar"
|
||||
alt="avatar"
|
||||
:user-id="item.userId"
|
||||
:src="item.avatar"
|
||||
:alt="item.username"
|
||||
:width="20"
|
||||
:disable-link="true"
|
||||
/>
|
||||
</template>
|
||||
</BaseItemGroup>
|
||||
<div class="donate-counts-text">100</div>
|
||||
<div class="donate-counts-text">{{ totalAmount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolTip>
|
||||
@@ -35,17 +40,15 @@
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<div class="donate-option">
|
||||
<div
|
||||
v-for="option in donateOptions"
|
||||
:key="option"
|
||||
class="donate-option"
|
||||
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
||||
@click="handleDonate(option)"
|
||||
>
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">10</div>
|
||||
</div>
|
||||
<div class="donate-option">
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">30</div>
|
||||
</div>
|
||||
<div class="donate-option">
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">100</div>
|
||||
<div class="donate-counts-text">{{ option }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,32 +56,46 @@
|
||||
|
||||
<script setup>
|
||||
import { Finance } from '@icon-park/vue-next'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const financing = Finance
|
||||
|
||||
const props = defineProps({})
|
||||
const props = defineProps({
|
||||
postId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
authorId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
isAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const avatar = ref('')
|
||||
const panelVisible = ref(false)
|
||||
const donatePanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
const donateItems = ref([
|
||||
// {
|
||||
// id: 1,
|
||||
// count: 100,
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// count: 300,
|
||||
// },
|
||||
// {
|
||||
// id: 3,
|
||||
// count: 500,
|
||||
// },
|
||||
])
|
||||
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
||||
const donating = ref(false)
|
||||
let hideTimer = null
|
||||
|
||||
const donateOptions = [10, 30, 100]
|
||||
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
||||
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
||||
const isAuthorUser = computed(() => {
|
||||
if (props.isAuthor) return true
|
||||
if (!authState.userId || !props.authorId) return false
|
||||
return Number(authState.userId) === Number(props.authorId)
|
||||
})
|
||||
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
panelVisible.value = true
|
||||
@@ -114,23 +131,79 @@ watch(panelVisible, async (visible) => {
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeSummary = (data) => ({
|
||||
totalAmount: data?.totalAmount ?? 0,
|
||||
donations: Array.isArray(data?.donations) ? data.donations : [],
|
||||
})
|
||||
|
||||
const loadDonations = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
} catch (e) {
|
||||
// ignore network errors for donation summary
|
||||
}
|
||||
}
|
||||
|
||||
const handleDonate = async (amount) => {
|
||||
if (!amount || donating.value) return
|
||||
if (!authState.loggedIn) {
|
||||
toast.error('请先登录后再打赏')
|
||||
panelVisible.value = false
|
||||
return
|
||||
}
|
||||
if (isAuthorUser.value) {
|
||||
toast.warning('不能给自己打赏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
donating.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
toast.error('请先登录后再打赏')
|
||||
} else {
|
||||
toast.error(data?.error || '打赏失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
toast.success('打赏成功,感谢你的支持!')
|
||||
panelVisible.value = false
|
||||
} catch (e) {
|
||||
toast.error('打赏失败,请稍后再试')
|
||||
} finally {
|
||||
donating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user && user.avatar) {
|
||||
avatar.value = user.avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
await updateAvatar()
|
||||
await loadDonations()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.postId,
|
||||
async () => {
|
||||
donationSummary.value = { totalAmount: 0, donations: [] }
|
||||
await loadDonations()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -217,6 +290,15 @@ onBeforeUnmount(() => {
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-option.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.donate-option.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.donate-option-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@@ -542,6 +542,27 @@
|
||||
被收录为精选
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'DONATION'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/users/${item.fromUser.id}`"
|
||||
>
|
||||
{{ item.fromUser.username }}
|
||||
</NuxtLink>
|
||||
在帖子
|
||||
<NuxtLink
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</NuxtLink>
|
||||
打赏了你
|
||||
<template v-if="item.content"> ,获得 {{ item.content }} 积分 </template>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_DELETED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
管理员
|
||||
|
||||
@@ -184,6 +184,27 @@
|
||||
}}</NuxtLink>
|
||||
参与,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'DONATE_SENT'">
|
||||
你在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中打赏了
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'DONATE_RECEIVED'">
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中打赏了你,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||
</div>
|
||||
@@ -248,6 +269,8 @@ const iconMap = {
|
||||
FEATURE: 'star',
|
||||
LOTTERY_JOIN: 'medal-one',
|
||||
LOTTERY_REWARD: 'fireworks',
|
||||
DONATE_SENT: 'paper-money-two',
|
||||
DONATE_RECEIVED: 'paper-money-two',
|
||||
POST_LIKE_CANCELLED: 'clear-icon',
|
||||
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
content-type="post"
|
||||
:content-id="postId"
|
||||
/>
|
||||
<DonateGroup />
|
||||
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
|
||||
</div>
|
||||
<div class="article-footer-actions">
|
||||
<div
|
||||
|
||||
@@ -31,6 +31,7 @@ const iconMap = {
|
||||
MENTION: 'HashtagKey',
|
||||
POST_DELETED: 'ClearIcon',
|
||||
POST_FEATURED: 'Star',
|
||||
DONATION: 'PaperMoneyTwo',
|
||||
}
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
@@ -334,6 +335,18 @@ function createFetchNotifications() {
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'DONATION') {
|
||||
arr.push({
|
||||
...n,
|
||||
src: n.fromUser ? n.fromUser.avatar : null,
|
||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markNotificationRead(n.id)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REGISTER_REQUEST') {
|
||||
arr.push({
|
||||
...n,
|
||||
|
||||
Reference in New Issue
Block a user