@ddj-v2/shop 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.npmignore +21 -0
- package/README.md +266 -0
- package/frontend/foo.page.ts +230 -0
- package/index.ts +681 -0
- package/model.ts +165 -0
- package/package.json +19 -0
- package/templates/coin_base.html +101 -0
- package/templates/coin_bill.html +59 -0
- package/templates/coin_exchange.html +40 -0
- package/templates/coin_gift.html +43 -0
- package/templates/coin_import.html +25 -0
- package/templates/coin_inc.html +39 -0
- package/templates/coin_mall.html +90 -0
- package/templates/coin_myrecord.html +74 -0
- package/templates/coin_record.html +54 -0
- package/templates/coin_show.html +62 -0
- package/templates/domain_coin_setting.html +31 -0
- package/templates/goods_add.html +50 -0
- package/templates/goods_edit.html +57 -0
- package/templates/goods_manage.html +79 -0
- package/templates/shop_manage_entries.html +22 -0
- package/templates/uname_change.html +42 -0
package/index.ts
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Context, UserModel, DomainModel, SettingModel, RecordModel, TokenModel, SystemModel, Handler, UserNotFoundError, UserAlreadyExistError, NotFoundError, ValidationError, param, PRIV, Types, query, STATUS, Logger
|
|
3
|
+
} from 'hydrooj';
|
|
4
|
+
import { CoinModel, GoodsModel } from './model';
|
|
5
|
+
import type { Goods, GoodsPurchaseModel } from './model';
|
|
6
|
+
|
|
7
|
+
const logger = new Logger('score-reward');
|
|
8
|
+
|
|
9
|
+
export interface ShopManageEntry {
|
|
10
|
+
key: string;
|
|
11
|
+
title: string;
|
|
12
|
+
href: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ShopBridge {
|
|
16
|
+
goodsModel: typeof GoodsModel;
|
|
17
|
+
registerGoodsPurchaseModel: typeof registerGoodsPurchaseModel;
|
|
18
|
+
registerShopManageEntry: typeof registerShopManageEntry;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const shopManageEntries = new Map<string, ShopManageEntry>();
|
|
22
|
+
|
|
23
|
+
export function registerShopManageEntry(entry: ShopManageEntry) {
|
|
24
|
+
if (!entry?.key || !entry?.title || !entry?.href) {
|
|
25
|
+
throw new Error('Invalid shop manage entry');
|
|
26
|
+
}
|
|
27
|
+
shopManageEntries.set(entry.key, entry);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getShopManageEntries(): ShopManageEntry[] {
|
|
31
|
+
return Array.from(shopManageEntries.values());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getPurchaseModel(purchaseModelId?: string): GoodsPurchaseModel | null {
|
|
35
|
+
if (!purchaseModelId) return null;
|
|
36
|
+
const model = (global.Hydro as any)?.model?.[purchaseModelId] as GoodsPurchaseModel | undefined;
|
|
37
|
+
if (!model || typeof model.purchase !== 'function') return null;
|
|
38
|
+
return model;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function invokePurchaseModel(uid: number, goods: Goods, num: number) {
|
|
42
|
+
const model = getPurchaseModel(goods.purchaseModelId);
|
|
43
|
+
if (!model) return;
|
|
44
|
+
const result = await model.purchase(uid, goods, num);
|
|
45
|
+
|
|
46
|
+
// Handle structured result { success: boolean; message?: string }
|
|
47
|
+
if (typeof result === 'object' && result !== null) {
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
const message = result.message || `商品 ${goods.name} 兌換失敗`;
|
|
50
|
+
throw new ValidationError('purchase', '', message);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle plain boolean
|
|
56
|
+
if (!result) throw new ValidationError('purchase', '', `商品 ${goods.name} 兌換失敗`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerGoodsPurchaseModel(modelId: string, model: GoodsPurchaseModel) {
|
|
60
|
+
if (!modelId) throw new Error('modelId is required');
|
|
61
|
+
if (!model || typeof model.purchase !== 'function') {
|
|
62
|
+
throw new Error('Invalid purchase model, purchase() is required');
|
|
63
|
+
}
|
|
64
|
+
(global.Hydro as any).model[modelId] = model;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//展示所有
|
|
68
|
+
class CoinShowHandler extends Handler {
|
|
69
|
+
@query('page', Types.PositiveInt, true)
|
|
70
|
+
@query('groupName', Types.string, true)
|
|
71
|
+
async get(domainId: string, page = 1, groupName?: string) {
|
|
72
|
+
const filter = { coin_now: { $exists: true } };
|
|
73
|
+
|
|
74
|
+
const groups = await UserModel.listGroup(domainId);
|
|
75
|
+
if (groupName) {
|
|
76
|
+
const groupInfo = groups.find((g) => g.name === groupName);
|
|
77
|
+
if (groupInfo) {
|
|
78
|
+
filter._id = { $in: groupInfo.uids };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [dudocs, upcount] = await this.paginate(
|
|
83
|
+
UserModel.getMulti(filter).sort({ coin_now: -1 }),
|
|
84
|
+
page,
|
|
85
|
+
'ranking'
|
|
86
|
+
);
|
|
87
|
+
const udict = await UserModel.getList(domainId, dudocs.map((x) => x._id));
|
|
88
|
+
const udocs = dudocs.map((x) => udict[x._id]);
|
|
89
|
+
|
|
90
|
+
this.response.template = 'coin_show.html';
|
|
91
|
+
this.response.body = { udocs, upcount, page, groupName, groups };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 發放硬幣
|
|
96
|
+
class CoinIncHandler extends Handler {
|
|
97
|
+
@query('uidOrName', Types.UidOrName, true)
|
|
98
|
+
async get(domainId: string, uidOrName: string) {
|
|
99
|
+
this.response.template = 'coin_inc.html';
|
|
100
|
+
this.response.body = { uidOrName };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@param('uidOrName', Types.UidOrName)
|
|
104
|
+
@param('amount', Types.Int)
|
|
105
|
+
@param('text', Types.String)
|
|
106
|
+
async post(domainId: string, uidOrName: string, amount: number, text: string) {
|
|
107
|
+
amount = parseInt(amount, 10);
|
|
108
|
+
const udoc = await UserModel.getById(domainId, +uidOrName)
|
|
109
|
+
|| await UserModel.getByUname(domainId, uidOrName)
|
|
110
|
+
|| await UserModel.getByEmail(domainId, uidOrName);
|
|
111
|
+
if (!udoc) {
|
|
112
|
+
throw new UserNotFoundError(uidOrName);
|
|
113
|
+
}
|
|
114
|
+
if (udoc._id === 0) {
|
|
115
|
+
throw new ValidationError(udoc.uname, '', '不能向 Guest 使用者發放硬幣');
|
|
116
|
+
}
|
|
117
|
+
await CoinModel.inc(udoc._id, this.user._id, amount, text, 1);
|
|
118
|
+
this.response.redirect = this.url('coin_inc');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//账单
|
|
123
|
+
class CoinBillHandler extends Handler {
|
|
124
|
+
@query('uid', Types.Int, true)
|
|
125
|
+
@query('page', Types.PositiveInt, true)
|
|
126
|
+
async get(domainId: string, uid = this.user._id, page = 1) {
|
|
127
|
+
const udoc = await UserModel.getById(domainId, uid);
|
|
128
|
+
const [bills, upcount] = await this.paginate(
|
|
129
|
+
await CoinModel.getUserBill(uid),
|
|
130
|
+
page,
|
|
131
|
+
'ranking'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const uids = new Set<number>([
|
|
135
|
+
...bills.map((x) => x.userId),
|
|
136
|
+
...bills.map((x) => x.rootId),
|
|
137
|
+
]);
|
|
138
|
+
const udict = await UserModel.getList(domainId, Array.from(uids));
|
|
139
|
+
|
|
140
|
+
this.response.template = 'coin_bill.html';
|
|
141
|
+
this.response.body = { udoc, bills, upcount, page, udict };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 批量匯入硬幣
|
|
146
|
+
class CoinImportHandler extends Handler {
|
|
147
|
+
async get() {
|
|
148
|
+
this.response.body.coins = [];
|
|
149
|
+
this.response.template = 'coin_import.html';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@param('coins', Types.Content)
|
|
153
|
+
@param('draft', Types.Boolean)
|
|
154
|
+
async post(domainId: string, _coins: string, draft: boolean) {
|
|
155
|
+
const coins = _coins.split('\n');
|
|
156
|
+
const udocs: { username: string, amount: number, text: string }[] = [];
|
|
157
|
+
const messages = [];
|
|
158
|
+
|
|
159
|
+
for (const i in coins) {
|
|
160
|
+
const u = coins[i];
|
|
161
|
+
if (!u.trim()) continue;
|
|
162
|
+
let [username, amount, text] = u.split('\t').map((t) => t.trim());
|
|
163
|
+
if (username && !amount && !text) {
|
|
164
|
+
const data = u.split(',').map((t) => t.trim());
|
|
165
|
+
[username, amount, text] = data;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!username) continue;
|
|
169
|
+
amount = parseInt(amount, 10);
|
|
170
|
+
if (isNaN(amount)) {
|
|
171
|
+
messages.push(`Line ${+i + 1}: Invalid amount.`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const user = await UserModel.getByUname(domainId, username);
|
|
176
|
+
if (!user) {
|
|
177
|
+
messages.push(`Line ${+i + 1}: User ${username} not found.`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
udocs.push({
|
|
182
|
+
username, amount, text
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
messages.push(`${udocs.length} coin records found.`);
|
|
187
|
+
|
|
188
|
+
if (!draft) {
|
|
189
|
+
for (const udoc of udocs) {
|
|
190
|
+
try {
|
|
191
|
+
const user = await UserModel.getByUname(domainId, udoc.username);
|
|
192
|
+
if (!user || !udoc.amount || udoc.amount === 0) continue;
|
|
193
|
+
await CoinModel.inc(user._id, this.user._id, udoc.amount, udoc.text, 1);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
messages.push(e.message);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.response.body.coins = udocs;
|
|
200
|
+
this.response.body.messages = messages;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
//增加商品
|
|
205
|
+
class GoodsAddHandler extends Handler {
|
|
206
|
+
async get() {
|
|
207
|
+
this.response.template = 'goods_add.html';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@param('name', Types.String)
|
|
211
|
+
@param('description', Types.String, true)
|
|
212
|
+
@param('price', Types.Int)
|
|
213
|
+
@param('num', Types.Int)
|
|
214
|
+
@param('objectId', Types.String, true)
|
|
215
|
+
async post(domainId: string, name: string, description = '', price: number, num: number, objectId = '') {
|
|
216
|
+
await GoodsModel.add(name, price, num, objectId.trim(), undefined, '', undefined, description);
|
|
217
|
+
this.response.body = { success: true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
//管理商品
|
|
222
|
+
class GoodsManageHandler extends Handler {
|
|
223
|
+
@query('page', Types.PositiveInt, true)
|
|
224
|
+
@query('keyword', Types.String, true)
|
|
225
|
+
@query('stock', Types.String, true)
|
|
226
|
+
async get(domainId: string, page = 1, keyword = '', stock = 'all') {
|
|
227
|
+
const query: Record<string, unknown> = {};
|
|
228
|
+
const kw = keyword.trim();
|
|
229
|
+
if (kw) {
|
|
230
|
+
const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
231
|
+
query.$or = [
|
|
232
|
+
{ name: regex },
|
|
233
|
+
{ objectId: regex },
|
|
234
|
+
{ description: regex },
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
if (stock === 'in') query.num = { $gt: 0 };
|
|
238
|
+
if (stock === 'out') query.num = 0;
|
|
239
|
+
if (stock === 'infinite') query.num = { $lt: 0 };
|
|
240
|
+
|
|
241
|
+
const [ddocs, dpcount] = await this.paginate(
|
|
242
|
+
GoodsModel.coll.find(query).sort({ _id: -1 }),
|
|
243
|
+
page,
|
|
244
|
+
'ranking'
|
|
245
|
+
);
|
|
246
|
+
this.response.template = 'goods_manage.html';
|
|
247
|
+
this.response.body = { ddocs, dpcount, page, keyword, stock };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class ShopManageEntriesHandler extends Handler {
|
|
252
|
+
async get() {
|
|
253
|
+
this.response.template = 'shop_manage_entries.html';
|
|
254
|
+
this.response.body = {
|
|
255
|
+
page_name: 'shop_manage_entries',
|
|
256
|
+
manageEntries: getShopManageEntries(),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class GoodsEditHandler extends Handler {
|
|
262
|
+
@param('id', Types.PositiveInt)
|
|
263
|
+
async get(domainId: string, id: number) {
|
|
264
|
+
const goods = await GoodsModel.get(id);
|
|
265
|
+
if (!goods) throw new NotFoundError(`商品 ${id} 不存在!`);
|
|
266
|
+
this.response.template = 'goods_edit.html';
|
|
267
|
+
this.response.body = { goods };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
@param('id', Types.PositiveInt)
|
|
271
|
+
@param('name', Types.String)
|
|
272
|
+
@param('description', Types.String, true)
|
|
273
|
+
@param('price', Types.Int)
|
|
274
|
+
@param('num', Types.Int)
|
|
275
|
+
@param('objectId', Types.String, true)
|
|
276
|
+
async postUpdate(domainId: string, id: number, name: string, description = '', price: number, num: number, objectId = '') {
|
|
277
|
+
const goods = await GoodsModel.get(id);
|
|
278
|
+
if (!goods) throw new NotFoundError(`商品 ${id} 不存在!`);
|
|
279
|
+
await GoodsModel.edit(id, name, price, num, objectId.trim(), undefined, undefined, description);
|
|
280
|
+
this.response.redirect = this.url('goods_manage');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@param('id', Types.PositiveInt)
|
|
284
|
+
async postDelete(domainId: string, id: number) {
|
|
285
|
+
await GoodsModel.delete(id);
|
|
286
|
+
this.response.redirect = this.url('goods_manage');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 兌換商城
|
|
291
|
+
class CoinMallHandler extends Handler {
|
|
292
|
+
@query('page', Types.PositiveInt, true)
|
|
293
|
+
@query('keyword', Types.String, true)
|
|
294
|
+
@query('stock', Types.String, true)
|
|
295
|
+
@query('purchasable', Types.String, true)
|
|
296
|
+
async get(domainId: string, page = 1, keyword = '', stock = 'all', purchasable = '0', uid = this.user._id) {
|
|
297
|
+
const udoc = await UserModel.getById(domainId, uid);
|
|
298
|
+
if (purchasable === '1' && stock === 'out') stock = 'all';
|
|
299
|
+
const query: Record<string, unknown> = {};
|
|
300
|
+
const andConditions: Record<string, unknown>[] = [];
|
|
301
|
+
const kw = keyword.trim();
|
|
302
|
+
if (kw) {
|
|
303
|
+
const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
304
|
+
andConditions.push({
|
|
305
|
+
$or: [
|
|
306
|
+
{ name: regex },
|
|
307
|
+
{ objectId: regex },
|
|
308
|
+
{ description: regex },
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (stock === 'in') andConditions.push({ num: { $gt: 0 } });
|
|
313
|
+
if (stock === 'out') andConditions.push({ num: 0 });
|
|
314
|
+
if (stock === 'infinite') andConditions.push({ num: { $lt: 0 } });
|
|
315
|
+
|
|
316
|
+
if (purchasable === '1') {
|
|
317
|
+
const currentCoin = typeof udoc.coin_now === 'number' ? udoc.coin_now : 0;
|
|
318
|
+
andConditions.push({ num: { $ne: 0 } });
|
|
319
|
+
andConditions.push({ price: { $lte: currentCoin } });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (andConditions.length) query.$and = andConditions;
|
|
323
|
+
|
|
324
|
+
const [ddocs, dpcount] = await this.paginate(
|
|
325
|
+
GoodsModel.coll.find(query).sort({ _id: -1 }),
|
|
326
|
+
page,
|
|
327
|
+
'ranking'
|
|
328
|
+
);
|
|
329
|
+
this.response.template = 'coin_mall.html';
|
|
330
|
+
this.response.body = { udoc, ddocs, dpcount, page, keyword, stock, purchasable };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 兌換商品
|
|
335
|
+
class CoinExchangeHandler extends Handler {
|
|
336
|
+
@param('id', Types.PositiveInt)
|
|
337
|
+
async get(domainId: string, id: number, uid = this.user._id) {
|
|
338
|
+
const goods = await GoodsModel.get(id);
|
|
339
|
+
const udoc = await UserModel.getById(domainId, uid);
|
|
340
|
+
if (!goods) throw new NotFoundError(`商品 ${id} 不存在!`);
|
|
341
|
+
this.response.template = 'coin_exchange.html';
|
|
342
|
+
this.response.body = { udoc, goods };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@param('id', Types.PositiveInt)
|
|
346
|
+
@param('num', Types.Int)
|
|
347
|
+
async post(domainId: string, id: number, num: number) {
|
|
348
|
+
const goods = await GoodsModel.get(id);
|
|
349
|
+
if (!goods) throw new NotFoundError(`商品 ${id} 不存在!`);
|
|
350
|
+
const udoc = await UserModel.getById(domainId, this.user._id);
|
|
351
|
+
if (num <= 0) {
|
|
352
|
+
throw new ValidationError(num, '', '商品數量必須大於 0');
|
|
353
|
+
}
|
|
354
|
+
const isInfiniteStock = goods.num < 0;
|
|
355
|
+
if (!isInfiniteStock && goods.num < num) {
|
|
356
|
+
throw new ValidationError(num, '', `商品 ${goods.name} 數量不足`);
|
|
357
|
+
}
|
|
358
|
+
const currentCoin = typeof udoc.coin_now === 'number' ? udoc.coin_now : 0;
|
|
359
|
+
if (currentCoin < goods.price * num) {
|
|
360
|
+
throw new ValidationError(currentCoin, '', '你的硬幣不足');
|
|
361
|
+
}
|
|
362
|
+
const amount = 0 - goods.price * num;
|
|
363
|
+
const objectText = goods.objectId ? `(物件ID:${goods.objectId})` : '';
|
|
364
|
+
const text = `兌換:${goods.name}${objectText}×${num}`;
|
|
365
|
+
if (!isInfiniteStock) {
|
|
366
|
+
const updated = await GoodsModel.updateStock(id, -num);
|
|
367
|
+
if (!updated) {
|
|
368
|
+
throw new ValidationError(num, '', `商品 ${goods.name} 數量不足`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
await invokePurchaseModel(this.user._id, goods, num);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
if (!isInfiniteStock) await GoodsModel.updateStock(id, num);
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
await CoinModel.inc(this.user._id, 1, amount, text, 0, id);
|
|
378
|
+
this.response.redirect = this.url('coin_myrecord');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 我的兌換紀錄
|
|
383
|
+
class CoinMyRecordHandler extends Handler {
|
|
384
|
+
@query('page', Types.PositiveInt, true)
|
|
385
|
+
@query('keyword', Types.String, true)
|
|
386
|
+
@query('objectId', Types.String, true)
|
|
387
|
+
async get(domainId: string, page = 1, keyword = '', objectId = '', uid = this.user._id) {
|
|
388
|
+
const query: Record<string, unknown> = uid === 0
|
|
389
|
+
? { status: { $gte: 0 } }
|
|
390
|
+
: { userId: uid, status: { $gte: 0 } };
|
|
391
|
+
|
|
392
|
+
const kw = keyword.trim();
|
|
393
|
+
if (kw) {
|
|
394
|
+
query.text = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const oid = objectId.trim();
|
|
398
|
+
if (oid) {
|
|
399
|
+
const goodsCursor = await GoodsModel.getMultiByObjectId(oid);
|
|
400
|
+
const goods = await goodsCursor.toArray();
|
|
401
|
+
const goodsIds = goods.map((g: any) => g._id);
|
|
402
|
+
if (!goodsIds.length) {
|
|
403
|
+
this.response.template = 'coin_myrecord.html';
|
|
404
|
+
this.response.body = { bills: [], upcount: 0, page, udict: {}, keyword, objectId };
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
query.status = { $in: goodsIds };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const [bills, upcount] = await this.paginate(
|
|
411
|
+
CoinModel.coll.find(query).sort({ status: -1, _id: -1 }),
|
|
412
|
+
page,
|
|
413
|
+
'ranking'
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const uids = new Set<number>([
|
|
417
|
+
...bills.map((x) => x.userId),
|
|
418
|
+
...bills.map((x) => x.rootId),
|
|
419
|
+
]);
|
|
420
|
+
const udict = await UserModel.getList(domainId, Array.from(uids));
|
|
421
|
+
|
|
422
|
+
this.response.template = 'coin_myrecord.html';
|
|
423
|
+
this.response.body = { bills, upcount, page, udict, keyword, objectId };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
@param('id', Types.ObjectId)
|
|
427
|
+
async post(domainId: string, id: ObjectId) {
|
|
428
|
+
throw new ValidationError('訂單', '', '取消訂單功能已停用');
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 所有人的兌換紀錄
|
|
433
|
+
class CoinRecordHandler extends Handler {
|
|
434
|
+
@query('uid', Types.Int, true)
|
|
435
|
+
@query('page', Types.PositiveInt, true)
|
|
436
|
+
async get(domainId: string, uid = 0, page = 1) {
|
|
437
|
+
const [bills, upcount] = await this.paginate(
|
|
438
|
+
await CoinModel.getUserRecord(uid),
|
|
439
|
+
page,
|
|
440
|
+
'ranking'
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const uids = new Set<number>([
|
|
444
|
+
...bills.map((x) => x.userId),
|
|
445
|
+
...bills.map((x) => x.rootId),
|
|
446
|
+
]);
|
|
447
|
+
const udict = await UserModel.getList(domainId, Array.from(uids));
|
|
448
|
+
|
|
449
|
+
this.response.template = 'coin_record.html';
|
|
450
|
+
this.response.body = { uid, bills, upcount, page, udict };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 贈送硬幣
|
|
455
|
+
class CoinGiftHandler extends Handler {
|
|
456
|
+
@query('uidOrName', Types.UidOrName, true)
|
|
457
|
+
async get(domainId: string, uidOrName: string) {
|
|
458
|
+
this.response.template = 'coin_gift.html';
|
|
459
|
+
this.response.body = { uidOrName };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
@param('password', Types.Password)
|
|
463
|
+
@param('uidOrName', Types.UidOrName)
|
|
464
|
+
@param('amount', Types.Int)
|
|
465
|
+
async post(domainId: string, password: string, uidOrName: string, amount: number) {
|
|
466
|
+
amount = parseInt(amount, 10);
|
|
467
|
+
if (amount <= 0) {
|
|
468
|
+
throw new ValidationError(amount, '', '贈送的硬幣必須大於 0');
|
|
469
|
+
}
|
|
470
|
+
const currentCoin = typeof this.user.coin_now === 'number' ? this.user.coin_now : 0;
|
|
471
|
+
if (amount > currentCoin) {
|
|
472
|
+
throw new ValidationError(currentCoin, '', '你的硬幣不足');
|
|
473
|
+
}
|
|
474
|
+
const udoc = await UserModel.getById(domainId, +uidOrName)
|
|
475
|
+
|| await UserModel.getByUname(domainId, uidOrName)
|
|
476
|
+
|| await UserModel.getByEmail(domainId, uidOrName);
|
|
477
|
+
if (!udoc) {
|
|
478
|
+
throw new UserNotFoundError(uidOrName);
|
|
479
|
+
}
|
|
480
|
+
if (udoc._id === this.user._id) {
|
|
481
|
+
throw new ValidationError(udoc.uname, '', '不能贈送硬幣給自己');
|
|
482
|
+
}
|
|
483
|
+
if (udoc._id === 0) {
|
|
484
|
+
throw new ValidationError(udoc.uname, '', '不能向 Guest 使用者贈送硬幣');
|
|
485
|
+
}
|
|
486
|
+
await this.user.checkPassword(password);
|
|
487
|
+
const text1 = `贈送:送給(${udoc.uname})。`;
|
|
488
|
+
const text2 = `贈送:來自(${this.user.uname})。`;
|
|
489
|
+
await CoinModel.inc(this.user._id, udoc._id, 0 - amount, text1, 0);
|
|
490
|
+
await CoinModel.inc(udoc._id, this.user._id, amount, text2, 0);
|
|
491
|
+
this.response.body = { success: true };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 使用者修改名稱
|
|
496
|
+
class UnameChangeHandler extends Handler {
|
|
497
|
+
async get({ domainId }) {
|
|
498
|
+
const udoc = await UserModel.getById(domainId, this.user._id);
|
|
499
|
+
const coinCost = SystemModel.get('coin.uname_change_cost') || 20;
|
|
500
|
+
const uidOrName = udoc.uname;
|
|
501
|
+
this.response.template = 'uname_change.html';
|
|
502
|
+
this.response.body = { uidOrName, coinCost };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@param('password', Types.Password)
|
|
506
|
+
@param('newUname', Types.Username)
|
|
507
|
+
async postFree(domainId: string, password: string, newUname: string) {
|
|
508
|
+
if (/^[+-]?\d+$/.test(newUname.trim())) {
|
|
509
|
+
throw new ValidationError(newUname, '', '使用者名稱不能為純數字');
|
|
510
|
+
}
|
|
511
|
+
if (this.user.olduname) {
|
|
512
|
+
throw new ValidationError('修改次數', '', '修改次數已達上限');
|
|
513
|
+
}
|
|
514
|
+
const udoc = await UserModel.getById(domainId, +newUname)
|
|
515
|
+
|| await UserModel.getByUname(domainId, newUname)
|
|
516
|
+
|| await UserModel.getByEmail(domainId, newUname);
|
|
517
|
+
if (udoc) {
|
|
518
|
+
throw new UserAlreadyExistError(newUname);
|
|
519
|
+
}
|
|
520
|
+
await this.user.checkPassword(password);
|
|
521
|
+
await UserModel.setById(this.user._id, { olduname: this.user.uname });
|
|
522
|
+
await UserModel.setUname(this.user._id, newUname);
|
|
523
|
+
await TokenModel.delByUid(this.user._id);
|
|
524
|
+
this.response.redirect = this.url('user_login');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
@param('password', Types.Password)
|
|
528
|
+
@param('newUname', Types.Username)
|
|
529
|
+
async postBycoin(domainId: string, password: string, newUname: string) {
|
|
530
|
+
if (/^[+-]?\d+$/.test(newUname.trim())) {
|
|
531
|
+
throw new ValidationError(newUname, '', '使用者名稱不能為純數字');
|
|
532
|
+
}
|
|
533
|
+
const udoc = await UserModel.getById(domainId, +newUname)
|
|
534
|
+
|| await UserModel.getByUname(domainId, newUname)
|
|
535
|
+
|| await UserModel.getByEmail(domainId, newUname);
|
|
536
|
+
if (udoc) {
|
|
537
|
+
throw new UserAlreadyExistError(newUname);
|
|
538
|
+
}
|
|
539
|
+
await this.user.checkPassword(password);
|
|
540
|
+
|
|
541
|
+
const coinCost = SystemModel.get('coin.uname_change_cost') || 20;
|
|
542
|
+
const currentCoin = typeof this.user.coin_now === 'number' ? this.user.coin_now : 0;
|
|
543
|
+
if (currentCoin < coinCost) {
|
|
544
|
+
throw new ValidationError('currentCoin', '', '你的硬幣不足');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await CoinModel.inc(this.user._id, 1, 0 - coinCost, '修改使用者名稱', 0);
|
|
548
|
+
await UserModel.setUname(this.user._id, newUname);
|
|
549
|
+
await TokenModel.delByUid(this.user._id);
|
|
550
|
+
this.response.redirect = this.url('user_login');
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
class CoinSettingHandler extends Handler {
|
|
555
|
+
async get() {
|
|
556
|
+
this.response.template = 'domain_coin_setting.html';
|
|
557
|
+
this.response.body = {
|
|
558
|
+
coin_enabled: this.domain.coin_enabled || false,
|
|
559
|
+
coin_amount: this.domain.coin_amount || 2,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
@param('coin_enabled', Types.Boolean)
|
|
564
|
+
@param('coin_amount', Types.Int)
|
|
565
|
+
async post( domainId: string, coin_enabled: boolean, coin_amount: number ) {
|
|
566
|
+
await DomainModel.edit(domainId, {
|
|
567
|
+
coin_enabled,
|
|
568
|
+
coin_amount,
|
|
569
|
+
});
|
|
570
|
+
this.back();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 配置项及路由
|
|
575
|
+
export async function apply(ctx: Context) {
|
|
576
|
+
ctx.inject(['setting'], (c) => {
|
|
577
|
+
c.setting.AccountSetting(
|
|
578
|
+
SettingModel.Setting('setting_storage', 'coin_now', 0, 'number', 'coin_now', null, 3),
|
|
579
|
+
SettingModel.Setting('setting_storage', 'coin_all', 0, 'number', 'coin_all', null, 3)
|
|
580
|
+
);
|
|
581
|
+
c.setting.SystemSetting(
|
|
582
|
+
SettingModel.Setting('domain_coin_setting', 'coin.uname_change_cost', 20, 'number', 'coin.uname_change_cost', '修改使用者名稱所需硬幣數量', 0)
|
|
583
|
+
);
|
|
584
|
+
c.setting.DomainSetting(
|
|
585
|
+
SettingModel.Setting('setting_storage', 'coin_enabled', false, 'boolean', '自動發放硬幣', '為此網域啟用首次 AC 硬幣發放功能',3),
|
|
586
|
+
SettingModel.Setting('setting_storage', 'coin_amount', 2, 'number', '每題硬幣數量', '每題首次 AC 可獲得的硬幣數量',3)
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
ctx.on('record/judge', async (rdoc, updated, pdoc) => {
|
|
591
|
+
try {
|
|
592
|
+
if (rdoc.status !== STATUS.STATUS_ACCEPTED) return;
|
|
593
|
+
if (rdoc.contest) return;
|
|
594
|
+
if (rdoc.rejudged) return;
|
|
595
|
+
if (!updated) return;
|
|
596
|
+
|
|
597
|
+
const ddoc = await DomainModel.get(rdoc.domainId);
|
|
598
|
+
const coinEnabled = ddoc?.coin_enabled || false;
|
|
599
|
+
if (!coinEnabled) return;
|
|
600
|
+
|
|
601
|
+
const result = await RecordModel.collStat.updateOne(
|
|
602
|
+
{
|
|
603
|
+
domainId: rdoc.domainId,
|
|
604
|
+
pid: rdoc.pid,
|
|
605
|
+
uid: rdoc.uid
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
$setOnInsert: {
|
|
609
|
+
_id: rdoc._id,
|
|
610
|
+
domainId: rdoc.domainId,
|
|
611
|
+
pid: rdoc.pid,
|
|
612
|
+
uid: rdoc.uid,
|
|
613
|
+
time: rdoc.time,
|
|
614
|
+
memory: rdoc.memory,
|
|
615
|
+
length: rdoc.code?.length || 0,
|
|
616
|
+
lang: rdoc.lang,
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{ upsert: true },
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// 只有首次 AC 時才發放硬幣
|
|
623
|
+
if (result.upsertedCount > 0) {
|
|
624
|
+
const coinAmount = +(ddoc?.coin_amount || 2);
|
|
625
|
+
const domainName = ddoc?.name || rdoc.domainId;
|
|
626
|
+
await CoinModel.inc( rdoc.uid, ddoc.owner, coinAmount, `答题:${domainName}(ID:${rdoc.pid})`, 1);
|
|
627
|
+
logger.info(`User ${rdoc.uid} earned ${coinAmount} coins for first AC on problem ${rdoc.pid} in domain ${domainName}`);
|
|
628
|
+
}
|
|
629
|
+
} catch (error) {
|
|
630
|
+
logger.error('Error in coin reward plugin:', error);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
ctx.Route('coin_show', '/coin/show', CoinShowHandler);
|
|
635
|
+
ctx.Route('coin_inc', '/coin/inc', CoinIncHandler, PRIV.PRIV_SET_PERM);
|
|
636
|
+
ctx.Route('coin_import', '/coin/import', CoinImportHandler, PRIV.PRIV_SET_PERM);
|
|
637
|
+
ctx.Route('coin_bill', '/coin/bill', CoinBillHandler, PRIV.PRIV_USER_PROFILE);
|
|
638
|
+
ctx.Route('coin_mall', '/coin/mall', CoinMallHandler, PRIV.PRIV_USER_PROFILE);
|
|
639
|
+
ctx.Route('coin_myrecord', '/coin/myrecord', CoinMyRecordHandler, PRIV.PRIV_USER_PROFILE);
|
|
640
|
+
ctx.Route('coin_exchange', '/coin/exchange/:id', CoinExchangeHandler, PRIV.PRIV_USER_PROFILE);
|
|
641
|
+
ctx.Route('coin_record', '/coin/record', CoinRecordHandler, PRIV.PRIV_SET_PERM);
|
|
642
|
+
// ctx.Route('coin_gift', '/coin/gift', CoinGiftHandler, PRIV.PRIV_USER_PROFILE);
|
|
643
|
+
ctx.Route('goods_add', '/goods/add', GoodsAddHandler, PRIV.PRIV_SET_PERM);
|
|
644
|
+
ctx.Route('goods_manage', '/goods/manage', GoodsManageHandler, PRIV.PRIV_SET_PERM);
|
|
645
|
+
ctx.Route('shop_manage_entries', '/shop/manage/entries', ShopManageEntriesHandler, PRIV.PRIV_SET_PERM);
|
|
646
|
+
ctx.Route('goods_edit', '/goods/:id/edit', GoodsEditHandler, PRIV.PRIV_SET_PERM);
|
|
647
|
+
ctx.Route('uname_change', '/uname/change', UnameChangeHandler, PRIV.PRIV_USER_PROFILE);
|
|
648
|
+
ctx.Route('domain_coin_setting', '/domain/coin', CoinSettingHandler, PRIV.PRIV_SET_PERM);
|
|
649
|
+
ctx.injectUI('DomainManage', 'domain_coin_setting',{family: 'Properties', icon: 'info' }, PRIV.PRIV_SET_PERM);
|
|
650
|
+
ctx.injectUI('UserDropdown', 'coin_bill', { icon: 'bold', displayName: '我的硬幣' });
|
|
651
|
+
const shopBridge: ShopBridge = {
|
|
652
|
+
goodsModel: GoodsModel,
|
|
653
|
+
registerGoodsPurchaseModel,
|
|
654
|
+
registerShopManageEntry,
|
|
655
|
+
};
|
|
656
|
+
(global.Hydro as any).shopBridge = shopBridge;
|
|
657
|
+
|
|
658
|
+
ctx.provide('coin', CoinModel);
|
|
659
|
+
ctx.provide('shop', GoodsModel);
|
|
660
|
+
ctx.provide('shop_bridge', shopBridge as any);
|
|
661
|
+
ctx.i18n.load('zh', {
|
|
662
|
+
coin_show: '展示硬幣',
|
|
663
|
+
coin_inc: '發放硬幣',
|
|
664
|
+
coin_import: '批量發放硬幣',
|
|
665
|
+
coin_bill: '發放紀錄',
|
|
666
|
+
coin_mall: '兌換商城',
|
|
667
|
+
coin_myrecord: '我的兌換紀錄',
|
|
668
|
+
coin_exchange: '兌換商品',
|
|
669
|
+
coin_record: '所有人的兌換紀錄',
|
|
670
|
+
coin_gift: '贈送硬幣',
|
|
671
|
+
goods_add: '新增商品',
|
|
672
|
+
goods_manage: '管理商品',
|
|
673
|
+
shop_manage_entries: '擴充管理',
|
|
674
|
+
goods_edit: '編輯商品',
|
|
675
|
+
uname_change: '修改使用者名稱',
|
|
676
|
+
domain_coin_setting: '硬幣設定',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export { CoinModel, GoodsModel };
|
|
681
|
+
export type { Goods, GoodsPurchaseModel };
|