Repository: thundernet8/YouzanPayPortal
Branch: master
Commit: c837ec85bf4b
Files: 23
Total size: 33.9 KB
Directory structure:
gitextract_9ehrtyej/
├── .gitignore
├── .prettierrc
├── .travis.yml
├── .tslintrc.json
├── LICENSE
├── README.md
├── db/
│ └── .gitkeep
├── envrc.sample
├── package.json
├── src/
│ ├── app.ts
│ ├── controller/
│ │ ├── payment.ts
│ │ └── status.ts
│ ├── enum/
│ │ ├── YZBusinessStatus.ts
│ │ └── YZPushType.ts
│ ├── env.ts
│ ├── interface/
│ │ ├── IOrder.ts
│ │ └── IYZPush.ts
│ ├── router.ts
│ ├── service/
│ │ ├── sqliteService.ts
│ │ ├── youzanPayService.ts
│ │ └── youzanTokenService.ts
│ └── utils/
│ └── logger.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
envrc
dist/
db/dev.db
db/prod.db
db/test.db
================================================
FILE: .prettierrc
================================================
{
"tabWidth": 4,
"printWidth": 100
}
================================================
FILE: .travis.yml
================================================
sudo: required
dist: trusty
language: node_js
node_js:
- "8"
matrix:
fast_finish: true
cache:
directories:
- node_modules
- "$HOME/.cache"
before_install:
# install yarn
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.0.1
- export PATH="$HOME/.yarn/bin:$PATH"
install:
- yarn
script:
- yarn lint
- yarn build
# - yarn test
branches:
only:
- master
- test
- release
================================================
FILE: .tslintrc.json
================================================
{
"extends": ["tslint-eslint-rules"],
"rules": {
"align": [true, "parameters", "statements"],
"class-name": true,
"comment-format": [true, "check-space"],
"curly": true,
"object-curly-spacing": true,
"eofline": true,
"forin": true,
"indent": [true, "spaces"],
"jsdoc-format": false,
"label-position": true,
"max-line-length": [false, 120],
"member-ordering": [
false,
{
"order": "statics-first"
}
],
"new-parens": true,
"no-any": false,
"no-arg": true,
"no-bitwise": true,
"no-conditional-assignment": true,
"no-consecutive-blank-lines": true,
"no-console": [true, "debug", "info", "log", "time", "timeEnd", "trace"],
"no-construct": true,
"no-constructor-vars": false,
"no-debugger": true,
"no-duplicate-variable": true,
"no-empty": false,
"no-eval": true,
"no-internal-module": true,
"no-namespace": true,
"no-reference": true,
"no-shadowed-variable": true,
"no-string-literal": true,
"no-switch-case-fall-through": false,
"no-trailing-whitespace": true,
"no-unused-expression": true,
"no-use-before-declare": false,
"no-var-keyword": true,
"no-var-requires": false,
"object-literal-sort-keys": false,
"one-line": [
true,
"check-catch",
"check-else",
"check-finally",
"check-open-brace",
"check-whitespace"
],
"one-variable-per-declaration": [true, "ignore-for-loop"],
"quotemark": [true, "double", "jsx-double"],
"radix": true,
"semicolon": [true, "always", "ignore-bound-class-methods"],
"switch-default": true,
"trailing-comma": [
true,
{
"singleline": "never",
"multiline": "never"
}
],
"triple-equals": [true, "allow-null-check"],
"typedef": false,
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
],
"use-isnan": true,
"variable-name": [
true,
"allow-leading-underscore",
"ban-keywords",
"check-format",
"allow-pascal-case"
],
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type",
"check-typecast"
]
}
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Touchumind
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
## YouzanQrPayPortal
**利用有赞云和有赞微小店实现个人收款解决方案 .**
[](https://github.com/thundernet8/YouzanPayPortal/issues)
[](https://github.com/thundernet8/YouzanPayPortal/network)
[](https://github.com/thundernet8/YouzanPayPortal/stargazers)
[](https://david-dm.org/thundernet8/YouzanPayPortal)
[](https://travis-ci.org/thundernet8/YouzanPayPortal)
[](https://github.com/thundernet8/YouzanPayPortal/blob/master/LICENSE)
[](https://github.com/prettier/prettier)
## Intro
利用有赞云和有赞微小店实现个人收款解决方案, 提供如下两个服务:
* 代理简化生成收款二维码的 API,支持微信支付宝扫描付款
* 接收有赞云的交易消息推送并处理(需要二次请求交易详情),简化订单状态通知到指定的服务器
核心原理是提供了一个支付中转层,将自有商户订单与有赞云的订单绑定起来,实现支付状态更新
为什么做成单独的服务:
* 方便对接任何系统
* 状态推送可以更灵活的自定义
* 单独服务模式维护减少对原有系统影响
## Usage
### 有赞云端
* 注册[有赞云](https://console.youzanyun.com/register) 开发者
* 创建[有赞微小店](https://h5.youzan.com/v2/index/wxdpc) 并扫码下载相应 APP 便于后续管理资金,注意这个小店在有赞后台看不到,只有 APP 可见
* 应用授权-有赞云控制台创建自用型应用并授权刚创建的店铺,在[推送服务]设置中设置推送网址*http://www.example.com/api/status* , 同时勾选下方的交易消息选项。
### 本服务
* 启动服务配置环境参数,复制`envrc.sample`为`envrc`并填写
其中:
`YOUZANYUN_CLIENT_ID`: 有赞云应用的 client_id
`YOUZANYUN_CLIENT_SECRET`: 有赞云应用的 client_secret
`YOUZAN_KDT_ID`: 有赞云应用绑定的微小店 ID
`SELF_SECRET`: 自有订单系统接收本服务推送数据的加盐 secret
`PUSH_API`: 自有订单系统接收本服务推送数据的地址
```
npm run start // 启动服务
npm run list // 查看服务列表及状态
npm run stop // 停止服务
```
* 提供公网服务,请使用 Nginx 代理至 Node Server,这样能够让有赞推送消息到达推送网址
### 自有订单系统端
* 接入自有商店订单系统,支付时请求服务生成收款二维码
* 接口地址示例: `http://www.example.com/api/payment/qrcode`
* 调用方法 `POST application/json`
* 请求数据 `name`: 商品名, `price`: 价格(分), `order_id`: 自有系统的订单号
* 返回数据
* 1. 正常情况 `qr_id`: 二维码 ID; `qr_url`: 有赞系统内支付地址(不推荐使用,需要付费者注册有赞); `qr_code`: Base64 图片数据,可直接作为 img 的 src 使用
* 2. 异常情况 `null`
```curl
curl -X POST -H 'Content-type: application/json' --data '{"name":"test name", "price": 1, "order_id": "your orderid"}' http://www.example.com/api/payment/qrcode
```
* 接收状态推送自有订单系统提供一个`PUSH_API`地址接收数据推送,数据格式如下:
```json
{
"tradeNo": "E20180201105656001353613-6377801",
"orderId": "1515174485676262",
"payment": 10,
"status": "TRADE_SUCCESS",
"sign": "c857c0f8d52ae9713a77b5c07dda93dc",
"time": "1517457474"
}
```
其中 sign 是`time`,`tradeNo`,`orderId`,`payment`,`status`,`SELF_SECRET`使用"|"拼接字符串的 MD5 特征值,请在状态接收端按此逻辑进行校验数据合法性
此外 status 的 enum 如下:
```typescript
enum YZBusinessStatus {
// 等待买家付款
WAIT_BUYER_PAY = "WAIT_BUYER_PAY",
// 待确认,包括(待成团:拼团订单、待接单:外卖订单)
WAIT_CONFIRM = "WAIT_CONFIRM",
// 等待卖家发货,即:买家已付款
WAIT_SELLER_SEND_GOODS = "WAIT_SELLER_SEND_GOODS",
// 等待买家确认收货,即:卖家已发货
WAIT_BUYER_CONFIRM_GOODS = "WAIT_BUYER_CONFIRM_GOODS",
// 买家已签收
TRADE_BUYER_SIGNED = "TRADE_BUYER_SIGNED",
// 交易成功
TRADE_SUCCESS = "TRADE_SUCCESS",
// 交易关闭
TRADE_CLOSED = "TRADE_CLOSED"
}
```
一般处理`TRADE_SUCCESS`和`TRADE_CLOSED`即可,返回 200 状态表示处理成功
## TODO
* [ ] 推送失败重推
================================================
FILE: db/.gitkeep
================================================
================================================
FILE: envrc.sample
================================================
YOUZANYUN_CLIENT_ID=
YOUZANYUN_CLIENT_SECRET=
YOUZAN_KDT_ID=
SELF_SECRET=
PUSH_API=http://www.example1.com;http://www.example2.com
================================================
FILE: package.json
================================================
{
"name": "youzanpayportal",
"version": "1.0.0",
"description": "",
"main": "index.js",
"repository": "git@github.com:thundernet8/YouzanPayPortal.git",
"author": "WuXueqian ",
"license": "MIT",
"scripts": {
"build": "rimraf dist && tsc",
"start":
"mkdirp logs && npm run build && cross-env NODE_ENV=production forever start dist/app.js",
"start:o": "cross-env NODE_ENV=production forever start dist/app.js",
"stop": "forever stop dist/app.js",
"start:dev": "mkdirp logs && cross-env NODE_ENV=development node dist/app.js",
"list": "forever list",
"lint": "npm run lint:ts",
"lint:ts": "tslint -e node_modules typings -c .tslintrc.json src/**/*.tsx",
"lint-staged": "lint-staged",
"lint-staged:ts": "tslint --fix -c .tslintrc.json",
"format": "prettier --write"
},
"lint-staged": {
"src/**/*.{ts,tsx}": ["format", "lint-staged:ts", "git add"]
},
"pre-commit": "lint-staged",
"devDependencies": {
"@types/koa": "^2.0.43",
"@types/node": "^9.4.0",
"cross-env": "^5.1.3",
"lint-staged": "^6.1.0",
"mkdirp": "^0.5.1",
"pre-commit": "^1.2.2",
"prettier": "^1.10.2",
"rimraf": "^2.6.2",
"tslint": "^5.9.1",
"tslint-eslint-rules": "^4.1.1",
"typescript": "^2.6.2"
},
"dependencies": {
"@koa/cors": "^2.2.1",
"axios": "^0.17.1",
"dotenv": "^5.0.0",
"koa": "^2.4.1",
"koa-bodyparser": "^4.2.0",
"koa-compress": "^2.0.0",
"koa-router": "^7.3.0",
"moment": "^2.20.1",
"sqlite3": "^3.1.13",
"winston": "^3.0.0-rc1",
"yz-open-sdk-nodejs": "^1.0.2"
},
"engines": {
"node": ">=8.0.0"
}
}
================================================
FILE: src/app.ts
================================================
import * as Koa from "koa";
import * as cors from "@koa/cors";
import * as compress from "koa-compress";
import * as bodyParser from "koa-bodyparser";
import getLogger from "./utils/logger";
import {
SERVER_HOST,
SERVER_PORT,
YOUZAN_CLIENT_ID,
YOUZAN_CLIENT_SECRET,
YOUZAN_KDT_ID,
SELF_SECRET,
PUSH_API
} from "./env";
import router from "./router";
import SqliteService from "./service/sqliteService";
if (!YOUZAN_CLIENT_ID || !YOUZAN_CLIENT_SECRET || !YOUZAN_KDT_ID || !SELF_SECRET || !PUSH_API) {
throw new Error(`Please check envrc file and fill required fields`);
}
const app = new Koa();
// x-response-time
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set("X-Response-Time", `${ms}ms`);
});
app.use(
compress({
filter: function(contentType) {
return /(json|html|text)/i.test(contentType);
},
threshold: 2048,
flush: require("zlib").Z_SYNC_FLUSH
})
);
app.use(bodyParser({}));
app.use(cors({ credentials: true }));
router(app);
app.on("error", error => {
getLogger().error(error.message || error.toString());
});
app.listen(SERVER_PORT, SERVER_HOST, () => {
SqliteService.init();
getLogger().info(`Server Is Listening at http://${SERVER_HOST}:${SERVER_PORT}`);
});
================================================
FILE: src/controller/payment.ts
================================================
import YouzanPayService from "../service/youzanPayService";
import getLogger from "../utils/logger";
export default class PaymentController {
private get logger() {
return getLogger();
}
/**
* 请求生成收款二维码
*/
public async genPayQr(ctx, _next) {
const payService = new YouzanPayService();
try {
const { name, price, order_id } = ctx.request.body;
const result = await payService.createQrCode(name, price, order_id);
if (!result) {
throw new Error("Create payment qrcode failed");
}
ctx.status = 200;
ctx.type = "application/json";
ctx.body = JSON.stringify(result);
} catch (error) {
this.logger.error(error.message || error.toString());
ctx.status = 500;
ctx.body = error.message || error.toString();
}
}
}
================================================
FILE: src/controller/status.ts
================================================
import YouzanPayService from "../service/youzanPayService";
import getLogger from "../utils/logger";
export default class StatusController {
private get logger() {
return getLogger();
}
/**
* 接收来自有赞推送的消息
*/
public async index(ctx) {
const data = ctx.request.body;
const yzPayService = new YouzanPayService();
this.logger.info(`Received youzan push message`);
process.nextTick(yzPayService.handleNotify.bind(yzPayService, data));
ctx.type = "application/json";
ctx.body = JSON.stringify({ code: 0, msg: "success" });
this.logger.info(`Replied youzan server`);
}
}
================================================
FILE: src/enum/YZBusinessStatus.ts
================================================
/**
* 业务状态(只包含交易消息)
* https://www.youzanyun.com/docs/guide/3401/3455
*/
enum YZBusinessStatus {
// 等待买家付款
WAIT_BUYER_PAY = "WAIT_BUYER_PAY",
// 待确认,包括(待成团:拼团订单、待接单:外卖订单)
WAIT_CONFIRM = "WAIT_CONFIRM",
// 等待卖家发货,即:买家已付款
WAIT_SELLER_SEND_GOODS = "WAIT_SELLER_SEND_GOODS",
// 等待买家确认收货,即:卖家已发货
WAIT_BUYER_CONFIRM_GOODS = "WAIT_BUYER_CONFIRM_GOODS",
// 买家已签收
TRADE_BUYER_SIGNED = "TRADE_BUYER_SIGNED",
// 交易成功
TRADE_SUCCESS = "TRADE_SUCCESS",
// 交易关闭
TRADE_CLOSED = "TRADE_CLOSED"
}
export default YZBusinessStatus;
================================================
FILE: src/enum/YZPushType.ts
================================================
enum YZPushType {
// 订单状态事件
TRADE_ORDER_STATE = "TRADE_ORDER_STATE",
// 退款事件
TRADE_ORDER_REFUND = "TRADE_ORDER_REFUND",
// 物流事件
TRADE_ORDER_EXPRESS = "TRADE_ORDER_EXPRESS",
// 商品状态事件
ITEM_STATE = "ITEM_STATE",
// 商品基础信息事件
ITEM_INFO = "ITEM_INFO",
// 积分
POINTS = "POINTS",
// 会员卡(商家侧)
SCRM_CARD = "SCRM_CARD",
// 会员卡(用户侧)
SCRM_CUSTOMER_CARD = "SCRM_CUSTOMER_CARD",
// 交易V1
TRADE = "TRADE",
// 商品V1
ITEM = "ITEM"
}
export default YZPushType;
================================================
FILE: src/env.ts
================================================
import * as path from "path";
// Init process.env
require("dotenv").config({ path: path.resolve(process.cwd(), "./envrc") });
export const IS_PROD = process.env.NODE_ENV === "production";
// Server
export const SERVER_HOST = IS_PROD ? "127.0.0.1" : "127.0.0.1";
export const SERVER_PORT = IS_PROD ? 8601 : 8601;
// Youzan
export const YOUZAN_CLIENT_ID = process.env.YOUZANYUN_CLIENT_ID || "";
export const YOUZAN_CLIENT_SECRET = process.env.YOUZANYUN_CLIENT_SECRET || "";
export const YOUZAN_KDT_ID = process.env.YOUZAN_KDT_ID || ""; // 店铺id
export const SELF_SECRET = process.env.SELF_SECRET || "";
// 推送目标服务器地址
export const PUSH_API = process.env.PUSH_API || "";
================================================
FILE: src/interface/IOrder.ts
================================================
export default interface IOrder {
ID: number;
ORDERID: string;
QRID: number;
}
================================================
FILE: src/interface/IYZPush.ts
================================================
import YZPushType from "../enum/YZPushType";
import YZBusinessStatus from "../enum/YZBusinessStatus";
/**
* 有赞推送消息结构
* https://www.youzanyun.com/docs/guide/3401/3449
*/
export default interface IYZPush {
mode: number;
id: string;
client_id: string;
type: YZPushType;
status: YZBusinessStatus;
msg: string;
kdt_id: number;
sign: string;
version: number;
test: boolean;
send_count: number;
}
================================================
FILE: src/router.ts
================================================
import * as Router from "koa-router";
import PaymentController from "./controller/payment";
import StatusController from "./controller/status";
export default function(app) {
const router = new Router();
const payMentontroller = new PaymentController();
const statusController = new StatusController();
router.post("/api/payment/qrcode", payMentontroller.genPayQr.bind(payMentontroller));
router.post("/api/status", statusController.index.bind(statusController));
app.use(router.routes()).use(router.allowedMethods());
}
================================================
FILE: src/service/sqliteService.ts
================================================
import * as sqlite3 from "sqlite3";
import * as path from "path";
import * as fs from "fs";
import getLogger from "../utils/logger";
import IOrder from "../interface/IOrder";
export default class SqliteService {
private get logger() {
return getLogger();
}
public static init() {
new SqliteService().maybeInit();
}
private get dbFile() {
let dbName = "";
switch (process.env.NODE_ENV) {
case "production":
dbName = "prod";
break;
case "test":
dbName = "test";
break;
default:
dbName = "dev";
}
return path.join(process.cwd(), `./db/${dbName}.db`);
}
private getDb() {
const fullFilepath = this.dbFile;
const Database =
process.env.NODE_ENV === "production" ? sqlite3.Database : sqlite3.verbose().Database;
return new Database(fullFilepath);
}
private maybeInit() {
const fullFilepath = this.dbFile;
if (!fs.existsSync(fullFilepath)) {
this.logger.info(
`DB file is not existed on path ${fullFilepath}, trying to add a new one.`
);
const db = this.getDb();
db.serialize(function() {
db.run(
"CREATE TABLE orders(ID INTEGER PRIMARY KEY AUTOINCREMENT, ORDERID CHAR(50), QRID INTEGER NOT NULL UNIQUE, PAYMENT INTEGER, STATUS CHAR(50))"
);
});
this.logger.info("Close db now");
db.close();
}
}
public insertRecord(orderId: string, qrId: number) {
this.logger.info(`Insert record ORDERID: ${orderId}, QRID: ${qrId}`);
const db = this.getDb();
const logger = this.logger;
return new Promise((resolve, reject) => {
db.serialize(function() {
const stmt = db.prepare("INSERT INTO orders VALUES (?, ?, ?, ?, ?)");
stmt.run([null, orderId, qrId, 0, ""], function(error, lastId) {
if (error) {
logger.error(error.message || error.toString());
stmt.finalize();
db.close();
reject(error);
} else {
logger.info(`Query result: insert row with ID: ${lastId}`);
stmt.finalize();
db.close();
resolve(lastId);
}
});
});
});
}
/**
* 更新订单将付款信息订单状态保存以便重试推送
* @param qrId
* @param payment
* @param status
*/
public updateRecord(qrId: number, payment: number, status: string) {
this.logger.info(`Update record QRID: ${qrId}, PAYMENT: ${payment}, STATUS: ${status}`);
const db = this.getDb();
const logger = this.logger;
return new Promise((resolve, reject) => {
db.serialize(function() {
const stmt = db.prepare("UPDATE orders SET PAYMENT=?, STATUS=? WHERE QRID=?");
stmt.run([payment, status, qrId], function(error, result) {
if (error) {
logger.error(error.message || error.toString());
stmt.finalize();
db.close();
reject(error);
} else {
logger.info(
`Query result: update row with QRID: ${qrId} and return result: ${result}`
);
stmt.finalize();
db.close();
resolve(result);
}
});
});
});
}
public findRecord(qrId: number) {
this.logger.info(`Query record by: QRID: ${qrId}`);
const db = this.getDb();
const logger = this.logger;
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM orders WHERE QRID=${qrId}`, function(error, row) {
if (error) {
logger.error(error.message || error.toString());
db.close();
reject(error);
} else {
logger.info(
row
? `Query result: first row: ${JSON.stringify(row)}`
: `Query result: 0 rows`
);
db.close();
resolve(row);
}
});
});
}
}
================================================
FILE: src/service/youzanPayService.ts
================================================
import * as YZClient from "yz-open-sdk-nodejs";
import * as Token from "../../node_modules/yz-open-sdk-nodejs/Token";
import * as crypto from "crypto";
import axios from "axios";
import * as moment from "moment";
import * as https from "https";
import * as qs from "querystring";
import { YOUZAN_CLIENT_ID, YOUZAN_CLIENT_SECRET, SELF_SECRET, PUSH_API } from "../env";
import getLogger from "../utils/logger";
import IYZPush from "../interface/IYZPush";
import YZPushType from "../enum/YZPushType";
import SqliteService from "./sqliteService";
import YouzanTokenService from "./youzanTokenService";
export default class YouzanPayService {
private get logger() {
return getLogger();
}
private async getYZClient() {
const tokenService = new YouzanTokenService();
const token = await tokenService.getToken();
this.logger.info(`Use token: ${token} to instantiate YZClient`);
return new YZClient(new Token(token));
}
/**
* 生成收款二维码
* @param name 商品名
* @param price 价格(单位分)
* @param originOrderId 原有商品系统的订单ID
*/
public async createQrCode(
name: string,
price: number,
originOrderId: string
): Promise<{ qr_id: string; qr_url: string; qr_code: string; qr_type: string } | null> {
this.logger.info(
`Trying create qrcode for product: ${name} with price ${price} and original order id ${originOrderId}`
);
price = Math.abs(price);
const params = {
qr_name: name,
qr_price: price,
qr_type: "QR_TYPE_DYNAMIC"
};
try {
const client = await this.getYZClient();
const resp = await client.invoke(
"youzan.pay.qrcode.create",
"3.0.0",
"GET",
params,
undefined
);
this.logger.info(`Service youzan.pay.qrcode.create invoke result: ${resp.body}`);
const result = JSON.parse(resp.body);
if (result.error_response) {
throw new Error(result.error_response.message);
}
const data = result.response;
this.logger.info(`Generate qrcode: id: ${data.qr_id}, url: ${data.qr_url}`);
await new SqliteService().insertRecord(originOrderId, data.qr_id);
return data;
} catch (error) {
this.logger.error(error.message || error.toString());
return null;
}
}
public async handleNotify(data: IYZPush) {
this.logger.info(`Trying to handle youzan push message: ${JSON.stringify(data || {})}`);
if (!data || data.test) {
this.logger.info(`Ignore youzan pushed test message`);
return true;
}
// 验证消息Sign
const signStr = YOUZAN_CLIENT_ID + data.msg + YOUZAN_CLIENT_SECRET;
const md5 = crypto.createHash("md5");
const sign = md5.update(signStr, "utf8").digest("hex");
if (sign !== data.sign) {
this.logger.error(`Verify push message sign failed, calculated sign: ${sign}`);
return;
}
// 只处理支付消息
if (data.type !== YZPushType.TRADE_ORDER_STATE) {
this.logger.info(`Ignore message with type: ${data.type}`);
return;
}
// 获取订单信息
if (!data.msg) {
this.logger.error(`Invalid message with empty msg field`);
return;
}
try {
this.logger.info(`Parsed order info: ${decodeURI(data.msg)}`);
const orderInfo = JSON.parse(decodeURI(data.msg));
const { qrId, uuid } = await this.fetchOrderQrId(orderInfo.tid);
if (!qrId) {
return;
}
const payment = parseInt((orderInfo.payment * 100).toFixed(0), 10);
const status = orderInfo.status || data.status;
// 先更新本地订单记录
await new SqliteService().updateRecord(qrId, payment, status);
// 在本地数据库查询对接的原始订单号
const record = await new SqliteService().findRecord(qrId);
if (!record) {
return;
}
// 推送数据到原始订单系统
await this.pushOrder(`${orderInfo.tid}-${qrId}`, record.ORDERID, payment, status, uuid);
} catch (error) {
this.logger.error(`Parse order info failed: ${error.message || error.toString()}`);
return;
}
}
/**
* 通过订单号查询订单详情并返回详情内的qr_id
* @param tid 有赞订单号
*/
private async fetchOrderQrId(tid: string) {
try {
this.logger.info(`Fetching detail for order: ${tid}`);
const client = await this.getYZClient();
const params = {
tid
};
const resp = await client.invoke("youzan.trade.get", "4.0.0", "GET", params, undefined);
this.logger.info(`Fetched order detail resp: ${resp}`);
const data = JSON.parse(resp.body);
this.logger.info(`Fetched order detail: ${JSON.stringify(resp.body)}`);
const qrId = data.response.qr_info.qr_id;
const fansInfo = data.response.full_order_info.buyer_info;
const { fans_id, buyer_id, fans_weixin_openid } = fansInfo;
const uuid = buyer_id || fans_weixin_openid || fans_id;
return {
qrId,
uuid
};
} catch (error) {
this.logger.error(`Fetch order detail failed: ${error.message || error.toString()}`);
return {
qrId: 0,
uuid: ""
};
}
}
private async pushOrder(
tradeNo: string,
originOrderId: string,
payment: number,
status: string,
uuid: string
) {
if (!originOrderId) {
return false;
}
const time = (moment.now().valueOf() / 1000).toFixed(0);
const sig = [time, tradeNo, originOrderId, payment.toString(), status, SELF_SECRET].join(
"|"
);
const data = {
tradeNo,
orderId: originOrderId,
payment,
status,
uuid,
sign: crypto
.createHash("md5")
.update(sig, "utf8")
.digest("hex"),
time
};
try {
const pushApis = PUSH_API.split(";");
const promises: Promise[] = [];
for (let i = 0; i < pushApis.length; i++) {
this.logger.info(
`Trying push order to ${pushApis[i]}, data: ${JSON.stringify(data)}`
);
promises.push(
axios
.create({
timeout: 30000,
withCredentials: false,
httpsAgent: new https.Agent({
rejectUnauthorized: false
}),
headers: {
"Content-type": "application/x-www-form-urlencoded"
}
})
.post(pushApis[i], qs.stringify(data))
.then(resp => {
if (resp.status !== 200) {
this.logger.error(
`Push order info to ${pushApis[i]} with wrong response status ${
resp.status
}`
);
return false;
} else {
this.logger.info(
`Push order info to ${pushApis[i]} successfully, response ${
resp.data
}`
);
// TODO record success push to db
return true;
}
})
);
}
await Promise.all(promises);
// TODO retry push
} catch (error) {
this.logger.error(
`Push order info to ${PUSH_API} failed: ${error.message || error.toString()}`
);
return false;
}
}
}
================================================
FILE: src/service/youzanTokenService.ts
================================================
import axios from "axios";
import * as https from "https";
import * as qs from "querystring";
import getLogger from "../utils/logger";
import * as moment from "moment";
import { YOUZAN_CLIENT_ID, YOUZAN_CLIENT_SECRET, YOUZAN_KDT_ID } from "../env";
export default class YouzanTokenService {
private logger = getLogger();
/**
* Memory cache
*/
private static store: { token: string; expiry: number } = {} as any;
private setToken(token: string, expiresIn: number) {
YouzanTokenService.store = {
token: token,
expiry: parseInt((moment.now().valueOf() / 1000 + expiresIn).toFixed(0), 10)
};
}
public async getToken() {
const cache = YouzanTokenService.store;
if (cache) {
const { token, expiry } = cache;
if (token && expiry - moment.now().valueOf() / 1000 > 10 * 60) {
this.logger.info(`Get token from cache: ${token}`);
return token;
}
}
const ax = axios.create({
timeout: 10000,
withCredentials: false,
httpsAgent: new https.Agent({
rejectUnauthorized: false
}),
headers: {
"Content-type": "application/x-www-form-urlencoded"
}
});
const data = {
client_id: YOUZAN_CLIENT_ID,
client_secret: YOUZAN_CLIENT_SECRET,
grant_type: "silent",
kdt_id: Number(YOUZAN_KDT_ID)
};
try {
this.logger.info(
`Trying to get access_token with request data: ${JSON.stringify(data)}`
);
const resp = await ax.post("https://open.youzan.com/oauth/token", qs.stringify(data));
if (resp.status !== 200) {
this.logger.error(
`Get access_token request failed with status: ${resp.status} and data: ${
resp.data
}`
);
throw new Error("Get access_token failed");
}
this.logger.info(
`Get access_token request successfully data: ${JSON.stringify(resp.data)}`
);
const { access_token, expires_in } = resp.data;
this.logger.info(`Got access_token: ${access_token}, expires_in: ${expires_in}`);
this.setToken(access_token, expires_in);
return access_token as string;
} catch (error) {
this.logger.error(
`Get access_token request failed with message: ${error.message || error.toString()}`
);
throw error;
}
}
}
================================================
FILE: src/utils/logger.ts
================================================
import * as winston from "winston";
import * as path from "path";
import * as moment from "moment";
function getLogger() {
const daySuffix = moment().format("YYYY-MM-DD");
const errorLogFile = path.join(process.cwd(), `./logs/error-${daySuffix}.log`);
const warnLogFile = path.join(process.cwd(), `./logs/warn-${daySuffix}.log`);
const infoLogFile = path.join(process.cwd(), `./logs/info-${daySuffix}.log`);
const combinedLogFile = path.join(process.cwd(), `./logs/combined-${daySuffix}.log`);
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: errorLogFile, level: "error" }),
new winston.transports.File({ filename: warnLogFile, level: "warn" }),
new winston.transports.File({ filename: infoLogFile, level: "info" }),
new winston.transports.File({ filename: combinedLogFile })
]
});
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple()
})
);
}
return logger;
}
export default getLogger;
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"strictNullChecks": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
// 支持装饰器
"experimentalDecorators": true,
// 装饰器元数据
"emitDecoratorMetadata": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"target": "es5",
"lib": ["es6", "es7"],
"typeRoots": ["node_modules/@types", "typings"],
"baseUrl": "./src",
"outDir": "./dist",
"allowJs": false
},
"exclude": ["node_modules", "dist", "**/*.spec.ts?"]
}