增加广播栏
This commit is contained in:
1 parent
85b0e70907
commit
d8786eb28a
21 files changed
+511
-54
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: strict-scope
|
||||||
|
description: 严格限定改动范围:只做用户明确要求的事;涉及数据库、服务端配置、数据行的改动,以及需要在多个方案里做选择时,先停下来说给用户、由用户决策,不自行决定。本项目(叶雷讲数学)内的一切开发与运维操作都适用。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 严格限定改动范围
|
||||||
|
|
||||||
|
## 三条硬规则
|
||||||
|
|
||||||
|
1. **只做被明确要求的事。** 要求是 A,就只做 A。不做"顺手一起弄好"的额外改动。
|
||||||
|
2. **需要改动数据库配置 / 数据行 / 线上状态时,先说给用户,由用户决定。** 觉得"这里应该改一下"不是动手的理由,是提问的理由。
|
||||||
|
3. **需要做选择时先问。** 有多个方案、或某个字段该填什么值、某条数据算不算"正确状态"——这些都不是我可以替用户定的。
|
||||||
|
|
||||||
|
## 属于「先说再做」的范围
|
||||||
|
|
||||||
|
| 类别 | 具体 |
|
||||||
|
|---|---|
|
||||||
|
| 服务端配置 | `.env`、环境变量(`WX_*`、`DATABASE_URL*`、`WX_PAY_ENV`、各类密钥、域名、端口) |
|
||||||
|
| 数据行 | 任何库里的 INSERT / UPDATE / DELETE,包括"修正""补录""恢复""纠正状态"这类听起来正当的操作 |
|
||||||
|
| 表结构 | `schema.prisma`、迁移文件、给某个库补迁移 |
|
||||||
|
| 线上状态 | 部署、重启进程、切换小程序数据源、清理线上数据 |
|
||||||
|
|
||||||
|
提出时把三件事说清楚:**发现了什么 → 建议怎么改 → 影响范围**,然后等回复。
|
||||||
|
|
||||||
|
## 不属于限制范围
|
||||||
|
|
||||||
|
- 用户明确要求过的改动,照做,不要在同一个任务里反复确认。
|
||||||
|
- 只读操作不受限:查数据、看日志、比对记录、跑诊断。
|
||||||
|
- 用户已经授权的那次操作,做完即可。
|
||||||
|
|
||||||
|
## 本项目真实踩过的坑
|
||||||
|
|
||||||
|
1. 用户说「删课程」,我执行了当时写的连带删除逻辑,把课程下 55 个课时一起删了,开发库数据丢失。
|
||||||
|
→ 要求是删课程,就该只删课程那一行;其余的要问。
|
||||||
|
2. 恢复那条被误删的数据时,我自作主张给 `status` 字段填了 0。
|
||||||
|
→ 用户的原话:「万一是我手动改的呢」。恢复数据必须原样,拿不准的值要问。
|
||||||
|
3. 发现开发库缺 4 个迁移,我自己判断后直接补上了。
|
||||||
|
→ 虽然是修复,但那是改表结构,应该先说。
|
||||||
|
4. 改动 `.env` 里的 `WX_PAY_ENV` 并重启服务。
|
||||||
|
→ 结论是对的,但涉及线上配置,应当先说明再动。
|
||||||
|
|
||||||
|
## 出问题时的处理
|
||||||
|
|
||||||
|
发现自己越界了:直接承认、说清改了什么、给出恢复方案,不要辩解,也不要顺手再改点别的来"补救"。
|
||||||
@@ -8,6 +8,22 @@ const updateManager = wx.getUpdateManager();
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
/** 广播文字默认色(红),配置里没填或填得不像颜色时用它 */
|
||||||
|
const DEFAULT_BROADCAST_COLOR = '#e60012';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只认安全的颜色写法,其余一律回退默认色。
|
||||||
|
* 后台是文本框,填错一个字符不该把首页样式搞坏,也不该让这个值变成注入点。
|
||||||
|
*/
|
||||||
|
function safeColor(value) {
|
||||||
|
const v = String(value || '').trim();
|
||||||
|
if (/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$|^#[0-9a-fA-F]{8}$/.test(v)) return v;
|
||||||
|
if (/^rgba?\([\d\s.,%]+\)$/.test(v)) return v;
|
||||||
|
if (/^hsla?\([\d\s.,%]+\)$/.test(v)) return v;
|
||||||
|
if (/^[a-zA-Z]{3,20}$/.test(v)) return v; // red / orange / tomato ...
|
||||||
|
return DEFAULT_BROADCAST_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
staticBase: config.STATIC_BASE_URL,
|
staticBase: config.STATIC_BASE_URL,
|
||||||
@@ -23,6 +39,12 @@ Page({
|
|||||||
title2: '',
|
title2: '',
|
||||||
title3: '',
|
title3: '',
|
||||||
footerText: '',
|
footerText: '',
|
||||||
|
/** 首页信息广播文案,空则不显示整条 */
|
||||||
|
broadcast: '',
|
||||||
|
/** 文案超出宽度时才横向滚动 */
|
||||||
|
broadcastScroll: false,
|
||||||
|
/** 广播文字颜色,取自 home.broadcast_color */
|
||||||
|
broadcastColor: DEFAULT_BROADCAST_COLOR,
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -32,11 +54,32 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
applyConfig() {
|
applyConfig() {
|
||||||
this.setData({
|
this.setData(
|
||||||
|
{
|
||||||
title1: store.cfg('home.title.paid'),
|
title1: store.cfg('home.title.paid'),
|
||||||
title2: store.cfg('home.title.trial'),
|
title2: store.cfg('home.title.trial'),
|
||||||
title3: store.cfg('home.title.public'),
|
title3: store.cfg('home.title.public'),
|
||||||
footerText: store.cfg('home.footer_text'),
|
footerText: store.cfg('home.footer_text'),
|
||||||
|
broadcast: store.cfg('home.broadcast') || '',
|
||||||
|
broadcastColor: safeColor(store.cfg('home.broadcast_color')),
|
||||||
|
broadcastScroll: false,
|
||||||
|
},
|
||||||
|
// 等渲染完再量宽度:只有真放不下才让它滚动,短文案就静静居中
|
||||||
|
() => this.measureBroadcast(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 量一下广播文字有没有超出容器,超了才加滚动动画。量不到就当不超。 */
|
||||||
|
measureBroadcast() {
|
||||||
|
if (!this.data.broadcast) return;
|
||||||
|
const query = wx.createSelectorQuery().in(this);
|
||||||
|
query.select('.broadcast-body').boundingClientRect();
|
||||||
|
query.select('.broadcast-text').boundingClientRect();
|
||||||
|
query.exec((res) => {
|
||||||
|
const box = res && res[0];
|
||||||
|
const text = res && res[1];
|
||||||
|
if (!box || !text) return;
|
||||||
|
if (text.width > box.width) this.setData({ broadcastScroll: true });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<!--index.wxml-->
|
<!--index.wxml-->
|
||||||
|
|
||||||
<scroll-view class="container" scroll-y enable-flex type="custom">
|
<scroll-view class="container" scroll-y enable-flex type="custom">
|
||||||
|
<!-- 信息广播:文案取自站点配置 home.broadcast,空则整条不显示。
|
||||||
|
文字放得下就静态居中,放不下就横向滚动(避免长通知被截断看不到)。 -->
|
||||||
|
<view class="broadcast" wx:if="{{broadcast}}">
|
||||||
|
<text class="broadcast-icon">📢</text>
|
||||||
|
<view class="broadcast-body">
|
||||||
|
<view class="broadcast-track {{broadcastScroll ? 'broadcast-scroll' : ''}}" style="color: {{broadcastColor}}">
|
||||||
|
<text class="broadcast-text">{{broadcast}}</text>
|
||||||
|
<text class="broadcast-text broadcast-clone" wx:if="{{broadcastScroll}}">{{broadcast}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="banner">
|
<view class="banner">
|
||||||
<swiper class="bannerswiper" autoplay interval="20000" circular indicator-dots indicator-color="rgba(255, 255, 255, .3)" indicator-active-color="rgba(255, 255, 255, 1.0)" indicator-type="normal" duration="1000" easing-function="easeOutCubic">
|
<swiper class="bannerswiper" autoplay interval="20000" circular indicator-dots indicator-color="rgba(255, 255, 255, .3)" indicator-active-color="rgba(255, 255, 255, 1.0)" indicator-type="normal" duration="1000" easing-function="easeOutCubic">
|
||||||
<swiper-item wx:for="{{banners}}" wx:for-item="item" wx:key="id" class="bannerswiperitem">
|
<swiper-item wx:for="{{banners}}" wx:for-item="item" wx:key="id" class="bannerswiperitem">
|
||||||
@@ -57,4 +69,3 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
@@ -147,3 +147,64 @@ page {
|
|||||||
top: 0;
|
top: 0;
|
||||||
width: 370rpx;
|
width: 370rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 信息广播(首页最上方,文案为空则整条不渲染)─────────────────
|
||||||
|
文案放得下就静态居中;放不下加 .broadcast-scroll 横向滚动,
|
||||||
|
并把同一段文字渲染两遍首尾相接,滚动到 -50% 时正好接上,看不出循环接缝。 */
|
||||||
|
.broadcast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 710rpx;
|
||||||
|
margin: 12rpx auto 0;
|
||||||
|
padding: 14rpx 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fff7e6;
|
||||||
|
border: 1rpx solid #ffe3a3;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-icon {
|
||||||
|
flex: none;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.broadcast-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 静态:居中显示,实在超了也留个省略号兜底 */
|
||||||
|
.broadcast-track {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #e60012; /* 兜底色:真实颜色由 home.broadcast_color 以内联样式覆盖 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动:两段文字并排,整体左移 50% 后无缝重来 */
|
||||||
|
.broadcast-scroll {
|
||||||
|
display: inline-flex;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
animation: broadcast-marquee 18s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 两段文字各带一个等宽右边距,整条宽度 = 2 ×(文字+间距),
|
||||||
|
所以左移 50% 正好是一段,循环无缝 */
|
||||||
|
.broadcast-text {
|
||||||
|
margin-right: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes broadcast-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ interface Stats {
|
|||||||
revenueFen: number;
|
revenueFen: number;
|
||||||
payEnabled: boolean;
|
payEnabled: boolean;
|
||||||
payEnv: number;
|
payEnv: number;
|
||||||
|
payOfferId: string;
|
||||||
|
/** 管理端当前查看的库 */
|
||||||
|
dataEnv: string;
|
||||||
|
/** 线上小程序当前读的库 */
|
||||||
|
miniProgramEnv: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@@ -45,11 +50,41 @@ export default function DashboardPage() {
|
|||||||
<Metric label="已退款" value={String(stats.refunded)} />
|
<Metric label="已退款" value={String(stats.refunded)} />
|
||||||
<Metric label="成交金额(元)" value={yuan(stats.revenueFen)} />
|
<Metric label="成交金额(元)" value={yuan(stats.revenueFen)} />
|
||||||
<Metric
|
<Metric
|
||||||
label="支付能力"
|
label="支付能力(当前查看的库)"
|
||||||
value={stats.payEnabled ? (stats.payEnv === 1 ? '已配置 · 沙箱' : '已配置 · 现网') : '未配置'}
|
value={stats.payEnabled ? (stats.payEnv === 1 ? '已配置 · 沙箱' : '已配置 · 现网') : '未配置'}
|
||||||
/>
|
/>
|
||||||
|
<Metric label="支付 OfferID" value={stats.payOfferId || '—'} />
|
||||||
|
<Metric
|
||||||
|
label="小程序当前读的库"
|
||||||
|
value={stats.miniProgramEnv === 'develop' ? '开发库' : '生产库'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
支付参数跟着数据源走:切到开发库用的是沙箱那一套,不会误扣真钱。
|
||||||
|
这里把当前生效的组合摊开,避免「以为在测试、其实在收钱」。
|
||||||
|
*/}
|
||||||
|
<Card title="当前支付配置">
|
||||||
|
<ul className="space-y-1 text-sm text-gray-600">
|
||||||
|
<li>
|
||||||
|
正在查看:<span className="font-medium">{stats.dataEnv === 'develop' ? '开发库' : '生产库'}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
支付环境:
|
||||||
|
<span className={stats.payEnv === 1 ? 'font-medium text-amber-600' : 'font-medium text-green-600'}>
|
||||||
|
{stats.payEnv === 1 ? '沙箱(不产生真实扣款)' : '现网(真实收款)'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
OfferID:<span className="font-mono">{stats.payOfferId || '—'}</span>
|
||||||
|
</li>
|
||||||
|
<li className="pt-1 text-xs text-gray-500">
|
||||||
|
小程序读的是「{stats.miniProgramEnv === 'develop' ? '开发库' : '生产库'}」,下单就用那个库对应的这套配置。
|
||||||
|
开发库默认走沙箱;生产库的环境由服务端 WX_PAY_ENV 决定。
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{!stats.payEnabled && (
|
{!stats.payEnabled && (
|
||||||
<Card title="支付尚未启用">
|
<Card title="支付尚未启用">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ interface SettingRow {
|
|||||||
remark: string | null;
|
remark: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染成开关(而不是文本框)的配置项。
|
||||||
|
*
|
||||||
|
* 约定:值 '1' = 开,其余(含库里没有这一行)= 关。所以默认就是关,不用先往库里插数据。
|
||||||
|
* key 要和服务端 src/common/site-flags.ts 里的一致。
|
||||||
|
*/
|
||||||
|
const SWITCHES = [
|
||||||
|
{
|
||||||
|
key: 'develop.show_owned_courses',
|
||||||
|
label: '使用开发库时,显示已购买课程',
|
||||||
|
hint: '关(默认):小程序读开发库时,课程不显示「已购」状态,也不返回已购课程列表。开:按实际权益正常显示。生产库不受这个开关影响。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [rows, setRows] = useState<SettingRow[]>([]);
|
const [rows, setRows] = useState<SettingRow[]>([]);
|
||||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
@@ -49,6 +63,28 @@ export default function SettingsPage() {
|
|||||||
.finally(() => setSavingKey(''));
|
.finally(() => setSavingKey(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isOn = (key: string) => (draft[key] ?? '') === '1';
|
||||||
|
|
||||||
|
/** 开关一点即存,不用再点「保存」。 */
|
||||||
|
const toggle = (key: string, label: string) => {
|
||||||
|
const next = isOn(key) ? '0' : '1';
|
||||||
|
setDraft({ ...draft, [key]: next });
|
||||||
|
setSavingKey(key);
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
api
|
||||||
|
.upsertSetting(key, next, label)
|
||||||
|
.then(() => {
|
||||||
|
setNotice(`已保存:${label} → ${next === '1' ? '开' : '关'}`);
|
||||||
|
load();
|
||||||
|
})
|
||||||
|
.catch((err: { message: string }) => setError(err.message))
|
||||||
|
.finally(() => setSavingKey(''));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开关项已经在上面单独渲染,别再以文本框的形式出现一次
|
||||||
|
const textRows = rows.filter((r) => !SWITCHES.some((s) => s.key === r.key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-semibold text-gray-900">系统配置</h1>
|
<h1 className="text-lg font-semibold text-gray-900">系统配置</h1>
|
||||||
@@ -59,8 +95,45 @@ export default function SettingsPage() {
|
|||||||
<p className="mb-4 text-xs text-gray-500">
|
<p className="mb-4 text-xs text-gray-500">
|
||||||
小程序首屏显示的标题、我的页面菜单文案、版本号等都在这里。修改后小程序下拉刷新即可生效。
|
小程序首屏显示的标题、我的页面菜单文案、版本号等都在这里。修改后小程序下拉刷新即可生效。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-5 space-y-4 border-b border-gray-100 pb-5">
|
||||||
|
{SWITCHES.map((s) => {
|
||||||
|
const on = isOn(s.key);
|
||||||
|
return (
|
||||||
|
<div key={s.key} className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
disabled={savingKey === s.key}
|
||||||
|
onClick={() => toggle(s.key, s.label)}
|
||||||
|
className={`mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors ${
|
||||||
|
on ? 'bg-green-600' : 'bg-gray-300'
|
||||||
|
} ${savingKey === s.key ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||||||
|
on ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
{s.label}
|
||||||
|
<span className={`ml-2 text-xs ${on ? 'text-green-600' : 'text-gray-400'}`}>
|
||||||
|
{savingKey === s.key ? '保存中…' : on ? '已开启' : '已关闭'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-gray-500">{s.hint}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-xs text-gray-400">{s.key}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rows.map((row) => (
|
{textRows.map((row) => (
|
||||||
<div key={row.key} className="flex items-center gap-3">
|
<div key={row.key} className="flex items-center gap-3">
|
||||||
<div className="w-56 shrink-0">
|
<div className="w-56 shrink-0">
|
||||||
<div className="text-sm text-gray-700">{row.remark || row.key}</div>
|
<div className="text-sm text-gray-700">{row.remark || row.key}</div>
|
||||||
@@ -77,10 +150,11 @@ export default function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 && <div className="py-6 text-center text-sm text-gray-400">暂无配置</div>}
|
{textRows.length === 0 && (
|
||||||
|
<div className="py-6 text-center text-sm text-gray-400">暂无配置</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +104,8 @@ export const api = {
|
|||||||
payEnabled: boolean;
|
payEnabled: boolean;
|
||||||
/** 0 现网 / 1 沙箱 */
|
/** 0 现网 / 1 沙箱 */
|
||||||
payEnv: number;
|
payEnv: number;
|
||||||
|
/** 当前数据源使用的虚拟支付 OfferID */
|
||||||
|
payOfferId: string;
|
||||||
/** 当前数据源 */
|
/** 当前数据源 */
|
||||||
dataEnv: string;
|
dataEnv: string;
|
||||||
/** 服务端是否配置了开发库 */
|
/** 服务端是否配置了开发库 */
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||||
@@ -54,3 +54,10 @@ WX_PAY_APPKEY=""
|
|||||||
WX_PAY_APPKEY_SANDBOX=""
|
WX_PAY_APPKEY_SANDBOX=""
|
||||||
# 0 现网 / 1 沙箱
|
# 0 现网 / 1 沙箱
|
||||||
WX_PAY_ENV=0
|
WX_PAY_ENV=0
|
||||||
|
|
||||||
|
# 开发库(后台把小程序数据源切到 develop 时)用的支付配置
|
||||||
|
# 留空时 = 同一 OfferID + 沙箱 AppKey + 沙箱环境,开发环境天然不收真钱
|
||||||
|
WX_OFFER_ID_DEVELOP=""
|
||||||
|
WX_PAY_APPKEY_DEVELOP=""
|
||||||
|
# 0 现网 / 1 沙箱
|
||||||
|
WX_PAY_ENV_DEVELOP=1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- settings.value 从 VARCHAR(255) 改成 TEXT。
|
||||||
|
-- 起因:首页广播的文案长度不固定,255 个字符不够放。
|
||||||
|
-- 只放宽长度,不改变可空性,已有数据原样保留。
|
||||||
|
ALTER TABLE `settings` MODIFY `value` TEXT NULL;
|
||||||
@@ -201,7 +201,8 @@ model CourseTip {
|
|||||||
model Setting {
|
model Setting {
|
||||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||||
key String @unique @db.VarChar(64)
|
key String @unique @db.VarChar(64)
|
||||||
value String? @db.VarChar(255)
|
/// TEXT:首页广播这类长文案要放进来,255 不够用
|
||||||
|
value String? @db.Text
|
||||||
remark String? @db.VarChar(255)
|
remark String? @db.VarChar(255)
|
||||||
|
|
||||||
@@map("settings")
|
@@map("settings")
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ export function getRequestId(req: Request): string {
|
|||||||
return (req as Request & { requestId?: string }).requestId ?? randomUUID();
|
return (req as Request & { requestId?: string }).requestId ?? randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取 Prisma 的错误码(形如 P2003)。
|
||||||
|
* 不 import Prisma 的错误类,避免为一次判断把整包类型拉进来。
|
||||||
|
*/
|
||||||
|
function prismaErrorCode(error: unknown): string | null {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error) {
|
||||||
|
const value = (error as { code?: unknown }).code;
|
||||||
|
if (typeof value === 'string' && /^P\d{4}$/.test(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为每个请求分配 requestId,并统一包装成功响应。
|
* 为每个请求分配 requestId,并统一包装成功响应。
|
||||||
*
|
*
|
||||||
@@ -79,6 +91,28 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
: ((body as { message?: string | string[] }).message as string) ?? exception.message;
|
: ((body as { message?: string | string[] }).message as string) ?? exception.message;
|
||||||
if (Array.isArray(message)) message = message.join(';');
|
if (Array.isArray(message)) message = message.join(';');
|
||||||
code = status === 401 ? ErrorCode.UNAUTHORIZED : status === 403 ? ErrorCode.FORBIDDEN : ErrorCode.BAD_REQUEST;
|
code = status === 401 ? ErrorCode.UNAUTHORIZED : status === 403 ? ErrorCode.FORBIDDEN : ErrorCode.BAD_REQUEST;
|
||||||
|
} else if (prismaErrorCode(exception)) {
|
||||||
|
// Prisma 的原始报错又长又难懂(还常被截断),翻成人话再返回。
|
||||||
|
// 最常见的是删除被外键挡住——以前这里直接返回 500「服务端异常」,看起来就像"点了没反应"。
|
||||||
|
const prismaCode = prismaErrorCode(exception)!;
|
||||||
|
if (prismaCode === 'P2003') {
|
||||||
|
status = 409;
|
||||||
|
code = ErrorCode.BAD_REQUEST;
|
||||||
|
message = '该记录被其他数据引用,不能直接删除。请先清理关联数据,或改用「停用」。';
|
||||||
|
} else if (prismaCode === 'P2025') {
|
||||||
|
status = 404;
|
||||||
|
code = ErrorCode.BAD_REQUEST;
|
||||||
|
message = '记录不存在,可能已被删除';
|
||||||
|
} else if (prismaCode === 'P2002') {
|
||||||
|
status = 409;
|
||||||
|
code = ErrorCode.BAD_REQUEST;
|
||||||
|
message = '该值已存在,请换一个';
|
||||||
|
}
|
||||||
|
this.logger.warn(
|
||||||
|
`数据库约束错误 [${requestId}] ${req.method} ${req.url}: ${prismaCode} ${
|
||||||
|
exception instanceof Error ? exception.message.split('\n')[0] : ''
|
||||||
|
}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`未处理异常 [${requestId}] ${req.method} ${req.url}: ${
|
`未处理异常 [${requestId}] ${req.method} ${req.url}: ${
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点开关(存在 settings 表里的 key/value)。
|
||||||
|
*
|
||||||
|
* 规定:**只在数据库里存"开"的值**。没这行、或值不是 '1',一律按关处理,
|
||||||
|
* 所以新增开关不需要往库里插数据,默认值天然就是关闭。
|
||||||
|
*/
|
||||||
|
export const SITE_FLAG_KEYS = {
|
||||||
|
/** 使用开发库时是否显示已购课程。开发库里带着老数据,容易让人误以为自己买过课。 */
|
||||||
|
showOwnedOnDevelop: 'develop.show_owned_courses',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SiteFlagKey = (typeof SITE_FLAG_KEYS)[keyof typeof SITE_FLAG_KEYS];
|
||||||
|
|
||||||
|
/** 读一个布尔开关,缺省为 false。 */
|
||||||
|
export async function readFlag(prisma: PrismaService, key: SiteFlagKey): Promise<boolean> {
|
||||||
|
const row = await prisma.active.setting.findUnique({ where: { key } });
|
||||||
|
return row?.value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「已购课程」现在该不该对外显示。
|
||||||
|
*
|
||||||
|
* 只在**小程序读开发库**时受开关约束——生产库永远正常显示,免得开关被误开影响真实用户。
|
||||||
|
*/
|
||||||
|
export async function showOwnedCourses(prisma: PrismaService): Promise<boolean> {
|
||||||
|
if (prisma.activeEnv !== 'develop') return true;
|
||||||
|
return readFlag(prisma, SITE_FLAG_KEYS.showOwnedOnDevelop);
|
||||||
|
}
|
||||||
@@ -55,6 +55,18 @@ const envSchema = z.object({
|
|||||||
/** 虚拟支付环境:0 现网 / 1 沙箱。切换后需重启服务端生效。 */
|
/** 虚拟支付环境:0 现网 / 1 沙箱。切换后需重启服务端生效。 */
|
||||||
WX_PAY_ENV: z.coerce.number().int().min(0).max(1).default(0),
|
WX_PAY_ENV: z.coerce.number().int().min(0).max(1).default(0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发库(数据源开关切到 develop 时)用的支付配置。
|
||||||
|
*
|
||||||
|
* 为什么要分开:小程序的数据源可以切到开发库,但支付配置以前是全局的,
|
||||||
|
* 切了库仍然用现网 OfferID 和现网密钥下单——那是在真收钱。
|
||||||
|
* 留空时按「同一 OfferID + 沙箱 AppKey + 沙箱环境」处理,开发环境天然不收真钱。
|
||||||
|
*/
|
||||||
|
WX_OFFER_ID_DEVELOP: z.string().default(''),
|
||||||
|
WX_PAY_APPKEY_DEVELOP: z.string().default(''),
|
||||||
|
/** 开发库的支付环境:0 现网 / 1 沙箱。默认沙箱。 */
|
||||||
|
WX_PAY_ENV_DEVELOP: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
|
|
||||||
/** 播放地址签名有效期(秒) */
|
/** 播放地址签名有效期(秒) */
|
||||||
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
||||||
|
|
||||||
|
|||||||
@@ -118,8 +118,10 @@ export class AdminService {
|
|||||||
delivered,
|
delivered,
|
||||||
refunded,
|
refunded,
|
||||||
revenueFen: revenue._sum.amount_fen ?? 0,
|
revenueFen: revenue._sum.amount_fen ?? 0,
|
||||||
payEnabled: this.wxpay.enabled,
|
// 支付配置跟着数据源走:切到开发库显示的就是开发那套(默认沙箱)
|
||||||
payEnv: this.wxpay.env,
|
payEnabled: this.wxpay.enabledFor(env),
|
||||||
|
payEnv: this.wxpay.configFor(env).env,
|
||||||
|
payOfferId: this.wxpay.configFor(env).offerId,
|
||||||
/** 管理端当前正在查看的库 */
|
/** 管理端当前正在查看的库 */
|
||||||
dataEnv: env,
|
dataEnv: env,
|
||||||
/** 线上小程序当前读的库(由独立开关控制,与 dataEnv 无关) */
|
/** 线上小程序当前读的库(由独立开关控制,与 dataEnv 无关) */
|
||||||
@@ -223,7 +225,40 @@ export class AdminService {
|
|||||||
async deleteResource(env: DataSourceEnv, name: string, id: number): Promise<{ deleted: true }> {
|
async deleteResource(env: DataSourceEnv, name: string, id: number): Promise<{ deleted: true }> {
|
||||||
const def = RESOURCES[name];
|
const def = RESOURCES[name];
|
||||||
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
|
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
|
||||||
await this.delegate(this.prisma.clientFor(env), def.delegate).delete({ where: { id } });
|
const db = this.prisma.clientFor(env);
|
||||||
|
|
||||||
|
// 课程不连带删除任何东西——课时是实打实的内容,删了不好恢复,要删得自己去「课时」列表里删。
|
||||||
|
// 订单和权益是交易记录,更加不能跟着删。
|
||||||
|
if (name === 'courses') {
|
||||||
|
const [lessons, products, orders, entitlements] = await Promise.all([
|
||||||
|
db.lesson.count({ where: { course_id: id } }),
|
||||||
|
db.product.count({ where: { course_id: id } }),
|
||||||
|
db.order.count({ where: { course_id: id } }),
|
||||||
|
db.entitlement.count({ where: { course_id: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const content: string[] = [];
|
||||||
|
if (lessons > 0) content.push(`${lessons} 个课时`);
|
||||||
|
if (products > 0) content.push(`${products} 个支付商品`);
|
||||||
|
if (content.length > 0) {
|
||||||
|
throw AppError.conflict(
|
||||||
|
`该课程下还有 ${content.join('、')},不会被连带删除。` +
|
||||||
|
'请先到对应的列表里删掉它们,再回来删课程。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orders > 0 || entitlements > 0) {
|
||||||
|
throw AppError.conflict(
|
||||||
|
`该课程下还有 ${orders} 笔订单、${entitlements} 条权益,属于交易记录,不能删除。` +
|
||||||
|
'请把课程状态改成「停用」把它下架。',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.course.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delegate(db, def.delegate).delete({ where: { id } });
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,10 +452,11 @@ export class AdminService {
|
|||||||
if (!order) throw AppError.notFound('订单不存在');
|
if (!order) throw AppError.notFound('订单不存在');
|
||||||
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
|
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
|
||||||
|
|
||||||
// 沙箱单在微信侧是另一套账,用现网的密钥查不到、也退不了
|
// 订单创建时的环境必须和当前数据源对应的环境一致,否则微信侧是两套账,查不到也退不了
|
||||||
if (order.env !== this.wxpay.env) {
|
const payEnv = this.wxpay.configFor(env).env;
|
||||||
|
if (order.env !== payEnv) {
|
||||||
throw AppError.conflict(
|
throw AppError.conflict(
|
||||||
`该订单是${order.env === 1 ? '沙箱' : '现网'}环境创建的,当前服务端是${this.wxpay.env === 1 ? '沙箱' : '现网'}环境,无法退款`,
|
`该订单是${order.env === 1 ? '沙箱' : '现网'}环境创建的,当前数据源对应${payEnv === 1 ? '沙箱' : '现网'}环境,无法退款`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +468,7 @@ export class AdminService {
|
|||||||
reasonCode,
|
reasonCode,
|
||||||
// 微信只要枚举值,管理员写的文字放进 biz_meta,事后能查回来
|
// 微信只要枚举值,管理员写的文字放进 biz_meta,事后能查回来
|
||||||
bizMeta: JSON.stringify({ reason: reason.slice(0, 200), source: 'admin' }),
|
bizMeta: JSON.stringify({ reason: reason.slice(0, 200), source: 'admin' }),
|
||||||
});
|
}, env);
|
||||||
|
|
||||||
// 退款是异步的:微信只是受理了,钱到账后才会推 xpay_refund_notify 回写「已退款」。
|
// 退款是异步的:微信只是受理了,钱到账后才会推 xpay_refund_notify 回写「已退款」。
|
||||||
// 先记下发起时间,后台据此显示「退款中」,也避免管理员重复点。
|
// 先记下发起时间,后台据此显示「退款中」,也避免管理员重复点。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||||
import { AppError } from '../../common/errors';
|
import { AppError } from '../../common/errors';
|
||||||
|
import { showOwnedCourses } from '../../common/site-flags';
|
||||||
import { staticUrls } from '../../common/urls';
|
import { staticUrls } from '../../common/urls';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
@@ -27,14 +28,16 @@ export class CoursesService {
|
|||||||
|
|
||||||
/** 课程列表。含未购买课程,供小程序做购买入口。 */
|
/** 课程列表。含未购买课程,供小程序做购买入口。 */
|
||||||
async list(userId: number) {
|
async list(userId: number) {
|
||||||
const [courses, owned] = await Promise.all([
|
// 小程序读开发库且开关关闭时,不显示「已购」状态(开发库里的老数据会误导人)
|
||||||
|
const [courses, canShowOwned] = await Promise.all([
|
||||||
this.prisma.active.course.findMany({
|
this.prisma.active.course.findMany({
|
||||||
where: { status: 1 },
|
where: { status: 1 },
|
||||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||||
include: { _count: { select: { lessons: true } } },
|
include: { _count: { select: { lessons: true } } },
|
||||||
}),
|
}),
|
||||||
this.ownedCourseIds(userId),
|
showOwnedCourses(this.prisma),
|
||||||
]);
|
]);
|
||||||
|
const owned = canShowOwned ? await this.ownedCourseIds(userId) : new Set<number>();
|
||||||
|
|
||||||
return courses.map((c) => ({
|
return courses.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -52,9 +55,11 @@ export class CoursesService {
|
|||||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||||
|
|
||||||
const owned = await this.prisma.active.entitlement.findUnique({
|
const owned =
|
||||||
|
(await showOwnedCourses(this.prisma)) &&
|
||||||
|
(await this.prisma.active.entitlement.findUnique({
|
||||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||||
});
|
})) !== null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: course.id,
|
id: course.id,
|
||||||
@@ -63,7 +68,7 @@ export class CoursesService {
|
|||||||
intro: course.intro,
|
intro: course.intro,
|
||||||
priceFen: course.price_fen,
|
priceFen: course.price_fen,
|
||||||
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
|
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
|
||||||
owned: owned !== null,
|
owned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,4 +148,3 @@ export class CoursesService {
|
|||||||
return course;
|
return course;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,10 @@ export class OrdersService {
|
|||||||
quantity = 1,
|
quantity = 1,
|
||||||
terminal: string | null = null,
|
terminal: string | null = null,
|
||||||
): Promise<{ outTradeNo: string; payData: PayData }> {
|
): Promise<{ outTradeNo: string; payData: PayData }> {
|
||||||
if (!this.wxpay.enabled) {
|
// 支付配置跟着「小程序当前数据源」走:切到开发库就用开发那套(默认沙箱),不会误收真钱
|
||||||
|
const source = this.prisma.activeEnv;
|
||||||
|
const pay = this.wxpay.configFor(source);
|
||||||
|
if (!this.wxpay.enabledFor(source)) {
|
||||||
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
|
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
|
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
|
||||||
@@ -76,7 +79,7 @@ export class OrdersService {
|
|||||||
quantity,
|
quantity,
|
||||||
amount_fen: amountFen,
|
amount_fen: amountFen,
|
||||||
status: OrderStatus.CREATED,
|
status: OrderStatus.CREATED,
|
||||||
env: this.wxpay.env,
|
env: pay.env,
|
||||||
terminal,
|
terminal,
|
||||||
attach: `course:${course.code}`,
|
attach: `course:${course.code}`,
|
||||||
},
|
},
|
||||||
@@ -84,9 +87,9 @@ export class OrdersService {
|
|||||||
|
|
||||||
const sessionKey = await this.payments.requireSessionKey(userId);
|
const sessionKey = await this.payments.requireSessionKey(userId);
|
||||||
const signData = JSON.stringify({
|
const signData = JSON.stringify({
|
||||||
offerId: this.wxpay.offerId,
|
offerId: pay.offerId,
|
||||||
buyQuantity: quantity,
|
buyQuantity: quantity,
|
||||||
env: this.wxpay.env,
|
env: pay.env,
|
||||||
currencyType: 'CNY',
|
currencyType: 'CNY',
|
||||||
productId: product.wx_product_id,
|
productId: product.wx_product_id,
|
||||||
goodsPrice: product.price_fen,
|
goodsPrice: product.price_fen,
|
||||||
@@ -99,7 +102,7 @@ export class OrdersService {
|
|||||||
payData: {
|
payData: {
|
||||||
signData,
|
signData,
|
||||||
mode: 'short_series_goods',
|
mode: 'short_series_goods',
|
||||||
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData),
|
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData, pay.appkey),
|
||||||
signature: this.wxpay.calcSignature(signData, sessionKey),
|
signature: this.wxpay.calcSignature(signData, sessionKey),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -289,7 +289,8 @@ export class PaymentsService {
|
|||||||
* 由定时任务每 5 分钟调用一次。
|
* 由定时任务每 5 分钟调用一次。
|
||||||
*/
|
*/
|
||||||
async reconcilePendingOrders(): Promise<number> {
|
async reconcilePendingOrders(): Promise<number> {
|
||||||
if (!this.wxpay.enabled) return 0;
|
const source = this.prisma.activeEnv;
|
||||||
|
if (!this.wxpay.enabledFor(source)) return 0;
|
||||||
|
|
||||||
const pending = await this.prisma.active.order.findMany({
|
const pending = await this.prisma.active.order.findMany({
|
||||||
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
||||||
@@ -301,7 +302,7 @@ export class PaymentsService {
|
|||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
for (const order of pending) {
|
for (const order of pending) {
|
||||||
try {
|
try {
|
||||||
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
|
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid, source);
|
||||||
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
|
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
|
||||||
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
|
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
|
||||||
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
|
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { createHmac } from 'node:crypto';
|
import { createHmac } from 'node:crypto';
|
||||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||||
|
import type { DataSourceEnv } from '../../common/admin-env';
|
||||||
import { AppError } from '../../common/errors';
|
import { AppError } from '../../common/errors';
|
||||||
|
|
||||||
const XPAY_BASE = 'https://api.weixin.qq.com';
|
const XPAY_BASE = 'https://api.weixin.qq.com';
|
||||||
@@ -39,27 +40,37 @@ export class WxPayService {
|
|||||||
|
|
||||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
||||||
|
|
||||||
/** 当前支付环境:0 现网 / 1 沙箱。 */
|
/**
|
||||||
get env(): number {
|
* 取某个数据源用的支付配置。
|
||||||
return this.cfg.WX_PAY_ENV;
|
*
|
||||||
|
* 生产库走现网 AppKey(WX_PAY_ENV 决定现网/沙箱),
|
||||||
|
* 开发库走沙箱 AppKey,OfferID 默认与生产共用——微信本来就只有一个 OfferID,
|
||||||
|
* 沙箱与现网的区别是 AppKey 加 env 值。
|
||||||
|
*/
|
||||||
|
configFor(source: DataSourceEnv): WxPayConfig {
|
||||||
|
if (source === 'develop') {
|
||||||
|
return {
|
||||||
|
offerId: this.cfg.WX_OFFER_ID_DEVELOP || this.cfg.WX_OFFER_ID,
|
||||||
|
appkey: this.cfg.WX_PAY_APPKEY_DEVELOP || this.cfg.WX_PAY_APPKEY_SANDBOX,
|
||||||
|
env: this.cfg.WX_PAY_ENV_DEVELOP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
offerId: this.cfg.WX_OFFER_ID,
|
||||||
|
appkey: this.cfg.WX_PAY_ENV === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY,
|
||||||
|
env: this.cfg.WX_PAY_ENV,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前环境对应的支付密钥。 */
|
/** 该数据源是否配好了支付。 */
|
||||||
private get appkey(): string {
|
enabledFor(source: DataSourceEnv): boolean {
|
||||||
return this.env === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY;
|
const c = this.configFor(source);
|
||||||
}
|
return c.offerId !== '' && c.appkey !== '';
|
||||||
|
|
||||||
get enabled(): boolean {
|
|
||||||
return this.cfg.WX_OFFER_ID !== '' && this.appkey !== '';
|
|
||||||
}
|
|
||||||
|
|
||||||
get offerId(): string {
|
|
||||||
return this.cfg.WX_OFFER_ID;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 服务端身份签名。uri 不带问号参数。 */
|
/** 服务端身份签名。uri 不带问号参数。 */
|
||||||
calcPaySig(uri: string, postBody: string): string {
|
calcPaySig(uri: string, postBody: string, appkey: string): string {
|
||||||
return createHmac('sha256', this.appkey).update(`${uri}&${postBody}`).digest('hex');
|
return createHmac('sha256', appkey).update(`${uri}&${postBody}`).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户态签名。 */
|
/** 用户态签名。 */
|
||||||
@@ -102,20 +113,21 @@ export class WxPayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 拼出带 access_token 与 pay_sig 的接口地址。 */
|
/** 拼出带 access_token 与 pay_sig 的接口地址。 */
|
||||||
private async signedUrl(uri: string, postBody: string): Promise<string> {
|
private async signedUrl(uri: string, postBody: string, cfg: WxPayConfig): Promise<string> {
|
||||||
const token = await this.accessToken();
|
const token = await this.accessToken();
|
||||||
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody)}`;
|
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody, cfg.appkey)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查单。用于发货推送丢失时兜底补发。
|
* 查单。用于发货推送丢失时兜底补发。
|
||||||
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
|
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
|
||||||
*/
|
*/
|
||||||
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
|
async queryOrder(outTradeNo: string, openid: string, source: DataSourceEnv): Promise<XpayQueryResult> {
|
||||||
|
const cfg = this.configFor(source);
|
||||||
const uri = '/xpay/query_order';
|
const uri = '/xpay/query_order';
|
||||||
const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo });
|
const body = JSON.stringify({ openid, env: cfg.env, order_id: outTradeNo });
|
||||||
|
|
||||||
const res = await fetch(await this.signedUrl(uri, body), {
|
const res = await fetch(await this.signedUrl(uri, body, cfg), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
@@ -142,13 +154,14 @@ export class WxPayService {
|
|||||||
reasonCode: string;
|
reasonCode: string;
|
||||||
/** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */
|
/** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */
|
||||||
bizMeta: string;
|
bizMeta: string;
|
||||||
}): Promise<Record<string, unknown>> {
|
}, source: DataSourceEnv): Promise<Record<string, unknown>> {
|
||||||
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
|
const cfg = this.configFor(source);
|
||||||
|
if (!cfg.offerId || !cfg.appkey) throw new AppError(4002, '虚拟支付未配置', 503);
|
||||||
|
|
||||||
// left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。
|
// left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。
|
||||||
// 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来,
|
// 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来,
|
||||||
// 而且把「这单根本不能退」这个真实原因盖掉了。
|
// 而且把「这单根本不能退」这个真实原因盖掉了。
|
||||||
const queried = await this.queryOrder(input.outTradeNo, input.openid);
|
const queried = await this.queryOrder(input.outTradeNo, input.openid, source);
|
||||||
const status = queried.order?.status;
|
const status = queried.order?.status;
|
||||||
const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status);
|
const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status);
|
||||||
const leftFee = queried.order?.left_fee;
|
const leftFee = queried.order?.left_fee;
|
||||||
@@ -183,10 +196,10 @@ export class WxPayService {
|
|||||||
refund_reason: input.reasonCode,
|
refund_reason: input.reasonCode,
|
||||||
/** 1-人工客服退款:后台管理员发起的都属于这一类 */
|
/** 1-人工客服退款:后台管理员发起的都属于这一类 */
|
||||||
req_from: '1',
|
req_from: '1',
|
||||||
env: this.env,
|
env: cfg.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(await this.signedUrl(uri, body), {
|
const res = await fetch(await this.signedUrl(uri, body, cfg), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
@@ -215,3 +228,10 @@ export interface XpayQueryResult {
|
|||||||
status?: number;
|
status?: number;
|
||||||
wx_order_id?: string;
|
wx_order_id?: string;
|
||||||
}
|
}
|
||||||
|
/** 某个数据源实际使用的支付配置。 */
|
||||||
|
export interface WxPayConfig {
|
||||||
|
offerId: string;
|
||||||
|
appkey: string;
|
||||||
|
/** 0 现网 / 1 沙箱 */
|
||||||
|
env: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||||
import { AppError } from '../../common/errors';
|
import { AppError } from '../../common/errors';
|
||||||
|
import { showOwnedCourses } from '../../common/site-flags';
|
||||||
import { staticUrls } from '../../common/urls';
|
import { staticUrls } from '../../common/urls';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@@ -59,6 +60,9 @@ export class UsersService {
|
|||||||
|
|
||||||
/** 我的课程。 */
|
/** 我的课程。 */
|
||||||
async entitlements(userId: number) {
|
async entitlements(userId: number) {
|
||||||
|
// 小程序读开发库且开关关闭时,已购课程列表直接返回空
|
||||||
|
if (!(await showOwnedCourses(this.prisma))) return [];
|
||||||
|
|
||||||
const rows = await this.prisma.active.entitlement.findMany({
|
const rows = await this.prisma.active.entitlement.findMany({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId },
|
||||||
orderBy: { granted_at: 'desc' },
|
orderBy: { granted_at: 'desc' },
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「禁止连带删除」的护栏。
|
||||||
|
*
|
||||||
|
* 起因:删课程时把课程下面 55 个课时一起删掉了(当时写的是连带删除),
|
||||||
|
* 而需求是「删课程只删课程,其余的我手动删」。
|
||||||
|
*
|
||||||
|
* 这条测试扫所有迁移文件,一旦有人写下 ON DELETE CASCADE 就会失败。
|
||||||
|
* 表结构的删除行为只允许 RESTRICT(有引用就不让删)或 SET NULL(只解引用不删行)。
|
||||||
|
*/
|
||||||
|
describe('数据库外键', () => {
|
||||||
|
it('任何迁移都不允许出现级联删除', () => {
|
||||||
|
const dir = join(process.cwd(), 'prisma', 'migrations');
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
let sql: string;
|
||||||
|
try {
|
||||||
|
sql = readFileSync(join(dir, name, 'migration.sql'), 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue; // migration_lock.toml 之类的非目录项
|
||||||
|
}
|
||||||
|
if (/ON DELETE CASCADE/i.test(sql)) offenders.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user