API 文档

Subscription: fetch

根据订阅参数获取订阅对象。

Request

GET /api/subscription/user-subscription/fetch/

Request Parameters

subscribe接口的BODY参数相同。

Response

200

获取对象功能(即已订阅)

{
            "id": 47,
            "user": 7815769374283,
            "followed_user": 3120595892220,
            "followed_category": null,
            "apply_scene": null,
            "subscription_type": "follow_user",
            "created_at": "2022-01-10T08:52:58.425956Z",
            "updated_at": "2022-01-10T08:52:58.425387Z",
            "entity_type": 100
}

404

对象不存在(即未订阅)

Activity: comment count

根据过滤条件查询评论数。

Request

GET /api/activity/comment/comment_count/

Request Parameters

NameValueDescriptionNotes
target_typeenum评论目标对象类型Comment.TargetType
metaJSON评论对象的meta属性包含有此参数内容目前TargetType.Dimzou_Reword的评论对象meta为:
{"node_id": node_id, "paragraph_id": paragraph_id}
其他类型的meta为空对象。

Example

GET /api/activity/comment/comment_count/?target_type=400&meta={%22node_id%22%3A%208553}

Response

200

返回总评论数

{
    "data": {
        "count": 7
    }
}

微信服务:生成JS接口的使用权限签名

前端在使用微信JS-SDK开发时,按照微信开放文档描述,必须先JS接口的获取使用权限签名,而该签名的生成算法涉及到获取access_token和jsapi_ticket,这些微信身份认证的步骤均必须由服务端实现,然后提供接口为前端解决签名生成的问题。

Request

HTTP request

POST https://www.featapi.com/api/weixin-service/sign/

继续阅读

用户JSON文档存储

背景

用户在网站上进行操作时,会产生一些重要的临时数据,如编辑了一半的文章块、输入中的评论等。一般来说,这些数据的生产成本是比较高的,当出现意外导致中断时,能尽可能的恢复这些临时数据可以大幅度提高用户体验。另外考虑用户多端登录的情况,可以进行“接力”操作也是一个用户体验提升点。

解决方案

目前的需要主要是高频写入,低频读取,文档写入时最好有冲突检测以避免错误覆盖,如有文档变更通知可以更好的实现多端同步功能。考虑到某些场景下文档可能较大,可较为便利的实现文档拆分,减小写入压力。

综合各方面考虑,选用CouchDB作为文档存储服务。

身份认证:CouchDB支持Proxy Authentication模式,外部服务可以根据一定的规则生成Http Headers用于登录。前期由前端直接调用身份认证服务的CouchDB token接口获取Http Headers,后期可考虑在服务端自动进行转换。

数据隔离:为每个Feat用户创建一个私有数据库,用户只能访问自己的数据库,用户数据中可以根据需要创建不同的JSON文档。

数据大小限制:目前限制一个JSON文档的大小是8M。

CouchDB部署

安装

参考 1. Installation — Apache CouchDB® 3.2 Documentation 进行安装

CouchDB服务需要向外暴露5984端口。

单节点模式请参考2.1. Single Node Setup — Apache CouchDB® 3.2 Documentation

多节点部署模式请参考2.2. Cluster Set Up — Apache CouchDB® 3.2 Documentation

下面的配置文件范例:


[chttpd_auth]
; proxy authentication secret. shared by CouchDB and feat accounts service.
secret = 609e42e49def19dcb2428027d95b9db7
require_valid_user = true
proxy_use_secret = true

[cors]
; cors origins. * is bad, set all frontend domain names here.
origins = *
headers = accept, authorization, content-type, origin, referer, X-Auth-CouchDB-UserName, X-Auth-CouchDB-Token
methods = GET, PUT, POST, HEAD, DELETE
credentials = true

[httpd]
enable_cors = true

[couch_peruser]
enable = true

[chttpd]
authentication_handlers = {chttpd_auth, cookie_authentication_handler},{chttpd_auth, proxy_authentication_handler},{chttpd_auth, default_authentication_handler}

放到相应的配置目录后重启服务。

反向代理

CouchDB 3做了一些安全方面的限制,导致Proxy Authentication登录的用户没有创建对应的用户账号。我们在CouchDB接口上做一些tricky的处理以便实现用户可以自建账号和数据库。

map $request_method $proxy_method {
        POST PUT;
        default $request_method;
}

server {
# port for elb
  listen 5984;
# server name. maybe couchdb.feat.com
  server_name localhost;

  set $couchdb_base "http://localhost:5984";

  error_page 418 = @normal;
  error_page 420 = @newuser;
  recursive_error_pages on;

  location @normal {
# proxy to couchdb
    proxy_pass $couchdb_base;
    proxy_redirect off;
    proxy_buffering off;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Auth-CouchDB-Roles "";
  }

# HACK: set roles = _admin when create user
  location @newuser {
    set $the_username "$http_x_auth_couchdb_username";

    proxy_pass "$couchdb_base/_users/org.couchdb.user:$the_username";
    proxy_redirect off;
    proxy_buffering off;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    proxy_set_header X-Auth-CouchDB-Roles _admin;

    proxy_method $proxy_method;

    set $body '{"name": "${the_username}", "roles": [], "type": "user"}';
    proxy_set_body $body;
  }

  location / {
    return 418;
  }

  location ~ /_users/org.couchdb.user:(?<username>.+) {
    set $the_username "$http_x_auth_couchdb_username";

    if ($the_username != $username) {
      return 418;
    }

    if ($request_method != PUT) {
      return 418;
    }

    return 420;
  }

  location /_users/create-new-user {
    if ($request_method !~* (POST|OPTIONS)) {
      return 405;
    }

    return 420;
  }

}

在elb上建议配置https和http2支持,可以使用标准443端口。

环境

FeatDev
ACCOUNT_SERVICE_HOSThttps://accounts.feat.comhttps://117.local.feat.com
COUCH_DB_HOSThttps://couchdb.feat.comhttps://10.0.10.117:5984

使用流程

  1. 调用 https://accounts.feat.com/couchdb/token/ 获取 CouchDB 身份认证信息,以及分配的用户数据库名称
  2. 用户首次使用 CouchDB 时,需要调用 https://couchdb.feat.com/_users/org.couchdb.user:{uid} ,以完成用户账号和用户数据库的创建
  3. 调用 CouchDB API,如 GET https://couchdb.feat.com/{dbname} 获取数据库信息

API

获取Token

GET https://accounts.feat.com/couchdb/token/

Headers: Authorization

Response

200 获取成功

{
    "data": {
        "headers": {
            "X-Auth-CouchDB-UserName": "4222704093994",
            "X-Auth-CouchDB-Token": "2197693d9ab1c56bf45633f6326d0078d670fd1d"
        },
        "dbname": "userdb-34323232373034303933393934"
    }
}

401 未授权

创建账号和数据库

PUT https://couchdb.feat.com/_users/org.couchdb.user:{uid}

Headers:

// 从 https://accounts.feat.com/couchdb/token/ 获取
{
    "X-Auth-CouchDB-UserName": "4222704093994",
    "X-Auth-CouchDB-Token": "2197693d9ab1c56bf45633f6326d0078d670fd1d"
}

Body: 可参考 CouchDB 的接口文档

说明:目前这个接口的请求会在反向代理 中被覆盖,以下为 body 示例:

{
    "name": "4222704093994",
    "type": "user",
    "roles": []
}

Response

201 创建成功

401 未授权,headers错误

409 用户已存在

Alternative

POST https://couchdb.feat.com/_users/create-new-user

Headers:同上

Body:无

Response:同上

文档的增删改查

GET/PUT/DELETE https://couchdb.feat.com/{dbname}/{docid}

参考文档 1.4. Documents — Apache CouchDB® 3.2 Documentation

示例代码

JS示例

可以使用CouchDB客户端进行连接,比如下面是PouchDB的例子:

     var PouchDB = require('pouchdb'); 
     class MyPouchDB extends PouchDB {
        constructor (db, opts) {
          opts.fetch = function(url, options) {
            options.credentials = 'omit';
            options.headers = {...options.headers, ...(opts.headers || {})};
            return PouchDB.fetch(url, options);
          }
          opts.skip_setup = true;
          super(db, opts);
        }

        setupPromise = undefined;

        ensure_user_and_db () {
// 这个函数只支持http、https adapter,如果要同时使用PouchDB的其他adapter需要做兼容处理。
          if (this.setupPromise) {
            return this.setupPromise;
          }

          this.setupPromise = this.info().then(res => {
            if (res && !res.error) {
              return Promise.resolve(res);
            }
            const err = res;
            if (err && err.error && err.error === 'not_found') {
              return this.signUp();
            } else {
              return Promise.reject(err);
            }
          }).catch(function (err) {
            if (err && err.status && err.status === 412) {
              return true;
            }
            return Promise.reject(err);
          })

          this.setupPromise.catch(() => {
            this.setupPromise = null;
          });

          return this.setupPromise;
        }

        signUp () {
          const username = this.__opts.headers['X-Auth-CouchDB-UserName'];
          const user = {
            _id: `org.couchdb.user:${username}`,
            name: username,
            roles: [],
            type: 'user',
          };

          const getBaseUrl = function (db) {
            const prebase = db.substr(-1, 1) === '/' ? db.substr(0, -1) : db;
            return prebase.split('/').slice(0, -1).join('/');
          }

          const usersDb = new this.constructor(getBaseUrl(this.name) + '/_users', this.__opts);

          return usersDb.put(user)
        }
      }
      class PouchDBStorage {
        constructor(db, options = {}) {
          if (typeof db !== "string" && options == {}) {
            this.db = db;
          } else {
            this.db = new MyPouchDB(db, options);
          }

          this.docRevs = {};
        }

        async getItem(key) {
          await this.db.ensure_user_and_db();
          try {
            const doc = await this.db.get(key);
            this.docRevs[key] = doc._rev;
            return JSON.stringify(doc.doc);
          } catch (err) {
            if (err && err.status && err.status === 404) {
              return undefined;
            } else {
              throw err;
            }
          }
        }

        async setItem(key, value) {
          await this.db.ensure_user_and_db();
          const doc = JSON.parse(value);
          const _rev = this.docRevs[key];

          try {
            const result = await this.db.put({ _id: key, _rev, doc });

            this.docRevs[key] = result.rev;
            return result;
          } catch (err) {
            if (err && err.status && err.status === 409) {
              // TODO: 版本冲突,需要重新获取最新版本并进行处理。这里的例子是覆盖远端修改。
              const new_value = await this.getItem(key);
              // value = merge(value, new_value)
              return await this.setItem(key, value);
            } else {
              throw err;
            }
          }
        }

        async removeItem(key) {
          await this.db.ensure_user_and_db();
          if (!this.docRevs[key]) {
            // 删除一个本地没有的key,也许是搞错了什么?这里只是简单获取最新版本并删除。
            const v = await this.getItem(key);
            if (!v) {
              return;
            }
          }

          // TODO: 跟setItem类似,这里也可能出现版本冲突,需要进行处理。
          await this.db.remove({ _id: key, _rev: this.docRevs[key] });
          delete this.docRevs[key];
        }
      }

      const getDbToken = async function (auth) {
        const ret = await fetch('http://127.0.0.1:8123/couchdb/token/', {headers: new Headers({"Authorization": auth})})
        return await ret.json()
      }


      const setupStorage = async function (opts) {
        const {data} = await getDbToken(opts);
        if (!data) {
          // TODO
          return;
        }
        const {headers, dbname} = data;
        const storage = new PouchDBStorage('http://127.0.0.1:5984/'+dbname, {headers});
        return storage;
      }

      const storage = await setupStorage("Bearer qAg3jdtuCUB4dWuc8d5pD81PnpCqUX");

      await storage.getItem('test') === undefined;
      await storage.setItem('test', '{"test": 1}');
      await storage.getItem('test') === '{"test": 1}';
      await storage.removeItem('test');
      await storage.getItem('test') === undefined;
      

拆分对象

更进一步的,如果想更优雅的处理多个对象的数据,减少每次提交的数据量和冲突,或者当一个对象大于8MB的时候,可以把一个cache key下的多个对象分别存储:

  • 使用key:subkey作为文档id
  • 使用find接口(pouchdb-find)或allDocs接口(匹配id前缀)进行批量获取
  • 单独管理每个子对象的版本

TBD

Redux Persist

通过类似 Redux Persist 的库插入state的持久化处理,使业务代码不需要考虑持久化的问题。

多端同步

通过 MyPouchDB.changes 接口可以监听其他端的文档变更,实现多端同步功能。

通过 MyPouchDB.sync 可实现local first开发。

TBD