11 min read

买房焦虑,打造成都二手房交易行情大屏-实现篇

买房焦虑,打造成都二手房交易行情大屏-实现篇

各位道友好呀,我是 “星辰编程理财”,上篇《买房焦虑,打造成都二手房交易行情大屏-需求篇》梳理了房地产下行的逻辑、当前的环境政策,还定制了数据指标和数据源。今天,咱们就来看看如何实现这个成都二手房交易行情大屏,我会给大家讲讲技术选型、步骤、主要逻辑,但技术细节咱就点到为止,不往技术细节的深坑里扎。

整体选型

在实现这个成都二手房交易行情大屏之前,我得先给大家介绍一下iStock Shell,这是一个我开源的金融数据查询工具,虽然还没完全完善,但我相信随着需求的驱动,它会越来越好用,具体介绍请点击去官网查看。这次大屏功能实现将iStock Shell作为底层框架,在这基础上实现一个cdesfhq(成都二手房行情)的查询命令。
在实现过程中,我遇到了两个堵点:

  1. 二手房数据入库:这些数据来自第三方平台,如房小团、贝壳等,需要入库才能被前端使用。而iStock Shell目前的设计思路是重前端、轻后端,大量业务逻辑交给前端处理,即iStock Shell的Web Worker层。
  2. 大屏组件:需要开发一个通用大屏组件去消费处理后的数据,整理出来需要卡片(card)组件、图表(chart)组件、大屏容器(data-grid)。目前已有chart组件,但card和data-grid还需要动手去实现。

棘手的数据入库

说到底,消费第三方数据就两条路:一是直接把数据 “原封不动” 送到前端;二是费点周折,处理好数据入库,再按需从库里捞出来给前端。第一种,iStock Shell轻松就能 hold 住,没啥大问题。第二种难点在于,得安排一堆定时任务去抓数据、入库,要是简单对付下当前业务,随便写写代码也能糊弄过去,但要想用轻巧代码实现规模化、标准化,还得前后端一体化,这难度就上去了。

数据查询分析这块,数据来源五花八门,但入库无非手动、自动两种方式。手动入库,得弄个数据管理界面让人操作;自动入库,就得有标准化接口或方法。顺着这个思路找,Airtable 最先进入我的选型视野,后来越研究越深入,发现 teable 非常符合我的需求,就它了,就这样成了我的数据管理工具。

至于定时任务咋实现、遵循啥标准、咋管理,我这找了很久的方案没合适的,要是各位道友有方案,欢迎来讨论交流。考虑到这定时任务也不是当下成都二手房交易行情大屏的 “刚需”,这项目时间拖得够久了,于是我先把大屏搞出来,后面再慢慢琢磨定时任务的方案。

大致实现步骤

组件实现

组件的实现相对简单,按照正常的开发需求流程来走。iStock Shell用 pnpm + 单一仓库模式管理,咱就在 src/packages/shell-ui/src 目录下新建组件目录,动手开干就完事儿。
组件开发图

部署数据管理服务

使用teable作为数据管理服务,它提供了容器化部署方案,非常方便,部署时可以参考该文档。teable提供Restful API接口,可以非常方便的增、删、改、查数据。
Restful API
部署参考文件docker-compose.yml:

version: '3.1'

services:
  teable:
    container_name: teable
    image: ghcr.nju.edu.cn/teableio/teable:latest
    restart: always
    ports:
      - '5175:3000'
    volumes:
      - /root/data/teable/data:/app/.assets:rw
    env_file:
      - .env
    environment:
      - NEXT_ENV_IMAGES_ALL_REMOTE=true
    networks:
      - teable
    depends_on:
      teable-db-migrate:
        condition: service_completed_successfully
      teable-cache:
        condition: service_healthy
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
      start_period: 5s
      interval: 5s
      timeout: 3s
      retries: 3

  teable-db:
    container_name: postgres
    image: postgres:15.4
    restart: always
    ports:
      - '42345:5432'
    volumes:
      - /root/data/teable/db:/var/lib/postgresql/data:rw
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    networks:
      - teable
    healthcheck:
      test: ['CMD-SHELL', "sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'"]
      interval: 10s
      timeout: 3s
      retries: 3

  teable-db-migrate:
    container_name: teable-db-migrate
    image: ghcr.nju.edu.cn/teableio/teable-db-migrate:latest
    environment:
      - PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
    networks:
      - teable
    depends_on:
      teable-db:
        condition: service_healthy

  teable-cache:
    container_name: redis
    image: redis:7.2.4
    restart: always
    expose:
      - '6379'
    volumes:
      - /root/data/teable/cache:/data:rw
    networks:
      - teable
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']
      interval: 10s
      timeout: 3s
      retries: 3

networks:
  teable:
    name: teable-network

volumes:
  teable-db: {}
  teable-data: {}
  teable-cache: {}

配置文件.env,自行修改填写:

# replace the default password
POSTGRES_PASSWORD=
REDIS_PASSWORD=
SECRET_KEY=

# replace the following with a publicly accessible address
PUBLIC_ORIGIN=

# ---------------------

# Postgres
POSTGRES_HOST=teable-db
POSTGRES_PORT=5432
POSTGRES_DB=teable
POSTGRES_USER=teable

# Redis
REDIS_HOST=teable-cache
REDIS_PORT=6379
REDIS_DB=0

# App
PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
BACKEND_CACHE_PROVIDER=redis
BACKEND_CACHE_REDIS_URI=redis://default:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}

# email
BACKEND_MAIL_HOST=
BACKEND_MAIL_PORT=465
BACKEND_MAIL_SECURE=true
BACKEND_MAIL_SENDER=
BACKEND_MAIL_SENDER_NAME=
BACKEND_MAIL_AUTH_USER=
BACKEND_MAIL_AUTH_PASS=

初始化命令开发

接下来,我们来实现成都二手房交易行情的命令。在项目根目录执行istock cmd init,初始化命令开发,然后按照提示填写相关信息即可。

PS D:\project\myself\istock-shell> istock cmd init
? 在哪个命令域下开发命令? cdfc
? 您期望命令相关文件名为?(文件名用-符号分割) cdesf
cdfc命令域文件夹创建成功
初始化命令开发已完成

初始化完成后,src/worker/domains/cdfc目录下已经有了相关初始化文件。iStock Shell把业务逻辑拆成了三层(Model、Service、Controller),开发方式类似Nestjs框架,不过它是在 Web Worker 里施展拳脚。

模型定义

创建cdfcjysj.model.tscdfceshqsj.model.ts文件,在cdfcjysj.model.tscdfceshqsj.model.ts中,我们定义了数据类型。这些model不仅是对数据类型的描述,如果需要存本地数据库,也是对数据库表的描述文件。
cdfcjysj.model.ts:

import { BaseModel, Model } from '@istock/iswork';

// 成都房产交易数据
@Model('cdfcjysj')
export class CdfcjysjModel extends BaseModel {
  时间!: string;
  类型!: '新房' | '二手房';
  区域类型!: '全市' | '中心城区' | '郊区新城';
  套!: number;
  面积!: number;
}

cdfceshqsj.model.ts:

import { BaseModel, Model } from '@istock/iswork';

// 成都房产二手行情数据
@Model('cdfceshqsj')
export class CdfceshqsjModel extends BaseModel {
  时间!: string;
  数据类型!:
    | '新增挂牌均价'
    | '新增挂牌中位数'
    | '涨价房源占比'
    | '涨价房源幅度'
    | '成交均价'
    | '成交中位数'
    | '平均成交周期'
    | '降价房源占比'
    | '降价房源幅度'
    | '新增挂牌量'
    | '成交量'
    | '存量挂牌';

  值!: number;
  单位!: string;
  数据源!: '房小团' | '数据源';
}

业务逻辑实现

cdesf.service.ts中,我们需要实现主要的业务逻辑。值得重点关注的地方:

  1. 使用模型提供的run方法调用teable提供的Restful API接口,如:CdfcjysjModel.run。
  2. ChartService是一个全局的提供者,可以直接依赖注入到本服务(CdesfService)里面使用。ChartService服务实例提供generateChartConfig方法把数据转换为图表组件所需要的配置数据,调用方式this.chartService.generateChartConfig,具体使用方法请查看chart.service.tschart.base.service.ts文件源码。

cdesf.service.ts(部分核心代码):

import { Injectable, type TModelData } from '@istock/iswork';
import { isNumber, toLocaleDateString } from '@istock/util';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import { CdfcjysjModel } from './cdfcjysj.model';
import { CdfceshqsjModel } from './cdfceshqsj.model';
import { ChartService } from '../../global/chart/chart.service';
import { type TChartOptions } from '../../global/chart/chart.base.service';
import { EChartType } from '@domains/global/chart/chart.cmd';

type TCdfcjysjResponse = {
  records: Array<{
    fields: TModelData<CdfcjysjModel>;
  }>;
};

type TCdfceshqsjResponse = {
  records: Array<{
    fields: TModelData<CdfceshqsjModel>;
  }>;
};

type TTableQuery = {
  filter?: Record<string, any>;
  orderBy?: Array<Record<string, any>>;
};

type TDataGridCard = { title: string; list: Array<{ name: string; value: string; description?: string }> };

@Injectable()
export class CdesfService {
  constructor(private readonly chartService: ChartService) {
    dayjs.extend(isoWeek);
  }

  /**
   * 获取数据单位
   * @param list
   * @private
   */
  #getListDataUnit(list: Array<TModelData<CdfceshqsjModel>>) {
    // ...
  }

  /**
   * 对比或环比转百分比
   * @param num1
   * @param num2
   * @private
   */
  #toPercentageFormat(num1: number | string, num2: number | string) {
    // ...
  }

  /**
   * 获取交易量数据
   * @param query
   * @param tableId
   */
  async getHouseTradeData(query: TTableQuery = {}, tableId: string = 'tblpAzrLGogn42iVjuq') {
    const response = await CdfcjysjModel.run<TCdfcjysjResponse>(`/api/table/${tableId}/record`, {
      method: 'get',
      query: {
        fieldKeyType: 'name',
        take: 1000,
        ...query,
      },
    });
    return response.records.map((record) => record.fields);
  }

  /**
   * 获取二手交易数据
   * @param query
   * @param tableId
   */
  async getOldHouseTradeData(query: TTableQuery = {}, tableId: string = 'tblVvH7U9z7UJn8v6E5') {
    const response = await CdfceshqsjModel.run<TCdfceshqsjResponse>(`/api/table/${tableId}/record`, {
      method: 'get',
      query: {
        fieldKeyType: 'name',
        take: 1000,
        ...query,
      },
    });
    return response.records.map((record) => record.fields);
  }

  /**
   * 获取整个大屏数据
   */
  async getHouseDataGridData() {
    const [monthTradeData, monthOldHouseTradeData, weekOldHouseTradeData]: [
      Array<TModelData<CdfcjysjModel>>,
      Array<TModelData<CdfceshqsjModel>>,
      Array<TModelData<CdfceshqsjModel>>,
    ] = await Promise.all([
      this.getHouseTradeData({
        filter: {
          conjunction: 'and',
          filterSet: [
            { fieldId: 'fldMBBgvSSurOxULC8b', operator: 'is', value: '二手房' },
            { fieldId: 'fldMMxxENoRejGm6l7G', operator: 'is', value: '全市' },
          ],
        },
        orderBy: [{ fieldId: 'fldvd5o55rIJD3RCQaJ', order: 'desc' }],
      }),
      this.getOldHouseTradeData({
        orderBy: [{ fieldId: 'fldyFiNVC3JNtbm87Cb', order: 'desc' }],
      }),
      this.getOldHouseTradeData(
        {
          orderBy: [{ fieldId: 'fldF3RiarNlFpB3sQsD', order: 'desc' }],
        },
        'tbl4q2BqKL2tq9pk1xD'
      ),
    ]);
    const cards = this.getHouseDataGridCards(monthTradeData, monthOldHouseTradeData, weekOldHouseTradeData);
    const charts = this.getHouseDataGridCharts(
      [...monthTradeData].reverse(),
      [...monthOldHouseTradeData].reverse(),
      [...weekOldHouseTradeData].reverse()
    );
    return {
      cards,
      charts,
    };
  }

  /**
   * 获取卡片数据
   * @param monthTradeData
   * @param monthOldHouseTradeData
   * @param weekOldHouseTradeData
   */
  getHouseDataGridCards(
    monthTradeData: Array<TModelData<CdfcjysjModel>>,
    monthOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>,
    weekOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>
  ): TDataGridCard[] {
    // ...
    const cards: TDataGridCard[] = [
      {
        title: '价',
        list: [],
      },
      {
        title: '量',
        list: [],
      },
      {
        title: '周转',
        list: [],
      },
      {
        title: '回报',
        list: [],
      },
      {
        title: '长期人口',
        list: [],
      },
    ];
    // ...
    return cards;
  }

  /**
   * 获取图表数据
   * @param monthTradeData
   * @param monthOldHouseTradeData
   * @param weekOldHouseTradeData
   */
  getHouseDataGridCharts(
    monthTradeData: Array<TModelData<CdfcjysjModel>>,
    monthOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>,
    weekOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>
  ): TChartOptions[] {
    const colorRange = ['#c94400', '#744f36'];
    const lineChildren = [
      { type: 'line', encode: { shape: 'smooth' } },
      { type: 'point', encode: { shape: 'point' }, tooltip: false },
    ];
    const 月价格走势列表 = monthOldHouseTradeData
      .filter((item) => ['成交均价', '新增挂牌均价'].includes(item.数据类型))
      .map((item) => {
        if (item.时间) item.时间 = toLocaleDateString(new Date(item.时间), 'YYYYMM');
        return { 时间: item.时间, 价格: item.值, 数据类型: item.数据类型 };
      });
 
    const 月成交均价图表: TChartOptions = this.chartService.generateChartConfig(
      {
        数据: 月价格走势列表,
        横轴: '时间',
        纵轴: '价格',
        类别: '数据类型',
        配置: {
          title: {
            title: '月成交均价走势',
          },
          type: 'view',
          scale: { color: { range: colorRange } },
          height: 400,
          children: lineChildren,
        },
      },
      EChartType.Line
    );
    // ...
    return [
      { options: 周量走势图表 },
      { options: 月成交均价图表 },
      { options: 月成交量图表 },
      { options: 涨价降价占比图表 },
    ];
  }
}

命令配置

cdesf.cmd.ts文件中,我们对命令如何使用进行了描述。命令配置描述为TControllerMethodCmdRoute类型。
cdfcjysj.model.ts:

import { type TControllerMethodCmdRoute } from '@istock/iswork';

const 成都二手房: TControllerMethodCmdRoute = {
  name: '成都二手房行情',
  cmd: 'cdesfhq',
  usage: 'cdesfhq',
  options: {},
  arguments: [],
  description: '成都二手房行情,数据来自房小团、贝壳。',
  remarks: '',
  example: 'cdesfhq',
};
export default {
  成都二手房,
};

控制逻辑

cdesf.controller.ts中,我们定义了getDataGrid方法将业务逻辑命令配置绑定起来,然后返回定义ShDataGridShText这两个组件接收消费数据。

cdesf.controller.ts:

import { CmdRoute, Controller, Method } from '@istock/iswork';
import { CdesfService } from './cdesf.service';
import cmdJson from './cdesf.cmd';

@Controller({
  alias: 'cdesf',
})
export class CdesfController {
  constructor(private readonly cdesfService: CdesfService) {}

  @CmdRoute(cmdJson.成都二手房)
  @Method({
    alias: cmdJson.成都二手房.cmd,
  })
  async getDataGrid() {
    const dataGrid = await this.cdesfService.getHouseDataGridData();
    return {
      output: [
        {
          component: 'ShDataGrid',
          props: {
            ...dataGrid,
          },
        },
        {
          component: 'ShText',
          props: {
            texts: [
              {
                type: 'warning',
                text: '注:主要展示数据来源于成都房小团,卡片中的“周新增挂牌量”和图表中的“周成交量/新增挂牌量走势”数据来自贝壳。数据仅提供参考,不构成任何建议。',
                tag: 'span',
              },
            ],
          },
        },
      ],
    };
  }
}

引用

datasource-register.ts注册cdfcjysj.model.tscdfceshqsj.model.ts模型。将模型绑定到对应数据源上。

新建cdfc.domain.ts文件作为该命令的应用域入口文件,最后cdfc.domain.ts需要导入到root.domain.ts根应用域

import { Domain } from '@istock/iswork';
import { CdesfController } from './cdesf/cdesf.controller';
import { CdesfService } from './cdesf/cdesf.service';

@Domain({
  name: 'cdfc',
  viewName: '成都房产',
  providers: [CdesfService],
  controllers: [CdesfController],
})
export class CdfcDomain {}

至此,开发完成!

实现效果

成都房产应用域下(输入命令yyjr cdfc),再输入cdesfhq命令,查看成都二手房交易行情。效果如下:
成都二手房交易行情查询效果

最后

说实话,成都的楼市确实让人看不懂,让人焦虑。通过本文开发提供的cdesfhq命令,希望能帮助你更深入地理解成都楼市,为你的决策提供有力的支持。如果这篇文章能给你带来一些启发和帮助,是莫大的荣幸与欣慰。另外如有任何问题或想法,欢迎留言交流。