auth-credit.service.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { Injectable } from '@angular/core';
  2. import { BehaviorSubject, Subject } from 'rxjs';
  3. import { CLOUD_FN } from './cloud-functions';
  4. import { ParseService } from './parse.service';
  5. export type AppUserRole = 'user' | 'admin';
  6. export interface AppUser {
  7. objectId: string;
  8. username: string;
  9. email?: string;
  10. phone?: string;
  11. displayName?: string;
  12. companyId?: string;
  13. role: AppUserRole;
  14. createdAt?: string;
  15. }
  16. export interface AuthSession {
  17. token: string;
  18. user: AppUser;
  19. }
  20. export interface CreditBalance {
  21. balance: number;
  22. gifted: number;
  23. totalRecharged: number;
  24. totalConsumed: number;
  25. }
  26. export interface CreditLedgerItem {
  27. objectId: string;
  28. userId?: string;
  29. type: 'gift' | 'recharge' | 'consume' | 'refund' | 'admin_adjust' | string;
  30. amount: number;
  31. balanceAfter: number;
  32. title: string;
  33. detail?: any;
  34. createdAt: string;
  35. }
  36. export interface RechargeOrder {
  37. objectId: string;
  38. amountCny: number;
  39. creditAmount: number;
  40. status: 'pending' | 'paid' | 'cancelled';
  41. createdAt: string;
  42. }
  43. export interface AdminUserRow extends AppUser {
  44. creditBalance: number;
  45. status?: string;
  46. }
  47. export interface AdminCreateUserInput {
  48. username: string;
  49. password: string;
  50. email?: string;
  51. phone?: string;
  52. displayName?: string;
  53. role?: AppUserRole;
  54. initialCredits?: number;
  55. }
  56. export interface CreditReserveResult {
  57. reservationId: string;
  58. balance: number;
  59. cost: number;
  60. }
  61. const SESSION_STORAGE_KEY = 'videoWorkflow.authSession';
  62. const LOCAL_USERS_KEY = 'videoWorkflow.localAuth.users';
  63. const LOCAL_LEDGER_KEY = 'videoWorkflow.localAuth.ledger';
  64. const LOCAL_ORDERS_KEY = 'videoWorkflow.localAuth.orders';
  65. const PARSE_API_HOST = 'https://server.fmode.cn';
  66. const PARSE_APP_ID = 'ncloudmaster';
  67. const LOGIN_SMS_COMPANY = 'E4KpGvTEto';
  68. const CREDIT_LIMIT_ENABLED = false;
  69. @Injectable({ providedIn: 'root' })
  70. export class AuthCreditService {
  71. private readonly sessionSubject = new BehaviorSubject<AuthSession | null>(this.readSession());
  72. private readonly loginRequiredSubject = new Subject<{ featureName: string }>();
  73. readonly session$ = this.sessionSubject.asObservable();
  74. readonly loginRequired$ = this.loginRequiredSubject.asObservable();
  75. readonly localMode = !CLOUD_FN.authCredit;
  76. constructor(private parse: ParseService) {}
  77. get session(): AuthSession | null {
  78. return this.sessionSubject.value;
  79. }
  80. get currentUser(): AppUser | null {
  81. return this.session?.user || null;
  82. }
  83. get isLoggedIn(): boolean {
  84. return !!this.session?.token;
  85. }
  86. get isAdmin(): boolean {
  87. return this.currentUser?.role === 'admin';
  88. }
  89. requestLogin(featureName = '该功能'): void {
  90. this.loginRequiredSubject.next({ featureName });
  91. }
  92. async register(input: {
  93. username: string;
  94. password: string;
  95. email?: string;
  96. phone?: string;
  97. displayName?: string;
  98. }): Promise<AuthSession> {
  99. const session = this.localMode
  100. ? this.localRegister(input)
  101. : await this.call<AuthSession>('register', input);
  102. this.setSession(session);
  103. return session;
  104. }
  105. async login(identifier: string, password: string): Promise<AuthSession> {
  106. const session = this.localMode
  107. ? this.localLogin(identifier, password)
  108. : await this.call<AuthSession>('login', { identifier, password });
  109. this.setSession(session);
  110. return session;
  111. }
  112. async sendMobileCode(mobile: string): Promise<void> {
  113. const normalized = this.normalizeMobile(mobile);
  114. if (!normalized) throw new Error('请输入正确的手机号');
  115. const resp = await fetch(`${PARSE_API_HOST}/api/apig/message`, {
  116. method: 'POST',
  117. headers: {
  118. 'Content-Type': 'application/json',
  119. 'X-Parse-Application-Id': PARSE_APP_ID,
  120. },
  121. body: JSON.stringify({ mobile: normalized, company: LOGIN_SMS_COMPANY }),
  122. });
  123. const result = await resp.json().catch(() => ({}));
  124. if (!resp.ok || result.code !== 1) {
  125. throw new Error(result.data?.msg || result.message || result.error || '验证码发送失败,请稍后重试');
  126. }
  127. }
  128. async loginWithMobileCode(mobile: string, code: string): Promise<AuthSession> {
  129. const normalized = this.normalizeMobile(mobile);
  130. const smsCode = String(code || '').trim();
  131. if (!normalized) throw new Error('请输入正确的手机号');
  132. if (smsCode.length < 4) throw new Error('请输入验证码');
  133. const loginUrl = `${PARSE_API_HOST}/api/fmode/mobile?mobile=${encodeURIComponent(normalized)}&code=${encodeURIComponent(smsCode)}`;
  134. const resp = await fetch(loginUrl, {
  135. method: 'GET',
  136. headers: { 'X-Parse-Application-Id': PARSE_APP_ID },
  137. });
  138. const result = await resp.json().catch(() => ({}));
  139. const token = result?.data?.token;
  140. if (!resp.ok || result.code !== 200 || !token) {
  141. throw new Error(result.mess || result.message || result.error || '验证码错误或已过期');
  142. }
  143. const parseUser = await this.fetchParseMe(token);
  144. const session: AuthSession = {
  145. token,
  146. user: this.parseUserToAppUser(parseUser, normalized),
  147. };
  148. if (this.localMode) this.ensureLocalCreditUser(session.user);
  149. this.setSession(session);
  150. return session;
  151. }
  152. async loginWithParsePassword(username: string, password: string): Promise<AuthSession> {
  153. const account = String(username || '').trim();
  154. const pass = String(password || '');
  155. if (!account || !pass) throw new Error('请输入账号和密码');
  156. const params = new URLSearchParams({ username: account, password: pass });
  157. const resp = await fetch(`${PARSE_API_HOST}/parse/login?${params.toString()}`, {
  158. method: 'GET',
  159. headers: { 'X-Parse-Application-Id': PARSE_APP_ID },
  160. });
  161. const loginData = await resp.json().catch(() => ({}));
  162. const token = loginData.sessionToken;
  163. if (!resp.ok || !token) {
  164. throw new Error(loginData.error || loginData.message || '账号或密码不正确');
  165. }
  166. const parseUser = await this.fetchParseMe(token);
  167. const session: AuthSession = {
  168. token,
  169. user: this.parseUserToAppUser(parseUser, parseUser.mobilePhoneNumber || ''),
  170. };
  171. if (this.localMode) this.ensureLocalCreditUser(session.user);
  172. this.setSession(session);
  173. return session;
  174. }
  175. async refreshMe(): Promise<AppUser | null> {
  176. if (!this.session) return null;
  177. if (this.localMode) return this.session.user;
  178. const user = await this.call<AppUser>('me', this.authPayload());
  179. this.setSession({ ...this.session, user });
  180. return user;
  181. }
  182. logout(): void {
  183. localStorage.removeItem(SESSION_STORAGE_KEY);
  184. this.sessionSubject.next(null);
  185. }
  186. async getBalance(): Promise<CreditBalance> {
  187. if (this.localMode) return this.localBalance();
  188. return this.call<CreditBalance>('balance', this.authPayload());
  189. }
  190. async getLedger(limit = 50): Promise<CreditLedgerItem[]> {
  191. if (this.localMode) return this.localLedger().slice(0, limit);
  192. return this.call<CreditLedgerItem[]>('ledger', { ...this.authPayload(), limit });
  193. }
  194. async createRechargeOrder(amountCny: number): Promise<RechargeOrder> {
  195. throw new Error('试运营阶段暂未开放在线充值,请联系管理员调整额度');
  196. if (this.localMode) return this.localCreateRechargeOrder(amountCny);
  197. return this.call<RechargeOrder>('createRechargeOrder', { ...this.authPayload(), amountCny });
  198. }
  199. async reserveCredit(operation: string, cost: number, title: string, detail: any = {}): Promise<CreditReserveResult> {
  200. if (!CREDIT_LIMIT_ENABLED) {
  201. const balance = this.currentUser ? (await this.getBalance().catch(() => null))?.balance || 0 : 0;
  202. return { reservationId: `credit-disabled-${Date.now()}`, balance, cost: 0 };
  203. }
  204. if (this.localMode) return this.localReserve(operation, cost, title, detail);
  205. return this.call<CreditReserveResult>('reserve', { ...this.authPayload(), operation, cost, title, detail });
  206. }
  207. async commitReservation(reservationId: string, detail: any = {}): Promise<void> {
  208. if (!CREDIT_LIMIT_ENABLED || reservationId.startsWith('credit-disabled-')) return;
  209. if (this.localMode) return;
  210. await this.call<void>('commitReservation', { ...this.authPayload(), reservationId, detail });
  211. }
  212. async refundReservation(reservationId: string, reason: string): Promise<void> {
  213. if (!CREDIT_LIMIT_ENABLED || reservationId.startsWith('credit-disabled-')) return;
  214. if (this.localMode) {
  215. this.localRefund(reservationId, reason);
  216. return;
  217. }
  218. await this.call<void>('refundReservation', { ...this.authPayload(), reservationId, reason });
  219. }
  220. async adminListUsers(): Promise<AdminUserRow[]> {
  221. if (this.localMode) return this.localUsers().map((u: any) => ({ ...u.user, creditBalance: u.balance || 0 }));
  222. return this.call<AdminUserRow[]>('adminListUsers', this.authPayload());
  223. }
  224. async adminCreateUser(input: AdminCreateUserInput): Promise<AppUser> {
  225. if (this.localMode) return this.localAdminCreateUser(input);
  226. return this.call<AppUser>('adminCreateUser', { ...this.authPayload(), ...input });
  227. }
  228. async adminAdjustCredit(userId: string, amount: number, note: string): Promise<void> {
  229. if (this.localMode) {
  230. const users = this.localUsers();
  231. const row = users.find((u: any) => u.user.objectId === userId);
  232. if (!row) throw new Error('未找到用户');
  233. row.balance = Math.max(0, Number(row.balance || 0) + Number(amount || 0));
  234. this.writeLocalUsers(users);
  235. this.appendLocalLedger({
  236. objectId: this.localId(),
  237. userId,
  238. type: 'admin_adjust',
  239. amount,
  240. balanceAfter: row.balance,
  241. title: note || '管理员调整',
  242. createdAt: new Date().toISOString(),
  243. });
  244. return;
  245. }
  246. await this.call<void>('adminAdjustCredit', { ...this.authPayload(), userId, amount, note });
  247. }
  248. async changePassword(oldPassword: string, newPassword: string): Promise<void> {
  249. if (this.localMode) {
  250. const session = this.session;
  251. if (!session) throw new Error('请先登录');
  252. const users = this.localUsers();
  253. const row = users.find((u: any) => u.user.objectId === session.user.objectId);
  254. if (!row || row.password !== oldPassword) throw new Error('原密码不正确');
  255. row.password = newPassword;
  256. this.writeLocalUsers(users);
  257. return;
  258. }
  259. await this.call<void>('changePassword', { ...this.authPayload(), oldPassword, newPassword });
  260. }
  261. private async call<T>(action: string, params: Record<string, any>): Promise<T> {
  262. const res = await this.parse.call<T>(CLOUD_FN.authCredit, { action, ...params });
  263. if (res.code !== 200 || !res.success) {
  264. throw new Error(res.error || '账号服务调用失败');
  265. }
  266. return res.data as T;
  267. }
  268. private authPayload(): Record<string, any> {
  269. if (!this.session) throw new Error('请先登录');
  270. return { sessionToken: this.session.token, userId: this.session.user.objectId };
  271. }
  272. private readSession(): AuthSession | null {
  273. try {
  274. const raw = localStorage.getItem(SESSION_STORAGE_KEY);
  275. return raw ? JSON.parse(raw) : null;
  276. } catch {
  277. return null;
  278. }
  279. }
  280. private setSession(session: AuthSession): void {
  281. localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
  282. this.sessionSubject.next(session);
  283. }
  284. private normalizeMobile(mobile: string): string {
  285. const value = String(mobile || '').replace(/\D/g, '').slice(0, 11);
  286. return /^1[3-9]\d{9}$/.test(value) ? value : '';
  287. }
  288. private async fetchParseMe(sessionToken: string): Promise<any> {
  289. const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
  290. method: 'GET',
  291. headers: {
  292. 'X-Parse-Application-Id': PARSE_APP_ID,
  293. 'X-Parse-Session-Token': sessionToken,
  294. },
  295. });
  296. const data = await resp.json().catch(() => ({}));
  297. if (!resp.ok || !data.objectId) {
  298. throw new Error(data.error || data.message || '登录成功但无法获取用户信息');
  299. }
  300. return data;
  301. }
  302. private parseUserToAppUser(parseUser: any, mobile: string): AppUser {
  303. const displayName = parseUser.nickname
  304. || parseUser.name
  305. || parseUser.displayName
  306. || this.maskMobile(parseUser.mobilePhoneNumber || mobile)
  307. || parseUser.username
  308. || parseUser.objectId;
  309. return {
  310. objectId: parseUser.objectId,
  311. username: parseUser.username || parseUser.mobilePhoneNumber || mobile || parseUser.objectId,
  312. email: parseUser.email || '',
  313. phone: parseUser.mobilePhoneNumber || mobile || '',
  314. displayName,
  315. companyId: parseUser.company?.objectId || '',
  316. role: parseUser.role === 'admin' || parseUser.isAdmin === true ? 'admin' : 'user',
  317. createdAt: parseUser.createdAt,
  318. };
  319. }
  320. private maskMobile(mobile: string): string {
  321. const value = String(mobile || '');
  322. return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
  323. }
  324. private ensureLocalCreditUser(user: AppUser): void {
  325. const users = this.localUsers();
  326. const row = users.find((u: any) => u.user.objectId === user.objectId);
  327. if (row) {
  328. row.user = { ...row.user, ...user };
  329. this.writeLocalUsers(users);
  330. return;
  331. }
  332. users.push({ user, password: '', balance: 10 });
  333. this.writeLocalUsers(users);
  334. this.appendLocalLedger({
  335. objectId: this.localId(),
  336. userId: user.objectId,
  337. type: 'gift',
  338. amount: 10,
  339. balanceAfter: 10,
  340. title: '初始赠送积分',
  341. createdAt: new Date().toISOString(),
  342. });
  343. }
  344. private localRegister(input: any): AuthSession {
  345. const users = this.localUsers();
  346. const identifier = String(input.username || '').trim();
  347. if (!identifier || !input.password) throw new Error('请输入账号和密码');
  348. const exists = users.some((u: any) =>
  349. u.user.username === identifier || (input.email && u.user.email === input.email) || (input.phone && u.user.phone === input.phone)
  350. );
  351. if (exists) throw new Error('账号已存在');
  352. const isFirst = users.length === 0;
  353. const user: AppUser = {
  354. objectId: this.localId(),
  355. username: identifier,
  356. email: input.email || '',
  357. phone: input.phone || '',
  358. displayName: input.displayName || identifier,
  359. role: isFirst ? 'admin' : 'user',
  360. createdAt: new Date().toISOString(),
  361. };
  362. users.push({ user, password: input.password, balance: 10 });
  363. this.writeLocalUsers(users);
  364. this.appendLocalLedger({
  365. objectId: this.localId(),
  366. userId: user.objectId,
  367. type: 'gift',
  368. amount: 10,
  369. balanceAfter: 10,
  370. title: '注册赠送积分',
  371. createdAt: new Date().toISOString(),
  372. });
  373. return { token: this.localId() + this.localId(), user };
  374. }
  375. private localLogin(identifier: string, password: string): AuthSession {
  376. const id = String(identifier || '').trim();
  377. const row = this.localUsers().find((u: any) =>
  378. (u.user.username === id || u.user.email === id || u.user.phone === id) && u.password === password
  379. );
  380. if (!row) throw new Error('账号或密码不正确');
  381. return { token: this.localId() + this.localId(), user: row.user };
  382. }
  383. private localBalance(): CreditBalance {
  384. const userId = this.currentUser?.objectId;
  385. const row = this.localUsers().find((u: any) => u.user.objectId === userId);
  386. const ledger = this.localLedger().filter((i: any) => i.userId === userId);
  387. return {
  388. balance: Number(row?.balance || 0),
  389. gifted: ledger.filter((i) => i.type === 'gift').reduce((s, i) => s + Number(i.amount || 0), 0),
  390. totalRecharged: ledger.filter((i) => i.type === 'recharge').reduce((s, i) => s + Number(i.amount || 0), 0),
  391. totalConsumed: Math.abs(ledger.filter((i) => i.type === 'consume').reduce((s, i) => s + Number(i.amount || 0), 0)),
  392. };
  393. }
  394. private localAdminCreateUser(input: AdminCreateUserInput): AppUser {
  395. if (!this.isAdmin) throw new Error('没有管理员权限');
  396. const username = String(input.username || '').trim();
  397. const password = String(input.password || '').trim();
  398. if (!username || !password) throw new Error('请输入用户名和初始密码');
  399. const users = this.localUsers();
  400. const exists = users.some((u: any) =>
  401. u.user.username === username || (input.email && u.user.email === input.email) || (input.phone && u.user.phone === input.phone)
  402. );
  403. if (exists) throw new Error('账号已存在');
  404. const initialCredits = Math.max(0, Number(input.initialCredits ?? 10));
  405. const user: AppUser = {
  406. objectId: this.localId(),
  407. username,
  408. email: input.email || '',
  409. phone: input.phone || '',
  410. displayName: input.displayName || username,
  411. role: input.role || 'user',
  412. createdAt: new Date().toISOString(),
  413. };
  414. users.push({ user, password, balance: initialCredits });
  415. this.writeLocalUsers(users);
  416. if (initialCredits > 0) {
  417. this.appendLocalLedger({
  418. objectId: this.localId(),
  419. userId: user.objectId,
  420. type: 'admin_adjust',
  421. amount: initialCredits,
  422. balanceAfter: initialCredits,
  423. title: '管理员发放初始积分',
  424. createdAt: new Date().toISOString(),
  425. });
  426. }
  427. return user;
  428. }
  429. private localCreateRechargeOrder(amountCny: number): RechargeOrder {
  430. if (!this.currentUser) throw new Error('请先登录');
  431. const order: RechargeOrder = {
  432. objectId: this.localId(),
  433. amountCny,
  434. creditAmount: Math.round(Number(amountCny || 0) * 10),
  435. status: 'pending',
  436. createdAt: new Date().toISOString(),
  437. };
  438. const orders = this.localOrders();
  439. orders.unshift({ ...order, userId: this.currentUser.objectId });
  440. localStorage.setItem(LOCAL_ORDERS_KEY, JSON.stringify(orders));
  441. return order;
  442. }
  443. private localReserve(operation: string, cost: number, title: string, detail: any): CreditReserveResult {
  444. if (!this.currentUser) throw new Error('请先登录');
  445. const users = this.localUsers();
  446. const row = users.find((u: any) => u.user.objectId === this.currentUser!.objectId);
  447. const amount = Math.max(0, Number(cost || 0));
  448. if (!row || Number(row.balance || 0) < amount) throw new Error('当前额度不足,请联系管理员调整额度');
  449. row.balance = Number(row.balance || 0) - amount;
  450. this.writeLocalUsers(users);
  451. const reservationId = this.localId();
  452. this.appendLocalLedger({
  453. objectId: reservationId,
  454. userId: this.currentUser.objectId,
  455. type: 'consume',
  456. amount: -amount,
  457. balanceAfter: row.balance,
  458. title,
  459. detail: { operation, ...detail, status: 'reserved' },
  460. createdAt: new Date().toISOString(),
  461. });
  462. return { reservationId, balance: row.balance, cost: amount };
  463. }
  464. private localRefund(reservationId: string, reason: string): void {
  465. const ledger = this.localLedger();
  466. const item = ledger.find((i: any) => i.objectId === reservationId && i.type === 'consume');
  467. if (!item || item.detail?.refunded) return;
  468. const users = this.localUsers();
  469. const row = users.find((u: any) => u.user.objectId === item.userId);
  470. const refund = Math.abs(Number(item.amount || 0));
  471. if (!row) return;
  472. row.balance = Number(row.balance || 0) + refund;
  473. item.detail = { ...(item.detail || {}), refunded: true, refundReason: reason };
  474. this.writeLocalUsers(users);
  475. localStorage.setItem(LOCAL_LEDGER_KEY, JSON.stringify(ledger));
  476. this.appendLocalLedger({
  477. objectId: this.localId(),
  478. userId: item.userId,
  479. type: 'refund',
  480. amount: refund,
  481. balanceAfter: row.balance,
  482. title: reason || '任务失败退回积分',
  483. createdAt: new Date().toISOString(),
  484. });
  485. }
  486. private localUsers(): any[] {
  487. try { return JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); } catch { return []; }
  488. }
  489. private writeLocalUsers(users: any[]): void {
  490. localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users));
  491. }
  492. private localLedger(): CreditLedgerItem[] {
  493. const userId = this.currentUser?.objectId;
  494. try {
  495. const rows = JSON.parse(localStorage.getItem(LOCAL_LEDGER_KEY) || '[]');
  496. return rows.filter((i: any) => !userId || i.userId === userId);
  497. } catch {
  498. return [];
  499. }
  500. }
  501. private appendLocalLedger(item: any): void {
  502. const rows = JSON.parse(localStorage.getItem(LOCAL_LEDGER_KEY) || '[]');
  503. rows.unshift(item);
  504. localStorage.setItem(LOCAL_LEDGER_KEY, JSON.stringify(rows));
  505. }
  506. private localOrders(): any[] {
  507. try { return JSON.parse(localStorage.getItem(LOCAL_ORDERS_KEY) || '[]'); } catch { return []; }
  508. }
  509. private localId(): string {
  510. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  511. let s = '';
  512. for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  513. return s;
  514. }
  515. }