10-authCreditManager.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. /**
  2. * 云函数:authCreditManager(账号、总积分、充值订单、管理员调账)
  3. *
  4. * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 authCredit。
  5. * 管理员策略:
  6. * 1. 第一位注册用户自动为 admin;
  7. * 2. 也可在 ADMIN_IDENTIFIERS 中配置用户名/邮箱/手机号。
  8. */
  9. const ADMIN_IDENTIFIERS = ['admin'];
  10. const REGISTER_GIFT_CREDITS = 10;
  11. const CREDIT_PER_CNY = 10;
  12. const PARSE_API_HOST = 'https://server.fmode.cn';
  13. const PARSE_APP_ID = 'ncloudmaster';
  14. async function handler(request, response) {
  15. try {
  16. await ensureTables();
  17. const action = pickParam(request, 'action') || '';
  18. if (action === 'register') return register(request, response);
  19. if (action === 'login') return login(request, response);
  20. if (action === 'me') return me(request, response);
  21. if (action === 'changePassword') return changePassword(request, response);
  22. if (action === 'balance') return balance(request, response);
  23. if (action === 'ledger') return ledger(request, response);
  24. if (action === 'reserve') return reserve(request, response);
  25. if (action === 'commitReservation') return commitReservation(request, response);
  26. if (action === 'refundReservation') return refundReservation(request, response);
  27. if (action === 'createRechargeOrder') return createRechargeOrder(request, response);
  28. if (action === 'adminListUsers') return adminListUsers(request, response);
  29. if (action === 'adminCreateUser') return adminCreateUser(request, response);
  30. if (action === 'adminAdjustCredit') return adminAdjustCredit(request, response);
  31. response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  32. } catch (error) {
  33. console.error('authCreditManager failed:', error.message, error.stack);
  34. response.json({ code: 500, success: false, error: error.message });
  35. }
  36. }
  37. async function ensureTables() {
  38. await Psql.query(`
  39. CREATE TABLE IF NOT EXISTS "AppUser" (
  40. "objectId" VARCHAR(50) PRIMARY KEY,
  41. "username" VARCHAR(120) UNIQUE NOT NULL,
  42. "email" VARCHAR(180) UNIQUE,
  43. "phone" VARCHAR(60) UNIQUE,
  44. "displayName" VARCHAR(180) DEFAULT '',
  45. "passwordHash" VARCHAR(255) NOT NULL,
  46. "passwordSalt" VARCHAR(120) NOT NULL,
  47. "role" VARCHAR(30) DEFAULT 'user',
  48. "status" VARCHAR(30) DEFAULT 'active',
  49. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  50. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  51. )
  52. `);
  53. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_email ON "AppUser" ("email")`);
  54. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_phone ON "AppUser" ("phone")`);
  55. await Psql.query(`
  56. CREATE TABLE IF NOT EXISTS "AppSession" (
  57. "token" VARCHAR(120) PRIMARY KEY,
  58. "userId" VARCHAR(50) NOT NULL,
  59. "expiresAt" TIMESTAMPTZ NOT NULL,
  60. "createdAt" TIMESTAMPTZ DEFAULT NOW()
  61. )
  62. `);
  63. await Psql.query(`
  64. CREATE TABLE IF NOT EXISTS "UserCreditAccount" (
  65. "userId" VARCHAR(50) PRIMARY KEY,
  66. "balance" NUMERIC(12,2) DEFAULT 0,
  67. "gifted" NUMERIC(12,2) DEFAULT 0,
  68. "totalRecharged" NUMERIC(12,2) DEFAULT 0,
  69. "totalConsumed" NUMERIC(12,2) DEFAULT 0,
  70. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  71. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  72. )
  73. `);
  74. await Psql.query(`
  75. CREATE TABLE IF NOT EXISTS "CreditLedger" (
  76. "objectId" VARCHAR(50) PRIMARY KEY,
  77. "userId" VARCHAR(50) NOT NULL,
  78. "type" VARCHAR(40) NOT NULL,
  79. "amount" NUMERIC(12,2) NOT NULL,
  80. "balanceAfter" NUMERIC(12,2) NOT NULL,
  81. "title" VARCHAR(255) DEFAULT '',
  82. "detail" JSONB DEFAULT '{}',
  83. "createdAt" TIMESTAMPTZ DEFAULT NOW()
  84. )
  85. `);
  86. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_credit_ledger_user_time ON "CreditLedger" ("userId", "createdAt" DESC)`);
  87. await Psql.query(`
  88. CREATE TABLE IF NOT EXISTS "CreditReservation" (
  89. "objectId" VARCHAR(50) PRIMARY KEY,
  90. "userId" VARCHAR(50) NOT NULL,
  91. "operation" VARCHAR(120) NOT NULL,
  92. "cost" NUMERIC(12,2) NOT NULL,
  93. "status" VARCHAR(30) DEFAULT 'reserved',
  94. "ledgerId" VARCHAR(50),
  95. "detail" JSONB DEFAULT '{}',
  96. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  97. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  98. )
  99. `);
  100. await Psql.query(`
  101. CREATE TABLE IF NOT EXISTS "RechargeOrder" (
  102. "objectId" VARCHAR(50) PRIMARY KEY,
  103. "userId" VARCHAR(50) NOT NULL,
  104. "amountCny" NUMERIC(12,2) NOT NULL,
  105. "creditAmount" NUMERIC(12,2) NOT NULL,
  106. "status" VARCHAR(30) DEFAULT 'pending',
  107. "detail" JSONB DEFAULT '{}',
  108. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  109. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  110. )
  111. `);
  112. }
  113. async function register(request, response) {
  114. const username = clean(pickParam(request, 'username'));
  115. const email = clean(pickParam(request, 'email'));
  116. const phone = clean(pickParam(request, 'phone'));
  117. const displayName = clean(pickParam(request, 'displayName')) || username;
  118. const password = String(pickParam(request, 'password') || '');
  119. if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或密码' });
  120. const exists = await Psql.query(
  121. `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
  122. [username, email, phone]
  123. );
  124. if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
  125. const countRows = await Psql.query(`SELECT COUNT(*)::int AS count FROM "AppUser"`);
  126. const isFirstUser = Number(countRows[0].count || 0) === 0;
  127. const role = isFirstUser || ADMIN_IDENTIFIERS.includes(username) || ADMIN_IDENTIFIERS.includes(email) || ADMIN_IDENTIFIERS.includes(phone)
  128. ? 'admin'
  129. : 'user';
  130. const salt = generateId(16);
  131. const userId = generateId();
  132. const hash = passwordHash(password, salt);
  133. await Psql.query(
  134. `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
  135. VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
  136. [userId, username, email, phone, displayName, hash, salt, role]
  137. );
  138. await Psql.query(
  139. `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
  140. [userId, REGISTER_GIFT_CREDITS]
  141. );
  142. await insertLedger(userId, 'gift', REGISTER_GIFT_CREDITS, REGISTER_GIFT_CREDITS, '注册赠送积分', {});
  143. const session = await createSession(userId);
  144. response.json({ code: 200, success: true, data: { token: session.token, user: await publicUser(userId) } });
  145. }
  146. async function login(request, response) {
  147. const identifier = clean(pickParam(request, 'identifier'));
  148. const password = String(pickParam(request, 'password') || '');
  149. const rows = await Psql.query(
  150. `SELECT * FROM "AppUser" WHERE "username"=$1 OR "email"=$1 OR "phone"=$1 LIMIT 1`,
  151. [identifier]
  152. );
  153. if (!rows.length) return response.json({ code: 401, success: false, error: '账号或密码不正确' });
  154. const row = rows[0];
  155. if (row.status !== 'active') return response.json({ code: 403, success: false, error: '账号已停用' });
  156. if (passwordHash(password, row.passwordSalt) !== row.passwordHash) {
  157. return response.json({ code: 401, success: false, error: '账号或密码不正确' });
  158. }
  159. const session = await createSession(row.objectId);
  160. response.json({ code: 200, success: true, data: { token: session.token, user: rowToUser(row) } });
  161. }
  162. async function me(request, response) {
  163. const session = await requireSession(request);
  164. response.json({ code: 200, success: true, data: session.user || await publicUser(session.userId) });
  165. }
  166. async function changePassword(request, response) {
  167. const session = await requireSession(request);
  168. const oldPassword = String(pickParam(request, 'oldPassword') || '');
  169. const newPassword = String(pickParam(request, 'newPassword') || '');
  170. if (!newPassword) return response.json({ code: 400, success: false, error: '缺少新密码' });
  171. const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [session.userId]);
  172. const row = rows[0];
  173. if (passwordHash(oldPassword, row.passwordSalt) !== row.passwordHash) {
  174. return response.json({ code: 400, success: false, error: '原密码不正确' });
  175. }
  176. const salt = generateId(16);
  177. await Psql.query(`UPDATE "AppUser" SET "passwordHash"=$1,"passwordSalt"=$2,"updatedAt"=NOW() WHERE "objectId"=$3`, [
  178. passwordHash(newPassword, salt), salt, session.userId
  179. ]);
  180. response.json({ code: 200, success: true, data: true });
  181. }
  182. async function balance(request, response) {
  183. const session = await requireSession(request);
  184. response.json({ code: 200, success: true, data: await getBalance(session.userId) });
  185. }
  186. async function ledger(request, response) {
  187. const session = await requireSession(request);
  188. const limit = Math.max(1, Math.min(200, Number(pickParam(request, 'limit') || 50)));
  189. const rows = await Psql.query(
  190. `SELECT * FROM "CreditLedger" WHERE "userId"=$1 ORDER BY "createdAt" DESC LIMIT ${limit}`,
  191. [session.userId]
  192. );
  193. response.json({ code: 200, success: true, data: rows.map(rowToLedger) });
  194. }
  195. async function reserve(request, response) {
  196. const session = await requireSession(request);
  197. const operation = clean(pickParam(request, 'operation')) || 'unknown';
  198. const cost = Number(pickParam(request, 'cost') || 0);
  199. const title = clean(pickParam(request, 'title')) || operation;
  200. const detail = pickParam(request, 'detail') || {};
  201. if (cost <= 0) return response.json({ code: 400, success: false, error: '积分消耗必须大于 0' });
  202. await Psql.query('BEGIN');
  203. try {
  204. const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [session.userId]);
  205. if (!accounts.length || Number(accounts[0].balance || 0) < cost) {
  206. await Psql.query('ROLLBACK');
  207. return response.json({ code: 402, success: false, error: '积分不足,请先充值' });
  208. }
  209. const newBalance = Number(accounts[0].balance || 0) - cost;
  210. await Psql.query(
  211. `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"="totalConsumed"+$2,"updatedAt"=NOW() WHERE "userId"=$3`,
  212. [newBalance, cost, session.userId]
  213. );
  214. const ledgerId = await insertLedger(session.userId, 'consume', -cost, newBalance, title, { operation, ...detail, status: 'reserved' });
  215. const reservationId = generateId();
  216. await Psql.query(
  217. `INSERT INTO "CreditReservation" ("objectId","userId","operation","cost","ledgerId","detail") VALUES ($1,$2,$3,$4,$5,$6)`,
  218. [reservationId, session.userId, operation, cost, ledgerId, JSON.stringify(detail || {})]
  219. );
  220. await Psql.query('COMMIT');
  221. response.json({ code: 200, success: true, data: { reservationId, balance: newBalance, cost } });
  222. } catch (e) {
  223. await Psql.query('ROLLBACK');
  224. throw e;
  225. }
  226. }
  227. async function commitReservation(request, response) {
  228. const session = await requireSession(request);
  229. const reservationId = clean(pickParam(request, 'reservationId'));
  230. await Psql.query(
  231. `UPDATE "CreditReservation" SET "status"='committed',"detail"=COALESCE("detail",'{}'::jsonb) || $1::jsonb,"updatedAt"=NOW()
  232. WHERE "objectId"=$2 AND "userId"=$3 AND "status"='reserved'`,
  233. [JSON.stringify(pickParam(request, 'detail') || {}), reservationId, session.userId]
  234. );
  235. response.json({ code: 200, success: true, data: true });
  236. }
  237. async function refundReservation(request, response) {
  238. const session = await requireSession(request);
  239. const reservationId = clean(pickParam(request, 'reservationId'));
  240. const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
  241. await refundByReservation(session.userId, reservationId, reason);
  242. response.json({ code: 200, success: true, data: true });
  243. }
  244. async function createRechargeOrder(request, response) {
  245. const session = await requireSession(request);
  246. const amountCny = Number(pickParam(request, 'amountCny') || 0);
  247. if (amountCny <= 0) return response.json({ code: 400, success: false, error: '充值金额必须大于 0' });
  248. const order = {
  249. objectId: generateId(),
  250. amountCny,
  251. creditAmount: Math.round(amountCny * CREDIT_PER_CNY),
  252. status: 'pending',
  253. createdAt: new Date().toISOString()
  254. };
  255. await Psql.query(
  256. `INSERT INTO "RechargeOrder" ("objectId","userId","amountCny","creditAmount","status","detail") VALUES ($1,$2,$3,$4,'pending',$5)`,
  257. [order.objectId, session.userId, order.amountCny, order.creditAmount, JSON.stringify({ creditPerCny: CREDIT_PER_CNY })]
  258. );
  259. response.json({ code: 200, success: true, data: order });
  260. }
  261. async function adminListUsers(request, response) {
  262. const session = await requireSession(request);
  263. await requireAdmin(session.userId);
  264. const rows = await Psql.query(`
  265. SELECT u."objectId",u."username",u."email",u."phone",u."displayName",u."role",u."status",u."createdAt",
  266. COALESCE(c."balance",0) AS "creditBalance"
  267. FROM "AppUser" u
  268. LEFT JOIN "UserCreditAccount" c ON c."userId"=u."objectId"
  269. ORDER BY u."createdAt" DESC
  270. LIMIT 1000
  271. `);
  272. response.json({ code: 200, success: true, data: rows });
  273. }
  274. async function adminCreateUser(request, response) {
  275. const session = await requireSession(request);
  276. await requireAdmin(session.userId);
  277. const username = clean(pickParam(request, 'username'));
  278. const email = clean(pickParam(request, 'email'));
  279. const phone = clean(pickParam(request, 'phone'));
  280. const displayName = clean(pickParam(request, 'displayName')) || username;
  281. const password = String(pickParam(request, 'password') || '');
  282. const roleInput = clean(pickParam(request, 'role')) || 'user';
  283. const role = roleInput === 'admin' ? 'admin' : 'user';
  284. const initialCredits = Math.max(0, Number(pickParam(request, 'initialCredits') ?? REGISTER_GIFT_CREDITS));
  285. if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或初始密码' });
  286. const exists = await Psql.query(
  287. `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
  288. [username, email, phone]
  289. );
  290. if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
  291. await Psql.query('BEGIN');
  292. try {
  293. const salt = generateId(16);
  294. const userId = generateId();
  295. await Psql.query(
  296. `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
  297. VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
  298. [userId, username, email, phone, displayName, passwordHash(password, salt), salt, role]
  299. );
  300. await Psql.query(
  301. `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
  302. [userId, initialCredits]
  303. );
  304. if (initialCredits > 0) {
  305. await insertLedger(userId, 'admin_adjust', initialCredits, initialCredits, '管理员发放初始积分', { adminUserId: session.userId });
  306. }
  307. await Psql.query('COMMIT');
  308. response.json({ code: 200, success: true, data: await publicUser(userId) });
  309. } catch (e) {
  310. await Psql.query('ROLLBACK');
  311. throw e;
  312. }
  313. }
  314. async function adminAdjustCredit(request, response) {
  315. const session = await requireSession(request);
  316. await requireAdmin(session.userId);
  317. const userId = clean(pickParam(request, 'userId'));
  318. const amount = Number(pickParam(request, 'amount') || 0);
  319. const note = clean(pickParam(request, 'note')) || '管理员调整';
  320. if (!userId || !amount) return response.json({ code: 400, success: false, error: '缺少用户或调整积分' });
  321. await Psql.query('BEGIN');
  322. try {
  323. await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
  324. const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
  325. const next = Math.max(0, Number(rows[0].balance || 0) + amount);
  326. await Psql.query(`UPDATE "UserCreditAccount" SET "balance"=$1,"updatedAt"=NOW() WHERE "userId"=$2`, [next, userId]);
  327. await insertLedger(userId, 'admin_adjust', amount, next, note, { adminUserId: session.userId });
  328. await Psql.query('COMMIT');
  329. response.json({ code: 200, success: true, data: true });
  330. } catch (e) {
  331. await Psql.query('ROLLBACK');
  332. throw e;
  333. }
  334. }
  335. async function refundByReservation(userId, reservationId, reason) {
  336. await Psql.query('BEGIN');
  337. try {
  338. const rows = await Psql.query(
  339. `SELECT * FROM "CreditReservation" WHERE "objectId"=$1 AND "userId"=$2 FOR UPDATE`,
  340. [reservationId, userId]
  341. );
  342. if (!rows.length || rows[0].status !== 'reserved') {
  343. await Psql.query('COMMIT');
  344. return;
  345. }
  346. const cost = Number(rows[0].cost || 0);
  347. const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
  348. const next = Number(accounts[0].balance || 0) + cost;
  349. await Psql.query(
  350. `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"=GREATEST(0,"totalConsumed"-$2),"updatedAt"=NOW() WHERE "userId"=$3`,
  351. [next, cost, userId]
  352. );
  353. await Psql.query(`UPDATE "CreditReservation" SET "status"='refunded',"updatedAt"=NOW() WHERE "objectId"=$1`, [reservationId]);
  354. await insertLedger(userId, 'refund', cost, next, reason, { reservationId });
  355. await Psql.query('COMMIT');
  356. } catch (e) {
  357. await Psql.query('ROLLBACK');
  358. throw e;
  359. }
  360. }
  361. async function requireLegacySession(request) {
  362. const token = clean(pickParam(request, 'sessionToken'));
  363. if (!token) throw new Error('请先登录');
  364. const rows = await Psql.query(
  365. `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
  366. [token]
  367. );
  368. if (!rows.length) throw new Error('登录已过期,请重新登录');
  369. return rows[0];
  370. }
  371. async function requireAdmin(userId) {
  372. const user = await publicUser(userId);
  373. if (user.role !== 'admin') throw new Error('没有管理员权限');
  374. }
  375. async function createSession(userId) {
  376. const token = generateId(32);
  377. await Psql.query(
  378. `INSERT INTO "AppSession" ("token","userId","expiresAt") VALUES ($1,$2,NOW()+INTERVAL '30 days')`,
  379. [token, userId]
  380. );
  381. return { token };
  382. }
  383. async function legacyPublicUser(userId) {
  384. const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
  385. if (!rows.length) throw new Error('用户不存在');
  386. return rowToUser(rows[0]);
  387. }
  388. async function requireSession(request) {
  389. const token = clean(pickParam(request, 'sessionToken'));
  390. if (!token) throw new Error('请先登录');
  391. const rows = await Psql.query(
  392. `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
  393. [token]
  394. );
  395. if (rows.length) return rows[0];
  396. const parseUser = await verifyParseSession(token);
  397. if (!parseUser?.objectId) throw new Error('登录已过期,请重新登录');
  398. await ensureCreditAccount(parseUser.objectId, REGISTER_GIFT_CREDITS);
  399. return {
  400. token,
  401. userId: parseUser.objectId,
  402. user: parseUserToPublicUser(parseUser),
  403. source: 'parse',
  404. };
  405. }
  406. async function publicUser(userId) {
  407. const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
  408. if (rows.length) return rowToUser(rows[0]);
  409. return {
  410. objectId: userId,
  411. username: userId,
  412. email: '',
  413. phone: '',
  414. displayName: userId,
  415. role: ADMIN_IDENTIFIERS.includes(userId) ? 'admin' : 'user'
  416. };
  417. }
  418. async function getBalance(userId) {
  419. await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
  420. const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
  421. const row = rows[0] || {};
  422. return {
  423. balance: Number(row.balance || 0),
  424. gifted: Number(row.gifted || 0),
  425. totalRecharged: Number(row.totalRecharged || 0),
  426. totalConsumed: Number(row.totalConsumed || 0)
  427. };
  428. }
  429. async function insertLedger(userId, type, amount, balanceAfter, title, detail) {
  430. const objectId = generateId();
  431. await Psql.query(
  432. `INSERT INTO "CreditLedger" ("objectId","userId","type","amount","balanceAfter","title","detail") VALUES ($1,$2,$3,$4,$5,$6,$7)`,
  433. [objectId, userId, type, amount, balanceAfter, title, JSON.stringify(detail || {})]
  434. );
  435. return objectId;
  436. }
  437. async function verifyParseSession(sessionToken) {
  438. if (typeof fetch !== 'function') return null;
  439. const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
  440. method: 'GET',
  441. headers: {
  442. 'X-Parse-Application-Id': PARSE_APP_ID,
  443. 'X-Parse-Session-Token': sessionToken,
  444. },
  445. });
  446. const data = await resp.json().catch(() => ({}));
  447. return resp.ok && data.objectId ? data : null;
  448. }
  449. async function ensureCreditAccount(userId, giftAmount) {
  450. const amount = Math.max(0, Number(giftAmount || 0));
  451. const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
  452. if (rows.length) return;
  453. await Psql.query(
  454. `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
  455. [userId, amount]
  456. );
  457. if (amount > 0) {
  458. await insertLedger(userId, 'gift', amount, amount, '初始赠送积分', { source: 'parse_user_login' });
  459. }
  460. }
  461. function parseUserToPublicUser(user) {
  462. const mobile = user.mobilePhoneNumber || user.mobile || '';
  463. return {
  464. objectId: user.objectId,
  465. username: user.username || mobile || user.objectId,
  466. email: user.email || '',
  467. phone: mobile,
  468. displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
  469. role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
  470. createdAt: user.createdAt
  471. };
  472. }
  473. function maskMobile(mobile) {
  474. const value = String(mobile || '');
  475. return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
  476. }
  477. function rowToUser(row) {
  478. return {
  479. objectId: row.objectId,
  480. username: row.username,
  481. email: row.email || '',
  482. phone: row.phone || '',
  483. displayName: row.displayName || row.username,
  484. role: row.role || 'user',
  485. createdAt: row.createdAt
  486. };
  487. }
  488. function rowToLedger(row) {
  489. return {
  490. objectId: row.objectId,
  491. type: row.type,
  492. amount: Number(row.amount || 0),
  493. balanceAfter: Number(row.balanceAfter || 0),
  494. title: row.title || '',
  495. detail: typeof row.detail === 'string' ? JSON.parse(row.detail || '{}') : (row.detail || {}),
  496. createdAt: row.createdAt
  497. };
  498. }
  499. function passwordHash(password, salt) {
  500. // fmode 云函数环境不保证可用 crypto;这里使用轻量确定性哈希。
  501. // 后续若平台支持 crypto,应替换为 PBKDF2/bcrypt。
  502. let h = 2166136261;
  503. const input = `${salt}:${password}`;
  504. for (let i = 0; i < input.length; i++) {
  505. h ^= input.charCodeAt(i);
  506. h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
  507. }
  508. return (h >>> 0).toString(16);
  509. }
  510. function pickParam(request, ...names) {
  511. const sources = [request.params, request.body, request];
  512. for (const src of sources) {
  513. if (!src || typeof src !== 'object') continue;
  514. for (const n of names) {
  515. const v = src[n];
  516. if (v !== undefined && v !== null && v !== '') return v;
  517. }
  518. }
  519. return null;
  520. }
  521. function clean(v) {
  522. return String(v || '').trim();
  523. }
  524. function generateId(len) {
  525. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  526. let s = '';
  527. const n = len || 10;
  528. for (let i = 0; i < n; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  529. return s;
  530. }