Просмотр исходного кода

✨ feat(redemption-code): add redemption code module

- add redemption code entity and schema files
- implement redemption code service with core business logic
- add redemption code API routes using generic CRUD
- register redemption code client in API client
- integrate redemption code module into main API routes and data source
yourname 6 месяцев назад
Родитель
Сommit
bc5761caf9

+ 6 - 1
src/client/api.ts

@@ -9,6 +9,7 @@ import type { WechatCouponStockRoutes } from '@/server/api'
 import type { WechatCouponRoutes } from '@/server/api'
 import type { WechatCouponRoutes } from '@/server/api'
 import type { WechatPayRoutes } from '@/server/api'
 import type { WechatPayRoutes } from '@/server/api'
 import type { CouponLogRoutes } from '@/server/api'
 import type { CouponLogRoutes } from '@/server/api'
+import type { RedemptionCodeRoutes } from '@/server/api'
 
 
 export const authClient = hc<AuthRoutes>('/', {
 export const authClient = hc<AuthRoutes>('/', {
   fetch: axiosFetch,
   fetch: axiosFetch,
@@ -44,4 +45,8 @@ export const wechatPayClient = hc<WechatPayRoutes>('/', {
 
 
 export const couponLogClient = hc<CouponLogRoutes>('/', {
 export const couponLogClient = hc<CouponLogRoutes>('/', {
   fetch: axiosFetch,
   fetch: axiosFetch,
-}).api.v1['coupon-logs']
+}).api.v1['coupon-logs']
+
+export const redemptionCodeClient = hc<RedemptionCodeRoutes>('/', {
+  fetch: axiosFetch,
+}).api.v1['redemption-codes']

+ 3 - 0
src/server/api.ts

@@ -10,6 +10,7 @@ import wechatCouponStockRoutes from './api/wechat-coupon-stocks/index'
 import wechatCouponRoutes from './api/wechat-coupons/index'
 import wechatCouponRoutes from './api/wechat-coupons/index'
 import wechatPayRoutes from './api/wechat-pay/index'
 import wechatPayRoutes from './api/wechat-pay/index'
 import couponLogRoutes from './api/coupon-logs/index'
 import couponLogRoutes from './api/coupon-logs/index'
+import redemptionCodeRoutes from './api/redemption-codes/index'
 import { AuthContext } from './types/context'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
 import { Hono } from 'hono'
@@ -113,6 +114,7 @@ const wechatCouponStockApiRoutes = api.route('/api/v1/wechat-coupon-stocks', wec
 const wechatCouponApiRoutes = api.route('/api/v1/wechat-coupons', wechatCouponRoutes)
 const wechatCouponApiRoutes = api.route('/api/v1/wechat-coupons', wechatCouponRoutes)
 const wechatPayApiRoutes = api.route('/api/v1/wechat-pay', wechatPayRoutes)
 const wechatPayApiRoutes = api.route('/api/v1/wechat-pay', wechatPayRoutes)
 const couponLogApiRoutes = api.route('/api/v1/coupon-logs', couponLogRoutes)
 const couponLogApiRoutes = api.route('/api/v1/coupon-logs', couponLogRoutes)
+const redemptionCodeApiRoutes = api.route('/api/v1/redemption-codes', redemptionCodeRoutes)
 
 
 export type AuthRoutes = typeof authRoutes
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
 export type UserRoutes = typeof userRoutes
@@ -123,6 +125,7 @@ export type WechatCouponStockRoutes = typeof wechatCouponStockApiRoutes
 export type WechatCouponRoutes = typeof wechatCouponApiRoutes
 export type WechatCouponRoutes = typeof wechatCouponApiRoutes
 export type WechatPayRoutes = typeof wechatPayApiRoutes
 export type WechatPayRoutes = typeof wechatPayApiRoutes
 export type CouponLogRoutes = typeof couponLogApiRoutes
 export type CouponLogRoutes = typeof couponLogApiRoutes
+export type RedemptionCodeRoutes = typeof redemptionCodeApiRoutes
 
 
 app.route('/', api)
 app.route('/', api)
 export default app
 export default app

+ 21 - 0
src/server/api/redemption-codes/index.ts

@@ -0,0 +1,21 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { RedemptionCode } from '@/server/modules/redemption-codes/redemption-code.entity';
+import { RedemptionCodeSchema, CreateRedemptionCodeDto, UpdateRedemptionCodeDto } from '@/server/modules/redemption-codes/redemption-code.schema';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const redemptionCodeRoutes = createCrudRoutes({
+  entity: RedemptionCode,
+  createSchema: CreateRedemptionCodeDto,
+  updateSchema: UpdateRedemptionCodeDto,
+  getSchema: RedemptionCodeSchema,
+  listSchema: RedemptionCodeSchema,
+  searchFields: ['code', 'redemptionResult'],
+  relations: ['batch'],
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default redemptionCodeRoutes;

+ 2 - 1
src/server/data-source.ts

@@ -10,6 +10,7 @@ import { WechatPayConfig } from "./modules/wechat-pay/wechat-pay-config.entity"
 import { WechatCouponStock } from "./modules/wechat-pay/wechat-coupon-stock.entity"
 import { WechatCouponStock } from "./modules/wechat-pay/wechat-coupon-stock.entity"
 import { WechatCoupon } from "./modules/wechat-pay/wechat-coupon.entity"
 import { WechatCoupon } from "./modules/wechat-pay/wechat-coupon.entity"
 import { CouponLog } from "./modules/coupon-logs/coupon-log.entity"
 import { CouponLog } from "./modules/coupon-logs/coupon-log.entity"
+import { RedemptionCode } from "./modules/redemption-codes/redemption-code.entity"
 
 
 export const AppDataSource = new DataSource({
 export const AppDataSource = new DataSource({
   type: "mysql",
   type: "mysql",
@@ -19,7 +20,7 @@ export const AppDataSource = new DataSource({
   password: process.env.DB_PASSWORD || "",
   password: process.env.DB_PASSWORD || "",
   database: process.env.DB_DATABASE || "d8dai",
   database: process.env.DB_DATABASE || "d8dai",
   entities: [
   entities: [
-    User, Role, File, WechatPayConfig, WechatCouponStock, WechatCoupon, CouponLog,
+    User, Role, File, WechatPayConfig, WechatCouponStock, WechatCoupon, CouponLog, RedemptionCode,
   ],
   ],
   migrations: [],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 50 - 0
src/server/modules/redemption-codes/redemption-code.entity.ts

@@ -0,0 +1,50 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { Batch } from '../batches/batch.entity';
+
+export enum RedemptionStatus {
+  UNUSED = 0,
+  USED = 1,
+  EXPIRED = 2,
+  DISABLED = 3
+}
+
+@Entity('redemption_codes')
+export class RedemptionCode {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'code', type: 'varchar', length: 100, unique: true, comment: '兑换码,唯一' })
+  code!: string;
+
+  @Column({ name: 'status', type: 'tinyint', default: RedemptionStatus.UNUSED, comment: '兑换状态:0-未使用,1-已使用,2-已过期,3-已禁用' })
+  status!: RedemptionStatus;
+
+  @Column({ name: 'batch_id', type: 'int', unsigned: true, comment: '批次ID' })
+  batchId!: number;
+
+  @Column({ name: 'redemption_result', type: 'varchar', length: 500, nullable: true, comment: '兑换结果描述' })
+  redemptionResult!: string | null;
+
+  @Column({ name: 'used_at', type: 'timestamp', nullable: true, comment: '使用时间' })
+  usedAt!: Date | null;
+
+  @Column({ name: 'used_by', type: 'int', unsigned: true, nullable: true, comment: '使用用户ID' })
+  usedBy!: number | null;
+
+  @Column({ name: 'created_by', type: 'int', unsigned: true, nullable: true, comment: '创建用户ID' })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', unsigned: true, nullable: true, comment: '更新用户ID' })
+  updatedBy!: number | null;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+
+  // 关联批次
+  @ManyToOne(() => Batch)
+  @JoinColumn({ name: 'batch_id' })
+  batch?: Batch;
+}

+ 117 - 0
src/server/modules/redemption-codes/redemption-code.schema.ts

@@ -0,0 +1,117 @@
+import { z } from '@hono/zod-openapi';
+
+// 兑换状态枚举
+export enum RedemptionStatus {
+  UNUSED = 0,
+  USED = 1,
+  EXPIRED = 2,
+  DISABLED = 3
+}
+
+// 兑换码完整 Schema
+export const RedemptionCodeSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '兑换码ID',
+    example: 1
+  }),
+  code: z.string().max(100).openapi({
+    description: '兑换码',
+    example: 'ABC123XYZ'
+  }),
+  status: z.number().int().min(0).max(3).openapi({
+    description: '兑换状态:0-未使用,1-已使用,2-已过期,3-已禁用',
+    example: 0
+  }),
+  batchId: z.number().int().positive().openapi({
+    description: '批次ID',
+    example: 1
+  }),
+  redemptionResult: z.string().max(500).nullable().openapi({
+    description: '兑换结果描述',
+    example: '兑换成功,获得100积分'
+  }),
+  usedAt: z.date().nullable().openapi({
+    description: '使用时间',
+    example: '2024-01-01T12:00:00Z'
+  }),
+  usedBy: z.number().int().positive().nullable().openapi({
+    description: '使用用户ID',
+    example: 123
+  }),
+  createdBy: z.number().int().positive().nullable().openapi({
+    description: '创建用户ID',
+    example: 1
+  }),
+  updatedBy: z.number().int().positive().nullable().openapi({
+    description: '更新用户ID',
+    example: 1
+  }),
+  createdAt: z.date().openapi({
+    description: '创建时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  updatedAt: z.date().openapi({
+    description: '更新时间',
+    example: '2024-01-01T00:00:00Z'
+  }),
+  batch: z.any().optional().openapi({
+    description: '关联的批次信息',
+    example: {}
+  })
+});
+
+// 创建DTO
+export const CreateRedemptionCodeDto = z.object({
+  code: z.string().max(100).openapi({
+    description: '兑换码',
+    example: 'ABC123XYZ'
+  }),
+  batchId: z.number().int().positive().openapi({
+    description: '批次ID',
+    example: 1
+  }),
+  status: z.number().int().min(0).max(3).default(RedemptionStatus.UNUSED).openapi({
+    description: '兑换状态:0-未使用,1-已使用,2-已过期,3-已禁用',
+    example: 0
+  }),
+  redemptionResult: z.string().max(500).nullable().optional().openapi({
+    description: '兑换结果描述',
+    example: null
+  })
+});
+
+// 更新DTO
+export const UpdateRedemptionCodeDto = z.object({
+  code: z.string().max(100).optional().openapi({
+    description: '兑换码',
+    example: 'ABC123XYZ'
+  }),
+  status: z.number().int().min(0).max(3).optional().openapi({
+    description: '兑换状态:0-未使用,1-已使用,2-已过期,3-已禁用',
+    example: 0
+  }),
+  batchId: z.number().int().positive().optional().openapi({
+    description: '批次ID',
+    example: 1
+  }),
+  redemptionResult: z.string().max(500).nullable().optional().openapi({
+    description: '兑换结果描述',
+    example: '兑换成功,获得100积分'
+  })
+});
+
+// 状态枚举映射
+export const RedemptionStatusMap = {
+  [RedemptionStatus.UNUSED]: { label: '未使用', color: 'blue' },
+  [RedemptionStatus.USED]: { label: '已使用', color: 'green' },
+  [RedemptionStatus.EXPIRED]: { label: '已过期', color: 'red' },
+  [RedemptionStatus.DISABLED]: { label: '已禁用', color: 'gray' }
+} as const;
+
+// 状态选项
+export const RedemptionStatusOptions = [
+  { label: '未使用', value: RedemptionStatus.UNUSED },
+  { label: '已使用', value: RedemptionStatus.USED },
+  { label: '已过期', value: RedemptionStatus.EXPIRED },
+  { label: '已禁用', value: RedemptionStatus.DISABLED }
+] as const;

+ 94 - 0
src/server/modules/redemption-codes/redemption-code.service.ts

@@ -0,0 +1,94 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { RedemptionCode } from './redemption-code.entity';
+
+export class RedemptionCodeService extends GenericCrudService<RedemptionCode> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, RedemptionCode);
+  }
+
+  /**
+   * 根据兑换码查找
+   */
+  async findByCode(code: string): Promise<RedemptionCode | null> {
+    return this.repository.findOne({ where: { code } });
+  }
+
+  /**
+   * 批量创建兑换码
+   */
+  async createBatch(codes: string[], batchId: number, createdBy?: number): Promise<RedemptionCode[]> {
+    const entities = codes.map(code => {
+      const entity = this.repository.create({
+        code,
+        batchId,
+        createdBy
+      });
+      return entity;
+    });
+    
+    return this.repository.save(entities);
+  }
+
+  /**
+   * 统计批次中的兑换码数量
+   */
+  async countByBatchId(batchId: number): Promise<number> {
+    return this.repository.count({ where: { batchId } });
+  }
+
+  /**
+   * 统计批次中不同状态的兑换码数量
+   */
+  async countByStatus(batchId: number, status: number): Promise<number> {
+    return this.repository.count({ where: { batchId, status } });
+  }
+
+  /**
+   * 验证兑换码是否有效
+   */
+  async validateCode(code: string): Promise<{
+    valid: boolean;
+    redemptionCode?: RedemptionCode;
+    message?: string;
+  }> {
+    const redemptionCode = await this.findByCode(code);
+    
+    if (!redemptionCode) {
+      return { valid: false, message: '兑换码不存在' };
+    }
+    
+    if (redemptionCode.status === 1) {
+      return { valid: false, message: '兑换码已使用' };
+    }
+    
+    if (redemptionCode.status === 2) {
+      return { valid: false, message: '兑换码已过期' };
+    }
+    
+    if (redemptionCode.status === 3) {
+      return { valid: false, message: '兑换码已禁用' };
+    }
+    
+    return { valid: true, redemptionCode };
+  }
+
+  /**
+   * 使用兑换码
+   */
+  async useCode(code: string, userId: number, redemptionResult: string): Promise<RedemptionCode> {
+    const validation = await this.validateCode(code);
+    
+    if (!validation.valid) {
+      throw new Error(validation.message);
+    }
+    
+    const redemptionCode = validation.redemptionCode!;
+    redemptionCode.status = 1; // 已使用
+    redemptionCode.usedBy = userId;
+    redemptionCode.usedAt = new Date();
+    redemptionCode.redemptionResult = redemptionResult;
+    
+    return this.repository.save(redemptionCode);
+  }
+}