forked from yoone/API
1
0
Fork 0

feat(订阅): 添加WooCommerce订阅管理功能

实现订阅模块的完整功能,包括:
- 添加订阅状态枚举
- 创建订阅实体和DTO
- 实现订阅同步和查询服务
- 添加订阅控制器提供API接口
- 配置订阅实体到数据库连接
This commit is contained in:
tikkhun 2025-11-13 15:10:20 +08:00
parent b8290d0cda
commit 795b13ce31
9 changed files with 2827 additions and 0 deletions

2541
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@ import { CustomerTag } from '../entity/customer_tag.entity';
import { Customer } from '../entity/customer.entity'; import { Customer } from '../entity/customer.entity';
import { DeviceWhitelist } from '../entity/device_whitelist'; import { DeviceWhitelist } from '../entity/device_whitelist';
import { AuthCode } from '../entity/auth_code'; import { AuthCode } from '../entity/auth_code';
import { Subscription } from '../entity/subscription.entity';
export default { export default {
// use for cookie sign key, should change to your own and keep security // use for cookie sign key, should change to your own and keep security
@ -72,6 +73,7 @@ export default {
Customer, Customer,
DeviceWhitelist, DeviceWhitelist,
AuthCode, AuthCode,
Subscription,
], ],
synchronize: true, synchronize: true,
logging: false, logging: false,

View File

@ -0,0 +1,34 @@
import { Controller, Inject, Param, Post, Get, Query } from '@midwayjs/core';
import { ApiOkResponse } from '@midwayjs/swagger';
import { SubscriptionService } from '../service/subscription.service';
import { errorResponse, successResponse } from '../utils/response.util';
import { BooleanRes, SubscriptionListRes } from '../dto/reponse.dto';
import { QuerySubscriptionDTO } from '../dto/subscription.dto';
@Controller('/subscription')
export class SubscriptionController {
@Inject()
subscriptionService: SubscriptionService;
@ApiOkResponse({ type: BooleanRes })
@Post('/sync/:siteId')
async sync(@Param('siteId') siteId: string) {
try {
await this.subscriptionService.syncSubscriptions(siteId);
return successResponse(true);
} catch (error) {
return errorResponse(error?.message || '同步失败');
}
}
@ApiOkResponse({ type: SubscriptionListRes })
@Get('/list')
async list(@Query() query: QuerySubscriptionDTO) {
try {
const data = await this.subscriptionService.getSubscriptionList(query);
return successResponse(data);
} catch (error) {
return errorResponse(error?.message || '获取失败');
}
}
}

View File

@ -23,6 +23,7 @@ import { OrderNote } from '../entity/order_note.entity';
import { PaymentMethodDTO } from './logistics.dto'; import { PaymentMethodDTO } from './logistics.dto';
import { Flavors } from '../entity/flavors.entity'; import { Flavors } from '../entity/flavors.entity';
import { Strength } from '../entity/strength.entity'; import { Strength } from '../entity/strength.entity';
import { Subscription } from '../entity/subscription.entity';
export class BooleanRes extends SuccessWrapper(Boolean) {} export class BooleanRes extends SuccessWrapper(Boolean) {}
//网站配置返回数据 //网站配置返回数据
@ -117,3 +118,8 @@ export class OrderDetailRes extends SuccessWrapper(OrderDetail) {}
export class PaymentMethodListRes extends SuccessArrayWrapper( export class PaymentMethodListRes extends SuccessArrayWrapper(
PaymentMethodDTO PaymentMethodDTO
) {} ) {}
// 订阅分页数据
export class SubscriptionPaginatedResponse extends PaginatedWrapper(Subscription) {}
// 订阅分页返回数据
export class SubscriptionListRes extends SuccessWrapper(SubscriptionPaginatedResponse) {}

View File

@ -0,0 +1,29 @@
import { ApiProperty } from '@midwayjs/swagger';
import { Rule, RuleType } from '@midwayjs/validate';
import { SubscriptionStatus } from '../enums/base.enum';
export class QuerySubscriptionDTO {
@ApiProperty({ example: 1, description: '页码' })
@Rule(RuleType.number().default(1))
current: number;
@ApiProperty({ example: 10, description: '每页大小' })
@Rule(RuleType.number().default(10))
pageSize: number;
@ApiProperty({ description: '站点ID' })
@Rule(RuleType.string().allow(''))
siteId: string;
@ApiProperty({ description: '订阅状态', enum: SubscriptionStatus })
@Rule(RuleType.string().valid(...Object.values(SubscriptionStatus)).allow(''))
status: SubscriptionStatus | '';
@ApiProperty({ description: '客户邮箱' })
@Rule(RuleType.string().allow(''))
customer_email: string;
@ApiProperty({ description: '关键字订阅ID、邮箱等' })
@Rule(RuleType.string().allow(''))
keyword: string;
}

View File

@ -0,0 +1,109 @@
import { ApiProperty } from '@midwayjs/swagger';
import { Exclude, Expose } from 'class-transformer';
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SubscriptionStatus } from '../enums/base.enum';
@Entity('subscription')
@Exclude()
export class Subscription {
@ApiProperty()
@PrimaryGeneratedColumn()
@Expose()
id: number;
@ApiProperty({ description: '来源站点唯一标识' })
@Column()
@Expose()
siteId: string;
@ApiProperty({ description: 'WooCommerce 订阅 ID' })
@Column()
@Expose()
externalSubscriptionId: string;
@ApiProperty({ type: SubscriptionStatus })
@Column({ type: 'enum', enum: SubscriptionStatus })
@Expose()
status: SubscriptionStatus;
@ApiProperty()
@Column({ default: '' })
@Expose()
currency: string;
@ApiProperty()
@Column('decimal', { precision: 10, scale: 2, default: 0 })
@Expose()
total: number;
@ApiProperty({ description: '计费周期 e.g. day/week/month/year' })
@Column({ default: '' })
@Expose()
billing_period: string;
@ApiProperty({ description: '计费周期间隔 e.g. 1/3/12' })
@Column({ type: 'int', default: 0 })
@Expose()
billing_interval: number;
@ApiProperty()
@Column({ type: 'int', default: 0 })
@Expose()
customer_id: number;
@ApiProperty()
@Column({ default: '' })
@Expose()
customer_email: string;
@ApiProperty({ description: '父订单/父订阅ID如有' })
@Column({ type: 'int', default: 0 })
@Expose()
parent_id: number;
@ApiProperty()
@Column({ type: 'timestamp', nullable: true })
@Expose()
start_date: Date;
@ApiProperty()
@Column({ type: 'timestamp', nullable: true })
@Expose()
trial_end: Date;
@ApiProperty()
@Column({ type: 'timestamp', nullable: true })
@Expose()
next_payment_date: Date;
@ApiProperty()
@Column({ type: 'timestamp', nullable: true })
@Expose()
end_date: Date;
@ApiProperty()
@Column({ type: 'json', nullable: true })
@Expose()
line_items: any[];
@ApiProperty()
@Column({ type: 'json', nullable: true })
@Expose()
meta_data: any[];
@ApiProperty({ example: '2022-12-12 11:11:11', description: '创建时间', required: true })
@CreateDateColumn()
@Expose()
createdAt: Date;
@ApiProperty({ example: '2022-12-12 11:11:11', description: '更新时间', required: true })
@UpdateDateColumn()
@Expose()
updatedAt: Date;
}

View File

@ -72,3 +72,14 @@ export enum ShipmentType {
export enum staticValue { export enum staticValue {
STATIC_CAPTCHA = 'yoone2025!@YOONE0923' STATIC_CAPTCHA = 'yoone2025!@YOONE0923'
} }
// WooCommerce Subscription status
// Reference: https://woocommerce.com/document/subscriptions/statuses/
export enum SubscriptionStatus {
ACTIVE = 'active', // 活跃
PENDING = 'pending', // 待处理/待激活
ON_HOLD = 'on-hold', // 暂停
CANCELLED = 'cancelled', // 已取消
EXPIRED = 'expired', // 已过期
PENDING_CANCELLATION = 'pending-cancel', // 待取消
}

View File

@ -0,0 +1,75 @@
import { Inject, Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository, Like } from 'typeorm';
import { WPService } from './wp.service';
import { Subscription } from '../entity/subscription.entity';
import { plainToClass } from 'class-transformer';
import { SubscriptionStatus } from '../enums/base.enum';
import { QuerySubscriptionDTO } from '../dto/subscription.dto';
@Provide()
export class SubscriptionService {
@Inject()
wPService: WPService;
@InjectEntityModel(Subscription)
subscriptionModel: Repository<Subscription>;
async syncSubscriptions(siteId: string) {
const subs = await this.wPService.getSubscriptions(siteId);
for (const sub of subs) {
await this.syncSingleSubscription(siteId, sub);
}
}
async syncSingleSubscription(siteId: string, sub: any) {
const { line_items, ...raw } = sub;
const entity: Partial<Subscription> = {
...raw,
externalSubscriptionId: String(raw.id),
siteId,
status: raw.status as SubscriptionStatus,
customer_email: raw?.billing?.email || raw?.customer_email || '',
line_items,
};
delete (entity as any).id;
const existing = await this.subscriptionModel.findOne({
where: { externalSubscriptionId: String(sub.id), siteId },
});
const saveEntity = plainToClass(Subscription, entity);
if (existing) {
await this.subscriptionModel.update({ id: existing.id }, saveEntity);
} else {
await this.subscriptionModel.save(saveEntity);
}
}
async getSubscriptionList({
current = 1,
pageSize = 10,
siteId,
status,
customer_email,
keyword,
}: QuerySubscriptionDTO) {
const where: any = {};
if (siteId) where.siteId = siteId;
if (status) where.status = status;
if (customer_email) where.customer_email = Like(`%${customer_email}%`);
if (keyword) {
where.externalSubscriptionId = Like(`%${keyword}%`);
}
const [list, total] = await this.subscriptionModel.findAndCount({
where,
order: { id: 'DESC' },
skip: (current - 1) * pageSize,
take: pageSize,
});
return {
list,
total,
current,
pageSize,
};
}
}

View File

@ -134,6 +134,26 @@ export class WPService {
); );
} }
/**
* WooCommerce Subscriptions
* wc/v1/subscriptions (Subscriptions ), 退 wc/v3/subscriptions
*/
async getSubscriptions(siteId: string): Promise<Record<string, any>[]> {
const site = this.geSite(siteId);
try {
return await this.fetchPagedData<Record<string, any>>(
'/wc/v1/subscriptions',
site
);
} catch (e) {
// fallback
return await this.fetchPagedData<Record<string, any>>(
'/wc/v3/subscriptions',
site
);
}
}
async getOrderRefund( async getOrderRefund(
siteId: string, siteId: string,
orderId: string, orderId: string,