买房焦虑,打造成都二手房交易行情大屏-实现篇
各位道友好呀,我是 “星辰编程理财”,上篇《买房焦虑,打造成都二手房交易行情大屏-需求篇》梳理了房地产下行的逻辑、当前的环境政策,还定制了数据指标和数据源。今天,咱们就来看看如何实现这个成都二手房交易行情大屏,我会给大家讲讲技术选型、步骤、主要逻辑
,但技术细节咱就点到为止,不往技术细节的深坑里扎。
整体选型
在实现这个成都二手房交易行情大屏之前,我得先给大家介绍一下iStock Shell
,这是一个我开源的金融数据查询工具,虽然还没完全完善,但我相信随着需求的驱动,它会越来越好用,具体介绍请点击去官网查看。这次大屏功能实现将iStock Shell
作为底层框架,在这基础上实现一个cdesfhq
(成都二手房行情)的查询命令。
在实现过程中,我遇到了两个堵点:
- 二手房数据入库:这些数据来自第三方平台,如房小团、贝壳等,需要入库才能被前端使用。而iStock Shell目前的设计思路是重前端、轻后端,大量业务逻辑交给前端处理,即iStock Shell的
Web Worker
层。 - 大屏组件:需要开发一个通用大屏组件去消费处理后的数据,整理出来需要卡片(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
接口,可以非常方便的增、删、改、查数据。
部署参考文件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.ts
和cdfceshqsj.model.ts
文件,在cdfcjysj.model.ts
和cdfceshqsj.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
中,我们需要实现主要的业务逻辑。值得重点关注的地方:
- 使用模型提供的
run
方法调用teable提供的Restful API
接口,如:CdfcjysjModel.run。 ChartService
是一个全局的提供者,可以直接依赖注入到本服务(CdesfService)里面使用。ChartService
服务实例提供generateChartConfig
方法把数据转换为图表组件所需要的配置数据,调用方式this.chartService.generateChartConfig
,具体使用方法请查看chart.service.ts
及chart.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
方法将业务逻辑
与命令配置
绑定起来,然后返回定义ShDataGrid
和ShText
这两个组件接收消费数据。
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.ts
和cdfceshqsj.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
命令,希望能帮助你更深入地理解成都楼市,为你的决策提供有力的支持。如果这篇文章能给你带来一些启发和帮助,是莫大的荣幸与欣慰。另外如有任何问题或想法,欢迎留言交流。