#!/usr/bin/env node const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main'; const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || ''; const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, ''); if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY'); const headers = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json', }; const pointer = (targetClass) => ({ type: 'Pointer', targetClass }); const lockedPermissions = { find: {}, count: {}, get: {}, create: {}, update: {}, delete: {}, addField: {}, }; const shared = { company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' }, isDeleted: { type: 'Boolean' }, sourceKey: { type: 'String' }, }; const schemas = { ReceivingAddress: { ...shared, receiverName: { type: 'String' }, mobile: { type: 'String' }, provinceName: { type: 'String' }, cityName: { type: 'String' }, countyName: { type: 'String' }, detailInfo: { type: 'String' }, postalCode: { type: 'String' }, isDefault: { type: 'Boolean' }, }, InvoiceProfile: { ...shared, invoiceType: { type: 'Number' }, title: { type: 'String' }, taxId: { type: 'String' }, mobile: { type: 'String' }, email: { type: 'String' }, address: { type: 'String' }, bankName: { type: 'String' }, bankAccount: { type: 'String' }, }, UserBankAccount: { ...shared, cardType: { type: 'String' }, bankName: { type: 'String' }, accountName: { type: 'String' }, accountNumber: { type: 'String' }, remark: { type: 'String' }, }, UserFavorite: { ...shared, targetId: { type: 'String' }, targetType: { type: 'Number' }, title: { type: 'String' }, url: { type: 'String' }, }, UnitLearningRecord: { ...shared, bookId: { type: 'Number' }, nodeId: { type: 'Number' }, wordIds: { type: 'Array' }, learnedCount: { type: 'Number' }, }, ServiceRequest: { ...shared, pubId: { type: 'Number' }, category: { type: 'String' }, title: { type: 'String' }, content: { type: 'String' }, contact: { type: 'String' }, payload: { type: 'Object' }, status: { type: 'String' }, reviewedAt: { type: 'Date' }, handledBy: pointer('_User'), }, CommerceProduct: { company: pointer('Company'), owner: pointer('_User'), legacyOwnerId: { type: 'Number' }, productId: { type: 'Number' }, name: { type: 'String' }, nodeId: { type: 'Number' }, retailPrice: { type: 'Number' }, marketPrice: { type: 'Number' }, unit: { type: 'String' }, stock: { type: 'Number' }, thumbnail: { type: 'String' }, gallery: { type: 'Array' }, summary: { type: 'String' }, content: { type: 'String' }, status: { type: 'String' }, sales: { type: 'Number' }, hot: { type: 'Boolean' }, isDeleted: { type: 'Boolean' }, sourceKey: { type: 'String' }, }, ShoppingCartItem: { ...shared, product: pointer('CommerceProduct'), productId: { type: 'Number' }, quantity: { type: 'Number' }, selected: { type: 'Boolean' }, }, ProductStockLedger: { company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' }, product: pointer('CommerceProduct'), productId: { type: 'Number' }, delta: { type: 'Number' }, stockBefore: { type: 'Number' }, stockAfter: { type: 'Number' }, remark: { type: 'String' }, sourceKey: { type: 'String' }, }, ProductComment: { ...shared, product: pointer('CommerceProduct'), productId: { type: 'Number' }, orderId: { type: 'String' }, title: { type: 'String' }, score: { type: 'Number' }, content: { type: 'String' }, images: { type: 'Array' }, anonymous: { type: 'Boolean' }, status: { type: 'String' }, }, CommerceOrder: { company: pointer('Company'), user: pointer('_User'), buyerLegacyId: { type: 'Number' }, orderId: { type: 'Number' }, orderNo: { type: 'String' }, status: { type: 'String' }, subtotal: { type: 'Number' }, discount: { type: 'Number' }, shipping: { type: 'Number' }, total: { type: 'Number' }, receiver: { type: 'Object' }, invoice: { type: 'Object' }, couponId: { type: 'String' }, paymentMode: { type: 'String' }, paidAt: { type: 'Date' }, shippedAt: { type: 'Date' }, signedAt: { type: 'Date' }, expressCompany: { type: 'String' }, expressNo: { type: 'String' }, expressRemark: { type: 'String' }, isDeleted: { type: 'Boolean' }, sourceKey: { type: 'String' }, }, CommerceOrderItem: { company: pointer('Company'), order: pointer('CommerceOrder'), orderId: { type: 'Number' }, product: pointer('CommerceProduct'), productId: { type: 'Number' }, seller: pointer('_User'), sellerLegacyId: { type: 'Number' }, name: { type: 'String' }, unitPrice: { type: 'Number' }, quantity: { type: 'Number' }, amount: { type: 'Number' }, thumbnail: { type: 'String' }, productSnapshot: { type: 'Object' }, sourceKey: { type: 'String' }, }, CommercePayment: { company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' }, order: pointer('CommerceOrder'), orderId: { type: 'Number' }, amount: { type: 'Number' }, mode: { type: 'String' }, status: { type: 'String' }, transactionNo: { type: 'String' }, paidAt: { type: 'Date' }, sourceKey: { type: 'String' }, }, CouponDefinition: { company: pointer('Company'), couponId: { type: 'Number' }, name: { type: 'String' }, amount: { type: 'Number' }, minAmount: { type: 'Number' }, maxAmount: { type: 'Number' }, startsAt: { type: 'Date' }, expiresAt: { type: 'Date' }, status: { type: 'String' }, totalQuantity: { type: 'Number' }, claimedQuantity: { type: 'Number' }, sourceKey: { type: 'String' }, }, UserCoupon: { ...shared, coupon: pointer('CouponDefinition'), couponId: { type: 'Number' }, status: { type: 'String' }, usedAt: { type: 'Date' }, expiresAt: { type: 'Date' }, }, MembershipStockLedger: { company: pointer('Company'), operator: pointer('_User'), operatorLegacyId: { type: 'Number' }, target: pointer('_User'), targetLegacyId: { type: 'Number' }, type: { type: 'String' }, groupBefore: { type: 'Number' }, groupAfter: { type: 'Number' }, quantity: { type: 'Number' }, operatorStockBefore: { type: 'Number' }, operatorStockAfter: { type: 'Number' }, targetStockBefore: { type: 'Number' }, targetStockAfter: { type: 'Number' }, remark: { type: 'String' }, sourceKey: { type: 'String' }, }, AppAuditLog: { company: pointer('Company'), user: pointer('_User'), legacyUserId: { type: 'Number' }, action: { type: 'String' }, targetClass: { type: 'String' }, targetId: { type: 'String' }, before: { type: 'Object' }, after: { type: 'Object' }, ip: { type: 'String' }, sourceKey: { type: 'String' }, }, LegacySyncCheckpoint: { company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' }, cursor: { type: 'String' }, state: { type: 'String' }, processedCount: { type: 'Number' }, lastBatchCount: { type: 'Number' }, lastFailureCount: { type: 'Number' }, lastAttemptAt: { type: 'Date' }, lastSuccessAt: { type: 'Date' }, leaseToken: { type: 'String' }, leaseUntil: { type: 'Date' }, lastServerTime: { type: 'Date' }, lastWatermark: { type: 'Number' }, }, LegacySyncConflict: { company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' }, legacyKey: { type: 'String' }, conflictType: { type: 'String' }, message: { type: 'String' }, resolved: { type: 'Boolean' }, resolvedAt: { type: 'Date' }, resolution: { type: 'String' }, }, LegacySyncDeadLetter: { company: pointer('Company'), sourceKey: { type: 'String' }, dataset: { type: 'String' }, legacyKey: { type: 'String' }, recordClasses: { type: 'Array' }, errorCode: { type: 'Number' }, errorMessage: { type: 'String' }, attempts: { type: 'Number' }, resolved: { type: 'Boolean' }, lastAttemptAt: { type: 'Date' }, resolvedAt: { type: 'Date' }, }, }; async function request(path, init = {}) { const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) throw new Error(`${path}: ${payload.error || response.status}`); return payload; } for (const [className, fields] of Object.entries(schemas)) { const existingResponse = await fetch(`${PARSE_URL}/schemas/${className}`, { headers }); const existing = await existingResponse.json().catch(() => ({})); if (existingResponse.status === 404 || (!existingResponse.ok && /does not exist/i.test(String(existing.error || '')))) { await request('/schemas/' + className, { method: 'POST', body: JSON.stringify({ className, fields, classLevelPermissions: lockedPermissions }), }); console.log(`created ${className}`); continue; } if (!existingResponse.ok || existing.error) throw new Error(`${className}: ${existing.error || existingResponse.status}`); const additions = Object.fromEntries(Object.entries(fields).filter(([name]) => !existing.fields?.[name])); if (Object.keys(additions).length) { await request('/schemas/' + className, { method: 'PUT', body: JSON.stringify({ fields: additions }) }); console.log(`updated ${className}: ${Object.keys(additions).join(', ')}`); } else console.log(`current ${className}`); }