|
@@ -0,0 +1,580 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 云函数:authCreditManager(账号、总积分、充值订单、管理员调账)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 authCredit。
|
|
|
|
|
+ * 管理员策略:
|
|
|
|
|
+ * 1. 第一位注册用户自动为 admin;
|
|
|
|
|
+ * 2. 也可在 ADMIN_IDENTIFIERS 中配置用户名/邮箱/手机号。
|
|
|
|
|
+ */
|
|
|
|
|
+const ADMIN_IDENTIFIERS = ['admin'];
|
|
|
|
|
+const REGISTER_GIFT_CREDITS = 10;
|
|
|
|
|
+const CREDIT_PER_CNY = 10;
|
|
|
|
|
+const PARSE_API_HOST = 'https://server.fmode.cn';
|
|
|
|
|
+const PARSE_APP_ID = 'ncloudmaster';
|
|
|
|
|
+
|
|
|
|
|
+async function handler(request, response) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await ensureTables();
|
|
|
|
|
+ const action = pickParam(request, 'action') || '';
|
|
|
|
|
+
|
|
|
|
|
+ if (action === 'register') return register(request, response);
|
|
|
|
|
+ if (action === 'login') return login(request, response);
|
|
|
|
|
+ if (action === 'me') return me(request, response);
|
|
|
|
|
+ if (action === 'changePassword') return changePassword(request, response);
|
|
|
|
|
+ if (action === 'balance') return balance(request, response);
|
|
|
|
|
+ if (action === 'ledger') return ledger(request, response);
|
|
|
|
|
+ if (action === 'reserve') return reserve(request, response);
|
|
|
|
|
+ if (action === 'commitReservation') return commitReservation(request, response);
|
|
|
|
|
+ if (action === 'refundReservation') return refundReservation(request, response);
|
|
|
|
|
+ if (action === 'createRechargeOrder') return createRechargeOrder(request, response);
|
|
|
|
|
+ if (action === 'adminListUsers') return adminListUsers(request, response);
|
|
|
|
|
+ if (action === 'adminCreateUser') return adminCreateUser(request, response);
|
|
|
|
|
+ if (action === 'adminAdjustCredit') return adminAdjustCredit(request, response);
|
|
|
|
|
+
|
|
|
|
|
+ response.json({ code: 400, success: false, error: `未知 action: ${action}` });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('authCreditManager failed:', error.message, error.stack);
|
|
|
|
|
+ response.json({ code: 500, success: false, error: error.message });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function ensureTables() {
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "AppUser" (
|
|
|
|
|
+ "objectId" VARCHAR(50) PRIMARY KEY,
|
|
|
|
|
+ "username" VARCHAR(120) UNIQUE NOT NULL,
|
|
|
|
|
+ "email" VARCHAR(180) UNIQUE,
|
|
|
|
|
+ "phone" VARCHAR(60) UNIQUE,
|
|
|
|
|
+ "displayName" VARCHAR(180) DEFAULT '',
|
|
|
|
|
+ "passwordHash" VARCHAR(255) NOT NULL,
|
|
|
|
|
+ "passwordSalt" VARCHAR(120) NOT NULL,
|
|
|
|
|
+ "role" VARCHAR(30) DEFAULT 'user',
|
|
|
|
|
+ "status" VARCHAR(30) DEFAULT 'active',
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
|
+ "updatedAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+ await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_email ON "AppUser" ("email")`);
|
|
|
|
|
+ await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_phone ON "AppUser" ("phone")`);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "AppSession" (
|
|
|
|
|
+ "token" VARCHAR(120) PRIMARY KEY,
|
|
|
|
|
+ "userId" VARCHAR(50) NOT NULL,
|
|
|
|
|
+ "expiresAt" TIMESTAMPTZ NOT NULL,
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "UserCreditAccount" (
|
|
|
|
|
+ "userId" VARCHAR(50) PRIMARY KEY,
|
|
|
|
|
+ "balance" NUMERIC(12,2) DEFAULT 0,
|
|
|
|
|
+ "gifted" NUMERIC(12,2) DEFAULT 0,
|
|
|
|
|
+ "totalRecharged" NUMERIC(12,2) DEFAULT 0,
|
|
|
|
|
+ "totalConsumed" NUMERIC(12,2) DEFAULT 0,
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
|
+ "updatedAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "CreditLedger" (
|
|
|
|
|
+ "objectId" VARCHAR(50) PRIMARY KEY,
|
|
|
|
|
+ "userId" VARCHAR(50) NOT NULL,
|
|
|
|
|
+ "type" VARCHAR(40) NOT NULL,
|
|
|
|
|
+ "amount" NUMERIC(12,2) NOT NULL,
|
|
|
|
|
+ "balanceAfter" NUMERIC(12,2) NOT NULL,
|
|
|
|
|
+ "title" VARCHAR(255) DEFAULT '',
|
|
|
|
|
+ "detail" JSONB DEFAULT '{}',
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+ await Psql.query(`CREATE INDEX IF NOT EXISTS idx_credit_ledger_user_time ON "CreditLedger" ("userId", "createdAt" DESC)`);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "CreditReservation" (
|
|
|
|
|
+ "objectId" VARCHAR(50) PRIMARY KEY,
|
|
|
|
|
+ "userId" VARCHAR(50) NOT NULL,
|
|
|
|
|
+ "operation" VARCHAR(120) NOT NULL,
|
|
|
|
|
+ "cost" NUMERIC(12,2) NOT NULL,
|
|
|
|
|
+ "status" VARCHAR(30) DEFAULT 'reserved',
|
|
|
|
|
+ "ledgerId" VARCHAR(50),
|
|
|
|
|
+ "detail" JSONB DEFAULT '{}',
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
|
+ "updatedAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(`
|
|
|
|
|
+ CREATE TABLE IF NOT EXISTS "RechargeOrder" (
|
|
|
|
|
+ "objectId" VARCHAR(50) PRIMARY KEY,
|
|
|
|
|
+ "userId" VARCHAR(50) NOT NULL,
|
|
|
|
|
+ "amountCny" NUMERIC(12,2) NOT NULL,
|
|
|
|
|
+ "creditAmount" NUMERIC(12,2) NOT NULL,
|
|
|
|
|
+ "status" VARCHAR(30) DEFAULT 'pending',
|
|
|
|
|
+ "detail" JSONB DEFAULT '{}',
|
|
|
|
|
+ "createdAt" TIMESTAMPTZ DEFAULT NOW(),
|
|
|
|
|
+ "updatedAt" TIMESTAMPTZ DEFAULT NOW()
|
|
|
|
|
+ )
|
|
|
|
|
+ `);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function register(request, response) {
|
|
|
|
|
+ const username = clean(pickParam(request, 'username'));
|
|
|
|
|
+ const email = clean(pickParam(request, 'email'));
|
|
|
|
|
+ const phone = clean(pickParam(request, 'phone'));
|
|
|
|
|
+ const displayName = clean(pickParam(request, 'displayName')) || username;
|
|
|
|
|
+ const password = String(pickParam(request, 'password') || '');
|
|
|
|
|
+ if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或密码' });
|
|
|
|
|
+
|
|
|
|
|
+ const exists = await Psql.query(
|
|
|
|
|
+ `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
|
|
|
|
|
+ [username, email, phone]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
|
|
|
|
|
+
|
|
|
|
|
+ const countRows = await Psql.query(`SELECT COUNT(*)::int AS count FROM "AppUser"`);
|
|
|
|
|
+ const isFirstUser = Number(countRows[0].count || 0) === 0;
|
|
|
|
|
+ const role = isFirstUser || ADMIN_IDENTIFIERS.includes(username) || ADMIN_IDENTIFIERS.includes(email) || ADMIN_IDENTIFIERS.includes(phone)
|
|
|
|
|
+ ? 'admin'
|
|
|
|
|
+ : 'user';
|
|
|
|
|
+ const salt = generateId(16);
|
|
|
|
|
+ const userId = generateId();
|
|
|
|
|
+ const hash = passwordHash(password, salt);
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
|
|
|
|
|
+ VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
|
|
|
|
|
+ [userId, username, email, phone, displayName, hash, salt, role]
|
|
|
|
|
+ );
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
|
|
|
|
|
+ [userId, REGISTER_GIFT_CREDITS]
|
|
|
|
|
+ );
|
|
|
|
|
+ await insertLedger(userId, 'gift', REGISTER_GIFT_CREDITS, REGISTER_GIFT_CREDITS, '注册赠送积分', {});
|
|
|
|
|
+ const session = await createSession(userId);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: { token: session.token, user: await publicUser(userId) } });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function login(request, response) {
|
|
|
|
|
+ const identifier = clean(pickParam(request, 'identifier'));
|
|
|
|
|
+ const password = String(pickParam(request, 'password') || '');
|
|
|
|
|
+ const rows = await Psql.query(
|
|
|
|
|
+ `SELECT * FROM "AppUser" WHERE "username"=$1 OR "email"=$1 OR "phone"=$1 LIMIT 1`,
|
|
|
|
|
+ [identifier]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!rows.length) return response.json({ code: 401, success: false, error: '账号或密码不正确' });
|
|
|
|
|
+ const row = rows[0];
|
|
|
|
|
+ if (row.status !== 'active') return response.json({ code: 403, success: false, error: '账号已停用' });
|
|
|
|
|
+ if (passwordHash(password, row.passwordSalt) !== row.passwordHash) {
|
|
|
|
|
+ return response.json({ code: 401, success: false, error: '账号或密码不正确' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const session = await createSession(row.objectId);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: { token: session.token, user: rowToUser(row) } });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function me(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: session.user || await publicUser(session.userId) });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function changePassword(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const oldPassword = String(pickParam(request, 'oldPassword') || '');
|
|
|
|
|
+ const newPassword = String(pickParam(request, 'newPassword') || '');
|
|
|
|
|
+ if (!newPassword) return response.json({ code: 400, success: false, error: '缺少新密码' });
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [session.userId]);
|
|
|
|
|
+ const row = rows[0];
|
|
|
|
|
+ if (passwordHash(oldPassword, row.passwordSalt) !== row.passwordHash) {
|
|
|
|
|
+ return response.json({ code: 400, success: false, error: '原密码不正确' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const salt = generateId(16);
|
|
|
|
|
+ await Psql.query(`UPDATE "AppUser" SET "passwordHash"=$1,"passwordSalt"=$2,"updatedAt"=NOW() WHERE "objectId"=$3`, [
|
|
|
|
|
+ passwordHash(newPassword, salt), salt, session.userId
|
|
|
|
|
+ ]);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function balance(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: await getBalance(session.userId) });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function ledger(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const limit = Math.max(1, Math.min(200, Number(pickParam(request, 'limit') || 50)));
|
|
|
|
|
+ const rows = await Psql.query(
|
|
|
|
|
+ `SELECT * FROM "CreditLedger" WHERE "userId"=$1 ORDER BY "createdAt" DESC LIMIT ${limit}`,
|
|
|
|
|
+ [session.userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ response.json({ code: 200, success: true, data: rows.map(rowToLedger) });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function reserve(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const operation = clean(pickParam(request, 'operation')) || 'unknown';
|
|
|
|
|
+ const cost = Number(pickParam(request, 'cost') || 0);
|
|
|
|
|
+ const title = clean(pickParam(request, 'title')) || operation;
|
|
|
|
|
+ const detail = pickParam(request, 'detail') || {};
|
|
|
|
|
+ if (cost <= 0) return response.json({ code: 400, success: false, error: '积分消耗必须大于 0' });
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query('BEGIN');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [session.userId]);
|
|
|
|
|
+ if (!accounts.length || Number(accounts[0].balance || 0) < cost) {
|
|
|
|
|
+ await Psql.query('ROLLBACK');
|
|
|
|
|
+ return response.json({ code: 402, success: false, error: '积分不足,请先充值' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const newBalance = Number(accounts[0].balance || 0) - cost;
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"="totalConsumed"+$2,"updatedAt"=NOW() WHERE "userId"=$3`,
|
|
|
|
|
+ [newBalance, cost, session.userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ const ledgerId = await insertLedger(session.userId, 'consume', -cost, newBalance, title, { operation, ...detail, status: 'reserved' });
|
|
|
|
|
+ const reservationId = generateId();
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "CreditReservation" ("objectId","userId","operation","cost","ledgerId","detail") VALUES ($1,$2,$3,$4,$5,$6)`,
|
|
|
|
|
+ [reservationId, session.userId, operation, cost, ledgerId, JSON.stringify(detail || {})]
|
|
|
|
|
+ );
|
|
|
|
|
+ await Psql.query('COMMIT');
|
|
|
|
|
+ response.json({ code: 200, success: true, data: { reservationId, balance: newBalance, cost } });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ await Psql.query('ROLLBACK');
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function commitReservation(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const reservationId = clean(pickParam(request, 'reservationId'));
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `UPDATE "CreditReservation" SET "status"='committed',"detail"=COALESCE("detail",'{}'::jsonb) || $1::jsonb,"updatedAt"=NOW()
|
|
|
|
|
+ WHERE "objectId"=$2 AND "userId"=$3 AND "status"='reserved'`,
|
|
|
|
|
+ [JSON.stringify(pickParam(request, 'detail') || {}), reservationId, session.userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ response.json({ code: 200, success: true, data: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function refundReservation(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const reservationId = clean(pickParam(request, 'reservationId'));
|
|
|
|
|
+ const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
|
|
|
|
|
+ await refundByReservation(session.userId, reservationId, reason);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function createRechargeOrder(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ const amountCny = Number(pickParam(request, 'amountCny') || 0);
|
|
|
|
|
+ if (amountCny <= 0) return response.json({ code: 400, success: false, error: '充值金额必须大于 0' });
|
|
|
|
|
+ const order = {
|
|
|
|
|
+ objectId: generateId(),
|
|
|
|
|
+ amountCny,
|
|
|
|
|
+ creditAmount: Math.round(amountCny * CREDIT_PER_CNY),
|
|
|
|
|
+ status: 'pending',
|
|
|
|
|
+ createdAt: new Date().toISOString()
|
|
|
|
|
+ };
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "RechargeOrder" ("objectId","userId","amountCny","creditAmount","status","detail") VALUES ($1,$2,$3,$4,'pending',$5)`,
|
|
|
|
|
+ [order.objectId, session.userId, order.amountCny, order.creditAmount, JSON.stringify({ creditPerCny: CREDIT_PER_CNY })]
|
|
|
|
|
+ );
|
|
|
|
|
+ response.json({ code: 200, success: true, data: order });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function adminListUsers(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ await requireAdmin(session.userId);
|
|
|
|
|
+ const rows = await Psql.query(`
|
|
|
|
|
+ SELECT u."objectId",u."username",u."email",u."phone",u."displayName",u."role",u."status",u."createdAt",
|
|
|
|
|
+ COALESCE(c."balance",0) AS "creditBalance"
|
|
|
|
|
+ FROM "AppUser" u
|
|
|
|
|
+ LEFT JOIN "UserCreditAccount" c ON c."userId"=u."objectId"
|
|
|
|
|
+ ORDER BY u."createdAt" DESC
|
|
|
|
|
+ LIMIT 1000
|
|
|
|
|
+ `);
|
|
|
|
|
+ response.json({ code: 200, success: true, data: rows });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function adminCreateUser(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ await requireAdmin(session.userId);
|
|
|
|
|
+
|
|
|
|
|
+ const username = clean(pickParam(request, 'username'));
|
|
|
|
|
+ const email = clean(pickParam(request, 'email'));
|
|
|
|
|
+ const phone = clean(pickParam(request, 'phone'));
|
|
|
|
|
+ const displayName = clean(pickParam(request, 'displayName')) || username;
|
|
|
|
|
+ const password = String(pickParam(request, 'password') || '');
|
|
|
|
|
+ const roleInput = clean(pickParam(request, 'role')) || 'user';
|
|
|
|
|
+ const role = roleInput === 'admin' ? 'admin' : 'user';
|
|
|
|
|
+ const initialCredits = Math.max(0, Number(pickParam(request, 'initialCredits') ?? REGISTER_GIFT_CREDITS));
|
|
|
|
|
+ if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或初始密码' });
|
|
|
|
|
+
|
|
|
|
|
+ const exists = await Psql.query(
|
|
|
|
|
+ `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
|
|
|
|
|
+ [username, email, phone]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query('BEGIN');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const salt = generateId(16);
|
|
|
|
|
+ const userId = generateId();
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
|
|
|
|
|
+ VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
|
|
|
|
|
+ [userId, username, email, phone, displayName, passwordHash(password, salt), salt, role]
|
|
|
|
|
+ );
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
|
|
|
|
|
+ [userId, initialCredits]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (initialCredits > 0) {
|
|
|
|
|
+ await insertLedger(userId, 'admin_adjust', initialCredits, initialCredits, '管理员发放初始积分', { adminUserId: session.userId });
|
|
|
|
|
+ }
|
|
|
|
|
+ await Psql.query('COMMIT');
|
|
|
|
|
+ response.json({ code: 200, success: true, data: await publicUser(userId) });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ await Psql.query('ROLLBACK');
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function adminAdjustCredit(request, response) {
|
|
|
|
|
+ const session = await requireSession(request);
|
|
|
|
|
+ await requireAdmin(session.userId);
|
|
|
|
|
+ const userId = clean(pickParam(request, 'userId'));
|
|
|
|
|
+ const amount = Number(pickParam(request, 'amount') || 0);
|
|
|
|
|
+ const note = clean(pickParam(request, 'note')) || '管理员调整';
|
|
|
|
|
+ if (!userId || !amount) return response.json({ code: 400, success: false, error: '缺少用户或调整积分' });
|
|
|
|
|
+
|
|
|
|
|
+ await Psql.query('BEGIN');
|
|
|
|
|
+ try {
|
|
|
|
|
+ await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
|
|
|
|
|
+ const next = Math.max(0, Number(rows[0].balance || 0) + amount);
|
|
|
|
|
+ await Psql.query(`UPDATE "UserCreditAccount" SET "balance"=$1,"updatedAt"=NOW() WHERE "userId"=$2`, [next, userId]);
|
|
|
|
|
+ await insertLedger(userId, 'admin_adjust', amount, next, note, { adminUserId: session.userId });
|
|
|
|
|
+ await Psql.query('COMMIT');
|
|
|
|
|
+ response.json({ code: 200, success: true, data: true });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ await Psql.query('ROLLBACK');
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function refundByReservation(userId, reservationId, reason) {
|
|
|
|
|
+ await Psql.query('BEGIN');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const rows = await Psql.query(
|
|
|
|
|
+ `SELECT * FROM "CreditReservation" WHERE "objectId"=$1 AND "userId"=$2 FOR UPDATE`,
|
|
|
|
|
+ [reservationId, userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!rows.length || rows[0].status !== 'reserved') {
|
|
|
|
|
+ await Psql.query('COMMIT');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const cost = Number(rows[0].cost || 0);
|
|
|
|
|
+ const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
|
|
|
|
|
+ const next = Number(accounts[0].balance || 0) + cost;
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"=GREATEST(0,"totalConsumed"-$2),"updatedAt"=NOW() WHERE "userId"=$3`,
|
|
|
|
|
+ [next, cost, userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ await Psql.query(`UPDATE "CreditReservation" SET "status"='refunded',"updatedAt"=NOW() WHERE "objectId"=$1`, [reservationId]);
|
|
|
|
|
+ await insertLedger(userId, 'refund', cost, next, reason, { reservationId });
|
|
|
|
|
+ await Psql.query('COMMIT');
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ await Psql.query('ROLLBACK');
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requireLegacySession(request) {
|
|
|
|
|
+ const token = clean(pickParam(request, 'sessionToken'));
|
|
|
|
|
+ if (!token) throw new Error('请先登录');
|
|
|
|
|
+ const rows = await Psql.query(
|
|
|
|
|
+ `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
|
|
|
|
|
+ [token]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!rows.length) throw new Error('登录已过期,请重新登录');
|
|
|
|
|
+ return rows[0];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requireAdmin(userId) {
|
|
|
|
|
+ const user = await publicUser(userId);
|
|
|
|
|
+ if (user.role !== 'admin') throw new Error('没有管理员权限');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function createSession(userId) {
|
|
|
|
|
+ const token = generateId(32);
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "AppSession" ("token","userId","expiresAt") VALUES ($1,$2,NOW()+INTERVAL '30 days')`,
|
|
|
|
|
+ [token, userId]
|
|
|
|
|
+ );
|
|
|
|
|
+ return { token };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function legacyPublicUser(userId) {
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
|
|
|
|
|
+ if (!rows.length) throw new Error('用户不存在');
|
|
|
|
|
+ return rowToUser(rows[0]);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requireSession(request) {
|
|
|
|
|
+ const token = clean(pickParam(request, 'sessionToken'));
|
|
|
|
|
+ if (!token) throw new Error('请先登录');
|
|
|
|
|
+ const rows = await Psql.query(
|
|
|
|
|
+ `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
|
|
|
|
|
+ [token]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (rows.length) return rows[0];
|
|
|
|
|
+
|
|
|
|
|
+ const parseUser = await verifyParseSession(token);
|
|
|
|
|
+ if (!parseUser?.objectId) throw new Error('登录已过期,请重新登录');
|
|
|
|
|
+ await ensureCreditAccount(parseUser.objectId, REGISTER_GIFT_CREDITS);
|
|
|
|
|
+ return {
|
|
|
|
|
+ token,
|
|
|
|
|
+ userId: parseUser.objectId,
|
|
|
|
|
+ user: parseUserToPublicUser(parseUser),
|
|
|
|
|
+ source: 'parse',
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function publicUser(userId) {
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
|
|
|
|
|
+ if (rows.length) return rowToUser(rows[0]);
|
|
|
|
|
+ return {
|
|
|
|
|
+ objectId: userId,
|
|
|
|
|
+ username: userId,
|
|
|
|
|
+ email: '',
|
|
|
|
|
+ phone: '',
|
|
|
|
|
+ displayName: userId,
|
|
|
|
|
+ role: ADMIN_IDENTIFIERS.includes(userId) ? 'admin' : 'user'
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function getBalance(userId) {
|
|
|
|
|
+ await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
|
|
|
|
|
+ const row = rows[0] || {};
|
|
|
|
|
+ return {
|
|
|
|
|
+ balance: Number(row.balance || 0),
|
|
|
|
|
+ gifted: Number(row.gifted || 0),
|
|
|
|
|
+ totalRecharged: Number(row.totalRecharged || 0),
|
|
|
|
|
+ totalConsumed: Number(row.totalConsumed || 0)
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function insertLedger(userId, type, amount, balanceAfter, title, detail) {
|
|
|
|
|
+ const objectId = generateId();
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "CreditLedger" ("objectId","userId","type","amount","balanceAfter","title","detail") VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
|
|
|
|
+ [objectId, userId, type, amount, balanceAfter, title, JSON.stringify(detail || {})]
|
|
|
|
|
+ );
|
|
|
|
|
+ return objectId;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function verifyParseSession(sessionToken) {
|
|
|
|
|
+ if (typeof fetch !== 'function') return null;
|
|
|
|
|
+ const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
|
|
|
|
|
+ method: 'GET',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'X-Parse-Application-Id': PARSE_APP_ID,
|
|
|
|
|
+ 'X-Parse-Session-Token': sessionToken,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await resp.json().catch(() => ({}));
|
|
|
|
|
+ return resp.ok && data.objectId ? data : null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function ensureCreditAccount(userId, giftAmount) {
|
|
|
|
|
+ const amount = Math.max(0, Number(giftAmount || 0));
|
|
|
|
|
+ const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
|
|
|
|
|
+ if (rows.length) return;
|
|
|
|
|
+ await Psql.query(
|
|
|
|
|
+ `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
|
|
|
|
|
+ [userId, amount]
|
|
|
|
|
+ );
|
|
|
|
|
+ if (amount > 0) {
|
|
|
|
|
+ await insertLedger(userId, 'gift', amount, amount, '初始赠送积分', { source: 'parse_user_login' });
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function parseUserToPublicUser(user) {
|
|
|
|
|
+ const mobile = user.mobilePhoneNumber || user.mobile || '';
|
|
|
|
|
+ return {
|
|
|
|
|
+ objectId: user.objectId,
|
|
|
|
|
+ username: user.username || mobile || user.objectId,
|
|
|
|
|
+ email: user.email || '',
|
|
|
|
|
+ phone: mobile,
|
|
|
|
|
+ displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
|
|
|
|
|
+ role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
|
|
|
|
|
+ createdAt: user.createdAt
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function maskMobile(mobile) {
|
|
|
|
|
+ const value = String(mobile || '');
|
|
|
|
|
+ return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rowToUser(row) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ objectId: row.objectId,
|
|
|
|
|
+ username: row.username,
|
|
|
|
|
+ email: row.email || '',
|
|
|
|
|
+ phone: row.phone || '',
|
|
|
|
|
+ displayName: row.displayName || row.username,
|
|
|
|
|
+ role: row.role || 'user',
|
|
|
|
|
+ createdAt: row.createdAt
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rowToLedger(row) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ objectId: row.objectId,
|
|
|
|
|
+ type: row.type,
|
|
|
|
|
+ amount: Number(row.amount || 0),
|
|
|
|
|
+ balanceAfter: Number(row.balanceAfter || 0),
|
|
|
|
|
+ title: row.title || '',
|
|
|
|
|
+ detail: typeof row.detail === 'string' ? JSON.parse(row.detail || '{}') : (row.detail || {}),
|
|
|
|
|
+ createdAt: row.createdAt
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function passwordHash(password, salt) {
|
|
|
|
|
+ // fmode 云函数环境不保证可用 crypto;这里使用轻量确定性哈希。
|
|
|
|
|
+ // 后续若平台支持 crypto,应替换为 PBKDF2/bcrypt。
|
|
|
|
|
+ let h = 2166136261;
|
|
|
|
|
+ const input = `${salt}:${password}`;
|
|
|
|
|
+ for (let i = 0; i < input.length; i++) {
|
|
|
|
|
+ h ^= input.charCodeAt(i);
|
|
|
|
|
+ h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
|
|
|
|
|
+ }
|
|
|
|
|
+ return (h >>> 0).toString(16);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function pickParam(request, ...names) {
|
|
|
|
|
+ const sources = [request.params, request.body, request];
|
|
|
|
|
+ for (const src of sources) {
|
|
|
|
|
+ if (!src || typeof src !== 'object') continue;
|
|
|
|
|
+ for (const n of names) {
|
|
|
|
|
+ const v = src[n];
|
|
|
|
|
+ if (v !== undefined && v !== null && v !== '') return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clean(v) {
|
|
|
|
|
+ return String(v || '').trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function generateId(len) {
|
|
|
|
|
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
|
|
|
+ let s = '';
|
|
|
|
|
+ const n = len || 10;
|
|
|
|
|
+ for (let i = 0; i < n; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
|
|
|
+ return s;
|
|
|
|
|
+}
|