feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
10
CLAUDE.md
10
CLAUDE.md
@@ -104,3 +104,13 @@ pnpm deploy:server # 部署后端到生产环境
|
||||
- 累计上课、本月上课、剩余课时集中在我的会员卡页面,个人资料卡不重复展示汇总。
|
||||
- 次数限制以 remainingTimes 是否为 null 判断,不能按卡种推断。次数进度表示已用占总次数,已用包括预约占用;不限次卡不伪造耗课数。
|
||||
- 会员卡加载失败显示重试,不当作无卡;会话变化时丢弃旧请求结果。
|
||||
|
||||
### 课后评价与成长档案
|
||||
- 评价归属 booking 模块,档案归属 user 模块;共享契约放 shared/src/types/member-care.ts,页面沿用 booking/profile/admin 目录,不新增顶层业务目录。
|
||||
- COMPLETED 后立即可评价,无 24 小时截止;完成后 24 小时仅提醒未评价预约。唯一 bookingId 防重复,提醒状态保存在 Booking 上,定时任务原子领取,未知发送结果不自动重发。
|
||||
- 星级均分与 NPS 分开:NPS 仅使用可选 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||
- 教练私密笔记必须在服务端过滤;课程批注必须属于该学员的已完成预约。体测允许缺项,不以缺项当 0;累计课时包含有效补录,里程碑为 10/30/50 节。
|
||||
- 照片仅用于学员与馆主之间的档案展示,不能用于公开宣传。学员本人按照片授权/撤回,馆主不能代授权。与馆图共用 COS 桶,对象前缀 `progress/`,上传为私有 ACL,读取用短时签名,禁止落库公共链接;上传凭证绑定学员及照片记录。
|
||||
- 新增迁移目录只放 migration.sql,测试沿用各模块 __tests__;部署配置与验收清单放 docs/member-care.md。
|
||||
|
||||
- 成长档案的两端共用 `components/MemberProgress.vue`,仅此跨端组件直接请求 progress API,避免主包引用 admin 分包 Store;馆主评价页面仍通过 admin Store 访问。
|
||||
|
||||
41
docs/member-care.md
Normal file
41
docs/member-care.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 课后评价与成长档案
|
||||
|
||||
入口:预约详情评价、个人中心「我的成长档案」、管理中心「课后评价」与会员档案「成长档案」。评价归属 booking 模块,档案归属 user 模块。共享契约在 `packages/shared/src/types/member-care.ts`。
|
||||
|
||||
## 产品口径
|
||||
|
||||
- 课程变为 COMPLETED 后即可评价,没有 24 小时截止。完成后 24 小时只提醒仍未评价的预约。
|
||||
- 同一预约只能评价一次。提醒状态写在 Booking 上,定时任务原子领取;发送结果未知或失败不自动重发。
|
||||
- 星级均分与 NPS 分开。NPS 只用可选的 0–10 推荐意愿(9–10 推荐者、0–6 贬损者),按中国自然月聚合并展示样本数。
|
||||
- 教练私密笔记只在服务端过滤。课程批注必须属于该学员的已完成预约。
|
||||
- 体测允许缺项,缺项不按 0 计算。累计课时含有效补录,里程碑为 10 / 30 / 50 节。
|
||||
- 成长照片仅用于学员与馆主之间的档案,不用于公开宣传。学员本人授权或撤回,馆主不能代授权。误传或学员主动删除会同时删除数据库记录和 COS 对象。
|
||||
|
||||
## 部署配置
|
||||
|
||||
新增环境变量(见 `packages/server/.env.example`):
|
||||
|
||||
| 变量 | 作用 |
|
||||
| --- | --- |
|
||||
| `WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW` | 课后评价订阅消息模板 ID |
|
||||
|
||||
成长照片与馆图共用 `COS_BUCKET`、`COS_SECRET_ID`、`COS_SECRET_KEY`、`COS_REGION`。对象写在 `progress/{userId}/` 下,上传带私有 ACL,读取用约 60 秒签名 URL,不落库公共链接。小程序合法域名沿用现有 COS 域名即可。
|
||||
|
||||
评价提醒模板字段由服务端按预约填充:`thing1` 课程名称(工作室名)、`thing2` 课程教练(Iris)、`time3` 课程时间、`thing4` 温馨提示。未配置模板 ID 时定时任务不领取预约。
|
||||
|
||||
回退:先回退应用代码。新增表可保留;`progress/` 下对象不会被旧代码读取。不要在生产直接 `DROP TABLE`。
|
||||
|
||||
## 迁移
|
||||
|
||||
目录 `packages/server/prisma/migrations/20260909120000_member_care/migration.sql`。发布时先 `prisma migrate deploy`,再发布后端,最后发布小程序。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- 学员在 CONFIRMED 或 COMPLETED 且未评价时都可以订阅提醒;核销后仍能订阅。
|
||||
- 完成后可立即评价;重复提交返回已评价。
|
||||
- 首页匿名均分不含评论文案;管理端趋势按中国自然月,NPS 与星级分开展示。
|
||||
- 私密笔记学员不可见;馆主在学员授权前不能读取照片,授权后可看,撤回后不能再签发。
|
||||
- 误传照片可删除,删除后档案和 COS 对象都不再保留。
|
||||
- 体测可只填一项,柔韧度允许负值;累计课时含未撤销补录。
|
||||
- 未配置评价模板时,定时任务不领取预约。
|
||||
- 小程序真机确认可上传、可用签名链接预览成长照片。
|
||||
76
packages/app/src/components/ClassReviewForm.vue
Normal file
76
packages/app/src/components/ClassReviewForm.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<view class="review">
|
||||
<text class="eyebrow">课后 · 留一点感受</text>
|
||||
<text class="title">这节课,感觉怎么样?</text>
|
||||
<text class="hint">文字与标签仅你和馆主可见;星级会计入首页匿名均分。</text>
|
||||
<view v-if="loading" class="hint">正在读取评价…</view>
|
||||
<view v-else-if="error" class="hint">{{ error }}<button class="secondary" @tap="load">重新加载</button></view>
|
||||
<template v-else-if="review">
|
||||
<text class="saved-stars">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</text>
|
||||
<view class="tags"><text v-for="tag in review.tags" :key="tag" class="tag selected">{{ tag }}</text></view>
|
||||
<text class="comment">{{ review.comment || '谢谢你留下这份反馈。' }}</text>
|
||||
<text class="hint">已评价 · {{ formatChinaDate(review.createdAt) }}</text>
|
||||
</template>
|
||||
<template v-else-if="canReview">
|
||||
<view class="stars"><button v-for="n in 5" :key="n" :aria-label="n + ' 星'" :class="{ chosen: rating >= n }" @tap="rating = n">{{ rating >= n ? '★' : '☆' }}</button></view>
|
||||
<text class="rating-label">{{ rating ? labels[rating - 1] : '轻触星星,为这节课评分' }}</text>
|
||||
<view class="tags"><button v-for="tag in REVIEW_TAGS" :key="tag" class="tag" :class="{ selected: tags.includes(tag) }" @tap="toggle(tag)">{{ tag }}</button></view>
|
||||
<text class="hint">可选,最多 3 个标签</text>
|
||||
<textarea v-model="comment" maxlength="200" placeholder="哪里让你有收获?还有什么可以做得更好?" />
|
||||
<text class="counter">{{ comment.length }} / 200</text>
|
||||
<picker :range="recommendations" @change="recommendation = Number($event.detail.value) - 1"><view class="recommend">你有多愿意推荐我们?<text>{{ recommendation < 0 ? '选填 ›' : recommendation + ' / 10 ›' }}</text></view></picker>
|
||||
<text class="hint">0 表示完全不愿意,10 表示非常愿意</text>
|
||||
<button class="primary" :loading="saving" :disabled="saving || !rating" @tap="submit">提交评价</button>
|
||||
</template>
|
||||
<text v-else class="hint">课程完成后即可评价。</text>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { REVIEW_TAGS } from '@mp-pilates/shared'
|
||||
import type { ClassReview } from '@mp-pilates/shared'
|
||||
import { get, post } from '../utils/request'
|
||||
import { formatChinaDate } from '../utils/format'
|
||||
const props = defineProps<{ bookingId: string }>()
|
||||
const review = ref<ClassReview | null>(null), canReview = ref(false), loading = ref(false), saving = ref(false), error = ref('')
|
||||
const rating = ref(0), tags = ref<string[]>([]), comment = ref(''), recommendation = ref(-1)
|
||||
const labels = ['不太满意', '有待改善', '整体还好', '很满意', '非常满意']
|
||||
const recommendations = ['暂不填写', ...Array.from({ length: 11 }, (_, n) => String(n))]
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
const seq = ++sequence; loading.value = true; error.value = ''
|
||||
try { const result = await get<{ review: ClassReview | null; canReview: boolean }>(`/booking/${props.bookingId}/review`); if (seq === sequence) { review.value = result.review; canReview.value = result.canReview } }
|
||||
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '评价加载失败' }
|
||||
finally { if (seq === sequence) loading.value = false }
|
||||
}
|
||||
function toggle(tag: string) {
|
||||
if (tags.value.includes(tag)) tags.value = tags.value.filter(t => t !== tag)
|
||||
else if (tags.value.length < 3) tags.value = [...tags.value, tag]
|
||||
else uni.showToast({ title: '最多选择 3 个标签', icon: 'none' })
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value || !rating.value) return
|
||||
saving.value = true
|
||||
try { review.value = await post<ClassReview>(`/booking/${props.bookingId}/review`, { rating: rating.value, tags: tags.value, comment: comment.value, ...(recommendation.value >= 0 ? { recommendation: recommendation.value } : {}) }); uni.showToast({ title: '谢谢你的反馈', icon: 'success' }) }
|
||||
catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '提交失败,请重试', icon: 'none' }) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch(() => props.bookingId, () => { review.value = null; rating.value = 0; tags.value = []; comment.value = ''; recommendation.value = -1; void load() }, { immediate: true })
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.review { margin: 28rpx 32rpx; padding: 32rpx; border: 1rpx solid #e7e0d7; border-radius: 28rpx; background: #fffdf9; color: #514943; }
|
||||
.eyebrow { display:block; color:#8c7867; font-size:22rpx; letter-spacing:3rpx; }
|
||||
.title { display:block; margin:18rpx 0; font-family:'Songti SC','STSong',serif; font-size:38rpx; }
|
||||
.hint { display:block; font-size:23rpx; line-height:1.8; color:#82796e; }
|
||||
.stars { display:flex; justify-content:space-between; margin:24rpx 0 8rpx; }
|
||||
.stars button { padding:0; margin:0; width:96rpx; height:96rpx; line-height:96rpx; font-size:60rpx; background:transparent; color:#b7ac9c; &::after {border:0;} &.chosen {color:#a5824b;} }
|
||||
.rating-label {display:block; text-align:center; color:#8c7867; font-size:24rpx; margin-bottom:24rpx;}
|
||||
.tags {display:flex;flex-wrap:wrap;gap:14rpx;margin:18rpx 0;}
|
||||
.tag {margin:0;padding:15rpx 20rpx;font-size:24rpx;line-height:1.5;border-radius:16rpx;background:#f4f1eb;color:#72695f;&::after{border:0;} &.selected{background:#e7eee7;color:#4d685c;}}
|
||||
textarea {margin-top:22rpx;padding:24rpx;width:100%;height:190rpx;box-sizing:border-box;background:#f6f3ed;border-radius:18rpx;font-size:26rpx;line-height:1.7;}
|
||||
.counter{display:block;text-align:right;color:#8b817b;font-size:21rpx;margin:10rpx 0 20rpx;}
|
||||
.recommend{display:flex;justify-content:space-between;gap:16rpx;align-items:center;min-height:88rpx;border-top:1rpx solid #eee8e0;font-size:24rpx;}
|
||||
.primary,.secondary{margin-top:24rpx;min-height:88rpx;line-height:88rpx;border-radius:22rpx;background:#617d70;color:#fff;font-size:27rpx;&::after{border:0;}}
|
||||
.secondary{background:#eee9df;color:#645c52;}.primary[disabled]{background:#d5dcd2;color:#697365;}
|
||||
.saved-stars{display:block;font-size:46rpx;color:#a5824b;margin-top:24rpx;}.comment{display:block;line-height:1.8;font-size:27rpx;margin:20rpx 0;white-space:pre-wrap;}
|
||||
</style>
|
||||
172
packages/app/src/components/MemberProgress.vue
Normal file
172
packages/app/src/components/MemberProgress.vue
Normal file
File diff suppressed because one or more lines are too long
@@ -40,6 +40,7 @@ const emit = defineEmits<{
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{ key: 'progress', type: 'item', title: '我的成长档案', path: '/pages/profile/progress', requireAuth: true },
|
||||
...(props.isAdmin
|
||||
? [{
|
||||
key: 'teaching-schedule',
|
||||
|
||||
9
packages/app/src/components/ReviewSummaryCard.vue
Normal file
9
packages/app/src/components/ReviewSummaryCard.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template><view v-if="summary && summary.count > 0" class="summary"><view><text class="label">来自真实课后反馈</text><text class="caption">每一次被听见,让练习更贴近你。</text></view><view class="score"><text>{{ summary.average }}<text class="star">★</text></text><text class="count">{{ summary.count }} 份评价</text></view></view></template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { get } from '../utils/request'
|
||||
const summary = ref<{ count: number; average: number | null } | null>(null)
|
||||
onShow(async () => { try { summary.value = await get('/reviews/summary') } catch { summary.value = null } })
|
||||
</script>
|
||||
<style scoped>.summary{margin:24rpx 32rpx;padding:26rpx;display:flex;justify-content:space-between;align-items:center;gap:20rpx;border:1rpx solid #e2e5d9;border-radius:24rpx;background:#f1f3e9;}.label{display:block;font-size:26rpx;color:#65735b;}.caption{display:block;font-size:22rpx;color:#8a8d7c;line-height:1.7;margin-top:10rpx;}.score{flex-shrink:0;text-align:right;font-size:46rpx;color:#6b785c;font-family:'Baskerville',serif;}.star{font-size:27rpx;margin-left:8rpx;color:#a1874b;}.count{display:block;font-size:21rpx;font-family:inherit;line-height:1.8;color:#858874;}</style>
|
||||
@@ -80,6 +80,12 @@
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/progress",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
@@ -170,6 +176,18 @@
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "member-progress",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "reviews",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<view class="item-arrow"><text class="arrow-text">›</text></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="list"><view class="list-item" @tap="navigate('/pages/admin/reviews')"><view class="item-left"><view class="item-icon-wrap icon--subscribe"><text class="item-icon-text">☆</text></view><view class="item-text-group"><text class="item-title">课后评价</text><text class="item-desc">学员反馈 · 星级均分 · 推荐意愿趋势</text></view></view><view class="item-arrow"><text class="arrow-text">›</text></view></view></view>
|
||||
<!-- Section header: 课程管理 -->
|
||||
<view class="section-header">
|
||||
<text class="section-title">课程管理</text>
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="member-tabs"><button :class="{ selected: activeTab === 'overview' }" @tap="activeTab = 'overview'">会员概览</button><button :class="{ selected: activeTab === 'progress' }" @tap="activeTab = 'progress'">成长档案</button><button @tap="goReviews">课后评价 ›</button></view>
|
||||
<MemberProgress v-if="activeTab === 'progress'" admin :user-id="userId" :refresh-key="progressRefreshKey" />
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<view class="section section--practice">
|
||||
<view class="section-heading">
|
||||
<text class="section-label">上课情况</text>
|
||||
@@ -138,8 +141,9 @@
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<view class="dock">
|
||||
<view v-if="activeTab === 'overview'" class="dock">
|
||||
<view class="dock-btn dock-btn--ghost" @tap="goEdit">
|
||||
<text class="dock-btn-text">编辑资料</text>
|
||||
</view>
|
||||
@@ -159,6 +163,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import type { AdminMemberDetail, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, BookingStatus } from '@mp-pilates/shared'
|
||||
import MemberProgress from '../../components/MemberProgress.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import {
|
||||
@@ -176,6 +181,7 @@ const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const userId = ref('')
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('overview'), progressRefreshKey = ref(0)
|
||||
const detail = ref<AdminMemberDetail | null>(null)
|
||||
|
||||
const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArrangableMembership))
|
||||
@@ -224,6 +230,7 @@ async function loadDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function goReviews() { uni.navigateTo({ url: `/pages/admin/reviews?userId=${userId.value}` }) }
|
||||
function goSupplement() {
|
||||
if (userId.value) uni.navigateTo({ url: `/pages/admin/member-supplement?userId=${userId.value}` })
|
||||
}
|
||||
@@ -250,6 +257,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
progressRefreshKey.value++
|
||||
if (userId.value) {
|
||||
loadDetail()
|
||||
}
|
||||
@@ -257,6 +265,9 @@ onShow(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.member-tabs {display:flex;margin:28rpx 32rpx 0;border-bottom:1rpx solid #e5dfd6;gap:8rpx;}
|
||||
.member-tabs button{flex:1;margin:0;padding:0;background:transparent;border-radius:0;line-height:88rpx;font-size:25rpx;color:#8b817b;border-bottom:4rpx solid transparent;&::after{border:0;}&.selected{border-color:#617d73;color:#617d73;}}
|
||||
|
||||
.page {
|
||||
--ink: #514943;
|
||||
--muted: #8b817b;
|
||||
|
||||
13
packages/app/src/pages/admin/member-progress.vue
Normal file
13
packages/app/src/pages/admin/member-progress.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress admin :user-id="userId" :booking-id="bookingId" :refresh-key="refreshKey" /></view></template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import MemberProgress from '../../components/MemberProgress.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
const navBarHeight = getSystemLayout().navBarHeight
|
||||
const userId = ref(''), bookingId = ref(''), refreshKey = ref(0)
|
||||
onLoad(query => { userId.value = String(query?.userId || ''); bookingId.value = String(query?.bookingId || '') })
|
||||
onShow(() => { refreshKey.value++ })
|
||||
</script>
|
||||
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>
|
||||
53
packages/app/src/pages/admin/reviews.vue
Normal file
53
packages/app/src/pages/admin/reviews.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||
<CustomNavBar :title="userId ? '学员评价历史' : '课后评价'" show-back />
|
||||
<view class="content">
|
||||
<text class="kicker">LISTEN & GROW</text><text class="title">听见每一次练习。</text><text class="intro">真实的感受,是下一次更好教学的起点。</text>
|
||||
<view v-if="error" class="state">{{ error }}<button @tap="reload">重新加载</button></view>
|
||||
<template v-else>
|
||||
<template v-if="!userId && trend.length">
|
||||
<picker mode="date" fields="month" :value="month" :end="today" @change="changeMonth($event.detail.value)"><view class="month-picker">{{ month }} 教学反馈 <text>切换月份 ›</text></view></picker>
|
||||
<view class="summary"><view><text class="number">{{ current?.average ?? '—' }}</text><text>星级均分 / 5</text><text class="muted">{{ current?.count || 0 }} 份评价</text></view><view><text class="number">{{ current?.nps ?? '—' }}</text><text>净推荐值 NPS</text><text class="muted">{{ current?.npsCount || 0 }} 份推荐评分</text></view></view>
|
||||
<view class="panel"><text class="section-title">近六个月 · 星级均分</text><view class="chart"><view v-for="item in trend" :key="item.month" class="column"><text>{{ item.average ?? '—' }}</text><view class="track"><view class="bar" :style="{ height: ((item.average || 0) / 5 * 100) + '%' }" /></view><text class="muted">{{ item.month.slice(5) }} 月</text></view></view></view>
|
||||
<view class="panel"><text class="section-title">推荐意愿趋势</text><view v-for="item in trend" :key="item.month" class="trend-row"><text>{{ item.month }}</text><text>{{ item.nps === null ? '暂无样本' : 'NPS ' + item.nps }}</text><text class="muted">{{ item.npsCount }} 人</text></view><text class="footnote">推荐意愿 9–10 分为推荐者,0–6 分为贬损者;NPS = 推荐者占比 − 贬损者占比。仅统计填写推荐分的评价,星级不参与 NPS。</text></view>
|
||||
</template>
|
||||
<view class="list-heading"><text>评价原声</text><text class="muted">{{ total }} 份 · 按提交时间</text></view>
|
||||
<view v-if="!rows.length && !loading" class="state">还没有评价<text class="intro">学员完成课程后,可在预约详情留下反馈。</text></view>
|
||||
<view v-for="row in rows" :key="row.id" class="panel">
|
||||
<view class="list-heading"><button class="member" @tap="openMember(row.booking.userId)">{{ row.booking.user.nickname || '学员' }} ›</button><text class="stars">{{ '★'.repeat(row.rating) }}{{ '☆'.repeat(5 - row.rating) }}</text></view>
|
||||
<text class="lesson">{{ formatChinaDate(row.booking.timeSlot.date) }} · {{ row.booking.timeSlot.startTime }}–{{ row.booking.timeSlot.endTime }}</text>
|
||||
<view class="tags"><text v-for="tag in row.tags" :key="tag">{{ tag }}</text></view><text class="comment">{{ row.comment || '这位学员留下了星级评分。' }}</text>
|
||||
<view class="list-heading"><text class="muted">{{ formatChinaDate(row.createdAt) }} 提交</text><text v-if="row.recommendation !== null" class="muted">推荐意愿 {{ row.recommendation }} / 10</text></view>
|
||||
</view>
|
||||
<view v-if="loading" class="state">正在读取反馈…</view><button v-else-if="rows.length < total" class="more" @tap="loadMore">加载更多</button>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { formatChinaDate } from '../../utils/format'
|
||||
import { useAdminStore } from './stores/admin'
|
||||
const store = useAdminStore(), navBarHeight = getSystemLayout().navBarHeight
|
||||
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), month = ref(today.slice(0, 7)), userId = ref('')
|
||||
const rows = ref<ReviewEntry[]>([]), trend = ref<ReviewSummary[]>([]), total = ref(0), page = ref(0), loading = ref(false), error = ref('')
|
||||
const current = computed(() => trend.value[trend.value.length - 1])
|
||||
let sequence = 0
|
||||
async function reload() {
|
||||
const seq = ++sequence; loading.value = true; error.value = ''
|
||||
try { const [list, summary] = await Promise.all([store.fetchReviews(userId.value, 1), userId.value ? Promise.resolve([]) : store.fetchReviewTrend(month.value)]); if (seq !== sequence) return; rows.value = list.data; total.value = list.total; page.value = 1; trend.value = summary }
|
||||
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '加载失败' } finally { if (seq === sequence) loading.value = false }
|
||||
}
|
||||
async function loadMore() { if (loading.value) return; loading.value = true; const seq = sequence; try { const list = await store.fetchReviews(userId.value, page.value + 1); if (seq !== sequence) return; rows.value = [...rows.value, ...list.data]; total.value = list.total; page.value++ } catch { uni.showToast({ title: '加载失败,请重试', icon: 'none' }) } finally { if (seq === sequence) loading.value = false } }
|
||||
function changeMonth(value: string) { month.value = value.slice(0, 7); void reload() }
|
||||
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${id}` }) }
|
||||
onLoad(query => { userId.value = String(query?.userId || '') })
|
||||
onShow(() => { void reload() })
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page{min-height:100vh;box-sizing:border-box;background:#fbf9f6;color:#514943;}.content{padding:36rpx 32rpx 80rpx;}.kicker{display:block;font-size:21rpx;letter-spacing:4rpx;color:#8b806e;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;margin:22rpx 0 14rpx;}.intro{display:block;font-size:25rpx;color:#8b8174;line-height:1.8;margin-bottom:30rpx;}.month-picker{display:flex;justify-content:space-between;align-items:center;min-height:88rpx;font-size:28rpx;}.month-picker text{font-size:23rpx;color:#728065;}.summary{display:flex;padding:34rpx 0;background:#e9eee3;border:1rpx solid #dce3d3;border-radius:24rpx;margin:12rpx 0 24rpx;}.summary>view{flex:1;text-align:center;border-right:1rpx solid #d2dac9;}.summary>view:last-child{border:0;}.summary text{display:block;font-size:24rpx;line-height:1.8;}.summary .number{font-size:64rpx;font-family:'Baskerville',serif;color:#576b50;line-height:1.2;margin-bottom:10rpx;}.summary .muted,.muted{font-size:22rpx;color:#8b8276;line-height:1.7;}.panel{border:1rpx solid #e6dfd4;border-radius:24rpx;background:#fffdf9;padding:28rpx;margin-bottom:22rpx;}.section-title{font-size:28rpx;}.chart{display:flex;gap:12rpx;margin-top:25rpx;}.column{flex:1;text-align:center;font-size:23rpx;color:#62715c;}.track{height:140rpx;display:flex;align-items:flex-end;justify-content:center;margin:14rpx 0;}.bar{width:34rpx;background:#97aa89;border-radius:6rpx 6rpx 0 0;}.trend-row{display:flex;justify-content:space-between;gap:16rpx;padding:20rpx 0;border-bottom:1rpx solid #eee8df;font-size:24rpx;}.footnote{display:block;font-size:22rpx;line-height:1.8;color:#938575;margin-top:22rpx;}.list-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:50rpx;font-size:29rpx;}.content>.list-heading{margin:34rpx 0 20rpx;}.member{margin:0;padding:0;background:transparent;color:#5f6856;font-size:28rpx;line-height:64rpx;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.member::after,.more::after{border:0;}.stars{font-size:29rpx;color:#ab8951;}.lesson{display:block;font-size:23rpx;color:#928575;margin:10rpx 0 22rpx;}.tags{display:flex;flex-wrap:wrap;gap:12rpx;}.tags text{padding:7rpx 16rpx;font-size:22rpx;border-radius:10rpx;background:#eff1e8;color:#748064;}.comment{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:28rpx;line-height:1.9;margin:22rpx 0;}.state{padding:50rpx 15rpx;text-align:center;font-size:26rpx;color:#8b806f;line-height:1.8;}.more{font-size:26rpx;background:#ebece2;color:#69745b;border-radius:20rpx;line-height:88rpx;}
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReviewEntry, ReviewSummary } from '@mp-pilates/shared'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { get, post, put, del } from '../../../utils/request'
|
||||
@@ -84,6 +85,9 @@ export interface UserMembership {
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
async function fetchReviews(userId = '', page = 1) { return get<{ data: ReviewEntry[]; total: number }>('/admin/reviews', { ...(userId ? { userId } : {}), page }) }
|
||||
async function fetchReviewTrend(month: string) { return get<ReviewSummary[]>('/admin/reviews/trend', { month }) }
|
||||
|
||||
// ── Card types ───────────────────────────────────────────────────
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
|
||||
@@ -293,6 +297,7 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
fetchReviews, fetchReviewTrend,
|
||||
fetchTeachingAnalytics,
|
||||
// State
|
||||
cardTypes,
|
||||
|
||||
@@ -89,6 +89,13 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
|
||||
<view v-if="canSubscribeReview" class="panel">
|
||||
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评,24 小时后提醒我' : '订阅课后评价提醒' }}</button>
|
||||
</view>
|
||||
<view v-if="isAdmin && booking?.status === BookingStatus.COMPLETED" class="panel">
|
||||
<button class="review-subscribe" @tap="openProgress">为这次课写评语</button>
|
||||
</view>
|
||||
<view v-if="showReminders" class="panel">
|
||||
<text class="panel-title">上课前</text>
|
||||
<view v-for="(item, index) in reminderNotes" :key="item" class="note-row">
|
||||
@@ -199,7 +206,7 @@ import type {
|
||||
TimeSlotWithBookingStatus,
|
||||
MembershipWithCardType,
|
||||
} from '@mp-pilates/shared'
|
||||
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
@@ -212,8 +219,17 @@ import {
|
||||
bookingTimelineDotClass,
|
||||
} from '../../utils/booking-helpers'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import ClassReviewForm from '../../components/ClassReviewForm.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
|
||||
import { requestSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||
import { get } from '../../utils/request'
|
||||
import { cacheSubscriptionMessageTemplateConfig } from '../../utils/wechat-subscription'
|
||||
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
|
||||
async function subscribeReview() {
|
||||
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
|
||||
}
|
||||
function openProgress() { if (booking.value) uni.navigateTo({ url: `/pages/admin/member-progress?userId=${booking.value.userId}&bookingId=${booking.value.id}` }) }
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -232,6 +248,12 @@ const slotData = ref<TimeSlotWithBookingStatus | null>(null)
|
||||
const showConfirmPopup = ref(false)
|
||||
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const canSubscribeReview = computed(() => {
|
||||
const current = booking.value
|
||||
if (!current || current.userId !== userStore.user?.id) return false
|
||||
if (current.status === BookingStatus.CONFIRMED) return true
|
||||
return current.status === BookingStatus.COMPLETED && !current.review
|
||||
})
|
||||
const showActions = computed(() =>
|
||||
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
||||
booking.value?.status === BookingStatus.CONFIRMED,
|
||||
@@ -614,6 +636,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onLoad((query) => {
|
||||
void get<SubscriptionMessageTemplateConfig>('/user/subscription-messages/templates').then(cacheSubscriptionMessageTemplateConfig).catch(() => {})
|
||||
updateLayout()
|
||||
const q = query as Record<string, string>
|
||||
|
||||
@@ -637,6 +660,7 @@ function updateLayout() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-subscribe {font-size:26rpx;background:#eaf0e8;color:#536f60;border-radius:20rpx;min-height:88rpx;line-height:88rpx;&::after{border:0;}}
|
||||
.page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<view class="card-handle"><view class="card-handle-bar" /></view>
|
||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||
<UpcomingBooking />
|
||||
<ReviewSummaryCard />
|
||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||
<FlashSaleSection ref="flashSaleRef" />
|
||||
<view :id="cardShopAnchorId">
|
||||
@@ -28,6 +29,7 @@ import { ref, nextTick, onUnmounted } from 'vue'
|
||||
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import BrandBanner from '../../components/BrandBanner.vue'
|
||||
import ReviewSummaryCard from '../../components/ReviewSummaryCard.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
import QuickEntry from '../../components/QuickEntry.vue'
|
||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||
</view>
|
||||
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="row-bottom">
|
||||
@@ -124,7 +124,7 @@
|
||||
<text class="row-end">— {{ endTime(booking) }}</text>
|
||||
</view>
|
||||
<text class="row-stamp" :class="stampClass(booking.status)">
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
|
||||
|
||||
12
packages/app/src/pages/profile/progress.vue
Normal file
12
packages/app/src/pages/profile/progress.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template><view class="page" :style="{ paddingTop: navBarHeight + 'px' }"><CustomNavBar title="成长档案" show-back /><MemberProgress :refresh-key="refreshKey" /></view></template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import MemberProgress from '../../components/MemberProgress.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
const navBarHeight = getSystemLayout().navBarHeight
|
||||
const refreshKey = ref(0)
|
||||
onShow(() => { refreshKey.value++ })
|
||||
</script>
|
||||
<style scoped>.page{min-height:100vh;background:#fbf9f6;box-sizing:border-box;}</style>
|
||||
@@ -13,6 +13,13 @@ export function formatPrice(cents: number): string {
|
||||
return (cents / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** 中国自然日 YYYY-MM-DD,用于时间戳与 UTC 日期列展示 */
|
||||
export function formatChinaDate(value: Date | string): string {
|
||||
const time = new Date(value).getTime()
|
||||
if (!Number.isFinite(time)) return ''
|
||||
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** 格式化日期为 YYYY-MM-DD */
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
|
||||
@@ -11,3 +11,6 @@ COS_REGION=ap-guangzhou
|
||||
COS_PUBLIC_BASE_URL=https://plates-1251306435.cos.ap-guangzhou.myqcloud.com
|
||||
COS_UPLOAD_PREFIX=mp/studio
|
||||
COS_UPLOAD_DURATION_SECONDS=1800
|
||||
|
||||
# WeChat subscribe message for class review reminders (24h after completion)
|
||||
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `bookings` ADD COLUMN `review_reminder_claimed_at` DATETIME(3) NULL,
|
||||
ADD COLUMN `review_reminder_due_at` DATETIME(3) NULL,
|
||||
ADD COLUMN `review_reminder_sent_at` DATETIME(3) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `booking_reviews` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`booking_id` VARCHAR(191) NOT NULL,
|
||||
`rating` INTEGER NOT NULL,
|
||||
`recommendation` INTEGER NULL,
|
||||
`tags` JSON NOT NULL,
|
||||
`comment` VARCHAR(200) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `booking_reviews_booking_id_key`(`booking_id`),
|
||||
INDEX `booking_reviews_created_at_idx`(`created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `body_metrics` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`recorded_at` DATE NOT NULL,
|
||||
`weight` DOUBLE NULL,
|
||||
`body_fat` DOUBLE NULL,
|
||||
`waist` DOUBLE NULL,
|
||||
`hip` DOUBLE NULL,
|
||||
`flexibility` DOUBLE NULL,
|
||||
`remark` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`operator_id` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `body_metrics_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `member_notes` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`booking_id` VARCHAR(191) NULL,
|
||||
`content` VARCHAR(1000) NOT NULL,
|
||||
`shared` BOOLEAN NOT NULL DEFAULT false,
|
||||
`operator_id` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `member_notes_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `progress_photos` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`object_key` VARCHAR(191) NOT NULL,
|
||||
`caption` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`recorded_at` DATE NOT NULL,
|
||||
`uploaded_at` DATETIME(3) NULL,
|
||||
`consented_at` DATETIME(3) NULL,
|
||||
`revoked_at` DATETIME(3) NULL,
|
||||
`operator_id` VARCHAR(191) NOT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `progress_photos_object_key_key`(`object_key`),
|
||||
INDEX `progress_photos_user_id_recorded_at_idx`(`user_id`, `recorded_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `booking_reviews` ADD CONSTRAINT `booking_reviews_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `body_metrics` ADD CONSTRAINT `body_metrics_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `member_notes` ADD CONSTRAINT `member_notes_booking_id_fkey` FOREIGN KEY (`booking_id`) REFERENCES `bookings`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `progress_photos` ADD CONSTRAINT `progress_photos_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -85,6 +85,9 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
bodyMetrics BodyMetric[]
|
||||
memberNotes MemberNote[]
|
||||
progressPhotos ProgressPhoto[]
|
||||
lessonSupplements LessonSupplement[]
|
||||
memberships Membership[]
|
||||
bookings Booking[]
|
||||
@@ -226,6 +229,11 @@ model Booking {
|
||||
membership Membership @relation(fields: [membershipId], references: [id])
|
||||
qualifiedInviteReferrals InviteReferral[]
|
||||
|
||||
review BookingReview?
|
||||
memberNotes MemberNote[]
|
||||
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
|
||||
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
|
||||
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
|
||||
statusHistory BookingStatusHistory[]
|
||||
|
||||
@@unique([userId, timeSlotId])
|
||||
@@ -402,3 +410,63 @@ model LessonSupplement {
|
||||
@@index([userId, revokedAt, createdAt])
|
||||
@@map("lesson_supplements")
|
||||
}
|
||||
|
||||
model BookingReview {
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique @map("booking_id")
|
||||
rating Int
|
||||
recommendation Int?
|
||||
tags Json
|
||||
comment String @db.VarChar(200)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
@@index([createdAt])
|
||||
@@map("booking_reviews")
|
||||
}
|
||||
|
||||
model BodyMetric {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
recordedAt DateTime @db.Date @map("recorded_at")
|
||||
weight Float?
|
||||
bodyFat Float? @map("body_fat")
|
||||
waist Float?
|
||||
hip Float?
|
||||
flexibility Float?
|
||||
remark String @default("") @db.VarChar(200)
|
||||
operatorId String @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
@@index([userId, recordedAt])
|
||||
@@map("body_metrics")
|
||||
}
|
||||
|
||||
model MemberNote {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
bookingId String? @map("booking_id")
|
||||
content String @db.VarChar(1000)
|
||||
shared Boolean @default(false)
|
||||
operatorId String @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||
@@index([userId, createdAt])
|
||||
@@map("member_notes")
|
||||
}
|
||||
|
||||
model ProgressPhoto {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
objectKey String @unique @map("object_key")
|
||||
caption String @default("") @db.VarChar(200)
|
||||
recordedAt DateTime @db.Date @map("recorded_at")
|
||||
uploadedAt DateTime? @map("uploaded_at")
|
||||
consentedAt DateTime? @map("consented_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
operatorId String @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
@@index([userId, recordedAt])
|
||||
@@map("progress_photos")
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('AuthService', () => {
|
||||
jest.clearAllMocks()
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
mockPrismaService.membership.count.mockResolvedValue(0)
|
||||
mockConfigService.get.mockReturnValue('tmpl-booking-confirmed')
|
||||
mockConfigService.get.mockImplementation((key: string) => key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED' ? 'tmpl-booking-confirmed' : '')
|
||||
})
|
||||
|
||||
// ── login ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -71,6 +71,7 @@ export class AuthService {
|
||||
|
||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||
const templates = [
|
||||
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
|
||||
@@ -408,6 +408,12 @@ describe('BookingService', () => {
|
||||
|
||||
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: BookingStatus.COMPLETED,
|
||||
reviewReminderDueAt: expect.any(Date),
|
||||
}),
|
||||
}))
|
||||
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
|
||||
})
|
||||
})
|
||||
|
||||
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal file
69
packages/server/src/booking/__tests__/review.service.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ReviewService, summarizeReviews } from '../review.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { CreateReviewDto } from '../dto/create-review.dto'
|
||||
import { validate } from 'class-validator'
|
||||
import 'reflect-metadata'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { ExecutionContext } from '@nestjs/common'
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||
import { ReviewController, PublicReviewController } from '../review.controller'
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../../auth/roles.guard'
|
||||
describe('Class reviews', () => {
|
||||
const db = { booking: { findFirst: jest.fn() }, bookingReview: { create: jest.fn(), findMany: jest.fn(), count: jest.fn() } }
|
||||
let service: ReviewService
|
||||
beforeEach(() => { jest.resetAllMocks(); service = new ReviewService(db as unknown as PrismaService) })
|
||||
const dto = { rating: 5, tags: ['氛围好'], comment: '很有收获' }
|
||||
it('does not expose another member booking or review', async () => {
|
||||
db.booking.findFirst.mockResolvedValue(null)
|
||||
await expect(service.get('other', 'booking')).rejects.toThrow('预约不存在')
|
||||
expect(db.booking.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'booking', userId: 'other' } }))
|
||||
await expect(service.create('other', 'booking', dto)).rejects.toThrow('预约不存在')
|
||||
expect(db.bookingReview.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['CONFIRMED', 'NO_SHOW', 'CANCELLED', 'PENDING_CONFIRMATION'])('rejects %s booking', async status => {
|
||||
db.booking.findFirst.mockResolvedValue({ status })
|
||||
await expect(service.create('member', 'booking', dto)).rejects.toThrow('完成课程后才能评价')
|
||||
})
|
||||
it('allows older completed lessons, with database uniqueness protecting double submits', async () => {
|
||||
db.booking.findFirst.mockResolvedValue({ status: 'COMPLETED', completedAt: new Date('2020-01-01') })
|
||||
db.bookingReview.create.mockResolvedValueOnce({ id: 'review' }).mockRejectedValueOnce(new Prisma.PrismaClientKnownRequestError('duplicate', { code: 'P2002', clientVersion: '5' }))
|
||||
await expect(service.create('member', 'booking', dto)).resolves.toEqual({ id: 'review' })
|
||||
await expect(service.create('member', 'booking', dto)).rejects.toThrow('已经评价')
|
||||
})
|
||||
it.each([{ rating: 0 }, { rating: 6 }, { rating: 2.5 }, { tags: ['伪造标签'] }, { tags: ['氛围好', '氛围好'] }, { tags: ['动作到位','氛围好','强度合适','讲解清晰'] }, { recommendation: 11 }, { recommendation: -1 }, { comment: '长'.repeat(201) }])('rejects invalid payload %j', async patch => {
|
||||
expect((await validate(Object.assign(new CreateReviewDto(), dto, patch))).length).toBeGreaterThan(0)
|
||||
})
|
||||
it('accepts optional recommendation including zero', async () => {
|
||||
expect(await validate(Object.assign(new CreateReviewDto(), dto, { recommendation: 0 }))).toHaveLength(0)
|
||||
})
|
||||
it('keeps star average separate from NPS and handles zero samples', () => {
|
||||
expect(summarizeReviews([])).toEqual({ count: 0, average: null, nps: null, npsCount: 0 })
|
||||
expect(summarizeReviews([{ rating: 5, recommendation: 0 }, { rating: 1, recommendation: 9 }, { rating: 4, recommendation: 7 }, { rating: 4, recommendation: null }])).toEqual({ count: 4, average: 3.5, nps: 0, npsCount: 3 })
|
||||
})
|
||||
it('groups the six Chinese calendar months across year and UTC boundaries', async () => {
|
||||
db.bookingReview.findMany.mockResolvedValue([{ rating: 5, recommendation: 10, createdAt: new Date('2025-12-31T16:00:00Z') }, { rating: 1, recommendation: 0, createdAt: new Date('2025-12-31T15:59:59Z') }])
|
||||
const result = await service.trend('2026-01')
|
||||
expect(result.map(r => r.month)).toEqual(['2025-08','2025-09','2025-10','2025-11','2025-12','2026-01'])
|
||||
expect(result[4].average).toBe(1); expect(result[5].average).toBe(5)
|
||||
expect(db.bookingReview.findMany.mock.calls[0][0].where.createdAt).toEqual({ gte: new Date('2025-07-31T16:00:00Z'), lt: new Date('2026-01-31T16:00:00Z') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Review authorization', () => {
|
||||
it('requires authentication for member and admin review endpoints', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, ReviewController)).toContain(JwtAuthGuard)
|
||||
})
|
||||
it('keeps the public summary unauthenticated', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PublicReviewController) || []).not.toContain(JwtAuthGuard)
|
||||
})
|
||||
it.each(['list', 'trend'] as const)('restricts %s to admins', method => {
|
||||
const handler = ReviewController.prototype[method]
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(RolesGuard)
|
||||
const guard = new RolesGuard(new Reflector())
|
||||
const context = (role: string) => ({ getHandler: () => handler, getClass: () => ReviewController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ReviewController, PublicReviewController } from './review.controller'
|
||||
import { ReviewService } from './review.service'
|
||||
import { Module } from '@nestjs/common'
|
||||
import { BookingController } from './booking.controller'
|
||||
import { BookingService } from './booking.service'
|
||||
@@ -8,8 +10,8 @@ import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||
controllers: [BookingController],
|
||||
providers: [BookingService],
|
||||
controllers: [BookingController, ReviewController, PublicReviewController],
|
||||
providers: [BookingService, ReviewService],
|
||||
exports: [BookingService],
|
||||
})
|
||||
export class BookingModule {}
|
||||
|
||||
@@ -183,6 +183,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -455,6 +456,7 @@ export class BookingService {
|
||||
}
|
||||
if (toStatus === BookingStatus.COMPLETED) {
|
||||
updateData.completedAt = new Date()
|
||||
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000)
|
||||
}
|
||||
|
||||
const updated = await tx.booking.update({
|
||||
@@ -493,6 +495,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -628,6 +631,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
user: { select: { id: true, nickname: true, phone: true } },
|
||||
},
|
||||
})
|
||||
@@ -653,6 +657,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
@@ -714,6 +719,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
orderBy: [
|
||||
{ timeSlot: { date: 'asc' } },
|
||||
@@ -740,6 +746,7 @@ export class BookingService {
|
||||
user: { select: { id: true, nickname: true, phone: true } },
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
@@ -842,6 +849,7 @@ export class BookingService {
|
||||
include: {
|
||||
timeSlot: true,
|
||||
membership: { include: { cardType: true } },
|
||||
review: { select: { rating: true } },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
8
packages/server/src/booking/dto/create-review.dto.ts
Normal file
8
packages/server/src/booking/dto/create-review.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ArrayMaxSize, ArrayUnique, IsArray, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'
|
||||
import { REVIEW_TAGS } from '@mp-pilates/shared'
|
||||
export class CreateReviewDto {
|
||||
@IsInt() @Min(1) @Max(5) rating!: number
|
||||
@IsOptional() @IsInt() @Min(0) @Max(10) recommendation?: number
|
||||
@IsArray() @ArrayMaxSize(3) @ArrayUnique() @IsIn(REVIEW_TAGS, { each: true }) tags!: string[]
|
||||
@IsString() @MaxLength(200) comment!: string
|
||||
}
|
||||
27
packages/server/src/booking/review.controller.ts
Normal file
27
packages/server/src/booking/review.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { ReviewService } from './review.service'
|
||||
import { CreateReviewDto } from './dto/create-review.dto'
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReviewController {
|
||||
constructor(private readonly service: ReviewService) {}
|
||||
@Get('booking/:id/review')
|
||||
get(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.get(userId, id) }
|
||||
@Post('booking/:id/review')
|
||||
create(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: CreateReviewDto) { return this.service.create(userId, id, dto) }
|
||||
@Get('admin/reviews') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
|
||||
list(@Query('userId') userId?: string, @Query('page') page?: string) { return this.service.list(userId, page ? Number(page) : 1) }
|
||||
@Get('admin/reviews/trend') @UseGuards(RolesGuard) @Roles(UserRole.ADMIN)
|
||||
trend(@Query('month') month?: string) { return this.service.trend(month) }
|
||||
}
|
||||
|
||||
@Controller('reviews')
|
||||
export class PublicReviewController {
|
||||
constructor(private readonly service: ReviewService) {}
|
||||
@Get('summary') summary() { return this.service.publicSummary() }
|
||||
}
|
||||
61
packages/server/src/booking/review.service.ts
Normal file
61
packages/server/src/booking/review.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { CreateReviewDto } from './dto/create-review.dto'
|
||||
|
||||
export function summarizeReviews(rows: { rating: number; recommendation: number | null }[]) {
|
||||
const recommendations = rows.filter(r => r.recommendation !== null)
|
||||
return {
|
||||
count: rows.length,
|
||||
average: rows.length ? Math.round(rows.reduce((sum, r) => sum + r.rating, 0) / rows.length * 10) / 10 : null,
|
||||
npsCount: recommendations.length,
|
||||
nps: recommendations.length ? Math.round(100 * (recommendations.filter(r => r.recommendation! >= 9).length - recommendations.filter(r => r.recommendation! <= 6).length) / recommendations.length) : null,
|
||||
}
|
||||
}
|
||||
@Injectable()
|
||||
export class ReviewService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
async get(userId: string, bookingId: string) {
|
||||
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId }, include: { review: true } })
|
||||
if (!booking) throw new NotFoundException('预约不存在')
|
||||
return { review: booking.review, canReview: booking.status === 'COMPLETED' && !booking.review }
|
||||
}
|
||||
async create(userId: string, bookingId: string, dto: CreateReviewDto) {
|
||||
const booking = await this.prisma.booking.findFirst({ where: { id: bookingId, userId } })
|
||||
if (!booking) throw new NotFoundException('预约不存在')
|
||||
if (booking.status !== 'COMPLETED') throw new BadRequestException('完成课程后才能评价')
|
||||
try {
|
||||
return await this.prisma.bookingReview.create({ data: { bookingId, rating: dto.rating, recommendation: dto.recommendation, tags: dto.tags, comment: dto.comment.trim() } })
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') throw new BadRequestException('这节课已经评价过了')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async publicSummary() {
|
||||
const result = await this.prisma.bookingReview.aggregate({ _count: { id: true }, _avg: { rating: true } })
|
||||
return { count: result._count.id, average: result._avg.rating === null ? null : Math.round(result._avg.rating * 10) / 10 }
|
||||
}
|
||||
async list(userId?: string, page = 1) {
|
||||
if (!Number.isInteger(page) || page < 1 || page > 10000) throw new BadRequestException('页码无效')
|
||||
const where = userId ? { booking: { userId } } : {}
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.bookingReview.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * 20, take: 20,
|
||||
include: { booking: { select: { userId: true, user: { select: { nickname: true } }, timeSlot: { select: { date: true, startTime: true, endTime: true } } } } } }),
|
||||
this.prisma.bookingReview.count({ where }),
|
||||
])
|
||||
return { data, total, page, limit: 20 }
|
||||
}
|
||||
async trend(month?: string) {
|
||||
const current = month || new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7)
|
||||
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(current)) throw new BadRequestException('月份格式应为 YYYY-MM')
|
||||
const end = new Date(current + '-01T00:00:00+08:00')
|
||||
const months = Array.from({ length: 6 }, (_, i) => {
|
||||
const date = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1 - (5 - i), 1))
|
||||
return date.toISOString().slice(0, 7)
|
||||
})
|
||||
const from = new Date(months[0] + '-01T00:00:00+08:00')
|
||||
const to = new Date(Date.UTC(Number(current.slice(0, 4)), Number(current.slice(5)), 1) - 8 * 3600000)
|
||||
const rows = await this.prisma.bookingReview.findMany({ where: { createdAt: { gte: from, lt: to } }, select: { rating: true, recommendation: true, createdAt: true } })
|
||||
return months.map(month => ({ month, ...summarizeReviews(rows.filter(r => new Date(r.createdAt.getTime() + 8 * 3600000).toISOString().startsWith(month))) }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ReviewReminderService } from '../review-reminder.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { Logger } from '@nestjs/common'
|
||||
describe('Review reminder queue', () => {
|
||||
const db = { booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() } }
|
||||
const messages = { sendReviewReminder: jest.fn() }, config = { get: jest.fn() }
|
||||
let service: ReviewReminderService
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
config.get.mockReturnValue('tmpl')
|
||||
service = new ReviewReminderService(db as unknown as PrismaService, messages as unknown as SubscriptionMessageService, config as unknown as ConfigService)
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
|
||||
db.booking.findMany.mockResolvedValue([{ id: 'b', userId: 'u', user: { openid: 'o' } }])
|
||||
})
|
||||
afterEach(() => jest.restoreAllMocks())
|
||||
it('does not consume queue when template is unconfigured', async () => { config.get.mockReturnValue(''); await service.run(); expect(db.booking.findMany).not.toHaveBeenCalled() })
|
||||
it('queries only due, unreviewed completed lessons and atomically claims a booking', async () => {
|
||||
db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockResolvedValue(true)
|
||||
await service.run()
|
||||
const where = db.booking.findMany.mock.calls[0][0].where
|
||||
expect(where.status).toBe('COMPLETED'); expect(where.review).toBeNull(); expect(where.reviewReminderDueAt.lte).toBeInstanceOf(Date)
|
||||
expect(db.booking.updateMany.mock.calls[0][0].where).toEqual({ id: 'b', status: 'COMPLETED', review: null, reviewReminderClaimedAt: null })
|
||||
expect(db.booking.update).toHaveBeenCalledWith({ where: { id: 'b' }, data: { reviewReminderSentAt: expect.any(Date) } })
|
||||
})
|
||||
it('does not send if another worker claimed or member already reviewed', async () => { db.booking.updateMany.mockResolvedValue({ count: 0 }); await service.run(); expect(messages.sendReviewReminder).not.toHaveBeenCalled() })
|
||||
it('does not mark a failed or unknown send as delivered or automatically release it', async () => { db.booking.updateMany.mockResolvedValue({ count: 1 }); messages.sendReviewReminder.mockRejectedValue(new Error('timeout')); await service.run(); expect(db.booking.update).not.toHaveBeenCalled(); expect(db.booking.updateMany).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FlashSaleService } from '../../flash-sale/flash-sale.service'
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { Logger } from '@nestjs/common'
|
||||
import { SchedulerService } from '../scheduler.service'
|
||||
@@ -33,6 +34,7 @@ describe('SchedulerService', () => {
|
||||
providers: [
|
||||
SchedulerService,
|
||||
{ provide: SlotGeneratorService, useValue: mockSlotGenerator },
|
||||
{ provide: FlashSaleService, useValue: { expireUnpaidReservations: jest.fn() } },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
|
||||
25
packages/server/src/scheduler/review-reminder.service.ts
Normal file
25
packages/server/src/scheduler/review-reminder.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Cron } from '@nestjs/schedule'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||
@Injectable()
|
||||
export class ReviewReminderService {
|
||||
private readonly logger = new Logger(ReviewReminderService.name)
|
||||
constructor(private readonly prisma: PrismaService, private readonly messages: SubscriptionMessageService, private readonly config: ConfigService) {}
|
||||
@Cron('*/5 * * * *')
|
||||
async run() {
|
||||
const templateId = this.config.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW')
|
||||
if (!templateId) return
|
||||
const rows = await this.prisma.booking.findMany({ where: { status: 'COMPLETED', review: null, reviewReminderDueAt: { lte: new Date(), gte: new Date(Date.now() - 7 * 86400000) }, reviewReminderClaimedAt: null }, include: { user: { select: { openid: true } } }, orderBy: { reviewReminderDueAt: 'asc' }, take: 100 })
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const claimed = await this.prisma.booking.updateMany({ where: { id: row.id, status: 'COMPLETED', review: null, reviewReminderClaimedAt: null }, data: { reviewReminderClaimedAt: new Date() } })
|
||||
if (!claimed.count) continue
|
||||
if (await this.messages.sendReviewReminder(row.userId, row.user.openid, row.id)) {
|
||||
await this.prisma.booking.update({ where: { id: row.id }, data: { reviewReminderSentAt: new Date() } })
|
||||
}
|
||||
} catch (error) { this.logger.error(`评价提醒未发送或结果未知: ${row.id}`, error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { ReviewReminderService } from './review-reminder.service'
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ScheduleModule } from '@nestjs/schedule'
|
||||
import { TimeSlotModule } from '../time-slot/time-slot.module'
|
||||
@@ -7,9 +10,10 @@ import { SchedulerService } from './scheduler.service'
|
||||
@Module({
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
UserModule, ConfigModule,
|
||||
TimeSlotModule,
|
||||
FlashSaleModule,
|
||||
],
|
||||
providers: [SchedulerService],
|
||||
providers: [SchedulerService, ReviewReminderService],
|
||||
})
|
||||
export class SchedulerModule {}
|
||||
|
||||
@@ -54,10 +54,11 @@ export class StudioUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildPostPolicy(params: {
|
||||
protected buildPostPolicy(params: {
|
||||
bucket: string
|
||||
key: string
|
||||
expiresAt: number
|
||||
privateRead?: boolean
|
||||
}): Record<string, string> {
|
||||
const secretId = this.getRequiredConfig('COS_SECRET_ID')
|
||||
const secretKey = this.getRequiredConfig('COS_SECRET_KEY')
|
||||
@@ -66,6 +67,7 @@ export class StudioUploadService {
|
||||
expiration: new Date(params.expiresAt * 1000).toISOString(),
|
||||
conditions: [
|
||||
{ bucket: params.bucket },
|
||||
...(params.privateRead ? [{ 'x-cos-acl': 'private' }] : []),
|
||||
['eq', '$key', params.key],
|
||||
{ success_action_status: '200' },
|
||||
{ 'q-sign-algorithm': 'sha1' },
|
||||
@@ -87,6 +89,7 @@ export class StudioUploadService {
|
||||
|
||||
return {
|
||||
key: params.key,
|
||||
...(params.privateRead ? { 'x-cos-acl': 'private' } : {}),
|
||||
policy: policyBase64,
|
||||
success_action_status: '200',
|
||||
'q-sign-algorithm': 'sha1',
|
||||
@@ -121,7 +124,7 @@ export class StudioUploadService {
|
||||
return `${startTime};${expiresAt}`
|
||||
}
|
||||
|
||||
private resolveExtension(fileName: string, contentType?: string): string {
|
||||
protected resolveExtension(fileName: string, contentType?: string): string {
|
||||
const cleanedName = fileName.trim().toLowerCase()
|
||||
const fileExtension = cleanedName.includes('.')
|
||||
? cleanedName.split('.').pop() ?? ''
|
||||
@@ -158,7 +161,7 @@ export class StudioUploadService {
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
private getRequiredConfig(key: string): string {
|
||||
protected getRequiredConfig(key: string): string {
|
||||
const value = this.configService.get<string>(key)?.trim()
|
||||
|
||||
if (!value) {
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('SlotGeneratorService', () => {
|
||||
where: expect.objectContaining({
|
||||
status: BookingStatus.CONFIRMED,
|
||||
}),
|
||||
data: { status: BookingStatus.COMPLETED },
|
||||
data: { status: BookingStatus.COMPLETED, completedAt: expect.any(Date), reviewReminderDueAt: expect.any(Date) },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ export class SlotGeneratorService {
|
||||
date: { lt: today },
|
||||
},
|
||||
},
|
||||
data: { status: BookingStatus.COMPLETED },
|
||||
data: { status: BookingStatus.COMPLETED, completedAt: new Date(), reviewReminderDueAt: new Date(Date.now() + 24 * 3600000) },
|
||||
})
|
||||
|
||||
this.logger.log(`Completed ${result.count} past bookings`)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { MemberProgressService } from '../member-progress.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
|
||||
import { BodyMetricDto } from '../dto/member-progress.dto'
|
||||
import { validate } from 'class-validator'
|
||||
import 'reflect-metadata'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { ExecutionContext } from '@nestjs/common'
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants'
|
||||
import { MemberProgressController, AdminMemberProgressController } from '../member-progress.controller'
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../../auth/roles.guard'
|
||||
describe('Member progress permissions and measurements', () => {
|
||||
const db = { user: { findUnique: jest.fn() }, booking: { count: jest.fn(), findFirst: jest.fn() }, lessonSupplement: { aggregate: jest.fn() }, bodyMetric: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, memberNote: { findMany: jest.fn(), create: jest.fn(), deleteMany: jest.fn() }, progressPhoto: { findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn(), create: jest.fn(), delete: jest.fn() } }
|
||||
const storage = { signedUrl: jest.fn(), credential: jest.fn(), verify: jest.fn(), removeObject: jest.fn() }
|
||||
let service: MemberProgressService
|
||||
beforeEach(() => { jest.resetAllMocks(); service = new MemberProgressService(db as unknown as PrismaService, storage as unknown as ProgressPhotoStorageService); db.user.findUnique.mockResolvedValue({ nickname: '学员' }); db.booking.count.mockResolvedValue(8); db.lessonSupplement.aggregate.mockResolvedValue({ _sum: { quantity: 3 } }); db.bodyMetric.findMany.mockResolvedValue([]); db.memberNote.findMany.mockResolvedValue([]); db.progressPhoto.findMany.mockResolvedValue([]) })
|
||||
it('filters private notes on the server, strips photo keys and counts only valid supplements', async () => {
|
||||
const result = await service.archive('member')
|
||||
expect(db.memberNote.findMany.mock.calls[0][0].where).toEqual({ userId: 'member', shared: true })
|
||||
expect(db.progressPhoto.findMany.mock.calls[0][0].select.objectKey).toBeUndefined()
|
||||
expect(db.lessonSupplement.aggregate.mock.calls[0][0].where).toEqual({ userId: 'member', revokedAt: null })
|
||||
expect(result.completedCount).toBe(11); expect(result.milestones).toEqual([10])
|
||||
await service.archive('member', true)
|
||||
expect(db.memberNote.findMany.mock.calls[1][0].where).toEqual({ userId: 'member' })
|
||||
})
|
||||
it('rejects missing metrics, future and invalid dates; preserves negative flexibility', async () => {
|
||||
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-01-01' })).rejects.toThrow('至少填写')
|
||||
await expect(service.addMetric('member', 'admin', { recordedAt: '2099-01-01', weight: 50 })).rejects.toThrow('日期')
|
||||
await expect(service.addMetric('member', 'admin', { recordedAt: '2025-02-30', weight: 50 })).rejects.toThrow('日期')
|
||||
await service.addMetric('member', 'admin', { recordedAt: '2025-01-01', flexibility: -3 })
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data.flexibility).toBe(-3)
|
||||
expect(db.bodyMetric.create.mock.calls[0][0].data.weight).toBeUndefined()
|
||||
})
|
||||
it('rejects non-finite, out of range and nonnumeric measurements', async () => {
|
||||
for (const weight of [0, -10, 501, Infinity, '50']) expect((await validate(Object.assign(new BodyMetricDto(), { recordedAt: '2025-01-01', weight }))).length).toBeGreaterThan(0)
|
||||
})
|
||||
it('only annotates the target member completed booking', async () => {
|
||||
db.booking.findFirst.mockResolvedValue(null)
|
||||
await expect(service.addNote('member', 'admin', { content: 'note', shared: false, bookingId: 'foreign' })).rejects.toThrow('只能批注')
|
||||
expect(db.booking.findFirst.mock.calls[0][0].where).toEqual({ id: 'foreign', userId: 'member', status: 'COMPLETED' })
|
||||
expect(db.memberNote.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it('never signs another member photo', async () => {
|
||||
db.progressPhoto.findFirst.mockResolvedValue(null)
|
||||
await expect(service.photoUrl('other', 'photo')).rejects.toThrow('照片不存在')
|
||||
expect(storage.signedUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
it('lets a member preview for informed consent but blocks admin until authorized', async () => {
|
||||
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'private/photo', consentedAt: null })
|
||||
storage.signedUrl.mockReturnValue('short-lived')
|
||||
await expect(service.photoUrl('member', 'photo', true)).rejects.toThrow('尚未授权')
|
||||
await expect(service.photoUrl('member', 'photo')).resolves.toEqual({ url: 'short-lived', expiresIn: 60 })
|
||||
})
|
||||
it('consent and revocation are scoped to current member and uploaded photo', async () => {
|
||||
db.progressPhoto.updateMany.mockResolvedValue({ count: 1 })
|
||||
await service.consent('member', 'photo', false)
|
||||
expect(db.progressPhoto.updateMany.mock.calls[0][0]).toEqual({ where: { id: 'photo', userId: 'member', uploadedAt: { not: null } }, data: { consentedAt: null, revokedAt: expect.any(Date) } })
|
||||
})
|
||||
it('requires confirmed storage upload before exposing a photo record', async () => {
|
||||
db.progressPhoto.findFirst.mockResolvedValue({ objectKey: 'key' })
|
||||
storage.verify.mockRejectedValue(new Error('not uploaded'))
|
||||
await expect(service.finishUpload('member', 'photo')).rejects.toThrow('not uploaded')
|
||||
expect(db.progressPhoto.update).not.toHaveBeenCalled()
|
||||
})
|
||||
it('deletes a photo from private storage and the database, scoped to the member', async () => {
|
||||
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
|
||||
await service.remove('member', 'photos', 'photo')
|
||||
expect(storage.removeObject).toHaveBeenCalledWith('progress/member/a.jpg')
|
||||
expect(db.progressPhoto.delete).toHaveBeenCalledWith({ where: { id: 'photo' } })
|
||||
})
|
||||
it('does not delete the database row if storage deletion fails', async () => {
|
||||
db.progressPhoto.findFirst.mockResolvedValue({ id: 'photo', objectKey: 'progress/member/a.jpg' })
|
||||
storage.removeObject.mockRejectedValue(new Error('cos down'))
|
||||
await expect(service.remove('member', 'photos', 'photo')).rejects.toThrow('cos down')
|
||||
expect(db.progressPhoto.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not delete another member metric, note or photo', async () => {
|
||||
db.bodyMetric.deleteMany.mockResolvedValue({ count: 0 })
|
||||
db.memberNote.deleteMany.mockResolvedValue({ count: 0 })
|
||||
db.progressPhoto.findFirst.mockResolvedValue(null)
|
||||
await expect(service.remove('other', 'metrics', 'm')).rejects.toThrow('记录不存在')
|
||||
await expect(service.remove('other', 'notes', 'n')).rejects.toThrow('记录不存在')
|
||||
await expect(service.remove('other', 'photos', 'p')).rejects.toThrow('照片不存在')
|
||||
expect(storage.removeObject).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Member progress authorization', () => {
|
||||
it('requires authentication on member and admin progress controllers', () => {
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, MemberProgressController)).toContain(JwtAuthGuard)
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(JwtAuthGuard)
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, AdminMemberProgressController)).toContain(RolesGuard)
|
||||
})
|
||||
it('restricts admin progress reads to admins', () => {
|
||||
const handler = AdminMemberProgressController.prototype.archive
|
||||
const guard = new RolesGuard(new Reflector())
|
||||
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
|
||||
expect(guard.canActivate(context('MEMBER'))).toBe(false)
|
||||
expect(guard.canActivate(context('ADMIN'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
|
||||
|
||||
describe('Progress photo private storage', () => {
|
||||
const config = { get: jest.fn() }
|
||||
let service: ProgressPhotoStorageService
|
||||
const originalFetch = global.fetch
|
||||
const defaults = {
|
||||
COS_BUCKET: 'plates-1251306435',
|
||||
COS_REGION: 'ap-guangzhou',
|
||||
COS_SECRET_ID: 'id',
|
||||
COS_SECRET_KEY: 'key',
|
||||
}
|
||||
const values: Record<string, string> = { ...defaults }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
Object.assign(values, defaults)
|
||||
config.get.mockImplementation((key: string) => values[key] || '')
|
||||
service = new ProgressPhotoStorageService(config as unknown as ConfigService)
|
||||
})
|
||||
afterEach(() => { global.fetch = originalFetch })
|
||||
|
||||
it('signs a private object under the member prefix in the shared studio bucket', () => {
|
||||
const credential = service.credential('member-1', 'pose.PNG')
|
||||
expect(credential.key).toMatch(/^progress\/member-1\/[0-9a-f-]+\.png$/)
|
||||
expect(credential.uploadUrl).toBe('https://plates-1251306435.cos.ap-guangzhou.myqcloud.com')
|
||||
expect(credential.formData['x-cos-acl']).toBe('private')
|
||||
expect(credential.formData.key).toBe(credential.key)
|
||||
})
|
||||
|
||||
it('treats missing or oversized objects and non-images as client errors after HEAD succeeds', async () => {
|
||||
const fetchMock = jest.fn()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '0' }) })
|
||||
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(BadRequestException)
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': String(11 * 1024 * 1024), 'content-type': 'image/jpeg' }) })
|
||||
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('图片大小无效')
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-length': '123', 'content-type': 'application/pdf' }) })
|
||||
await expect(service.verify('progress/member/a.jpg')).rejects.toThrow('仅支持图片文件')
|
||||
})
|
||||
|
||||
it('allows verify when COS omits content-length for an image object', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }) }) as unknown as typeof fetch
|
||||
await expect(service.verify('progress/member/a.jpg')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a missing COS object as retryable and ignores 404 on delete', async () => {
|
||||
const fetchMock = jest.fn()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, headers: new Headers() })
|
||||
await expect(service.verify('progress/member/a.jpg')).rejects.toBeInstanceOf(ServiceUnavailableException)
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404 })
|
||||
await expect(service.removeObject('progress/member/a.jpg')).resolves.toBeUndefined()
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 })
|
||||
await expect(service.removeObject('progress/member/a.jpg')).rejects.toThrow('照片删除失败')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { buildClassReviewSubscribeData } from '../subscription-message.service'
|
||||
|
||||
describe('Class review subscribe payload', () => {
|
||||
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
|
||||
expect(buildClassReviewSubscribeData({
|
||||
studioName: 'Focus Core 普拉提工作室',
|
||||
date: new Date('2026-09-09T00:00:00.000Z'),
|
||||
startTime: '10:00:00',
|
||||
})).toEqual({
|
||||
thing1: { value: 'Focus Core 普拉提工作室' },
|
||||
thing2: { value: 'Iris' },
|
||||
time3: { value: '2026年09月09日 10:00' },
|
||||
thing4: { value: '欢迎留下这节课的感受' },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a short course name and trims thing fields to 20 characters', () => {
|
||||
const data = buildClassReviewSubscribeData({
|
||||
studioName: '超长工作室名称用来测试微信订阅消息字段截断是否生效',
|
||||
date: new Date('2026-01-02T00:00:00.000Z'),
|
||||
startTime: '09:30',
|
||||
})
|
||||
expect(data.thing1.value).toHaveLength(20)
|
||||
expect(data.thing1.value).toBe('超长工作室名称用来测试微信订阅消息字段截断是否生效'.slice(0, 20))
|
||||
expect(data.time3.value).toBe('2026年01月02日 09:30')
|
||||
})
|
||||
})
|
||||
21
packages/server/src/user/dto/member-progress.dto.ts
Normal file
21
packages/server/src/user/dto/member-progress.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, Matches } from 'class-validator'
|
||||
export class BodyMetricDto {
|
||||
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
|
||||
@IsOptional() @IsNumber() @Min(1) @Max(500) weight?: number
|
||||
@IsOptional() @IsNumber() @Min(1) @Max(75) bodyFat?: number
|
||||
@IsOptional() @IsNumber() @Min(10) @Max(300) waist?: number
|
||||
@IsOptional() @IsNumber() @Min(10) @Max(300) hip?: number
|
||||
@IsOptional() @IsNumber() @Min(-50) @Max(100) flexibility?: number
|
||||
@IsOptional() @IsString() @MaxLength(200) remark?: string
|
||||
}
|
||||
export class MemberNoteDto {
|
||||
@IsString() @MinLength(1) @MaxLength(1000) content!: string
|
||||
@IsBoolean() shared!: boolean
|
||||
@IsOptional() @IsString() bookingId?: string
|
||||
}
|
||||
export class ProgressPhotoDto {
|
||||
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
|
||||
@IsString() @MaxLength(200) caption!: string
|
||||
@IsString() @MaxLength(100) fileName!: string
|
||||
}
|
||||
export class PhotoConsentDto { @IsBoolean() consent!: boolean }
|
||||
27
packages/server/src/user/member-progress.controller.ts
Normal file
27
packages/server/src/user/member-progress.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { MemberProgressService } from './member-progress.service'
|
||||
import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
|
||||
@Controller('user/progress') @UseGuards(JwtAuthGuard)
|
||||
export class MemberProgressController {
|
||||
constructor(private readonly service: MemberProgressService) {}
|
||||
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
|
||||
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
|
||||
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
|
||||
@Delete('photos/:id') removePhoto(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.remove(userId, 'photos', id) }
|
||||
}
|
||||
@Controller('admin/members/:userId/progress') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN)
|
||||
export class AdminMemberProgressController {
|
||||
constructor(private readonly service: MemberProgressService) {}
|
||||
@Get() archive(@Param('userId') userId: string) { return this.service.archive(userId, true) }
|
||||
@Post('metrics') metric(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.addMetric(userId, operatorId, dto) }
|
||||
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
|
||||
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
|
||||
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }
|
||||
@Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) }
|
||||
@Get('photos/:id/url') url(@Param('userId') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id, true) }
|
||||
}
|
||||
87
packages/server/src/user/member-progress.service.ts
Normal file
87
packages/server/src/user/member-progress.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { BodyMetricDto, MemberNoteDto, ProgressPhotoDto } from './dto/member-progress.dto'
|
||||
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
|
||||
@Injectable()
|
||||
export class MemberProgressService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly storage: ProgressPhotoStorageService) {}
|
||||
private async member(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { nickname: true } })
|
||||
if (!user) throw new NotFoundException('学员不存在')
|
||||
return user
|
||||
}
|
||||
private date(value: string) {
|
||||
const date = new Date(value + 'T00:00:00.000Z')
|
||||
if (!Number.isFinite(date.getTime()) || date.toISOString().slice(0, 10) !== value || value > new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)) throw new BadRequestException('请选择有效的记录日期,不能晚于今天')
|
||||
return date
|
||||
}
|
||||
async archive(userId: string, admin = false) {
|
||||
const user = await this.member(userId)
|
||||
const [count, supplements, metrics, notes, photos] = await Promise.all([
|
||||
this.prisma.booking.count({ where: { userId, status: 'COMPLETED' } }),
|
||||
this.prisma.lessonSupplement.aggregate({ where: { userId, revokedAt: null }, _sum: { quantity: true } }),
|
||||
this.prisma.bodyMetric.findMany({ where: { userId }, orderBy: [{ recordedAt: 'desc' }, { createdAt: 'desc' }] }),
|
||||
this.prisma.memberNote.findMany({ where: { userId, ...(admin ? {} : { shared: true }) }, orderBy: { createdAt: 'desc' }, include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } } }),
|
||||
this.prisma.progressPhoto.findMany({ where: { userId, uploadedAt: { not: null } }, orderBy: { recordedAt: 'desc' }, select: { id: true, caption: true, recordedAt: true, consentedAt: true, revokedAt: true } }),
|
||||
])
|
||||
const completedCount = count + (supplements._sum.quantity || 0)
|
||||
return { nickname: user.nickname, completedCount, milestones: [10, 30, 50].filter(n => completedCount >= n), metrics, notes, photos }
|
||||
}
|
||||
async addMetric(userId: string, operatorId: string, dto: BodyMetricDto) {
|
||||
await this.member(userId)
|
||||
if ([dto.weight, dto.bodyFat, dto.waist, dto.hip, dto.flexibility].every(v => v == null)) throw new BadRequestException('请至少填写一项体测数据')
|
||||
return this.prisma.bodyMetric.create({ data: { ...dto, userId, operatorId, recordedAt: this.date(dto.recordedAt) } })
|
||||
}
|
||||
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
|
||||
await this.member(userId)
|
||||
if (!dto.content.trim()) throw new BadRequestException('请填写笔记内容')
|
||||
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
|
||||
return this.prisma.memberNote.create({ data: { userId, operatorId, content: dto.content.trim(), shared: dto.shared, bookingId: dto.bookingId || null } })
|
||||
}
|
||||
async remove(userId: string, kind: string, id: string) {
|
||||
if (kind === 'metrics') {
|
||||
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })
|
||||
if (!result.count) throw new NotFoundException('记录不存在')
|
||||
return { deleted: true }
|
||||
}
|
||||
if (kind === 'notes') {
|
||||
const result = await this.prisma.memberNote.deleteMany({ where: { id, userId } })
|
||||
if (!result.count) throw new NotFoundException('记录不存在')
|
||||
return { deleted: true }
|
||||
}
|
||||
if (kind === 'photos') {
|
||||
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
|
||||
if (!photo) throw new NotFoundException('照片不存在')
|
||||
await this.storage.removeObject(photo.objectKey)
|
||||
await this.prisma.progressPhoto.delete({ where: { id } })
|
||||
return { deleted: true }
|
||||
}
|
||||
throw new BadRequestException('记录类型无效')
|
||||
}
|
||||
async upload(userId: string, operatorId: string, dto: ProgressPhotoDto) {
|
||||
await this.member(userId)
|
||||
const recordedAt = this.date(dto.recordedAt)
|
||||
const credential = this.storage.credential(userId, dto.fileName)
|
||||
const photo = await this.prisma.progressPhoto.create({ data: { userId, operatorId, objectKey: credential.key, recordedAt, caption: dto.caption } })
|
||||
return { ...credential, id: photo.id }
|
||||
}
|
||||
async finishUpload(userId: string, id: string) {
|
||||
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId } })
|
||||
if (!photo) throw new NotFoundException('照片不存在')
|
||||
await this.storage.verify(photo.objectKey)
|
||||
await this.prisma.progressPhoto.update({ where: { id }, data: { uploadedAt: new Date() } })
|
||||
return { uploaded: true }
|
||||
}
|
||||
async consent(userId: string, id: string, consent: boolean) {
|
||||
const result = await this.prisma.progressPhoto.updateMany({ where: { id, userId, uploadedAt: { not: null } }, data: { consentedAt: consent ? new Date() : null, revokedAt: consent ? null : new Date() } })
|
||||
if (!result.count) throw new NotFoundException('照片不存在')
|
||||
return { consent }
|
||||
}
|
||||
async photoUrl(userId: string, id: string, admin = false) {
|
||||
const photo = await this.prisma.progressPhoto.findFirst({ where: { id, userId, uploadedAt: { not: null } } })
|
||||
if (!photo) throw new NotFoundException('照片不存在')
|
||||
// Members can privately preview their own photo to make an informed consent decision.
|
||||
if (admin && !photo.consentedAt) throw new ForbiddenException('学员尚未授权展示这张照片')
|
||||
return { url: this.storage.signedUrl(photo.objectKey), expiresIn: 60 }
|
||||
}
|
||||
}
|
||||
45
packages/server/src/user/progress-photo-storage.service.ts
Normal file
45
packages/server/src/user/progress-photo-storage.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { createHash, createHmac, randomUUID } from 'crypto'
|
||||
import { StudioUploadService } from '../studio/studio-upload.service'
|
||||
@Injectable()
|
||||
export class ProgressPhotoStorageService extends StudioUploadService {
|
||||
constructor(config: ConfigService) { super(config) }
|
||||
private endpoint() {
|
||||
const bucket = this.getRequiredConfig('COS_BUCKET')
|
||||
const region = this.getRequiredConfig('COS_REGION')
|
||||
return { bucket, host: `${bucket}.cos.${region}.myqcloud.com` }
|
||||
}
|
||||
credential(userId: string, fileName: string) {
|
||||
const { bucket, host } = this.endpoint()
|
||||
const extension = this.resolveExtension(fileName)
|
||||
const key = `progress/${userId}/${randomUUID()}.${extension}`
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 300
|
||||
return { key, uploadUrl: `https://${host}`, expiresAt, formData: this.buildPostPolicy({ bucket, key, expiresAt, privateRead: true }) }
|
||||
}
|
||||
signedUrl(key: string, method = 'get') {
|
||||
const { host } = this.endpoint()
|
||||
const keyTime = `${Math.floor(Date.now() / 1000) - 5};${Math.floor(Date.now() / 1000) + 60}`
|
||||
const signKey = createHmac('sha1', this.getRequiredConfig('COS_SECRET_KEY')).update(keyTime).digest('hex')
|
||||
const http = `${method}\n/${key}\n\nhost=${encodeURIComponent(host)}\n`
|
||||
const toSign = `sha1\n${keyTime}\n${createHash('sha1').update(http).digest('hex')}\n`
|
||||
const signature = createHmac('sha1', signKey).update(toSign).digest('hex')
|
||||
const query = new URLSearchParams({ 'q-sign-algorithm': 'sha1', 'q-ak': this.getRequiredConfig('COS_SECRET_ID'), 'q-sign-time': keyTime, 'q-key-time': keyTime, 'q-header-list': 'host', 'q-url-param-list': '', 'q-signature': signature })
|
||||
return `https://${host}/${key}?${query}`
|
||||
}
|
||||
async verify(key: string) {
|
||||
const response = await fetch(this.signedUrl(key, 'head'), { method: 'HEAD', signal: AbortSignal.timeout(10000) })
|
||||
if (!response.ok) throw new ServiceUnavailableException('图片尚未上传成功,请重试')
|
||||
const rawSize = response.headers.get('content-length')
|
||||
if (rawSize != null && rawSize !== '') {
|
||||
const size = Number(rawSize)
|
||||
if (!Number.isFinite(size) || size <= 0 || size > 10 * 1024 * 1024) throw new BadRequestException('图片大小无效')
|
||||
}
|
||||
const type = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
||||
if (type && !type.startsWith('image/')) throw new BadRequestException('仅支持图片文件')
|
||||
}
|
||||
async removeObject(key: string) {
|
||||
const response = await fetch(this.signedUrl(key, 'delete'), { method: 'DELETE', signal: AbortSignal.timeout(10000) })
|
||||
if (!response.ok && response.status !== 404) throw new ServiceUnavailableException('照片删除失败,请重试')
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,17 @@ function stringifyDebugPayload(payload: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClassReviewSubscribeData(input: { studioName: string | null; date: Date; startTime: string }) {
|
||||
const calendar = input.date.toISOString().slice(0, 10)
|
||||
const [year, month, day] = calendar.split('-')
|
||||
return {
|
||||
thing1: { value: (input.studioName || '普拉提私教').slice(0, 20) },
|
||||
thing2: { value: 'Iris' },
|
||||
time3: { value: `${year}年${month}月${day}日 ${input.startTime.slice(0, 5)}` },
|
||||
thing4: { value: '欢迎留下这节课的感受' },
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionMessageService {
|
||||
private readonly logger = new Logger(SubscriptionMessageService.name)
|
||||
@@ -221,6 +232,32 @@ export class SubscriptionMessageService {
|
||||
return true
|
||||
}
|
||||
|
||||
async sendReviewReminder(userId: string, openid: string, bookingId: string): Promise<boolean> {
|
||||
const templateId = this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', '')
|
||||
if (!templateId) return false
|
||||
const booking = await this.prisma.booking.findFirst({
|
||||
where: { id: bookingId, userId, status: 'COMPLETED' },
|
||||
include: { timeSlot: { select: { date: true, startTime: true } } },
|
||||
})
|
||||
if (!booking) return false
|
||||
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
|
||||
const data = buildClassReviewSubscribeData({ studioName: studio?.name ?? null, date: booking.timeSlot.date, startTime: booking.timeSlot.startTime })
|
||||
const consent = await this.prisma.subscriptionMessageConsent.findUnique({ where: { userId_templateId_scene: { userId, templateId, scene: SubscriptionMessageScene.CLASS_REVIEW } } })
|
||||
if (!consent || consent.sentCount >= consent.acceptCount) return false
|
||||
// Reserve quota before external I/O. Ambiguous network outcomes must not cause duplicate sends.
|
||||
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({ where: { id: consent.id, sentCount: consent.sentCount, acceptCount: { gt: consent.sentCount } }, data: { sentCount: { increment: 1 }, lastSentAt: new Date() } })
|
||||
if (!claimed.count) return false
|
||||
const token = await this.getAccessToken()
|
||||
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ touser: openid, template_id: templateId, page: `pages/booking/detail?id=${bookingId}`, data }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!response.ok) throw new Error('评价提醒发送结果未知')
|
||||
const result = await response.json() as WechatSubscribeSendResponse
|
||||
return !result.errcode
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
const now = Date.now()
|
||||
if (this.accessTokenCache && this.accessTokenCache.expireAt > now) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { MemberProgressController, AdminMemberProgressController } from './member-progress.controller'
|
||||
import { MemberProgressService } from './member-progress.service'
|
||||
import { ProgressPhotoStorageService } from './progress-photo-storage.service'
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { AuthModule } from '../auth/auth.module'
|
||||
@@ -10,8 +13,8 @@ import { LessonSupplementController } from './lesson-supplement.controller'
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ConfigModule],
|
||||
controllers: [UserController, LessonSupplementController],
|
||||
providers: [LessonSupplementService, UserService, SubscriptionMessageService],
|
||||
controllers: [MemberProgressController, AdminMemberProgressController, UserController, LessonSupplementController],
|
||||
providers: [MemberProgressService, ProgressPhotoStorageService, LessonSupplementService, UserService, SubscriptionMessageService],
|
||||
exports: [UserService, SubscriptionMessageService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -96,6 +96,7 @@ export class UserService {
|
||||
|
||||
private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig {
|
||||
const templates = [
|
||||
{ templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
|
||||
{
|
||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||
|
||||
@@ -68,6 +68,7 @@ export enum InviteReferralStatus {
|
||||
|
||||
// ===== Subscribe Message =====
|
||||
export enum SubscriptionMessageScene {
|
||||
CLASS_REVIEW = 'CLASS_REVIEW',
|
||||
ORDER_PAID = 'ORDER_PAID',
|
||||
BOOKING_CREATED = 'BOOKING_CREATED',
|
||||
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
|
||||
|
||||
@@ -123,3 +123,5 @@ export type {
|
||||
} from './types/index'
|
||||
|
||||
export { FlashSalePhase } from './types/index'
|
||||
|
||||
export * from './types/member-care'
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Booking {
|
||||
}
|
||||
|
||||
export interface BookingWithDetails extends Booking {
|
||||
readonly review?: { rating: number } | null
|
||||
readonly timeSlot: {
|
||||
readonly date: string
|
||||
readonly startTime: string
|
||||
|
||||
23
packages/shared/src/types/member-care.ts
Normal file
23
packages/shared/src/types/member-care.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const REVIEW_TAGS = ['动作到位', '氛围好', '强度合适', '讲解清晰', '耐心细致', '收获满满'] as const
|
||||
export interface ClassReview {
|
||||
id: string; bookingId: string; rating: number; recommendation: number | null; tags: string[]; comment: string; createdAt: string
|
||||
}
|
||||
export interface ReviewEntry extends ClassReview {
|
||||
booking: { userId: string; user: { nickname: string }; timeSlot: { date: string; startTime: string; endTime: string } }
|
||||
}
|
||||
export interface ReviewSummary {
|
||||
month: string; count: number; average: number | null; nps: number | null; npsCount: number
|
||||
}
|
||||
export interface BodyMetricRecord {
|
||||
id: string; recordedAt: string; weight: number | null; bodyFat: number | null; waist: number | null; hip: number | null; flexibility: number | null; remark: string
|
||||
}
|
||||
export interface MemberNoteRecord {
|
||||
id: string; content: string; shared: boolean; createdAt: string; bookingId: string | null
|
||||
booking: { timeSlot: { date: string; startTime: string } } | null
|
||||
}
|
||||
export interface ProgressPhotoRecord {
|
||||
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null
|
||||
}
|
||||
export interface ProgressArchive {
|
||||
nickname: string; completedCount: number; milestones: number[]; metrics: BodyMetricRecord[]; notes: MemberNoteRecord[]; photos: ProgressPhotoRecord[]
|
||||
}
|
||||
Reference in New Issue
Block a user