|
|
@@ -1,507 +1,439 @@
|
|
|
/**
|
|
|
- * 云函数:authCreditManager(账号、总积分、充值订单、管理员调账)
|
|
|
+ * Cloud function: authCreditManager
|
|
|
*
|
|
|
- * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 authCredit。
|
|
|
- * 管理员策略:
|
|
|
- * 1. 第一位注册用户自动为 admin;
|
|
|
- * 2. 也可在 ADMIN_IDENTIFIERS 中配置用户名/邮箱/手机号。
|
|
|
+ * This file is the account and credit gateway for the current video-workflow
|
|
|
+ * project. It intentionally uses the existing Parse user session and APIGAuth
|
|
|
+ * balance instead of creating a second AppUser/UserCreditAccount system.
|
|
|
+ *
|
|
|
+ * Data source of truth:
|
|
|
+ * - Login/session: Parse _User + X-Parse-Session-Token
|
|
|
+ * - Balance: APIGAuth.count for VIDEO_WORKFLOW_APIG_ID
|
|
|
+ * - Consumption marker: APIGAuth.used + APIGAuth.lastBilling
|
|
|
+ * - Recharge: existing APIGOrder/saveRecharge platform endpoints
|
|
|
*/
|
|
|
-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';
|
|
|
+
|
|
|
+const PARSE_API_HOST = env('PARSE_API_HOST', 'https://server.fmode.cn');
|
|
|
+const PARSE_APP_ID = env('PARSE_APP_ID', 'ncloudmaster');
|
|
|
+const VIDEO_WORKFLOW_APIG_ID = env('VIDEO_WORKFLOW_APIG_ID', '6pFf6EAdKT');
|
|
|
+const DEFAULT_APIG_TITLE = env('VIDEO_WORKFLOW_APIG_TITLE', '短视频AI工作流');
|
|
|
+const DEFAULT_UNIT_PRICE_CNY = Number(env('VIDEO_WORKFLOW_APIG_UNIT_PRICE_CNY', '0.1')) || 0.1;
|
|
|
|
|
|
async function handler(request, response) {
|
|
|
try {
|
|
|
- await ensureTables();
|
|
|
- const action = pickParam(request, 'action') || '';
|
|
|
+ const action = clean(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 === 'rechargeContext') return rechargeContext(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);
|
|
|
+ if (action === 'saveRecharge') return saveRecharge(request, response);
|
|
|
+ if (action === 'adminListUsers') return unsupported(response, '账号列表请继续使用 Parse 用户体系或后续单独建设管理后台。');
|
|
|
+ if (action === 'adminCreateUser') return unsupported(response, '账号创建请继续使用 Parse 用户体系,不再由 authCreditManager 自建账号。');
|
|
|
+ if (action === 'adminAdjustCredit') return unsupported(response, '调额请直接调整 APIGAuth 或后续建设受控管理员云函数。');
|
|
|
+ if (action === 'register' || action === 'login' || action === 'changePassword') {
|
|
|
+ return unsupported(response, '当前项目使用 Parse 手机验证码登录,不再使用 authCreditManager 自建密码账号。');
|
|
|
+ }
|
|
|
|
|
|
- response.json({ code: 400, success: false, error: `未知 action: ${action}` });
|
|
|
+ return response.json({ code: 400, success: false, error: `未知 action: ${action || '(empty)'}` });
|
|
|
} catch (error) {
|
|
|
- console.error('authCreditManager failed:', error.message, error.stack);
|
|
|
- response.json({ code: 500, success: false, error: error.message });
|
|
|
+ const status = Number(error && (error.status || error.statusCode)) || 500;
|
|
|
+ console.error('authCreditManager failed:', error && error.message, error && error.stack);
|
|
|
+ return response.json({
|
|
|
+ code: status,
|
|
|
+ success: false,
|
|
|
+ error: error && error.message ? error.message : '账号积分服务调用失败',
|
|
|
+ detail: error && error.detail ? error.detail : undefined,
|
|
|
+ });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-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 });
|
|
|
+ const { user } = await requireParseUser(request);
|
|
|
+ return response.json({ code: 200, success: true, data: parseUserToAppUser(user) });
|
|
|
}
|
|
|
|
|
|
async function balance(request, response) {
|
|
|
- const session = await requireSession(request);
|
|
|
- response.json({ code: 200, success: true, data: await getBalance(session.userId) });
|
|
|
+ const context = await loadCreditContext(request);
|
|
|
+ return response.json({ code: 200, success: true, data: toCreditBalance(context) });
|
|
|
}
|
|
|
|
|
|
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) });
|
|
|
+ const context = await loadCreditContext(request);
|
|
|
+ const limit = clampNumber(pickParam(request, 'limit'), 1, 200, 50);
|
|
|
+ const items = [];
|
|
|
+ const lastBilling = context.auth && context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
|
|
|
+ ? context.auth.lastBilling
|
|
|
+ : null;
|
|
|
+
|
|
|
+ if (lastBilling) {
|
|
|
+ items.push({
|
|
|
+ objectId: lastBilling.reservationId || lastBilling.idempotencyKey || `lastBilling-${context.auth.objectId}`,
|
|
|
+ userId: context.user.objectId,
|
|
|
+ type: lastBilling.status === 'refunded' ? 'refund' : 'consume',
|
|
|
+ amount: -Math.abs(Number(lastBilling.costCredits || 0)),
|
|
|
+ balanceAfter: Number(lastBilling.balanceAfter || context.balance),
|
|
|
+ title: lastBilling.title || lastBilling.operation || '生成扣费记录',
|
|
|
+ detail: lastBilling,
|
|
|
+ createdAt: lastBilling.updatedAt || context.auth.updatedAt || context.auth.createdAt || new Date().toISOString(),
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return response.json({ code: 200, success: true, data: items.slice(0, limit) });
|
|
|
+}
|
|
|
+
|
|
|
+async function rechargeContext(request, response) {
|
|
|
+ const context = await loadCreditContext(request);
|
|
|
+ return response.json({
|
|
|
+ code: 200,
|
|
|
+ success: true,
|
|
|
+ data: {
|
|
|
+ authId: context.auth.objectId,
|
|
|
+ userId: context.user.objectId,
|
|
|
+ payUserId: readCompanyId(context.user) || context.auth.objectId,
|
|
|
+ apig: {
|
|
|
+ objectId: context.apig.objectId,
|
|
|
+ title: context.apig.title,
|
|
|
+ count: context.balance,
|
|
|
+ priceStep: normalizePriceSteps(context.apig.priceStep),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
async function reserve(request, response) {
|
|
|
- const session = await requireSession(request);
|
|
|
+ const context = await loadCreditContext(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' });
|
|
|
+ const detail = normalizeObject(pickParam(request, 'detail'));
|
|
|
+ const cost = Math.max(0, Number(pickParam(request, 'cost') || 0));
|
|
|
+ const idempotencyKey = clean(pickParam(request, 'idempotencyKey'))
|
|
|
+ || `${operation}:${context.user.objectId}:${stableJson({ cost, title, detail })}`;
|
|
|
+
|
|
|
+ if (cost <= 0) {
|
|
|
+ return response.json({
|
|
|
+ code: 200,
|
|
|
+ success: true,
|
|
|
+ data: { reservationId: `free-${Date.now()}`, balance: context.balance, cost: 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 lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
|
|
|
+ ? context.auth.lastBilling
|
|
|
+ : null;
|
|
|
+ if (lastBilling && lastBilling.idempotencyKey === idempotencyKey) {
|
|
|
+ if (lastBilling.status === 'reserved' || lastBilling.status === 'submitted' || lastBilling.status === 'committed') {
|
|
|
+ return response.json({
|
|
|
+ code: 200,
|
|
|
+ success: true,
|
|
|
+ data: {
|
|
|
+ reservationId: lastBilling.reservationId,
|
|
|
+ balance: Number(lastBilling.balanceAfter || context.balance),
|
|
|
+ cost: Number(lastBilling.costCredits || cost),
|
|
|
+ reused: true,
|
|
|
+ },
|
|
|
+ });
|
|
|
}
|
|
|
- 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;
|
|
|
}
|
|
|
+
|
|
|
+ if (context.balance < cost) {
|
|
|
+ return response.json({
|
|
|
+ code: 402,
|
|
|
+ success: false,
|
|
|
+ error: `余额不足:当前 ${context.balance},本次需要 ${cost}`,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const reservationId = generateId(16);
|
|
|
+ const oldUsed = Number(context.auth.used || 0);
|
|
|
+ const nextBalance = context.balance - cost;
|
|
|
+ const nextUsed = oldUsed + cost;
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const lastBillingPatch = {
|
|
|
+ kind: 'video_workflow_consume',
|
|
|
+ status: 'reserved',
|
|
|
+ reservationId,
|
|
|
+ idempotencyKey,
|
|
|
+ operation,
|
|
|
+ title,
|
|
|
+ costCredits: cost,
|
|
|
+ unitPriceCny: DEFAULT_UNIT_PRICE_CNY,
|
|
|
+ balanceBefore: context.balance,
|
|
|
+ balanceAfter: nextBalance,
|
|
|
+ usedBefore: oldUsed,
|
|
|
+ usedAfter: nextUsed,
|
|
|
+ detail,
|
|
|
+ updatedAt: now,
|
|
|
+ };
|
|
|
+
|
|
|
+ await updateApigAuth(context.auth.objectId, {
|
|
|
+ count: nextBalance,
|
|
|
+ used: nextUsed,
|
|
|
+ lastBilling: lastBillingPatch,
|
|
|
+ }, context.sessionToken);
|
|
|
+
|
|
|
+ return response.json({
|
|
|
+ code: 200,
|
|
|
+ success: true,
|
|
|
+ data: { reservationId, balance: nextBalance, cost },
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
async function commitReservation(request, response) {
|
|
|
- const session = await requireSession(request);
|
|
|
+ const context = await loadCreditContext(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 });
|
|
|
+ const detail = normalizeObject(pickParam(request, 'detail'));
|
|
|
+ const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
|
|
|
+ ? context.auth.lastBilling
|
|
|
+ : null;
|
|
|
+
|
|
|
+ if (!lastBilling || lastBilling.reservationId !== reservationId) {
|
|
|
+ return response.json({ code: 200, success: true, data: true, ignored: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ await updateApigAuth(context.auth.objectId, {
|
|
|
+ lastBilling: {
|
|
|
+ ...lastBilling,
|
|
|
+ status: 'committed',
|
|
|
+ commitDetail: detail,
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
+ },
|
|
|
+ }, context.sessionToken);
|
|
|
+
|
|
|
+ return response.json({ code: 200, success: true, data: true });
|
|
|
}
|
|
|
|
|
|
async function refundReservation(request, response) {
|
|
|
- const session = await requireSession(request);
|
|
|
+ const context = await loadCreditContext(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 });
|
|
|
+ const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
|
|
|
+ ? context.auth.lastBilling
|
|
|
+ : null;
|
|
|
+
|
|
|
+ if (!lastBilling || lastBilling.reservationId !== reservationId || lastBilling.status === 'refunded') {
|
|
|
+ return response.json({ code: 200, success: true, data: true, ignored: true });
|
|
|
+ }
|
|
|
+
|
|
|
+ const refundCredits = Math.abs(Number(lastBilling.costCredits || 0));
|
|
|
+ const currentUsed = Number(context.auth.used || 0);
|
|
|
+ const nextBalance = context.balance + refundCredits;
|
|
|
+ const nextUsed = Math.max(0, currentUsed - refundCredits);
|
|
|
+
|
|
|
+ await updateApigAuth(context.auth.objectId, {
|
|
|
+ count: nextBalance,
|
|
|
+ used: nextUsed,
|
|
|
+ lastBilling: {
|
|
|
+ ...lastBilling,
|
|
|
+ status: 'refunded',
|
|
|
+ refundReason: reason,
|
|
|
+ balanceAfter: nextBalance,
|
|
|
+ usedAfter: nextUsed,
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
+ },
|
|
|
+ }, context.sessionToken);
|
|
|
+
|
|
|
+ return 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;
|
|
|
+ const context = await loadCreditContext(request);
|
|
|
+ const count = Math.max(0, Number(pickParam(request, 'count') || 0));
|
|
|
+ const amountCny = Math.max(0, Number(pickParam(request, 'amountCny') || pickParam(request, 'price') || 0));
|
|
|
+ const params = normalizeObject(pickParam(request, 'params'));
|
|
|
+
|
|
|
+ if (count <= 0 || amountCny <= 0) {
|
|
|
+ return response.json({ code: 400, success: false, error: '缺少有效的充值积分或充值金额' });
|
|
|
+ }
|
|
|
+
|
|
|
+ const payUserId = readCompanyId(context.user) || context.auth.objectId;
|
|
|
+ const order = await requestJson(`${apiBase()}/api/apig/created-apigorder`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ user: payUserId,
|
|
|
+ fcompany: payUserId,
|
|
|
+ type: clean(pickParam(request, 'type')) || 'wxpay',
|
|
|
+ authid: context.auth.objectId,
|
|
|
+ params,
|
|
|
+ apigid: context.apig.objectId,
|
|
|
+ oldCount: context.balance,
|
|
|
+ count,
|
|
|
+ amountCny,
|
|
|
+ }),
|
|
|
+ }, 20000);
|
|
|
+
|
|
|
+ if (order && Number(order.code) === 200 && order.data) {
|
|
|
+ return response.json({ code: 200, success: true, data: order.data });
|
|
|
}
|
|
|
+
|
|
|
+ return response.json({
|
|
|
+ code: 500,
|
|
|
+ success: false,
|
|
|
+ error: order && (order.message || order.error) || '创建 APIGOrder 失败',
|
|
|
+ raw: order,
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
-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: '缺少用户或调整积分' });
|
|
|
+async function saveRecharge(request, response) {
|
|
|
+ const context = await loadCreditContext(request);
|
|
|
+ const payUserId = clean(pickParam(request, 'payUserId')) || readCompanyId(context.user) || context.auth.objectId;
|
|
|
+ const authId = clean(pickParam(request, 'authId')) || context.auth.objectId;
|
|
|
+ const apigId = clean(pickParam(request, 'apigId')) || context.apig.objectId;
|
|
|
+ const oldCount = Math.max(0, Number(pickParam(request, 'oldCount') || context.balance || 0));
|
|
|
+ const count = Math.max(0, Number(pickParam(request, 'count') || 0));
|
|
|
+ const orderId = clean(pickParam(request, 'orderId'));
|
|
|
|
|
|
- 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;
|
|
|
+ if (!authId || !apigId || count <= 0) {
|
|
|
+ return response.json({ code: 400, success: false, error: '缺少有效的充值到账参数' });
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-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;
|
|
|
+ const saved = await requestJson(`${apiBase()}/api/apig/saveRecharge`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ user: payUserId,
|
|
|
+ authComp: payUserId,
|
|
|
+ authid: authId,
|
|
|
+ apigid: apigId,
|
|
|
+ oldCount,
|
|
|
+ count,
|
|
|
+ orderid: orderId,
|
|
|
+ }),
|
|
|
+ }, 20000);
|
|
|
+
|
|
|
+ if (saved && saved.code && Number(saved.code) >= 400) {
|
|
|
+ return response.json({
|
|
|
+ code: Number(saved.code),
|
|
|
+ success: false,
|
|
|
+ error: saved.message || saved.error || '充值到账失败',
|
|
|
+ raw: saved,
|
|
|
+ });
|
|
|
}
|
|
|
-}
|
|
|
|
|
|
-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];
|
|
|
+ return response.json({ code: 200, success: true, data: true, raw: saved });
|
|
|
}
|
|
|
|
|
|
-async function requireAdmin(userId) {
|
|
|
- const user = await publicUser(userId);
|
|
|
- if (user.role !== 'admin') throw new Error('没有管理员权限');
|
|
|
+async function loadCreditContext(request) {
|
|
|
+ const { sessionToken, user } = await requireParseUser(request);
|
|
|
+ const auth = await ensureVideoWorkflowAuth(user.objectId, sessionToken);
|
|
|
+ const apigInfo = auth && auth.objectId ? await fetchApigPayInfo(auth.objectId).catch(() => null) : null;
|
|
|
+ const apig = normalizeApig(auth, apigInfo);
|
|
|
+ const balance = Number(apigInfo && apigInfo.count !== undefined ? apigInfo.count : auth.count || 0);
|
|
|
+ const used = Number(apigInfo && apigInfo.used !== undefined ? apigInfo.used : auth.used || 0);
|
|
|
+ return { sessionToken, user, auth: { ...auth, count: balance, used }, apig, balance, used };
|
|
|
}
|
|
|
|
|
|
-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 requireParseUser(request) {
|
|
|
+ const sessionToken = clean(pickParam(request, 'sessionToken') || headerValue(request, 'x-parse-session-token'));
|
|
|
+ if (!sessionToken) {
|
|
|
+ const error = new Error('请先登录');
|
|
|
+ error.status = 401;
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+
|
|
|
+ const user = await parseRequest('GET', '/users/me?include=company', undefined, sessionToken);
|
|
|
+ if (!user || !user.objectId) {
|
|
|
+ const error = new Error('登录已失效,请重新登录');
|
|
|
+ error.status = 401;
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+
|
|
|
+ return { sessionToken, user };
|
|
|
}
|
|
|
|
|
|
-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 findVideoWorkflowAuth(userId, sessionToken) {
|
|
|
+ const where = encodeURIComponent(JSON.stringify({
|
|
|
+ api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
|
|
|
+ user: { __type: 'Pointer', className: '_User', objectId: userId },
|
|
|
+ }));
|
|
|
+ const data = await parseRequest('GET', `/classes/APIGAuth?where=${where}&include=api&limit=1`, undefined, sessionToken);
|
|
|
+ return Array.isArray(data.results) ? data.results[0] || null : null;
|
|
|
}
|
|
|
|
|
|
-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];
|
|
|
+async function ensureVideoWorkflowAuth(userId, sessionToken) {
|
|
|
+ const existing = await findVideoWorkflowAuth(userId, sessionToken);
|
|
|
+ if (existing) return existing;
|
|
|
|
|
|
- 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',
|
|
|
+ const body = {
|
|
|
+ api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
|
|
|
+ user: { __type: 'Pointer', className: '_User', objectId: userId },
|
|
|
+ count: 0,
|
|
|
+ used: 0,
|
|
|
};
|
|
|
+ const created = await parseRequest('POST', '/classes/APIGAuth', body, sessionToken);
|
|
|
+ return { ...body, ...created };
|
|
|
}
|
|
|
|
|
|
-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 updateApigAuth(authId, patch, sessionToken) {
|
|
|
+ return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(authId)}`, patch, sessionToken);
|
|
|
}
|
|
|
|
|
|
-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 fetchApigPayInfo(authId) {
|
|
|
+ const resp = await requestJson(`${apiBase()}/api/apig/getApig`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ authid: authId }),
|
|
|
+ }, 15000);
|
|
|
+ if (resp && Number(resp.code) === 200 && resp.data) return resp.data;
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+async function parseRequest(method, path, body, sessionToken) {
|
|
|
+ const url = `${apiBase()}/parse${path}`;
|
|
|
+ const headers = {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-Parse-Application-Id': PARSE_APP_ID,
|
|
|
};
|
|
|
+ if (sessionToken) headers['X-Parse-Session-Token'] = sessionToken;
|
|
|
+ const init = { method, headers };
|
|
|
+ if (body !== undefined) init.body = JSON.stringify(body);
|
|
|
+ const { status, ok, rawText, data } = await requestJsonWithMeta(url, init, 15000);
|
|
|
+ if (!ok) {
|
|
|
+ const error = new Error(readErrorMessage(data, rawText) || `Parse ${method} ${path} failed`);
|
|
|
+ error.status = status || 500;
|
|
|
+ error.detail = data || rawText || '';
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ return data;
|
|
|
}
|
|
|
|
|
|
-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 requestJson(url, init, timeoutMs) {
|
|
|
+ const result = await requestJsonWithMeta(url, init, timeoutMs);
|
|
|
+ if (!result.ok) {
|
|
|
+ const error = new Error(readErrorMessage(result.data, result.rawText) || `HTTP ${result.status}`);
|
|
|
+ error.status = result.status || 500;
|
|
|
+ error.detail = result.data || result.rawText || '';
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ return result.data;
|
|
|
}
|
|
|
|
|
|
-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' });
|
|
|
+async function requestJsonWithMeta(url, init, timeoutMs) {
|
|
|
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
|
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs || 15000) : null;
|
|
|
+ try {
|
|
|
+ const resp = await fetch(url, { ...init, signal: controller ? controller.signal : undefined });
|
|
|
+ const rawText = await resp.text();
|
|
|
+ const data = safeJson(rawText);
|
|
|
+ return { status: resp.status, ok: resp.ok, rawText, data };
|
|
|
+ } catch (error) {
|
|
|
+ if (error && error.name === 'AbortError') {
|
|
|
+ const timeoutError = new Error('请求超时,请稍后重试');
|
|
|
+ timeoutError.status = 504;
|
|
|
+ throw timeoutError;
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+ } finally {
|
|
|
+ if (timer) clearTimeout(timer);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function parseUserToPublicUser(user) {
|
|
|
+function parseUserToAppUser(user) {
|
|
|
const mobile = user.mobilePhoneNumber || user.mobile || '';
|
|
|
return {
|
|
|
objectId: user.objectId,
|
|
|
@@ -509,72 +441,139 @@ function parseUserToPublicUser(user) {
|
|
|
email: user.email || '',
|
|
|
phone: mobile,
|
|
|
displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
|
|
|
+ companyId: readCompanyId(user),
|
|
|
role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
|
|
|
- createdAt: user.createdAt
|
|
|
+ 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) {
|
|
|
+function toCreditBalance(context) {
|
|
|
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
|
|
|
+ balance: Number(context.balance || 0),
|
|
|
+ gifted: 0,
|
|
|
+ totalRecharged: Number(context.balance || 0) + Number(context.used || 0),
|
|
|
+ totalConsumed: Number(context.used || 0),
|
|
|
+ authId: context.auth.objectId,
|
|
|
+ apigId: context.apig.objectId,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-function rowToLedger(row) {
|
|
|
+function normalizeApig(auth, apigInfo) {
|
|
|
+ const source = apigInfo || auth.api || {};
|
|
|
+ const objectId = source.objectId || source.api && source.api.objectId || auth.api && auth.api.objectId || VIDEO_WORKFLOW_APIG_ID;
|
|
|
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
|
|
|
+ objectId,
|
|
|
+ title: source.title || source.api && source.api.title || auth.api && auth.api.title || DEFAULT_APIG_TITLE,
|
|
|
+ count: Number(source.count !== undefined ? source.count : auth.count || 0),
|
|
|
+ used: Number(source.used !== undefined ? source.used : auth.used || 0),
|
|
|
+ price: Number(source.price || auth.api && auth.api.price || DEFAULT_UNIT_PRICE_CNY),
|
|
|
+ priceStep: source.priceStep || source.api && source.api.priceStep || [],
|
|
|
};
|
|
|
}
|
|
|
|
|
|
-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 normalizePriceSteps(raw) {
|
|
|
+ const rows = Array.isArray(raw) ? raw : [];
|
|
|
+ return rows
|
|
|
+ .map((item) => {
|
|
|
+ const row = item && typeof item === 'object' ? item : {};
|
|
|
+ return { count: Number(row.count || 0), price: Number(row.price || 0) };
|
|
|
+ })
|
|
|
+ .filter((item) => item.count > 0 && item.price > 0);
|
|
|
+}
|
|
|
+
|
|
|
+function readCompanyId(user) {
|
|
|
+ const company = user && user.company;
|
|
|
+ return company && typeof company === 'object' ? clean(company.objectId) : '';
|
|
|
+}
|
|
|
+
|
|
|
+function unsupported(response, message) {
|
|
|
+ return response.json({ code: 501, success: false, error: message });
|
|
|
}
|
|
|
|
|
|
function pickParam(request, ...names) {
|
|
|
- const sources = [request.params, request.body, request];
|
|
|
+ const sources = [request && request.params, request && request.body, request && request.query, 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;
|
|
|
+ for (const name of names) {
|
|
|
+ const value = src[name];
|
|
|
+ if (value !== undefined && value !== null && value !== '') return value;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
-function clean(v) {
|
|
|
- return String(v || '').trim();
|
|
|
+function headerValue(request, name) {
|
|
|
+ const headers = request && request.headers || {};
|
|
|
+ const target = String(name || '').toLowerCase();
|
|
|
+ for (const key of Object.keys(headers)) {
|
|
|
+ if (key.toLowerCase() === target) return headers[key];
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+function env(name, fallback) {
|
|
|
+ if (typeof process !== 'undefined' && process.env && process.env[name] !== undefined) {
|
|
|
+ return process.env[name];
|
|
|
+ }
|
|
|
+ return fallback;
|
|
|
}
|
|
|
|
|
|
-function generateId(len) {
|
|
|
+function apiBase() {
|
|
|
+ return String(PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
|
|
|
+}
|
|
|
+
|
|
|
+function clean(value) {
|
|
|
+ return String(value || '').trim();
|
|
|
+}
|
|
|
+
|
|
|
+function clampNumber(value, min, max, fallback) {
|
|
|
+ const n = Number(value);
|
|
|
+ if (!Number.isFinite(n)) return fallback;
|
|
|
+ return Math.max(min, Math.min(max, Math.floor(n)));
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeObject(value) {
|
|
|
+ if (!value) return {};
|
|
|
+ if (typeof value === 'object') return value;
|
|
|
+ if (typeof value === 'string') {
|
|
|
+ const parsed = safeJson(value);
|
|
|
+ return parsed && typeof parsed === 'object' ? parsed : { value };
|
|
|
+ }
|
|
|
+ return { value };
|
|
|
+}
|
|
|
+
|
|
|
+function stableJson(value) {
|
|
|
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
|
+ if (value && typeof value === 'object') {
|
|
|
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
|
+ }
|
|
|
+ return JSON.stringify(value);
|
|
|
+}
|
|
|
+
|
|
|
+function safeJson(text) {
|
|
|
+ try {
|
|
|
+ return text ? JSON.parse(text) : null;
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function readErrorMessage(data, fallback) {
|
|
|
+ if (typeof data === 'string') return data;
|
|
|
+ if (!data || typeof data !== 'object') return fallback || '';
|
|
|
+ return data.error || data.message || data.msg || data.data && (data.data.error || data.data.message) || fallback || '';
|
|
|
+}
|
|
|
+
|
|
|
+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 generateId(length) {
|
|
|
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;
|
|
|
+ let id = '';
|
|
|
+ for (let i = 0; i < (length || 10); i += 1) {
|
|
|
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
|
+ }
|
|
|
+ return id;
|
|
|
}
|